|
|
@@ -0,0 +1,248 @@
|
|
|
+package com.etotem.cfc.service.huawei.impl;
|
|
|
+
|
|
|
+import com.alibaba.fastjson.JSONObject;
|
|
|
+import com.etotem.cfc.entity.HealthDataSourceRecord;
|
|
|
+import com.etotem.cfc.entity.HuaweiHealthToken;
|
|
|
+import com.etotem.cfc.mapper.HuaweiHealthTokenMapper;
|
|
|
+import com.etotem.cfc.service.HealthDataSourceRecordService;
|
|
|
+import com.etotem.cfc.service.attribution.model.IndicatorReading;
|
|
|
+import com.etotem.cfc.service.attribution.model.NormEntry;
|
|
|
+import com.etotem.cfc.service.huawei.HuaweiHealthApiClient;
|
|
|
+import com.etotem.cfc.service.huawei.HuaweiHealthDataPoint;
|
|
|
+import com.etotem.cfc.service.huawei.HuaweiHealthDataType;
|
|
|
+import com.etotem.cfc.service.huawei.HuaweiHealthTokenCrypto;
|
|
|
+import com.etotem.cfc.service.huawei.HealthSyncService;
|
|
|
+import com.etotem.cfc.service.huawei.SyncResult;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 华为运动健康数据同步引擎实现(D3)。
|
|
|
+ *
|
|
|
+ * <p>按数据类型拉取数据,复用 {@code NormCompareEngine} 百分位带归一化,
|
|
|
+ * 映射体.* 子维入库 {@code health_data_sources}(sourceType='huawei_health')。</p>
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class HealthSyncServiceImpl implements HealthSyncService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private HuaweiHealthApiClient huaweiHealthApiClient;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private HuaweiHealthTokenMapper tokenMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private HuaweiHealthTokenCrypto tokenCrypto;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private HealthDataSourceRecordService dataSourceRecordService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private com.etotem.cfc.service.attribution.NormCompareEngine normCompareEngine;
|
|
|
+
|
|
|
+ /** 每条记录保留的最近数据点上限(防止 parsedResult 过大) */
|
|
|
+ private static final int MAX_RAW_POINTS = 20;
|
|
|
+
|
|
|
+ /** 归一化置信度:有数据则 0.9,常模缺失时下调至 0.6 */
|
|
|
+ private static final BigDecimal CONFIDENCE_NORMAL = new BigDecimal("0.90");
|
|
|
+ private static final BigDecimal CONFIDENCE_LOW = new BigDecimal("0.60");
|
|
|
+
|
|
|
+ // ==================== 各数据类型默认百分位常模(成人) ====================
|
|
|
+ // 若后续接入 health_norm_reference / attribution_norm_values,此处可替换为 DB 常模。
|
|
|
+ private static final Map<String, double[]> DEFAULT_NORMS = new LinkedHashMap<>();
|
|
|
+ static {
|
|
|
+ // STEP(步数):P5/P25/P50/P75/P95
|
|
|
+ DEFAULT_NORMS.put("step", new double[]{3000, 5000, 7000, 10000, 15000});
|
|
|
+ // DISTANCE(距离,km)
|
|
|
+ DEFAULT_NORMS.put("distance", new double[]{2.0, 3.5, 5.0, 7.5, 12.0});
|
|
|
+ // HEART_RATE(心率,bpm,静息)
|
|
|
+ DEFAULT_NORMS.put("heart_rate", new double[]{50.0, 58.0, 65.0, 75.0, 90.0});
|
|
|
+ // SLEEP(睡眠,小时)
|
|
|
+ DEFAULT_NORMS.put("sleep", new double[]{4.5, 5.5, 6.5, 7.5, 8.5});
|
|
|
+ // BODY_WEIGHT:按 BMI(kg/m²)归一化,mock 返回 auxiliaryValue=BMI
|
|
|
+ DEFAULT_NORMS.put("body_weight", new double[]{18.5, 20.0, 22.0, 24.0, 26.0});
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 百分位带 → 得分映射(0-100 统一量纲) ====================
|
|
|
+ private static final Map<String, Integer> BAND_SCORE = new LinkedHashMap<>();
|
|
|
+ static {
|
|
|
+ BAND_SCORE.put(IndicatorReading.BAND_VERY_LOW, 20);
|
|
|
+ BAND_SCORE.put(IndicatorReading.BAND_LOW, 40);
|
|
|
+ BAND_SCORE.put(IndicatorReading.BAND_MID, 65);
|
|
|
+ BAND_SCORE.put(IndicatorReading.BAND_HIGH, 80);
|
|
|
+ BAND_SCORE.put(IndicatorReading.BAND_VERY_HIGH, 90);
|
|
|
+ BAND_SCORE.put(IndicatorReading.BAND_UNKNOWN, 50);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public SyncResult sync(Long memberId, String dataType) {
|
|
|
+ SyncResult result = new SyncResult();
|
|
|
+ result.setDataType(dataType);
|
|
|
+
|
|
|
+ HuaweiHealthDataType type = HuaweiHealthDataType.of(dataType);
|
|
|
+ if (type == null) {
|
|
|
+ result.setSuccess(false);
|
|
|
+ result.setError("不支持的数据类型: " + dataType);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 权限/授权校验:仅已绑定成员可拉取
|
|
|
+ if (!isBound(memberId)) {
|
|
|
+ result.setSuccess(false);
|
|
|
+ result.setError("成员未绑定华为账号,请先获取授权");
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ List<HuaweiHealthDataPoint> points;
|
|
|
+ try {
|
|
|
+ points = huaweiHealthApiClient.fetchByType(memberId, dataType);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("华为健康数据拉取失败 memberId={} type={}: {}", memberId, dataType, e.getMessage(), e);
|
|
|
+ result.setSuccess(false);
|
|
|
+ result.setError("拉取失败: " + e.getMessage());
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ result.setPulledCount(points == null ? 0 : points.size());
|
|
|
+
|
|
|
+ if (points == null || points.isEmpty()) {
|
|
|
+ result.setSuccess(true);
|
|
|
+ result.setStoredCount(0);
|
|
|
+ result.setNormalizedScore(null);
|
|
|
+ log.info("华为健康数据为空 memberId={} type={}", memberId, dataType);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 取最新一条数据点作为本期代表值(云端按日聚合)
|
|
|
+ HuaweiHealthDataPoint latest = points.get(points.size() - 1);
|
|
|
+
|
|
|
+ // 归一化:复用 NormCompareEngine 百分位带
|
|
|
+ double numericValue = selectNumericValue(type, latest);
|
|
|
+ List<NormEntry> norms = buildNorms(type);
|
|
|
+ List<IndicatorReading> readings = new ArrayList<>();
|
|
|
+ IndicatorReading reading = new IndicatorReading();
|
|
|
+ reading.setMemberId(memberId);
|
|
|
+ reading.setMemberName("member");
|
|
|
+ reading.setSource("huawei_health");
|
|
|
+ reading.setCode(type.getType());
|
|
|
+ reading.setDisplayName(type.getLabel());
|
|
|
+ reading.setNumericValue(numericValue);
|
|
|
+ reading.setUnit(type.getUnit());
|
|
|
+ reading.setValue(String.valueOf(numericValue));
|
|
|
+ readings.add(reading);
|
|
|
+
|
|
|
+ com.etotem.cfc.service.attribution.model.CompareResult compare =
|
|
|
+ normCompareEngine.compare(new ArrayList<>(), readings, norms, new java.util.LinkedHashMap<>());
|
|
|
+ IndicatorReading banded = compare.getReadings().get(0);
|
|
|
+
|
|
|
+ String band = banded.getBand() == null ? IndicatorReading.BAND_UNKNOWN : banded.getBand();
|
|
|
+ String status = banded.getStatus() == null ? IndicatorReading.STATUS_PENDING : banded.getStatus();
|
|
|
+ Integer score = BAND_SCORE.getOrDefault(band, 50);
|
|
|
+ BigDecimal confidence = IndicatorReading.STATUS_PENDING.equals(status) ? CONFIDENCE_LOW : CONFIDENCE_NORMAL;
|
|
|
+
|
|
|
+ result.setNormalizedScore(score);
|
|
|
+ result.setBand(band);
|
|
|
+ result.setStatus(status);
|
|
|
+
|
|
|
+ // 构建 parsedResult JSON(保留原始 + 归一化,供审计与前端展示)
|
|
|
+ JSONObject parsedResult = new JSONObject();
|
|
|
+ parsedResult.put("dataType", type.getType());
|
|
|
+ parsedResult.put("label", type.getLabel());
|
|
|
+ parsedResult.put("sourceType", "huawei_health");
|
|
|
+ parsedResult.put("memberId", memberId);
|
|
|
+ parsedResult.put("collectDate", latest.getCollectDate());
|
|
|
+ parsedResult.put("collectedAt", latest.getCollectedAt() != null
|
|
|
+ ? latest.getCollectedAt().getTime() : null);
|
|
|
+ parsedResult.put("value", latest.getValue());
|
|
|
+ parsedResult.put("unit", latest.getUnit());
|
|
|
+ if (latest.getAuxiliaryValue() != null) {
|
|
|
+ parsedResult.put("auxiliaryValue", latest.getAuxiliaryValue());
|
|
|
+ parsedResult.put("auxiliaryLabel", latest.getAuxiliaryLabel());
|
|
|
+ }
|
|
|
+ if (latest.getRawJson() != null && !latest.getRawJson().isEmpty()) {
|
|
|
+ parsedResult.put("raw", latest.getRawJson());
|
|
|
+ }
|
|
|
+ JSONObject normalized = new JSONObject();
|
|
|
+ normalized.put("band", band);
|
|
|
+ normalized.put("status", status);
|
|
|
+ normalized.put("percentile", banded.getPercentile());
|
|
|
+ normalized.put("score", score);
|
|
|
+ normalized.put("normSource", banded.getNormSource());
|
|
|
+ parsedResult.put("normalized", normalized);
|
|
|
+ parsedResult.put("dimensionCodes", type.getDimensionCodes());
|
|
|
+ parsedResult.put("syncedAt", System.currentTimeMillis());
|
|
|
+
|
|
|
+ // 入库 health_data_sources(一条记录代表本期该类型最新数据)
|
|
|
+ HealthDataSourceRecord record = new HealthDataSourceRecord();
|
|
|
+ record.setMemberId(memberId);
|
|
|
+ record.setSourceType("huawei_health");
|
|
|
+ record.setDimensionCodes(type.getDimensionCodes());
|
|
|
+ record.setParsedResult(parsedResult.toJSONString());
|
|
|
+ record.setConfidence(confidence);
|
|
|
+ record.setCreatedAt(new Date());
|
|
|
+ dataSourceRecordService.saveRecord(record);
|
|
|
+ result.setStoredCount(1);
|
|
|
+
|
|
|
+ result.setSuccess(true);
|
|
|
+ log.info("华为健康数据同步完成 memberId={} type={} value={} band={} status={} score={}",
|
|
|
+ memberId, dataType, numericValue, band, status, score);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public List<SyncResult> syncAll(Long memberId) {
|
|
|
+ List<SyncResult> results = new ArrayList<>();
|
|
|
+ for (HuaweiHealthDataType type : HuaweiHealthDataType.values()) {
|
|
|
+ results.add(sync(memberId, type.getType()));
|
|
|
+ }
|
|
|
+ return results;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 判断指定成员当前是否持有有效华为授权。 */
|
|
|
+ private boolean isBound(Long memberId) {
|
|
|
+ if (memberId == null) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ Long cnt = tokenMapper.selectCount(
|
|
|
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<HuaweiHealthToken>()
|
|
|
+ .eq(HuaweiHealthToken::getMemberId, memberId)
|
|
|
+ .eq(HuaweiHealthToken::getStatus, "ACTIVE"));
|
|
|
+ return cnt != null && cnt > 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 按数据类型选取用于归一化的数值:体重使用 BMI(auxiliaryValue),其余使用 value。 */
|
|
|
+ private double selectNumericValue(HuaweiHealthDataType type, HuaweiHealthDataPoint point) {
|
|
|
+ if (type == HuaweiHealthDataType.BODY_WEIGHT && point.getAuxiliaryValue() != null) {
|
|
|
+ return point.getAuxiliaryValue();
|
|
|
+ }
|
|
|
+ return point.getValue();
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<NormEntry> buildNorms(HuaweiHealthDataType type) {
|
|
|
+ double[] bands = DEFAULT_NORMS.get(type.getType());
|
|
|
+ List<NormEntry> norms = new ArrayList<>();
|
|
|
+ NormEntry n = new NormEntry();
|
|
|
+ n.setNormType("huawei_health");
|
|
|
+ n.setCode(type.getType());
|
|
|
+ n.setDisplayName(type.getLabel());
|
|
|
+ n.setAgeBand("all");
|
|
|
+ n.setGender("all");
|
|
|
+ n.setP5(bands[0]);
|
|
|
+ n.setP25(bands[1]);
|
|
|
+ n.setP50(bands[2]);
|
|
|
+ n.setP75(bands[3]);
|
|
|
+ n.setP95(bands[4]);
|
|
|
+ n.setUnit(type.getUnit());
|
|
|
+ n.setSource("huawei_health_default");
|
|
|
+ norms.add(n);
|
|
|
+ return norms;
|
|
|
+ }
|
|
|
+}
|