فهرست منبع

fix(health): 修复报告改绑维度评分重复主键与脏数据残留

改绑/解绑时 refreshFromGutReport 无条件 insert,而
health_dimension_scores 上有 uk_member_dim_date 唯一键,
同一成员重复刷新即抛 Duplicate entry 导致整个改绑事务回滚。

- saveScore 改为按 (member_id, dimension, assess_date) upsert,
  使 10 处 refreshFromGutReport 调用点可重复执行
- 新增 rebuildFromMemberReports:清除成员全部 REPORT 源维度分后,
  按其当前绑定的有效报告重新派生
- reassignReport 及报告编辑/解绑三处旧成员调用改用重建,
  此前是拿已不属于自己的报告再插一遍,既残留脏数据又撞唯一键
- 新增 getMemberBoundReports:严格按 subject_id 过滤,
  避免 getMemberReports 带出 subject_id 为空的未绑定报告

实测解绑->重绑后评分与基线完全一致,无重复键。
Xiaogang Liao 6 روز پیش
والد
کامیت
606f12eb53

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthDimensionScoreMapper.java

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.etotem.cfc.entity.HealthDimensionScore;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Delete;
 import org.apache.ibatis.annotations.Select;
 import java.util.List;
 
@@ -17,6 +18,15 @@ public interface HealthDimensionScoreMapper extends BaseMapper<HealthDimensionSc
         "WHERE h.member_id = #{memberId}")
     List<HealthDimensionScore> selectLatestByMember(@Param("memberId") Long memberId);
 
+    @Select("SELECT * FROM health_dimension_scores WHERE member_id = #{memberId} " +
+        "AND dimension = #{dimension} AND assess_date = #{assessDate} LIMIT 1")
+    HealthDimensionScore selectByMemberDimDate(@Param("memberId") Long memberId,
+        @Param("dimension") String dimension, @Param("assessDate") java.util.Date assessDate);
+
+    @Delete("DELETE FROM health_dimension_scores WHERE member_id = #{memberId} " +
+        "AND data_source = 'REPORT'")
+    int deleteReportScoresByMember(@Param("memberId") Long memberId);
+
     @Select("SELECT * FROM health_dimension_scores WHERE member_id = #{memberId} " +
         "AND dimension = #{dimension} ORDER BY assess_date DESC LIMIT 1")
     HealthDimensionScore selectLatest(@Param("memberId") Long memberId, @Param("dimension") String dimension);

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DimensionScoreService.java

@@ -14,6 +14,13 @@ public interface DimensionScoreService {
 
     void refreshFromGutReport(Long memberId, Long reportId);
 
+    /**
+     * 按成员当前绑定的有效报告重建全部 REPORT 来源维度分(幂等)。
+     * 用于报告解绑/改绑:先清除该成员全部报告派生分,再按其现有绑定报告重新派生,
+     * 从而真正移除已迁出报告对该成员的影响。
+     */
+    void rebuildFromMemberReports(Long memberId);
+
     /**
      * 根据 DAN 测评报告更新维度分数
      * @param memberId 家庭成员ID

+ 21 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -1240,7 +1240,7 @@ public class HealthReportService {
         
         // 2. 重算旧成员维度(如果原来有绑定)
         if (oldSubjectId != null) {
-            dimensionScoreService.refreshFromGutReport(oldSubjectId, reportId);
+            dimensionScoreService.rebuildFromMemberReports(oldSubjectId);
             foodRecommendService.recalculateForUser(oldSubjectId);
             log.info("已重算旧归属成员 {} 的五维能量与食材推荐", oldSubjectId);
         }
@@ -1262,6 +1262,24 @@ public class HealthReportService {
      * @param familyId 家庭ID
      * @return 未绑定归属的报告列表
      */
+    /**
+     * 获取严格绑定到该成员的有效报告(不含 subject_id 为空的未绑定报告)。
+     * 用于重建 REPORT 来源维度分——与 getMemberReports 语义不同,
+     * 后者会带出 user_id 匹配的未绑定报告,不适合参与维度分派生。
+     */
+    public List<HealthReport> getMemberBoundReports(Long memberId) {
+        if (memberId == null) {
+            return Collections.emptyList();
+        }
+        return healthReportMapper.selectList(
+                new LambdaQueryWrapper<HealthReport>()
+                        .eq(HealthReport::getSubjectId, memberId)
+                        .eq(HealthReport::getStatus, "active")
+                        .orderByAsc(HealthReport::getReportDate)
+                        .orderByAsc(HealthReport::getId)
+        );
+    }
+
     public List<HealthReport> getUnlinkedReports(Long familyId) {
         return healthReportMapper.selectList(
                 new LambdaQueryWrapper<HealthReport>()
@@ -1808,7 +1826,7 @@ public class HealthReportService {
             // 若有旧成员(解绑或切换成员),也需重算旧成员的维度
             if (oldSubjectId != null && !oldSubjectId.equals(subjectId)) {
                 try {
-                    dimensionScoreService.refreshFromGutReport(oldSubjectId, reportId);
+                    dimensionScoreService.rebuildFromMemberReports(oldSubjectId);
                     log.info("编辑后已重算旧成员{}的7维健康评分(成员已切换)", oldSubjectId);
                 } catch (Exception e) {
                     log.warn("刷新旧成员{}健康评分失败: {}", oldSubjectId, e.getMessage());
@@ -1822,7 +1840,7 @@ public class HealthReportService {
         } else if (subjectId == null && oldSubjectId != null) {
             // 用户主动解绑(subjectId=null),重算旧成员(移除该报告的影响)
             try {
-                dimensionScoreService.refreshFromGutReport(oldSubjectId, reportId);
+                dimensionScoreService.rebuildFromMemberReports(oldSubjectId);
                 log.info("解绑成员后已重算旧成员{}的7维健康评分", oldSubjectId);
             } catch (Exception e) {
                 log.warn("刷新旧成员{}健康评分失败: {}", oldSubjectId, e.getMessage());

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/DimensionScoreServiceImpl.java

@@ -12,6 +12,7 @@ import com.etotem.cfc.entity.HealthNormReference;
 import com.etotem.cfc.entity.CognitiveNorm;
 import com.etotem.cfc.entity.HealthReport;
 import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.HealthDimensionScoreMapper;
 import com.etotem.cfc.mapper.HealthNormReferenceMapper;
 import com.etotem.cfc.mapper.CognitiveNormMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
@@ -50,6 +51,9 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
     @Resource
     private HealthDimensionScoreService scoreService;
 
+    @Resource
+    private HealthDimensionScoreMapper scoreMapper;
+
     @Resource
     private HealthNormReferenceMapper normMapper;
 
@@ -346,7 +350,23 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
     public void refreshFromGutReport(Long memberId, Long reportId) {
         HealthReport report = healthReportService.getReportById(reportId);
         if (report == null) return;
+        applyReportScores(memberId, report, reportId);
+    }
+
+    @Override
+    @Transactional
+    public void rebuildFromMemberReports(Long memberId) {
+        if (memberId == null) return;
+        int removed = scoreMapper.deleteReportScoresByMember(memberId);
+        List<HealthReport> reports = healthReportService.getMemberBoundReports(memberId);
+        for (HealthReport r : reports) {
+            applyReportScores(memberId, r, r.getId());
+        }
+        log.info("重建成员{}的 REPORT 维度分: 清除{}条, 按{}份绑定报告重新派生",
+            memberId, removed, reports.size());
+    }
 
+    private void applyReportScores(Long memberId, HealthReport report, Long reportId) {
         List<HealthGutFlora> gutFlora = healthReportService.getGutFloraByReportId(reportId);
         List<HealthIndicator> indicators = healthReportService.getReportIndicators(reportId);
         List<HealthDiseaseRisk> diseaseRisks = healthReportService.getDiseaseRisksByReportId(reportId);

+ 11 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthDimensionScoreServiceImpl.java

@@ -17,7 +17,17 @@ public class HealthDimensionScoreServiceImpl implements HealthDimensionScoreServ
     @Override
     @Transactional
     public void saveScore(HealthDimensionScore score) {
-        scoreMapper.insert(score);
+        // uk_member_dim_date 为 (member_id, dimension, assess_date) 唯一键。
+        // refreshFromGutReport 存在 10 处调用且可能被重复执行(改绑/编辑重算/补录),
+        // 原来纯 insert 第二次必然抛 Duplicate entry,导致整个改绑事务回滚。
+        HealthDimensionScore existing = scoreMapper.selectByMemberDimDate(
+            score.getMemberId(), score.getDimension(), score.getAssessDate());
+        if (existing != null) {
+            score.setId(existing.getId());
+            scoreMapper.updateById(score);
+        } else {
+            scoreMapper.insert(score);
+        }
     }
 
     @Override