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

merge(S-cfc-81): sync cfclub (concurrent fix 9e6825ef8) into task/S-cfc-81 before push

Sisyphus Agent 4 дней назад
Родитель
Сommit
045e542012

+ 10 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/FoodRecommendController.java

@@ -92,7 +92,16 @@ public class FoodRecommendController {
         if (reportId != null) {
             return Result.success(foodRecommendService.getCautionFoodsByReport(reportId));
         }
-        List<FoodRecommendIndex> foods = foodRecommendService.getCautionFoods(memberId != null ? memberId : userId);
+        // 成员视角(memberId 语义 = family_members.id):先用 resolveMemberViewToUserId 归一为 users.id,
+        // 规避 users 与 family_members.id 数值重叠时把成员误判为无关用户(家庭72 书宁宁案例)。
+        Long queryUid;
+        if (memberId != null) {
+            Long resolved = foodRecommendService.resolveMemberViewToUserId(memberId);
+            queryUid = resolved != null ? resolved : userId;
+        } else {
+            queryUid = userId;
+        }
+        List<FoodRecommendIndex> foods = foodRecommendService.getCautionFoods(queryUid);
         return Result.success(foods);
     }
 

+ 10 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -1972,9 +1972,17 @@ public class HealthReportController {
             foods = foodService.listByReportId(reportId);
         } else {
             // 未指定报告:优先用 memberId(成员视角),其次 JWT userId(本人视角)
-            // food_recommend_idx 以 subjectId 维度写入,家庭主账号 JWT userId 查不到家庭成员的推荐
+            // food_recommend_idx 以 subjectId 维度写入,家庭主账号 JWT userId 查不到家庭成员的推荐。
+            // 成员视角须用 resolveMemberViewToUserId 归一(family_members.id → users.id),
+            // 规避 users 与 family_members.id 数值重叠时把成员误判为无关用户(家庭72 书宁宁案例)。
             Long memberId = params != null ? ParamUtils.getLong(params.get("memberId")) : null;
-            Long uid = memberId != null ? memberId : (userId != null ? userId : -1L);
+            Long uid;
+            if (memberId != null) {
+                Long resolved = foodRecommendService.resolveMemberViewToUserId(memberId);
+                uid = resolved != null ? resolved : -1L;
+            } else {
+                uid = userId != null ? userId : -1L;
+            }
             foods = foodRecommendService.getCurrentFoodsAsFoodList(uid);
         }
         return Result.success(foods);

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java

@@ -94,6 +94,34 @@ public class FoodRecommendService {
         return subjectId;
     }
 
+    /**
+     * 成员视角解析:把 {@code family_members.id}(成员档案ID)解析为 food_recommend_idx 统一键 users.id。
+     *
+     * <p>与 {@link #resolveFoodIndexUserId(Long)} 的差异:该方法语义明确是「成员视角」,持续优先按
+     * family_members 反查(即使该数值在 users 表也有同名 id——users 与 family_members 独立自增,数值高度
+     * 重叠,成长页/食材页以 family_members.id 切换成员)。旧逻辑(resolveFoodIndexUserId)为防
+     * user→member 误归一而优先查 users,导致 memberId 与 users.id 数值重叠时把成员误判为无关用户:
+     * 如家庭72 书宁宁 family_members.id=62(user_id=34)与 users.id=62("用户2712")重叠,
+     * 成员视角查询 62 被误归一为 62 → food_recommend_idx 查 user_id=62 无数据,成长页看不到食材。</p>
+     *
+     * @param memberId family_members.id 或已是 users.id(非成员卡时原样返回,兼容前端直接传 userId)
+     * @return 该成员档案对应登录用户 users.id;memberId 为 null 返回 null;member 无登录账号返回 memberId
+     */
+    public Long resolveMemberViewToUserId(Long memberId) {
+        if (memberId == null) return null;
+        try {
+            // 成员视角:始终优先按 family_members.id 反查登录用户(与 resolveFoodIndexUserId 的 users 优先互补)
+            FamilyMember m = familyMemberMapper.selectById(memberId);
+            if (m != null) {
+                return m.getUserId() != null ? m.getUserId() : memberId;
+            }
+        } catch (Exception e) {
+            log.warn("成员视角解析 food_recommend_idx 键失败 memberId={}: {}", memberId, e.getMessage());
+        }
+        // 非成员卡(前端直接传 users.id 等)→ 原样返回
+        return memberId;
+    }
+
     @Transactional
     public void recalculateForUser(Long userId) {
         // T28 方向1:入参 userId 可能是 family_members.id(报告确认链路 subjectId,经 resolveSubjectToMemberId 归一)
@@ -110,6 +138,17 @@ public class FoodRecommendService {
             refreshFamilyOverviews(writeKey);
             return;
         }
+        // 写键权威化:报告已绑定成员(subject_id 非空 = family_members.id 口径)时,以成员视角解析写入键,
+        // 规避 users 与 family_members.id 数值重叠时 resolveFoodIndexUserId 的 users 优先启发式误判。
+        // 如家庭72 报告63:subject_id=84(CF 成员卡, user_id=27),而 users.id=84 是无关用户"用户1905",
+        // resolveFoodIndexUserId(84) 会把食材错写到 user_id=84;成员视角解析得真实登录用户 27。
+        // subject_id 为空(历史数据/无绑定)时保持原 writeKey 不变,兼容既有行为与单测。
+        if (report.getSubjectId() != null) {
+            Long memberViewKey = resolveMemberViewToUserId(report.getSubjectId());
+            if (memberViewKey != null) {
+                writeKey = memberViewKey;
+            }
+        }
 
         // 优先从 foods 表读取(正常确认流程写入 foods 表);
         // 若 foods 表无数据(审核路径只写 report_food_suitability),回退到 report_food_suitability

+ 29 - 9
cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java

@@ -672,6 +672,12 @@ public class ReportCollectService {
             subjectId = healthReportService.matchFamilyMemberByName(
                     payload.getSummary().getPersonName(), draft.getFamilyId());
         }
+        // 兜底:PDF 未提取到姓名或姓名无法匹配到成员时,回退用户上传时选择的成员(draft.member_id)。
+        // 旧逻辑忽略 draft.memberId 导致 subject_id 落空(报告无成员名、成员视角列表不可见、
+        // 食材推荐无法重算)——家庭72 报告63 案例(PDF 无姓名,draft.member_id=27)。
+        if (subjectId == null && draft.getMemberId() != null) {
+            subjectId = draft.getMemberId();
+        }
 
         HealthReport report = buildReportFromPayload(draft, payload, subjectId);
         List<HealthIndicator> indicators = buildIndicatorsFromPayload(payload);
@@ -696,23 +702,28 @@ public class ReportCollectService {
             }
         }
 
-        if (subjectId != null) {
-            healthReportService.bindReportToMember(created.getId(), subjectId);
+        // 下游统一使用归一化后的成员卡 id(created.subject_id 已由 resolveSubjectToMemberId 归一为
+        // family_members.id 口径),保证 bindReportToMember / 维度刷新 / 食材重算与报告归属一致。
+        // 旧逻辑传原始 subjectId(可能仍是 users.id 语义,如 draft.member_id=27),导致 getLatestReport
+        // 查不到报告(subject_id 已归一为成员卡 84)而误清空食材推荐——家庭72 报告63 案例。
+        Long memberCardId = created.getSubjectId() != null ? created.getSubjectId() : subjectId;
+        if (memberCardId != null) {
+            healthReportService.bindReportToMember(created.getId(), memberCardId);
             try {
-                dimensionScoreService.refreshFromGutReport(subjectId, created.getId());
+                dimensionScoreService.refreshFromGutReport(memberCardId, created.getId());
             } catch (Exception e) {
-                log.warn("刷新7维健康评分失败 memberId={}", subjectId, e);
+                log.warn("刷新7维健康评分失败 memberId={}", memberCardId, e);
             }
             try {
-                healthReportService.refreshMemberIndicatorCurrent(subjectId, created.getId());
-                log.info("已刷新成员{}的指标当前值diff", subjectId);
+                healthReportService.refreshMemberIndicatorCurrent(memberCardId, created.getId());
+                log.info("已刷新成员{}的指标当前值diff", memberCardId);
             } catch (Exception e) {
-                log.warn("刷新指标当前值diff失败 memberId={}", subjectId, e);
+                log.warn("刷新指标当前值diff失败 memberId={}", memberCardId, e);
             }
             try {
-                foodRecommendService.recalculateForUser(subjectId);
+                foodRecommendService.recalculateForUser(memberCardId);
             } catch (Exception e) {
-                log.warn("刷新食材推荐指数失败 memberId={}", subjectId, e);
+                log.warn("刷新食材推荐指数失败 memberId={}", memberCardId, e);
             }
         }
 
@@ -765,6 +776,15 @@ public class ReportCollectService {
             report.setPersonName(s.getPersonName());
             report.setReportDate(parseDate(s.getReportDate()));
         }
+        // personName 回填:PDF 未提取到姓名(空)但已解析出 subject(draft.memberId 兜底)时,
+        // 从成员档案回填昵称,避免报告无成员名(家庭72 报告63 案例)。
+        if ((report.getPersonName() == null || report.getPersonName().trim().isEmpty())
+                && report.getSubjectId() != null) {
+            String subjectName = healthReportService.resolveSubjectName(report.getSubjectId(), draft.getFamilyId());
+            if (subjectName != null && !subjectName.isEmpty()) {
+                report.setPersonName(subjectName);
+            }
+        }
         if (report.getReportDate() == null) {
             report.setReportDate(new Date());
         }

+ 191 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/FoodRecommendMemberViewResolutionTest.java

@@ -0,0 +1,191 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * 回归测试:resolveMemberViewToUserId — 成员视角(family_members.id 语义)解析为 users.id。
+ *
+ * 背景(家庭72 报告问题3):成长页「饮食推荐」以 family_members.id=62 查食材(/api/health/foods?memberId=62)。
+ * users.id=62("用户2712")与 family_members.id=62(书宁宁, user_id=34)数值重叠。
+ * 旧逻辑(resolveFoodIndexUserId)为防 user→member 误归一,优先查 users 表 → 误判 62 为 users.id
+ * → food_recommend_idx 查 user_id=62 无数据(书宁宁食材实际挂 user_id=34)→ 成长页看不到食材。
+ *
+ * 修复:成员视角新增 resolveMemberViewToUserId(语义明确为 family_members.id),始终优先按
+ * family_members 反查其登录用户 users.id,规避 user 视角的 users 优先启发式。
+ */
+class FoodRecommendMemberViewResolutionTest {
+
+    @Mock private FoodRecommendIndexMapper foodRecommendIndexMapper;
+    @Mock private FoodRecommendIdxLogMapper foodRecommendIdxLogMapper;
+    @Mock private ReportFoodSuitabilityMapper reportFoodSuitabilityMapper;
+    @Mock private FamilyMemberMapper familyMemberMapper;
+    @Mock private UserMapper userMapper;
+    @Mock private HealthReportService healthReportService;
+    @Mock private FoodService foodService;
+    @Mock private FamilyFoodOverviewService familyFoodOverviewService;
+
+    private FoodRecommendService service;
+
+    @BeforeEach
+    void setup() throws Exception {
+        MockitoAnnotations.openMocks(this);
+        service = new FoodRecommendService();
+        setField(service, "foodRecommendIndexMapper", foodRecommendIndexMapper);
+        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();
+    }
+
+    private static void setField(Object target, String fieldName, Object value) throws Exception {
+        java.lang.reflect.Field f = FoodRecommendService.class.getDeclaredField(fieldName);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private FamilyMember member(Long id, Long userId, Long familyId) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setUserId(userId);
+        m.setFamilyId(familyId);
+        return m;
+    }
+
+    private User memberUser(Long id) {
+        User u = new User();
+        u.setId(id);
+        u.setNickname("用户" + id);
+        return u;
+    }
+
+    @Test
+    void memberView_familyMember62_resolvesToLoginUserId34() {
+        // 家庭72 书宁宁:family_members.id=62, user_id=34;users.id=62 是无关用户"用户2712"
+        when(familyMemberMapper.selectById(62L))
+                .thenReturn(member(62L, 34L, 72L));
+
+        Long resolved = service.resolveMemberViewToUserId(62L);
+
+        assertEquals(34L, resolved, "成员视角 family_members.id=62 应解析到其登录用户 users.id=34,"
+                + "而非数值重叠的无关用户 users.id=62");
+    }
+
+    @Test
+    void memberView_cfMember84_resolvesToLoginUserId27() {
+        // 家庭72 CF:family_members.id=84, user_id=27
+        when(familyMemberMapper.selectById(84L))
+                .thenReturn(member(84L, 27L, 72L));
+
+        Long resolved = service.resolveMemberViewToUserId(84L);
+
+        assertEquals(27L, resolved);
+    }
+
+    @Test
+    void memberView_notAMemberId_returnsAsIs() {
+        // 非成员卡(例如前端直接传 users.id)→ 原样返回
+        when(familyMemberMapper.selectById(999L)).thenReturn(null);
+
+        Long resolved = service.resolveMemberViewToUserId(999L);
+
+        assertEquals(999L, resolved);
+    }
+
+    @Test
+    void memberView_memberWithoutLoginUser_keepsMemberId() {
+        // 无登录账号的档案成员(user_id=null)→ 保留 member id(与 resolveFoodIndexUserId 语义一致)
+        when(familyMemberMapper.selectById(23L))
+                .thenReturn(member(23L, null, 72L));
+
+        Long resolved = service.resolveMemberViewToUserId(23L);
+
+        assertEquals(23L, resolved);
+    }
+
+    @Test
+    void memberView_null_returnsNull() {
+        assertNull(service.resolveMemberViewToUserId(null));
+    }
+
+    // === recalculateForUser 写侧 writeKey 权威化(家庭72 报告63 案例) ===
+
+    @Test
+    void recalculateForUser_reportSubjectId_authoritativeWriteKeyOverridesCollision() {
+        // 报告63:subject_id=84(CF 成员卡),user_id=27;users.id=84 是无关用户"用户1905"
+        // 旧逻辑 writeKey=resolveFoodIndexUserId(84)=84(users 优先碰撞误判);
+        // 权威化后 writeKey=resolveMemberViewToUserId(84)=27(CF 真实登录用户),食材应写入 user_id=27
+        HealthReport report = new HealthReport();
+        report.setId(63L);
+        report.setUserId(27L);
+        report.setSubjectId(84L);
+        report.setStatus("active");
+
+        when(familyMemberMapper.selectById(84L)).thenReturn(member(84L, 27L, 72L));
+        when(userMapper.selectById(84L)).thenReturn(memberUser(84L));
+        when(healthReportService.getLatestReport(84L)).thenReturn(report);
+
+        // 报告有食材:food id=10 score=5
+        Food food = new Food();
+        food.setId(10L);
+        food.setName("黄豆");
+        food.setScore(5);
+        when(foodService.listByReportId(63L)).thenReturn(java.util.Collections.singletonList(food));
+
+        // 已有索引为空 → 走 insert 分支
+        when(foodRecommendIndexMapper.selectList(any())).thenReturn(new java.util.ArrayList<>());
+        // refreshFamilyOverviews 内部反查成员家庭:返回空列表即可
+        when(familyMemberMapper.selectList(any())).thenReturn(new java.util.ArrayList<>());
+
+        service.recalculateForUser(84L);
+
+        // 验证写入的 FoodRecommendIndex.userId = 27(CF 登录用户),而非碰撞误判的 84
+        // (1个食材且无已有索引 → 恰好 1 次 index insert;另有 1 次 idx-log insert,属不同 mapper)
+        org.mockito.ArgumentCaptor<FoodRecommendIndex> insCap =
+                org.mockito.ArgumentCaptor.forClass(FoodRecommendIndex.class);
+        verify(foodRecommendIndexMapper).insert(insCap.capture());
+        assertEquals(27L, insCap.getValue().getUserId(),
+                "食材写入键应为成员84的登录用户 users.id=27,而非数值重叠的无关用户 users.id=84");
+    }
+
+    private static void installLambdaColumnCache() {
+        try {
+            java.lang.reflect.Field f = com.baomidou.mybatisplus.core.toolkit.LambdaUtils.class.getDeclaredField("COLUMN_CACHE_MAP");
+            f.setAccessible(true);
+            @SuppressWarnings("unchecked")
+            Map<String, Map<String, com.baomidou.mybatisplus.core.toolkit.support.ColumnCache>> cache =
+                    (Map<String, Map<String, com.baomidou.mybatisplus.core.toolkit.support.ColumnCache>>) f.get(null);
+            if (!cache.containsKey(FoodRecommendIndex.class.getName())) {
+                Map<String, com.baomidou.mybatisplus.core.toolkit.support.ColumnCache> cols = new HashMap<>();
+                cols.put("USERID", new com.baomidou.mybatisplus.core.toolkit.support.ColumnCache("user_id", "user_id", null));
+                cols.put("FOODID", new com.baomidou.mybatisplus.core.toolkit.support.ColumnCache("food_id", "food_id", null));
+                cols.put("FOODNAME", new com.baomidou.mybatisplus.core.toolkit.support.ColumnCache("food_name", "food_name", null));
+                cols.put("INDEXSCORE", new com.baomidou.mybatisplus.core.toolkit.support.ColumnCache("index_score", "index_score", null));
+                cols.put("HEALTHREPORTID", new com.baomidou.mybatisplus.core.toolkit.support.ColumnCache("health_report_id", "health_report_id", null));
+                cols.put("CALCULATEDAT", new com.baomidou.mybatisplus.core.toolkit.support.ColumnCache("calculated_at", "calculated_at", null));
+                cols.put("CREATEDAT", new com.baomidou.mybatisplus.core.toolkit.support.ColumnCache("created_at", "created_at", null));
+                cache.put(FoodRecommendIndex.class.getName(), cols);
+            }
+            if (!cache.containsKey(com.etotem.cfc.entity.FamilyMember.class.getName())) {
+                Map<String, com.baomidou.mybatisplus.core.toolkit.support.ColumnCache> cols = new HashMap<>();
+                cols.put("USERID", new com.baomidou.mybatisplus.core.toolkit.support.ColumnCache("user_id", "user_id", null));
+                cols.put("FAMILYID", new com.baomidou.mybatisplus.core.toolkit.support.ColumnCache("family_id", "family_id", null));
+                cache.put(com.etotem.cfc.entity.FamilyMember.class.getName(), cols);
+            }
+        } catch (Exception ignored) {
+        }
+    }
+}