Browse Source

feat(health.dataSource.bridgeWrite): S-cfc-130 GAP-A 桥接实现 - HealthDataSourceBridgeService 五维消费链路 + 同步引擎同事务接线 + DEVICE 注释 + 死注入解除

Sisyphus Agent 3 days ago
parent
commit
47d203981

+ 174 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthDataSourceBridgeService.java

@@ -0,0 +1,174 @@
+package com.etotem.cfc.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.etotem.cfc.entity.HealthDataSourceRecord;
+import com.etotem.cfc.entity.HealthDimensionScore;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.sql.Date;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * D/X 域(华为/小米运动健康)同步数据桥接服务 —— 能力键 {@code health.dataSource.bridgeWrite}(GAP-A 补建)。
+ *
+ * <p>设计契约:{@code docs/superpowers/specs/2026-11-08-dx-health-bridge.md}</p>
+ * <ul>
+ *   <li>§3.1 字段映射:{@code health_data_sources} → {@code health_dimension_scores}——
+ *       {@code member_id} 直取;{@code dimension} 由 {@code dimension_codes} 切分、剥离 {@code body.} 前缀且仅
+ *       ∈ {growth,sleep,vision,immunity,nutrition,gut,exercise}(未知码丢弃);{@code score}/{@code percentile}
+ *       直取 {@code parsed_result.normalized.*};{@code data_source} = {@code DEVICE}(§6 ①);{@code tier} = 2
+ *       (§6 ②);{@code raw_data} 直存整条 parsed_result;{@code assess_date} 取指标采集点日期。</li>
+ *   <li>§3.2 触发点 A(§6 ③ 已拍板:同步内事务桥接)——由
+ *       {@code HealthSyncServiceImpl}/{@code XiaomiHealthServiceImpl} 在 {@code saveRecord} 后同事务调用。</li>
+ *   <li>防负向回归(§3.1):写行前读取既有最新分,既有行 tier 更高(数值更小,如 REPORT tier=1)时跳过不覆盖。</li>
+ *   <li>同日多行「读最新覆盖」语义由读侧 {@code getLatest}(created_at DESC LIMIT 1)保证(§6 ④ 不加唯一索引)。</li>
+ * </ul>
+ */
+@Slf4j
+@Service
+public class HealthDataSourceBridgeService {
+
+    /** §6 ① 已拍板:D/X 设备采集 data_source = DEVICE */
+    private static final String SOURCE_DEVICE = "DEVICE";
+
+    /** §6 ② 已拍板:D/X 设备自动采集可信度 tier = 2(AI 分析级) */
+    private static final int TIER_DEVICE = 2;
+
+    /** §3.1 合法 body 子维(剥离 body. 前缀后),未知码丢弃 */
+    private static final Set<String> VALID_DIMENSIONS = new HashSet<String>(Arrays.asList(
+            "growth", "sleep", "vision", "immunity", "nutrition", "gut", "exercise"));
+
+    @Resource
+    private HealthDimensionScoreService dimensionScoreService;
+
+    /**
+     * 契约入口:批量桥接(触发点 A 同批写入,§3.2/§6 ③)。同批记录按序写入,
+     * 同日多行「读最新覆盖」由读侧 {@code getLatest}(created_at DESC LIMIT 1)保证。
+     */
+    @Transactional
+    public void bridge(List<HealthDataSourceRecord> records) {
+        if (records == null || records.isEmpty()) {
+            return;
+        }
+        for (HealthDataSourceRecord record : records) {
+            bridge(record);
+        }
+    }
+
+    /** 单条桥接重载(同步引擎 saveRecord 后同事务调用)。 */
+    public void bridge(HealthDataSourceRecord record) {
+        if (record == null || record.getMemberId() == null) {
+            return;
+        }
+        List<String> dimensions = extractDimensions(record.getDimensionCodes());
+        if (dimensions.isEmpty()) {
+            return;
+        }
+        JSONObject parsed = parseParsedResult(record.getParsedResult());
+        if (parsed == null) {
+            return;
+        }
+        Integer score = normalizedScore(parsed);
+        Integer percentile = normalizedPercentile(parsed);
+        Date assessDate = collectDate(parsed);
+        for (String dimension : dimensions) {
+            writeIfHigherPriority(record, dimension, score, percentile, assessDate);
+        }
+    }
+
+    // ==================== 内部实现 ====================
+
+    /** §3.1 维度码切分 + 剥离 body. 前缀 + 合法集过滤(未知码丢弃,可多行展开如 body_weight)。 */
+    private List<String> extractDimensions(String dimensionCodes) {
+        List<String> result = new ArrayList<String>();
+        if (dimensionCodes == null || dimensionCodes.isEmpty()) {
+            return result;
+        }
+        for (String code : dimensionCodes.split(",")) {
+            String trimmed = code == null ? "" : code.trim();
+            if (trimmed.isEmpty()) {
+                continue;
+            }
+            String dim = trimmed.startsWith("body.") ? trimmed.substring("body.".length()) : trimmed;
+            if (VALID_DIMENSIONS.contains(dim)) {
+                result.add(dim);
+            }
+        }
+        return result;
+    }
+
+    /** 解析 parsed_result JSON;非法 JSON 返回 null(跳过本记录,不阻断同步)。 */
+    private JSONObject parseParsedResult(String parsedResult) {
+        if (parsedResult == null || parsedResult.isEmpty()) {
+            return null;
+        }
+        try {
+            return JSON.parseObject(parsedResult);
+        } catch (Exception e) {
+            log.warn("health.dataSource.bridgeWrite 解析 parsed_result 失败,跳过桥接: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    /** score 直取 parsed_result.normalized.score(0-100 统一量纲)。 */
+    private Integer normalizedScore(JSONObject parsed) {
+        JSONObject normalized = parsed.getJSONObject("normalized");
+        return normalized == null ? null : normalized.getInteger("score");
+    }
+
+    /** percentile 直取 parsed_result.normalized.percentile(可空)。 */
+    private Integer normalizedPercentile(JSONObject parsed) {
+        JSONObject normalized = parsed.getJSONObject("normalized");
+        return normalized == null ? null : normalized.getInteger("percentile");
+    }
+
+    /** assess_date 取指标采集点日期(collectDate,yyyy-MM-dd)。 */
+    private Date collectDate(JSONObject parsed) {
+        String collectDate = parsed.getString("collectDate");
+        if (collectDate == null || collectDate.isEmpty()) {
+            return null;
+        }
+        try {
+            return Date.valueOf(collectDate);
+        } catch (IllegalArgumentException e) {
+            log.warn("health.dataSource.bridgeWrite collectDate 非法: {}", collectDate);
+            return null;
+        }
+    }
+
+    /** 写行判据:既有行 tier 更高(数值更小,如 REPORT tier=1)时不得覆盖(防负向回归,§3.1)。 */
+    private void writeIfHigherPriority(HealthDataSourceRecord record, String dimension,
+                                       Integer score, Integer percentile, Date assessDate) {
+        HealthDimensionScore existing = null;
+        try {
+            existing = dimensionScoreService.getLatestScore(record.getMemberId(), dimension);
+        } catch (Exception e) {
+            log.warn("health.dataSource.bridgeWrite 读取既有最新分失败,跳过维度 {}: {}", dimension, e.getMessage());
+            return;
+        }
+        if (existing != null && existing.getTier() != null && existing.getTier().intValue() < TIER_DEVICE) {
+            log.debug("health.dataSource.bridgeWrite 跳过覆盖维度 {}:既有行 tier={} 高于 DEVICE(tier={})",
+                    dimension, existing.getTier(), TIER_DEVICE);
+            return;
+        }
+
+        HealthDimensionScore target = new HealthDimensionScore();
+        target.setMemberId(record.getMemberId());
+        target.setDimension(dimension);
+        target.setScore(score);
+        target.setPercentile(percentile);
+        target.setDataSource(SOURCE_DEVICE);
+        target.setTier(Integer.valueOf(TIER_DEVICE));
+        target.setRawData(record.getParsedResult());
+        target.setAssessDate(assessDate);
+        dimensionScoreService.saveScore(target);
+    }
+}

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/huawei/impl/HealthSyncServiceImpl.java

@@ -5,6 +5,7 @@ import com.etotem.cfc.entity.HealthDataSourceRecord;
 import com.etotem.cfc.entity.HuaweiHealthToken;
 import com.etotem.cfc.entity.HuaweiHealthToken;
 import com.etotem.cfc.mapper.HuaweiHealthTokenMapper;
 import com.etotem.cfc.mapper.HuaweiHealthTokenMapper;
 import com.etotem.cfc.service.HealthDataSourceRecordService;
 import com.etotem.cfc.service.HealthDataSourceRecordService;
+import com.etotem.cfc.service.HealthDataSourceBridgeService;
 import com.etotem.cfc.service.attribution.model.IndicatorReading;
 import com.etotem.cfc.service.attribution.model.IndicatorReading;
 import com.etotem.cfc.service.attribution.model.NormEntry;
 import com.etotem.cfc.service.attribution.model.NormEntry;
 import com.etotem.cfc.service.huawei.HuaweiHealthApiClient;
 import com.etotem.cfc.service.huawei.HuaweiHealthApiClient;
@@ -15,6 +16,7 @@ import com.etotem.cfc.service.huawei.HealthSyncService;
 import com.etotem.cfc.service.huawei.SyncResult;
 import com.etotem.cfc.service.huawei.SyncResult;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import java.math.BigDecimal;
 import java.math.BigDecimal;
@@ -49,6 +51,13 @@ public class HealthSyncServiceImpl implements HealthSyncService {
     @Resource
     @Resource
     private com.etotem.cfc.service.attribution.NormCompareEngine normCompareEngine;
     private com.etotem.cfc.service.attribution.NormCompareEngine normCompareEngine;
 
 
+    /**
+     * GAP-A 桥接服务(能力键 health.dataSource.bridgeWrite,§6 ③ 触发点 A):
+     * saveRecord 后同事务写入 health_dimension_scores,使 /api/dimension/overview 读到 D/X 得分。
+     */
+    @Resource
+    private HealthDataSourceBridgeService healthDataSourceBridgeService;
+
     /** 每条记录保留的最近数据点上限(防止 parsedResult 过大) */
     /** 每条记录保留的最近数据点上限(防止 parsedResult 过大) */
     private static final int MAX_RAW_POINTS = 20;
     private static final int MAX_RAW_POINTS = 20;
 
 
@@ -84,6 +93,7 @@ public class HealthSyncServiceImpl implements HealthSyncService {
     }
     }
 
 
     @Override
     @Override
+    @Transactional
     public SyncResult sync(Long memberId, String dataType) {
     public SyncResult sync(Long memberId, String dataType) {
         SyncResult result = new SyncResult();
         SyncResult result = new SyncResult();
         result.setDataType(dataType);
         result.setDataType(dataType);
@@ -189,6 +199,8 @@ public class HealthSyncServiceImpl implements HealthSyncService {
         record.setConfidence(confidence);
         record.setConfidence(confidence);
         record.setCreatedAt(new Date());
         record.setCreatedAt(new Date());
         dataSourceRecordService.saveRecord(record);
         dataSourceRecordService.saveRecord(record);
+        // GAP-A 桥接:saveRecord 后同事务写入 health_dimension_scores(触发点 A,§6 ③)
+        healthDataSourceBridgeService.bridge(record);
         result.setStoredCount(1);
         result.setStoredCount(1);
 
 
         result.setSuccess(true);
         result.setSuccess(true);

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

@@ -7,7 +7,6 @@ import com.etotem.cfc.dto.DimensionNode;
 import com.etotem.cfc.service.DimensionRegistryService;
 import com.etotem.cfc.service.DimensionRegistryService;
 import com.etotem.cfc.service.DimensionCodeMapper;
 import com.etotem.cfc.service.DimensionCodeMapper;
 import com.etotem.cfc.entity.HealthDimensionScore;
 import com.etotem.cfc.entity.HealthDimensionScore;
-import com.etotem.cfc.entity.HealthDataSourceRecord;
 import com.etotem.cfc.entity.HealthGutFlora;
 import com.etotem.cfc.entity.HealthGutFlora;
 import com.etotem.cfc.entity.HealthIndicator;
 import com.etotem.cfc.entity.HealthIndicator;
 import com.etotem.cfc.entity.HealthDiseaseRisk;
 import com.etotem.cfc.entity.HealthDiseaseRisk;
@@ -22,7 +21,6 @@ import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.service.DimensionScoreService;
 import com.etotem.cfc.service.DimensionScoreService;
 import com.etotem.cfc.service.HealthDimensionScoreService;
 import com.etotem.cfc.service.HealthDimensionScoreService;
 import com.etotem.cfc.service.HealthReportService;
 import com.etotem.cfc.service.HealthReportService;
-import com.etotem.cfc.service.HealthDataSourceRecordService;
 import com.etotem.cfc.entity.DanAssessmentResult;
 import com.etotem.cfc.entity.DanAssessmentResult;
 import com.etotem.cfc.mapper.DanAssessmentResultMapper;
 import com.etotem.cfc.mapper.DanAssessmentResultMapper;
 import com.etotem.cfc.service.DimensionEnergySyncService;
 import com.etotem.cfc.service.DimensionEnergySyncService;
@@ -69,9 +67,6 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
     @Lazy
     @Lazy
     private HealthReportService healthReportService;
     private HealthReportService healthReportService;
 
 
-    @Resource
-    private HealthDataSourceRecordService dataSourceRecordService;
-
     @Resource
     @Resource
     private DimensionEnergySyncService dimensionEnergySyncService;
     private DimensionEnergySyncService dimensionEnergySyncService;
 
 

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/xiaomi/impl/XiaomiHealthServiceImpl.java

@@ -3,6 +3,7 @@ package com.etotem.cfc.service.xiaomi.impl;
 import com.alibaba.fastjson.JSONObject;
 import com.alibaba.fastjson.JSONObject;
 import com.etotem.cfc.entity.HealthDataSourceRecord;
 import com.etotem.cfc.entity.HealthDataSourceRecord;
 import com.etotem.cfc.service.HealthDataSourceRecordService;
 import com.etotem.cfc.service.HealthDataSourceRecordService;
+import com.etotem.cfc.service.HealthDataSourceBridgeService;
 import com.etotem.cfc.service.XiaomiHealthTokenService;
 import com.etotem.cfc.service.XiaomiHealthTokenService;
 import com.etotem.cfc.service.attribution.model.IndicatorReading;
 import com.etotem.cfc.service.attribution.model.IndicatorReading;
 import com.etotem.cfc.service.attribution.model.NormEntry;
 import com.etotem.cfc.service.attribution.model.NormEntry;
@@ -13,6 +14,7 @@ import com.etotem.cfc.service.xiaomi.XiaomiHealthService;
 import com.etotem.cfc.service.xiaomi.XiaomiSyncResult;
 import com.etotem.cfc.service.xiaomi.XiaomiSyncResult;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import java.math.BigDecimal;
 import java.math.BigDecimal;
@@ -48,6 +50,13 @@ public class XiaomiHealthServiceImpl implements XiaomiHealthService {
     @Resource
     @Resource
     private com.etotem.cfc.service.attribution.NormCompareEngine normCompareEngine;
     private com.etotem.cfc.service.attribution.NormCompareEngine normCompareEngine;
 
 
+    /**
+     * GAP-A 桥接服务(能力键 health.dataSource.bridgeWrite,§6 ③ 触发点 A):
+     * saveRecord 后同事务写入 health_dimension_scores,使 /api/dimension/overview 读到 D/X 得分。
+     */
+    @Resource
+    private HealthDataSourceBridgeService healthDataSourceBridgeService;
+
     /** 每条记录保留的最近数据点上限(防止 parsedResult 过大) */
     /** 每条记录保留的最近数据点上限(防止 parsedResult 过大) */
     private static final int MAX_RAW_POINTS = 20;
     private static final int MAX_RAW_POINTS = 20;
 
 
@@ -83,6 +92,7 @@ public class XiaomiHealthServiceImpl implements XiaomiHealthService {
     }
     }
 
 
     @Override
     @Override
+    @Transactional
     public XiaomiSyncResult sync(Long memberId, String dataType) {
     public XiaomiSyncResult sync(Long memberId, String dataType) {
         XiaomiSyncResult result = new XiaomiSyncResult();
         XiaomiSyncResult result = new XiaomiSyncResult();
         result.setDataType(dataType);
         result.setDataType(dataType);
@@ -188,6 +198,8 @@ public class XiaomiHealthServiceImpl implements XiaomiHealthService {
         record.setConfidence(confidence);
         record.setConfidence(confidence);
         record.setCreatedAt(new Date());
         record.setCreatedAt(new Date());
         dataSourceRecordService.saveRecord(record);
         dataSourceRecordService.saveRecord(record);
+        // GAP-A 桥接:saveRecord 后同事务写入 health_dimension_scores(触发点 A,§6 ③)
+        healthDataSourceBridgeService.bridge(record);
         result.setStoredCount(1);
         result.setStoredCount(1);
 
 
         result.setSuccess(true);
         result.setSuccess(true);

+ 1 - 1
cfc-backend/src/main/resources/schema.sql

@@ -4002,7 +4002,7 @@ CREATE TABLE IF NOT EXISTS health_dimension_scores (
     dimension VARCHAR(20) NOT NULL COMMENT '维度编码: growth/sleep/vision/immunity/nutrition/gut/exercise',
     dimension VARCHAR(20) NOT NULL COMMENT '维度编码: growth/sleep/vision/immunity/nutrition/gut/exercise',
     score INT NOT NULL COMMENT '评分(0-100)',
     score INT NOT NULL COMMENT '评分(0-100)',
     percentile INT COMMENT '百分位',
     percentile INT COMMENT '百分位',
-    data_source VARCHAR(20) COMMENT '数据来源: MANUAL/REPORT/PHOTO/VOICE/AVERAGE',
+    data_source VARCHAR(20) COMMENT '数据来源: MANUAL/REPORT/PHOTO/VOICE/AVERAGE/DEVICE',
     tier INT DEFAULT 3 COMMENT '数据可信度层级: 1=最高(报告) 2=中(AI分析) 3=基础(手动)',
     tier INT DEFAULT 3 COMMENT '数据可信度层级: 1=最高(报告) 2=中(AI分析) 3=基础(手动)',
     raw_data TEXT COMMENT '原始数据JSON',
     raw_data TEXT COMMENT '原始数据JSON',
     assess_date DATE COMMENT '评估日期',
     assess_date DATE COMMENT '评估日期',