소스 검색

fix(report): 报告列表补家庭隔离,修复解绑时"报告不属于该家庭"

getMemberReports 此前只按 subject_id/user_id 匹配,完全不带 family_id 条件,
因此会把其他家庭的报告带进成员列表。实测家庭72成员返回7条中有5条来自
家庭67/70;小程序照单显示"取消归属",点击后在 reassignReport 权限校验
(报告必须属于该家庭)处抛异常。列表侧与权限侧必须遵守同一条家庭边界。

- getMemberReports 在归一化 id 集合时同步解析成员所在家庭,
  查询追加 family_id = ? 约束;无法解析家庭时返回空列表,
  与 /report/list "无家庭信息返回空列表" 的既有语义一致
- 该方法的另两个调用方(FamilyMemberNutritionProfileService 营养画像)
  同步受益,避免营养画像跨家庭取报告
- 同步修复 HealthReportServiceTest 中 2 条被上次提交破坏的断言
  (旧成员重算已由 refreshFromGutReport 改为 rebuildFromMemberReports;
  pom 全局 skipTests=true 导致当时未被发现)
- 新增 getMemberReports_constrainsToMemberFamily 回归测试

实测 member 21 列表由 7 条收敛为 2 条(均 family_id=72),越界为空;
报告42 解绑/重绑均返回 200 且净状态不变。
Xiaogang Liao 6 일 전
부모
커밋
e399aeabd6

+ 16 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -315,6 +315,7 @@ public class HealthReportService {
         }
         Set<Long> ids = new HashSet<>();
         ids.add(memberId);
+        Long familyId = null;
         try {
             FamilyMember fm = familyMemberMapper.selectById(memberId);
             if (fm != null) {
@@ -322,21 +323,36 @@ public class HealthReportService {
                 if (fm.getUserId() != null) {
                     ids.add(fm.getUserId());
                 }
+                familyId = fm.getFamilyId();
             }
             // memberId 可能是 users.id,反查其关联的 family_members 行
             List<FamilyMember> linked = familyMemberMapper.selectList(
                     new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getUserId, memberId));
             for (FamilyMember item : linked) {
                 ids.add(item.getId());
+                if (familyId == null) {
+                    familyId = item.getFamilyId();
+                }
             }
         } catch (Exception e) {
             log.warn("归一家庭成员ID失败 memberId={}: {}", memberId, e.getMessage());
         }
+
+        // 家庭隔离:报告必须归属该成员所在家庭。
+        // 此前只按 subject_id/user_id 匹配、完全不带 family_id 条件,会把其他家庭的
+        // 报告带进成员列表(实测家庭72成员返回7条中5条越界)。小程序据此显示
+        // "取消归属",点击后在 reassignReport 权限校验处抛"报告不属于该家庭"。
+        // 列表侧必须与权限侧遵守同一条家庭边界。
+        if (familyId == null) {
+            log.warn("无法解析成员所属家庭,返回空报告列表 memberId={}", memberId);
+            return Collections.emptyList();
+        }
         return healthReportMapper.selectList(
                 new LambdaQueryWrapper<HealthReport>()
                         .and(w -> w.in(HealthReport::getSubjectId, ids)
                                 .or(w2 -> w2.isNull(HealthReport::getSubjectId)
                                         .in(HealthReport::getUserId, ids)))
+                        .eq(HealthReport::getFamilyId, familyId)
                         .eq(HealthReport::getStatus, "active")
                         .orderByDesc(HealthReport::getCreatedAt)
         );

+ 52 - 2
cfc-backend/src/test/java/com/etotem/cfc/service/HealthReportServiceTest.java

@@ -29,6 +29,9 @@ public class HealthReportServiceTest {
     @Mock
     private com.etotem.cfc.mapper.HealthReportPermissionMapper healthReportPermissionMapper;
 
+    @Mock
+    private com.etotem.cfc.mapper.FamilyMemberMapper familyMemberMapper;
+
     @Mock
     private DimensionScoreService dimensionScoreService;
 
@@ -43,9 +46,11 @@ public class HealthReportServiceTest {
     @BeforeEach
     public void setUp() {
         MockitoAnnotations.openMocks(this);
+        initMybatisPlusMetadata();
         service = new HealthReportService();
         setField(service, "healthReportMapper", healthReportMapper);
         setField(service, "healthReportPermissionMapper", healthReportPermissionMapper);
+        setField(service, "familyMemberMapper", familyMemberMapper);
         setField(service, "dimensionScoreService", dimensionScoreService);
         setField(service, "foodRecommendService", foodRecommendService);
         setField(service, "jdbcTemplate", jdbcTemplate);
@@ -61,6 +66,20 @@ public class HealthReportServiceTest {
         }
     }
 
+    /**
+     * 纯单测没有 Spring/MyBatis 初始化,LambdaQueryWrapper.getSqlSegment() 解析
+     * @TableField 需要实体元数据缓存,否则抛 can not find lambda cache。
+     * 这里显式注册,使 SQL 片段可被断言。
+     */
+    private void initMybatisPlusMetadata() {
+        org.apache.ibatis.builder.MapperBuilderAssistant assistant =
+                new org.apache.ibatis.builder.MapperBuilderAssistant(
+                        new com.baomidou.mybatisplus.core.MybatisConfiguration(), "");
+        assistant.setCurrentNamespace("health-report-service-test");
+        com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
+                assistant, HealthReport.class);
+    }
+
     private HealthReport report(Long id, Long familyId, Long subjectId, String status) {
         HealthReport r = new HealthReport();
         r.setId(id);
@@ -91,7 +110,8 @@ public class HealthReportServiceTest {
 
         verify(jdbcTemplate).update(eq("UPDATE health_reports SET subject_id = ? WHERE id = ?"),
                 eq(null), eq(1L));
-        verify(dimensionScoreService).refreshFromGutReport(eq(20L), eq(1L));
+        verify(dimensionScoreService).rebuildFromMemberReports(eq(20L));
+        verify(dimensionScoreService, never()).refreshFromGutReport(eq(20L), anyLong());
         verify(foodRecommendService).recalculateForUser(eq(20L));
     }
 
@@ -115,7 +135,7 @@ public class HealthReportServiceTest {
 
         verify(jdbcTemplate).update(eq("UPDATE health_reports SET subject_id = ? WHERE id = ?"),
                 eq(30L), eq(1L));
-        verify(dimensionScoreService).refreshFromGutReport(eq(20L), eq(1L));
+        verify(dimensionScoreService).rebuildFromMemberReports(eq(20L));
         verify(dimensionScoreService).refreshFromGutReport(eq(30L), eq(1L));
         verify(foodRecommendService).recalculateForUser(eq(20L));
         verify(foodRecommendService).recalculateForUser(eq(30L));
@@ -140,6 +160,36 @@ public class HealthReportServiceTest {
         verify(jdbcTemplate, never()).update(anyString(), any(), anyLong());
     }
 
+    // ── getMemberReports:家庭隔离 ─────────────────────────────────────
+
+    /**
+     * 回归:getMemberReports 曾只按 subject_id/user_id 匹配,完全不带 family_id 条件,
+     * 导致家庭72的成员列表混入家庭67/70的报告(实测 7 条中 5 条越界)。
+     * 小程序照单显示"取消归属",点击后在 reassignReport 权限校验处抛
+     * "报告不属于该家庭"。列表侧必须与权限侧保持同一条家庭边界。
+     */
+    @Test
+    void getMemberReports_constrainsToMemberFamily() {
+        com.etotem.cfc.entity.FamilyMember fm = new com.etotem.cfc.entity.FamilyMember();
+        fm.setId(21L);
+        fm.setFamilyId(72L);
+        fm.setUserId(12L);
+        when(familyMemberMapper.selectById(21L)).thenReturn(fm);
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(healthReportMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        service.getMemberReports(21L);
+
+        org.mockito.ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper> captor =
+            org.mockito.ArgumentCaptor.forClass(
+                com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class);
+        verify(healthReportMapper).selectList(captor.capture());
+        String sql = captor.getValue().getSqlSegment();
+        assertTrue(sql.contains("family_id"), "getMemberReports 必须限定 family_id,实际SQL: " + sql);
+        assertTrue(captor.getValue().getParamNameValuePairs().containsValue(72L),
+            "查询条件必须携带成员所在家庭ID 72,实际: " + captor.getValue().getParamNameValuePairs());
+    }
+
     // ── getUnlinkedReports ──────────────────────────────────────────────
 
     @Test