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

fix(T28-FIX,AC-09): resolveFoodIndexUserId 数值碰撞加固 + 历史迁移脚本 V253

- 注入 UserMapper,resolveFoodIndexUserId 判别顺序改为先查 users 表防
  users.id 与 family_members.id 数值碰撞误判(数据越权+正确性破坏)
- recalculateForUser 注释同步说明入参覆盖 members + 登录者两条路径
- 补全单测 3 项:碰撞返回原值 / 已 users.id 无 member / 写侧保持 users.id
- 新增 V253 迁移脚本(只写不执行):memberId 键历史行迁移为 users.id
- 测试环境兼容:纯单测无 MyBatis TableInfo,setup 预置 MP 列名缓存使
  getCustomSqlSegment 可解析反查键
Agnes Agent 1 неделя назад
Родитель
Сommit
4de8cb16af

+ 25 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java

@@ -11,6 +11,7 @@ import com.etotem.cfc.mapper.FoodRecommendIndexMapper;
 import com.etotem.cfc.mapper.FoodRecommendIdxLogMapper;
 import com.etotem.cfc.mapper.ReportFoodSuitabilityMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -35,6 +36,9 @@ public class FoodRecommendService {
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
+    @Resource
+    private UserMapper userMapper;
+
     @Resource
     private HealthReportService healthReportService;
 
@@ -55,17 +59,31 @@ public class FoodRecommendService {
      * {@code family_members.user_id}(即 users.id)关联 food_recommend_idx —— 若把 memberId 原样写入
      * {@code food_recommend_idx.user_id},两条键不唯一对齐,家庭总览重算链路失效(AC-09 P0)。
      * <p>
+     * 判别顺序(先查 users 表防碰撞):{@code users} 表与 {@code family_members} 表各自独立自增,id 数值空间
+     * 高度重叠 —— 若前端传的是 {@code users.id}(如 {@code /api/food/recommendation/index?userId=5}),而
+     * {@code family_members.id=5} 恰有记录(memberId=5, user_id=100),只查 family_members 会误判为 memberId=5
+     * → 返回 100 → 读到成员 100 的数据而非用户 5 自己的数据(数据越权 + 正确性破坏)。故先判定是否已是
+     * {@code users.id},命中即幂等返回,杜绝与 family_members.id 数值碰撞误判。
+     * <p>
      * 规则:
      * <ul>
-     *   <li>命中 {@code family_members.id} 且该成员有登录账号(user_id 非空)→ 返回其 {@code users.id}(登录成员统一键);</li>
+     *   <li>命中 {@code users.id} → 原样返回(已是登录用户统一键,幂等;优先判定,防数值碰撞);</li>
+     *   <li>未命中 {@code users.id},命中 {@code family_members.id} 且该成员有登录账号(user_id 非空)→
+     *       返回其 {@code users.id}(登录成员统一键);</li>
      *   <li>命中 {@code family_members.id} 但 user_id 为空(纯档案成员,无登录账号)→ 原样返回 memberId
      *       (历史口径:无 users.id 可挂,且家庭总览/按家庭查询天然排除此类成员,不会参与 min 计算);</li>
-     *   <li>未命中 {@code family_members.id} → 视为已是 {@code users.id},原样返回(幂等,兼容已按 users.id 写入的历史数据)。</li>
+     *   <li>两者均未命中 → 视为已是 {@code users.id},原样返回(幂等,兼容未落库的新账号/历史数据)。</li>
      * </ul>
      */
     public Long resolveFoodIndexUserId(Long subjectId) {
         if (subjectId == null) return null;
         try {
+            // 优先判定是否已是 users.id(避免与 family_members.id 数值碰撞误判:users 与 family_members 独立自增,
+            // id 数值空间高度重叠,若只查 family_members 会把 users.id 误判为 memberId 而错归一)
+            if (userMapper.selectById(subjectId) != null) {
+                return subjectId;  // 已是 users.id,幂等返回
+            }
+            // 否则尝试按 family_members.id 归一
             FamilyMember m = familyMemberMapper.selectById(subjectId);
             if (m != null) {
                 return m.getUserId() != null ? m.getUserId() : subjectId;
@@ -78,10 +96,11 @@ public class FoodRecommendService {
 
     @Transactional
     public void recalculateForUser(Long userId) {
-        // T28 方向1:入参可能是 family_members.id(报告确认链路 subjectId,经 resolveSubjectToMemberId 归一)
-        // 或 users.id;food_recommend_idx 的写入键统一归一为成员真实登录用户 users.id,
-        // 与家庭总览读侧(family_members.user_id 关联)对齐。读侧 getLatestReport / snapshotFoodIndices 的
-        // subject 语义仍用原始入参(health_reports.subject_id 本就是 memberId)。
+        // T28 方向1:入参 userId 可能是 family_members.id(报告确认链路 subjectId,经 resolveSubjectToMemberId 归一)
+        // 或 users.id(前端直接传 userId / 家庭总览读侧);food_recommend_idx 的写入键统一归一为成员真实登录用户
+        // users.id(resolveFoodIndexUserId 先查 users 表防数值碰撞,再按 family_members.id 归一),与家庭总览读侧
+        // (family_members.user_id 关联)对齐。读侧 getLatestReport / snapshotFoodIndices 的 subject 语义仍用原始入参
+        // (health_reports.subject_id 本就是 memberId)。
         Long writeKey = resolveFoodIndexUserId(userId);
         HealthReport report = healthReportService.getLatestReport(userId);
         if (report == null) {

+ 65 - 0
cfc-backend/src/main/resources/db/migration/V253__migrate_food_recommend_idx_to_users_id.sql

@@ -0,0 +1,65 @@
+-- ============================================================================
+-- V253: 把 food_recommend_idx 现存以 memberId 为键的行迁移为 users.id
+-- 需求: T28-FIX AC-09 P0 加固(方向 A 写侧统一)
+--
+-- 背景:
+--   报告确认路径曾把 family_members.id(memberId)写入 food_recommend_idx.user_id,
+--   而家庭总览按 family_members.user_id(users.id)关联,导致总览重算链路失效(AC-09 P0)。
+--   主理人裁决方向 A(写侧统一为 users.id)后,代码侧 resolveFoodIndexUserId 已实现
+--   归一(先查 users 表防数值碰撞,再按 family_members.id 归一)。本脚本把现存以
+--   memberId 为键的历史行迁移为对应 family_members.user_id,与写侧口径对齐。
+--
+-- 风险与约束:
+--   * 仅 UPDATE,不 DELETE —— 只改键,不丢任何食材行。
+--   * food_recommend_idx 存在 UNIQUE KEY uk_fri_user_food (user_id, food_id),
+--     若目标 user_id+food_id 已存在(用户既有 users.id 键行,又有 memberId 误写行),
+--     UPDATE 会因唯一键冲突失败 —— 本脚本不处理该合并场景,冲突行会整体回滚,
+--     需人工评估(是否以最新 health_report_id 覆盖,需主理人二次确认)。
+--   * 纯档案成员(family_members.user_id 为 NULL)无 users.id 可挂,保持原键不动,
+--     家庭总览/按家庭查询天然排除此类成员,不影响 min 计算。
+--   * 执行前先跑下方只读评估段确认规模;执行后重跑 FAMILY_FOOD_OVERVIEW_RECALCULATE_TEST。
+--   * !! 本脚本待主理人确认后手动执行,不含在应用启动迁移里 !!
+-- ============================================================================
+
+-- ----------------------------------------------------------------------------
+-- 只读评估(迁移前先跑,确认规模与可迁移比例)
+-- ----------------------------------------------------------------------------
+SELECT
+  COUNT(*) AS total_rows,
+  SUM(CASE WHEN fm.id IS NOT NULL AND fm.user_id IS NOT NULL THEN 1 ELSE 0 END) AS can_migrate,
+  SUM(CASE WHEN fm.id IS NOT NULL AND fm.user_id IS NULL THEN 1 ELSE 0 END) AS orphan_memberId_no_user_id,
+  SUM(CASE WHEN fm.id IS NULL THEN 1 ELSE 0 END) AS already_users_id_rows
+FROM food_recommend_idx i LEFT JOIN family_members fm ON fm.id = i.user_id;
+
+-- ----------------------------------------------------------------------------
+-- 备份旧值到日志表(可选,仅保留轨迹,previous_index/new_index 置 NULL 不覆盖)
+-- ----------------------------------------------------------------------------
+INSERT INTO food_recommend_idx_log (user_id, food_id, food_name, previous_index, new_index, health_report_id, change_reason, changed_at)
+SELECT
+  i.user_id AS user_id,
+  i.food_id,
+  i.food_name,
+  NULL AS previous_index,
+  NULL AS new_index,
+  i.health_report_id,
+  CONCAT('T28-FIX migration: memberId(', i.user_id, ') -> userId(', fm.user_id, ')') AS change_reason,
+  NOW() AS changed_at
+FROM food_recommend_idx i
+JOIN family_members fm ON fm.id = i.user_id
+WHERE fm.user_id IS NOT NULL AND i.user_id != fm.user_id;
+
+-- ----------------------------------------------------------------------------
+-- 迁移 food_recommend_idx(SET 为对应 user_id)
+-- ----------------------------------------------------------------------------
+UPDATE food_recommend_idx i
+JOIN family_members fm ON fm.id = i.user_id
+SET i.user_id = fm.user_id
+WHERE fm.user_id IS NOT NULL AND i.user_id != fm.user_id;
+
+-- ----------------------------------------------------------------------------
+-- 同步迁移 food_recommend_idx_log 表(可选,日志键对齐)
+-- ----------------------------------------------------------------------------
+UPDATE food_recommend_idx_log l
+JOIN family_members fm ON fm.id = l.user_id
+SET l.user_id = fm.user_id
+WHERE fm.user_id IS NOT NULL AND l.user_id != fm.user_id;

+ 90 - 0
cfc-backend/src/test/java/com/etotem/cfc/integration/service/FoodRecommendServiceTest.java

@@ -3,6 +3,8 @@ package com.etotem.cfc.service;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.*;
 import com.etotem.cfc.service.HealthReportService;
+import com.baomidou.mybatisplus.core.toolkit.LambdaUtils;
+import com.baomidou.mybatisplus.core.toolkit.support.ColumnCache;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
@@ -44,6 +46,9 @@ public class FoodRecommendServiceTest {
     @Mock
     private FamilyMemberMapper familyMemberMapper;
 
+    @Mock
+    private UserMapper userMapper;
+
     @Mock
     private HealthReportService healthReportService;
 
@@ -63,9 +68,35 @@ public class FoodRecommendServiceTest {
         setField(service, "foodRecommendIdxLogMapper", foodRecommendIdxLogMapper);
         setField(service, "reportFoodSuitabilityMapper", reportFoodSuitabilityMapper);
         setField(service, "familyMemberMapper", familyMemberMapper);
+        setField(service, "userMapper", userMapper);
         setField(service, "healthReportService", healthReportService);
         setField(service, "foodService", foodService);
         setField(service, "familyFoodOverviewService", familyFoodOverviewService);
+        installLambdaColumnCache();
+    }
+
+    /**
+     * MP 3.5.3.1 纯单测环境(无 MyBatis TableInfo 初始化)下,LambdaQueryWrapper 的列名缓存
+     * (LambdaUtils.COLUMN_CACHE_MAP)为空:tryInitCache 经 getColumnMap 拿不到缓存时
+     * Assert.notNull 抛 MybatisPlusException(can not find lambda cache for this entity)。
+     * 这里直接向缓存预置 FamilyMember 列名映射(外层 key = 实体类全名;内层 key =
+     * LambdaUtils.formatKey(property) = 字段名转大写),使 getCustomSqlSegment 能解析反查键。
+     */
+    private static void installLambdaColumnCache() {
+        try {
+            java.lang.reflect.Field f = LambdaUtils.class.getDeclaredField("COLUMN_CACHE_MAP");
+            f.setAccessible(true);
+            @SuppressWarnings("unchecked")
+            Map<String, Map<String, ColumnCache>> cache =
+                    (Map<String, Map<String, ColumnCache>>) f.get(null);
+            if (!cache.containsKey(FamilyMember.class.getName())) {
+                Map<String, ColumnCache> cols = new HashMap<>();
+                cols.put("USERID", new ColumnCache("user_id", "user_id", null));
+                cache.put(FamilyMember.class.getName(), cols);
+            }
+        } catch (Exception ignored) {
+            // 缓存注入失败时走原有断言路径
+        }
     }
 
     private static void setField(Object target, String fieldName, Object value) throws Exception {
@@ -262,6 +293,8 @@ public class FoodRecommendServiceTest {
                 (com.baomidou.mybatisplus.core.conditions.AbstractWrapper<FamilyMember, ?, ?>) wrapperCaptor.getValue();
         // MP 3.5.x 的 paramNameValuePairs 在 getSqlSegment()/getCustomSqlSegment() 时才惰性填充(eq() 时不写),
         // 必须先强制生成 SQL 段,再读取参数值,否则拿到的 map 为空。
+        // 纯单测环境无 MyBatis TableInfo 初始化,列名缓存由 setup() 中 installLambdaColumnCache() 预置,
+        // 保证 getCustomSqlSegment() 能解析 user_id 列而不抛 can not find lambda cache。
         aw.getCustomSqlSegment();
         java.util.Map<String, Object> params = aw.getParamNameValuePairs();
         assertTrue(params.values().contains(81330L), "反查应使用归一化 users.id 键");
@@ -281,6 +314,63 @@ public class FoodRecommendServiceTest {
         assertEquals(81330L, result.get(0).getUserId());
     }
 
+    // === T28-FIX AC-09 P0 加固:resolveFoodIndexUserId 数值碰撞防护 ===
+    // users 表与 family_members 表各自独立自增,id 数值空间高度重叠。若前端传的是 users.id(如 userId=500),
+    // 而 family_members.id=500 恰有记录(memberId=500, user_id=100),旧逻辑只查 family_members 会误判为
+    // memberId=500 → 返回 100 → 读到成员 100 的数据而非用户 500 自己的数据。加固后必须优先判定 users 表。
+
+    @Test
+    public void resolveFoodIndexUserId_usersIdNotMember_returnedAsIs() {
+        // 模拟数值碰撞:users.id=500 存在,family_members.id=500 也存在(memberId=500, user_id=100)。
+        // 加固后优先走 users 表,返回原值 500L(旧逻辑会误判为 memberId=500 → 返回 100)。
+        User user = new User();
+        user.setId(500L);
+        when(userMapper.selectById(500L)).thenReturn(user);
+        when(familyMemberMapper.selectById(500L)).thenReturn(mockMember(500L, 100L));
+
+        Long resolved = service.resolveFoodIndexUserId(500L);
+
+        assertEquals(500L, resolved);
+    }
+
+    @Test
+    public void resolveFoodIndexUserId_alreadyInUsers_noMember() {
+        // users.id 命中,family_members.id 无记录 → 返回原值(已是 users.id,幂等)
+        User user = new User();
+        user.setId(500L);
+        when(userMapper.selectById(500L)).thenReturn(user);
+        when(familyMemberMapper.selectById(500L)).thenReturn(null);
+
+        Long resolved = service.resolveFoodIndexUserId(500L);
+
+        assertEquals(500L, resolved);
+    }
+
+    @Test
+    public void recalculateForUser_usersIdInput_keepsUserId() {
+        // 前端传 users.id=500:users 表命中,且无 family_members.id=500 记录 → 写入键 = 500(不被误归一为 100)
+        User user = new User();
+        user.setId(500L);
+        when(userMapper.selectById(500L)).thenReturn(user);
+        when(familyMemberMapper.selectById(500L)).thenReturn(null);
+        HealthReport report = mockReport(1L, 1L);
+        when(healthReportService.getLatestReport(500L)).thenReturn(report);
+        Food food = new Food();
+        food.setId(100L);
+        food.setName("食材A");
+        food.setScore(-25);
+        when(foodService.listByReportId(1L)).thenReturn(Collections.singletonList(food));
+        when(foodRecommendIndexMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        service.recalculateForUser(500L);
+
+        ArgumentCaptor<FoodRecommendIndex> idxCaptor = ArgumentCaptor.forClass(FoodRecommendIndex.class);
+        verify(foodRecommendIndexMapper).insert(idxCaptor.capture());
+        // 关键断言:写入键保持 users.id=500,绝不被 family_members.id 碰撞误归一为 100
+        assertEquals(500L, idxCaptor.getValue().getUserId());
+        assertEquals(-25, idxCaptor.getValue().getIndexScore());
+    }
+
     // === getIndexByUser ===
 
     @Test