|
|
@@ -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);
|
|
|
+ }
|
|
|
+}
|