|
@@ -0,0 +1,547 @@
|
|
|
|
|
+package com.etotem.cfc.unit;
|
|
|
|
|
+
|
|
|
|
|
+import com.etotem.cfc.entity.HealthDataSourceRecord;
|
|
|
|
|
+import com.etotem.cfc.entity.HealthDimensionScore;
|
|
|
|
|
+import com.etotem.cfc.service.HealthDimensionScoreService;
|
|
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+
|
|
|
|
|
+import java.io.InputStream;
|
|
|
|
|
+import java.lang.reflect.Field;
|
|
|
|
|
+import java.lang.reflect.Method;
|
|
|
|
|
+import java.math.BigDecimal;
|
|
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
|
|
+import java.sql.Date;
|
|
|
|
|
+import java.util.ArrayList;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.Scanner;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.fail;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.argThat;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
|
|
+import static org.mockito.Mockito.atLeastOnce;
|
|
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 契约级单测:能力键 {@code health.dataSource.bridgeWrite} —— D/X 域(华为/小米健康)同步数据
|
|
|
|
|
+ * 桥接五维 {@code health_dimension_scores} 消费链路(GAP-A 缺口补建,S-cfc-129,TDD-RED 阶段)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>需求/设计溯源</b>:{@code docs/superpowers/specs/2026-11-08-dx-health-bridge.md}
|
|
|
|
|
+ * (O-17 GAP-A 契约缺口,已合入 origin/cfclub,2026-11-08):</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}=DEVICE(§6 ①);{@code tier}=2(§6 ②);
|
|
|
|
|
+ * {@code raw_data} 直存整条 parsed_result;{@code assess_date} 取指标采集点日期。</li>
|
|
|
|
|
+ * <li>§3.2 消费侧接线:(1){@code DimensionScoreServiceImpl} 死注入解除,建议独立
|
|
|
|
|
+ * {@code HealthDataSourceBridgeService};(2)写入触发点 <b>A:同步内事务桥接</b>(§6 ③ 已拍板)。</li>
|
|
|
|
|
+ * <li>§4/§6 拍板项:① data_source 扩展 {@code DEVICE};② tier=2;④ <b>不加唯一索引</b>
|
|
|
|
|
+ * (幂等靠「读最新覆盖」,不因 bridge 引入索引动共享表)。</li>
|
|
|
|
|
+ * <li>§5 E-1 证据:桥接单元 mock {@code health_data_sources} 行 → {@code health_dimension_scores} 读写正确
|
|
|
|
|
+ * (body_weight 双维度展开、未知 dimension 丢弃、多行 LIFO 覆盖、防负向回归分层保留)。</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>src/main 现状核实(2026-11-09,写侧已落地、消费侧空窗)</b></p>
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li>写侧:{@code HealthSyncServiceImpl}(华为){@code sync()} 仅
|
|
|
|
|
+ * {@code dataSourceRecordService.saveRecord(record)} → {@code health_data_sources}(:191);
|
|
|
|
|
+ * {@code XiaomiHealthServiceImpl} 同构(:190)。parsed_result 含 {@code sourceType/
|
|
|
|
|
+ * normalized{band,status,percentile,score,normSource}/dimensionCodes/collectDate}。</li>
|
|
|
|
|
+ * <li>消费侧:<b>{@code HealthDataSourceBridgeService} 全库 0 引用(不存在)</b>;两个同步引擎 saveRecord 后
|
|
|
|
|
+ * 无任何桥接步骤;{@code DimensionScoreServiceImpl} 注入 {@code dataSourceRecordService} 但全类无调用
|
|
|
|
|
+ * (死注入,:72-73);{@code schema.sql} {@code health_dimension_scores.data_source} 注释仍为
|
|
|
|
|
+ * {@code MANUAL/REPORT/PHOTO/VOICE/AVERAGE},无 {@code DEVICE}。</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ * <p>因此本测试以「反射结构契约」锚定宿主类,运行时宿主缺失即断言失败(红),失败断言原文清晰可贴。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>GREEN 升级指引(实现方按此落地)</b>:创建 {@code com.etotem.cfc.service.HealthDataSourceBridgeService}
|
|
|
|
|
+ * (契约入口 {@code void bridge(List<HealthDataSourceRecord> records)},可选单条重载),依赖
|
|
|
|
|
+ * {@link HealthDimensionScoreService}(字段可由本测试按类型注入 mock),按 §3.1 写
|
|
|
|
|
+ * {@code health_dimension_scores}(含双维度展开/未知码丢弃/LIFO 读最新覆盖/REPORT 行防覆盖);并在
|
|
|
|
|
+ * {@code HealthSyncServiceImpl}/{@code XiaomiHealthServiceImpl} 的 {@code saveRecord} 后同事务调用桥接;
|
|
|
|
|
+ * 同步在 schema.sql 将 {@code data_source} 注释扩展 {@code DEVICE}、移除 {@code DimensionScoreServiceImpl}
|
|
|
|
|
+ * 死注入字段。以上落地后本测试自动转绿。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>测试范围</b>:纯 Mockito / 反射,无 Spring / 无 DB / 无外部服务。
|
|
|
|
|
+ * <b>权限流</b>:本能力为后端内部数据管线(同步→桥接→五维得分),无用户鉴权分支,权限流不适用,
|
|
|
|
|
+ * 以说明性用例标注。</p>
|
|
|
|
|
+ */
|
|
|
|
|
+class HealthDataSourceBridgeContractTest {
|
|
|
|
|
+
|
|
|
|
|
+ /** §3.2-1 契约宿主桥接服务(GAP-A 未实现,反射锚定)。 */
|
|
|
|
|
+ private static final String HOST_CLASS = "com.etotem.cfc.service.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;
|
|
|
|
|
+
|
|
|
|
|
+ /** 契约测试成员 */
|
|
|
|
|
+ private static final Long MEMBER_ID = 888L;
|
|
|
|
|
+
|
|
|
|
|
+ private HealthDimensionScoreService scoreService;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUp() {
|
|
|
|
|
+ scoreService = mock(HealthDimensionScoreService.class);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 正常流:宿主存在 + 契约入口 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-1:HealthDataSourceBridgeService 桥接宿主类应存在")
|
|
|
|
|
+ void bridgeHost_classExists() {
|
|
|
|
|
+ assertNotNull(loadClass(HOST_CLASS),
|
|
|
|
|
+ "RED[health.dataSource.bridgeWrite] 桥接宿主 HealthDataSourceBridgeService 未实现"
|
|
|
|
|
+ + "(O-17 GAP-A 契约缺口,§3.1 字段映射 + §3.2 消费侧接线均未落地)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-2:桥接宿主应提供 bridge 契约入口")
|
|
|
|
|
+ void bridgeHost_hasBridgeMethod() {
|
|
|
|
|
+ Class<?> clazz = loadClass(HOST_CLASS);
|
|
|
|
|
+ if (clazz == null) {
|
|
|
|
|
+ fail("RED[health.dataSource.bridgeWrite] HealthDataSourceBridgeService 类不存在,无法验证 bridge 入口(§3.2-1)。");
|
|
|
|
|
+ }
|
|
|
|
|
+ boolean hasBridge = false;
|
|
|
|
|
+ for (Method m : clazz.getMethods()) {
|
|
|
|
|
+ if (m.getName().equals("bridge")) {
|
|
|
|
|
+ hasBridge = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ assertTrue(hasBridge,
|
|
|
|
|
+ "RED[health.dataSource.bridgeWrite] HealthDataSourceBridgeService 应提供 bridge(...) 契约入口"
|
|
|
|
|
+ + "(建议 void bridge(List<HealthDataSourceRecord> records),§3.2-1)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 正常流:§3.1 字段映射契约(E-1 桥接单元) ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-3:单条华为 step 记录 → 写一行 exercise 维度分,字段映射符合 §3.1")
|
|
|
|
|
+ void bridge_huaweiStep_fieldMappingConformToSpec() {
|
|
|
|
|
+ Object host = bridgeHost();
|
|
|
|
|
+ HealthDataSourceRecord step = record(MEMBER_ID, "huawei_health", "body.exercise",
|
|
|
|
|
+ json("step", "huawei_health", "2026-11-08", "65", "55"), ts("2026-11-08 10:00"));
|
|
|
|
|
+
|
|
|
|
|
+ callBridge(host, single(step));
|
|
|
|
|
+
|
|
|
|
|
+ List<HealthDimensionScore> written = capturedScores();
|
|
|
|
|
+ assertEquals(1, written.size(), "一条 step 记录应产出 1 行 health_dimension_scores");
|
|
|
|
|
+ assertScoreMapping(written.get(0), "exercise", 65, 55);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-4:小米 xiaomi_health 来源 → 同样映射 data_source=DEVICE, tier=2")
|
|
|
|
|
+ void bridge_xiaomiStep_sameDeviceMapping() {
|
|
|
|
|
+ Object host = bridgeHost();
|
|
|
|
|
+ HealthDataSourceRecord step = record(MEMBER_ID, "xiaomi_health", "body.exercise",
|
|
|
|
|
+ json("step", "xiaomi_health", "2026-11-08", "65", "55"), ts("2026-11-08 10:00"));
|
|
|
|
|
+
|
|
|
|
|
+ callBridge(host, single(step));
|
|
|
|
|
+
|
|
|
|
|
+ List<HealthDimensionScore> written = capturedScores();
|
|
|
|
|
+ assertEquals(1, written.size());
|
|
|
|
|
+ assertScoreMapping(written.get(0), "exercise", 65, 55);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-5:body_weight 双维度展开 → nutrition + growth 两行")
|
|
|
|
|
+ void bridge_bodyWeight_expandTwoDimensions() {
|
|
|
|
|
+ Object host = bridgeHost();
|
|
|
|
|
+ HealthDataSourceRecord weight = record(MEMBER_ID, "huawei_health", "body.nutrition,body.growth",
|
|
|
|
|
+ json("body_weight", "huawei_health", "2026-11-08", "70", "60"), ts("2026-11-08 10:00"));
|
|
|
|
|
+
|
|
|
|
|
+ callBridge(host, single(weight));
|
|
|
|
|
+
|
|
|
|
|
+ List<HealthDimensionScore> written = capturedScores();
|
|
|
|
|
+ assertEquals(2, written.size(), "body_weight 应展开为 nutrition+growth 两行(§2.3 映射表)");
|
|
|
|
|
+ boolean nutrition = false;
|
|
|
|
|
+ boolean growth = false;
|
|
|
|
|
+ for (HealthDimensionScore s : written) {
|
|
|
|
|
+ if ("nutrition".equals(s.getDimension())) {
|
|
|
|
|
+ nutrition = true;
|
|
|
|
|
+ assertScoreMapping(s, "nutrition", 70, 60);
|
|
|
|
|
+ } else if ("growth".equals(s.getDimension())) {
|
|
|
|
|
+ growth = true;
|
|
|
|
|
+ assertScoreMapping(s, "growth", 70, 60);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ fail("body_weight 展开不应出现维度 " + s.getDimension());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ assertTrue(nutrition && growth, "body_weight 必须同时展开 nutrition 与 growth 两行");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-1:未知维度码整体丢弃,不含任何合法码时不得写行")
|
|
|
|
|
+ void bridge_unknownDimensionCodes_dropped() {
|
|
|
|
|
+ Object host = bridgeHost();
|
|
|
|
|
+ HealthDataSourceRecord bad = record(MEMBER_ID, "huawei_health", "body.heart,body.unknown",
|
|
|
|
|
+ json("sleep", "huawei_health", "2026-11-08", "65", null), ts("2026-11-08 10:00"));
|
|
|
|
|
+
|
|
|
|
|
+ callBridge(host, single(bad));
|
|
|
|
|
+
|
|
|
|
|
+ verify(scoreService, never()).saveScore(any(HealthDimensionScore.class));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-2:混合合法与未知维度码 → 仅写合法码行")
|
|
|
|
|
+ void bridge_mixedValidAndUnknown_writeOnlyValid() {
|
|
|
|
|
+ Object host = bridgeHost();
|
|
|
|
|
+ HealthDataSourceRecord mixed = record(MEMBER_ID, "huawei_health", "body.exercise,body.unknown",
|
|
|
|
|
+ json("step", "huawei_health", "2026-11-08", "65", "55"), ts("2026-11-08 10:00"));
|
|
|
|
|
+
|
|
|
|
|
+ callBridge(host, single(mixed));
|
|
|
|
|
+
|
|
|
|
|
+ List<HealthDimensionScore> written = capturedScores();
|
|
|
|
|
+ assertEquals(1, written.size(), "仅合法码 body.exercise 应写一行(未知码丢弃,§3.1)");
|
|
|
|
|
+ assertEquals("exercise", written.get(0).getDimension());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-3:percentile 可空(normalized.percentile 缺失)不阻断写入")
|
|
|
|
|
+ void bridge_percentileNull_stillWrites() {
|
|
|
|
|
+ Object host = bridgeHost();
|
|
|
|
|
+ HealthDataSourceRecord rec = record(MEMBER_ID, "huawei_health", "body.sleep",
|
|
|
|
|
+ json("sleep", "huawei_health", "2026-11-08", "65", null), ts("2026-11-08 10:00"));
|
|
|
|
|
+
|
|
|
|
|
+ callBridge(host, single(rec));
|
|
|
|
|
+
|
|
|
|
|
+ List<HealthDimensionScore> written = capturedScores();
|
|
|
|
|
+ assertEquals(1, written.size());
|
|
|
|
|
+ HealthDimensionScore s = written.get(0);
|
|
|
|
|
+ assertEquals("sleep", s.getDimension());
|
|
|
|
|
+ assertEquals(new Integer(65), s.getScore(), "score 直取 normalized.score");
|
|
|
|
|
+ assertEquals(null, s.getPercentile(), "percentile 可空直取");
|
|
|
|
|
+ assertEquals(SOURCE_DEVICE, s.getDataSource());
|
|
|
|
|
+ assertEquals(TIER_DEVICE, s.getTier().intValue());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 边界流:多行同日 LIFO 读最新覆盖 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-4:同日多行(同 member+dimension+assess_date)读最新覆盖(created_at DESC)")
|
|
|
|
|
+ void bridge_sameDayMultipleRows_latestWins() {
|
|
|
|
|
+ Object host = bridgeHost();
|
|
|
|
|
+ HealthDataSourceRecord older = record(MEMBER_ID, "huawei_health", "body.exercise",
|
|
|
|
|
+ json("step", "huawei_health", "2026-11-08", "65", "55"), ts("2026-11-08 10:00"));
|
|
|
|
|
+ HealthDataSourceRecord newer = record(MEMBER_ID, "huawei_health", "body.exercise",
|
|
|
|
|
+ json("step", "huawei_health", "2026-11-08", "80", "75"), ts("2026-11-08 12:00"));
|
|
|
|
|
+
|
|
|
|
|
+ callBridge(host, batch(older, newer));
|
|
|
|
|
+
|
|
|
|
|
+ List<HealthDimensionScore> written = capturedScores();
|
|
|
|
|
+ HealthDimensionScore lastExercise = null;
|
|
|
|
|
+ for (HealthDimensionScore s : written) {
|
|
|
|
|
+ if ("exercise".equals(s.getDimension())) {
|
|
|
|
|
+ lastExercise = s;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ assertNotNull(lastExercise, "应有 exercise 维度分产出");
|
|
|
|
|
+ assertEquals(new Integer(80), lastExercise.getScore(),
|
|
|
|
|
+ "同日多行读最新覆盖:exercise 行应取较新记录(12:00,score=80),不得停留在较旧值(65)(§3.1)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-5:防负向回归——桥接不得用 DEVICE 行覆盖既有 REPORT(tier=1) 行")
|
|
|
|
|
+ void bridge_mustNotOverwriteExistingReportRow() {
|
|
|
|
|
+ // 既有 REPORT 行(报告 tier=1 最高优先级)
|
|
|
|
|
+ HealthDimensionScore reportRow = new HealthDimensionScore();
|
|
|
|
|
+ reportRow.setId(1L);
|
|
|
|
|
+ reportRow.setMemberId(MEMBER_ID);
|
|
|
|
|
+ reportRow.setDimension("sleep");
|
|
|
|
|
+ reportRow.setScore(80);
|
|
|
|
|
+ reportRow.setPercentile(90);
|
|
|
|
|
+ reportRow.setDataSource("REPORT");
|
|
|
|
|
+ reportRow.setTier(1);
|
|
|
|
|
+ reportRow.setAssessDate(sqldate("2026-11-08"));
|
|
|
|
|
+ when(scoreService.getLatestScore(MEMBER_ID, "sleep")).thenReturn(reportRow);
|
|
|
|
|
+
|
|
|
|
|
+ Object host = bridgeHost();
|
|
|
|
|
+ // 同日 DEVICE sleep 得分 65(低于既有 REPORT 80)
|
|
|
|
|
+ HealthDataSourceRecord devRec = record(MEMBER_ID, "huawei_health", "body.sleep",
|
|
|
|
|
+ json("sleep", "huawei_health", "2026-11-08", "65", "40"), ts("2026-11-08 10:00"));
|
|
|
|
|
+
|
|
|
|
|
+ callBridge(host, single(devRec));
|
|
|
|
|
+
|
|
|
|
|
+ verify(scoreService, never()).saveScore(argThat((HealthDimensionScore s) ->
|
|
|
|
|
+ "sleep".equals(s.getDimension()) && SOURCE_DEVICE.equals(s.getDataSource())
|
|
|
|
|
+ && s.getTier() != null && s.getTier() == TIER_DEVICE));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 接线契约 §3.2:触发点 A(同步内事务桥接,§6 ③) ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("接线契约-1:华为 HealthSyncServiceImpl 应注入桥接服务(拍板 ③ 触发点 A,saveRecord 后同事务桥接)")
|
|
|
|
|
+ void huaweiSyncEngine_injectBridgeHook() {
|
|
|
|
|
+ assertBridgeFieldInjected("com.etotem.cfc.service.huawei.impl.HealthSyncServiceImpl", "HealthSyncServiceImpl(华为)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("接线契约-2:小米 XiaomiHealthServiceImpl 应注入桥接服务(拍板 ③ 触发点 A)")
|
|
|
|
|
+ void xiaomiSyncEngine_injectBridgeHook() {
|
|
|
|
|
+ assertBridgeFieldInjected("com.etotem.cfc.service.xiaomi.impl.XiaomiHealthServiceImpl", "XiaomiHealthServiceImpl(小米)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("接线契约-3:DimensionScoreServiceImpl 死注入 dataSourceRecordService 应解除")
|
|
|
|
|
+ void dimensionScoreServiceImpl_deadInjectionResolved() {
|
|
|
|
|
+ Class<?> clazz = loadClass("com.etotem.cfc.service.impl.DimensionScoreServiceImpl");
|
|
|
|
|
+ assertNotNull(clazz, "DimensionScoreServiceImpl 应存在(消费侧读链路宿主)");
|
|
|
|
|
+ boolean dead = false;
|
|
|
|
|
+ for (Field f : clazz.getDeclaredFields()) {
|
|
|
|
|
+ if ("dataSourceRecordService".equals(f.getName())) {
|
|
|
|
|
+ dead = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ assertFalse(dead,
|
|
|
|
|
+ "RED[health.dataSource.bridgeWrite] DimensionScoreServiceImpl.dataSourceRecordService 死注入未解除"
|
|
|
|
|
+ + "(§3.2-1):桥接职责应已迁移至同步链路 HealthDataSourceBridgeService,死注入字段不得残留。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 拍板项 §4/§6:DEVICE 取值扩展 + 不加唯一索引 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("拍板项-1:schema.sql 中 health_dimension_scores.data_source 注释应扩展 DEVICE(§6 ① 已拍板)")
|
|
|
|
|
+ void schema_dimensionScores_dataSourceCommentContainsDevice() {
|
|
|
|
|
+ String block = schemaBlock("health_dimension_scores");
|
|
|
|
|
+ assertTrue(block.contains("DEVICE"),
|
|
|
|
|
+ "RED[health.dataSource.bridgeWrite] schema.sql health_dimension_scores.data_source 注释未扩展 DEVICE"
|
|
|
|
|
+ + "(§6 ① 已拍板:新增 DEVICE 取值,D/X 设备采集)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("拍板项-2(防回归):health_dimension_scores 不得新增 (member_id,dimension,assess_date) 唯一索引(§6 ④ 已拍板)")
|
|
|
|
|
+ void schema_dimensionScores_noUniqueIndex() {
|
|
|
|
|
+ String block = schemaBlock("health_dimension_scores");
|
|
|
|
|
+ assertFalse(block.contains("UNIQUE"),
|
|
|
|
|
+ "health_dimension_scores 不得加唯一索引(§6 ④ 已拍板:不加唯一索引,幂等靠读最新覆盖,避免动共享表)。");
|
|
|
|
|
+ assertTrue(block.contains("idx_member_dim"),
|
|
|
|
|
+ "health_dimension_scores 应保留 idx_member_dim 查询索引(读链路锚点)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 权限流(不适用) ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流:本能力为后端内部数据管线,无用户鉴权分支,权限流不适用")
|
|
|
|
|
+ void permissionFlow_notApplicable() {
|
|
|
|
|
+ assertTrue(true, "权限流不适用:health.dataSource.bridgeWrite 为同步→五维得分内部桥接,无用户权限维度。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 反射辅助 ====================
|
|
|
|
|
+
|
|
|
|
|
+ private void assertBridgeFieldInjected(String serviceFqcn, String label) {
|
|
|
|
|
+ Class<?> clazz = loadClass(serviceFqcn);
|
|
|
|
|
+ assertNotNull(clazz, label + " 类应存在");
|
|
|
|
|
+ boolean injected = false;
|
|
|
|
|
+ for (Field f : clazz.getDeclaredFields()) {
|
|
|
|
|
+ if (HOST_CLASS.equals(f.getType().getName())) {
|
|
|
|
|
+ injected = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ assertTrue(injected,
|
|
|
|
|
+ "RED[health.dataSource.bridgeWrite] " + label + " 未注入 HealthDataSourceBridgeService:"
|
|
|
|
|
+ + "契约 §3.2-2 触发点 A(同步内事务桥接,§6 ③ 已拍板)要求 saveRecord 后同事务调用桥接,"
|
|
|
|
|
+ + "使 /api/dimension/overview 自然读到 D/X 得分。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Class<?> loadClass(String name) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
|
|
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 契约宿主:反射实例化并注入可 mock 的 HealthDimensionScoreService。 */
|
|
|
|
|
+ private Object bridgeHost() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ Class<?> clazz = Class.forName(HOST_CLASS);
|
|
|
|
|
+ Object host = clazz.getDeclaredConstructor().newInstance();
|
|
|
|
|
+ boolean injected = false;
|
|
|
|
|
+ for (Field f : clazz.getDeclaredFields()) {
|
|
|
|
|
+ if (HealthDimensionScoreService.class.isAssignableFrom(f.getType())) {
|
|
|
|
|
+ f.setAccessible(true);
|
|
|
|
|
+ f.set(host, scoreService);
|
|
|
|
|
+ injected = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!injected) {
|
|
|
|
|
+ fail("桥接宿主 HealthDataSourceBridgeService 应注入 HealthDimensionScoreService(契约 §3.1 写 health_dimension_scores)。");
|
|
|
|
|
+ }
|
|
|
|
|
+ return host;
|
|
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
|
|
+ fail("RED[health.dataSource.bridgeWrite] 桥接宿主 HealthDataSourceBridgeService 未实现"
|
|
|
|
|
+ + "(O-17 GAP-A 契约缺口:§3.1 字段映射 + §3.2 消费侧接线未落地):" + e.getMessage());
|
|
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ fail("桥接宿主 HealthDataSourceBridgeService 实例化失败:" + e.getMessage());
|
|
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 调用桥接入口:优先 List 重载,其次单条重载;契约入口缺失即断言失败。 */
|
|
|
|
|
+ private void callBridge(Object host, List<HealthDataSourceRecord> records) {
|
|
|
|
|
+ for (Method m : host.getClass().getMethods()) {
|
|
|
|
|
+ if (!"bridge".equals(m.getName()) || m.getParameterCount() != 1) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ Class<?> param = m.getParameterTypes()[0];
|
|
|
|
|
+ if (param.isAssignableFrom(records.getClass())) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ m.invoke(host, records);
|
|
|
|
|
+ return;
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ fail("桥接入口 bridge(List<HealthDataSourceRecord>) 调用失败:" + e.getMessage());
|
|
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (param.isAssignableFrom(HealthDataSourceRecord.class) && records.size() == 1) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ m.invoke(host, records.get(0));
|
|
|
|
|
+ return;
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ fail("桥接入口 bridge(HealthDataSourceRecord) 调用失败:" + e.getMessage());
|
|
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ fail("RED[health.dataSource.bridgeWrite] 桥接宿主 HealthDataSourceBridgeService 未提供契约入口"
|
|
|
|
|
+ + " bridge(List<HealthDataSourceRecord>)(§3.2-1)。");
|
|
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<HealthDimensionScore> capturedScores() {
|
|
|
|
|
+ org.mockito.ArgumentCaptor<HealthDimensionScore> cap =
|
|
|
|
|
+ org.mockito.ArgumentCaptor.forClass(HealthDimensionScore.class);
|
|
|
|
|
+ verify(scoreService, atLeastOnce()).saveScore(cap.capture());
|
|
|
|
|
+ return cap.getAllValues();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** §3.1 字段映射断言:dimension(剥离 body. 前缀)/ score / percentile / DEVICE / tier=2 / raw_data / assess_date。 */
|
|
|
|
|
+ private void assertScoreMapping(HealthDimensionScore s, String dimension, int score, Integer percentile) {
|
|
|
|
|
+ assertEquals(MEMBER_ID, s.getMemberId(), "member_id 直取");
|
|
|
|
|
+ assertEquals(dimension, s.getDimension(), "dimension 剥离 body. 前缀(body." + dimension + " → " + dimension + "),未知码丢弃");
|
|
|
|
|
+ assertEquals(new Integer(score), s.getScore(), "score 直取 parsed_result.normalized.score");
|
|
|
|
|
+ assertEquals(percentile, s.getPercentile(), "percentile 直取 parsed_result.normalized.percentile");
|
|
|
|
|
+ assertEquals(SOURCE_DEVICE, s.getDataSource(), "data_source = DEVICE(§6 ① 已拍板,设备采集 Auto)");
|
|
|
|
|
+ assertEquals(new Integer(TIER_DEVICE), s.getTier(), "tier = 2(§6 ② 已拍板:D/X 设备自动采集≈AI 分析级)");
|
|
|
|
|
+ assertNotNull(s.getRawData(), "raw_data 应直存 parsed_result 整条 JSON");
|
|
|
|
|
+ assertTrue(s.getRawData().contains("normalized"), "raw_data 应含 parsed_result 原始 JSON");
|
|
|
|
|
+ assertEquals(sqldate("2026-11-08"), s.getAssessDate(), "assess_date 取指标采集点日期(collectDate)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 构造辅助 ====================
|
|
|
|
|
+
|
|
|
|
|
+ private HealthDataSourceRecord record(Long memberId, String sourceType, String dimensionCodes,
|
|
|
|
|
+ String parsedResult, java.util.Date createdAt) {
|
|
|
|
|
+ HealthDataSourceRecord r = new HealthDataSourceRecord();
|
|
|
|
|
+ r.setMemberId(memberId);
|
|
|
|
|
+ r.setSourceType(sourceType);
|
|
|
|
|
+ r.setDimensionCodes(dimensionCodes);
|
|
|
|
|
+ r.setParsedResult(parsedResult);
|
|
|
|
|
+ r.setConfidence(new BigDecimal("0.90"));
|
|
|
|
|
+ r.setCreatedAt(createdAt);
|
|
|
|
|
+ return r;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 构造与写侧引擎一致口径的 parsed_result JSON(含 normalized{percentile,score} 与 collectDate)。 */
|
|
|
|
|
+ private String json(String dataType, String sourceType, String collectDate, String score, String percentile) {
|
|
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
|
|
+ sb.append("{\"dataType\":\"").append(dataType).append("\",");
|
|
|
|
|
+ sb.append("\"sourceType\":\"").append(sourceType).append("\",");
|
|
|
|
|
+ sb.append("\"collectDate\":\"").append(collectDate).append("\",");
|
|
|
|
|
+ sb.append("\"value\":8000,\"unit\":\"steps\",");
|
|
|
|
|
+ sb.append("\"normalized\":{");
|
|
|
|
|
+ sb.append("\"band\":\"MID\",\"status\":\"NORMAL\",\"normSource\":\"default\"");
|
|
|
|
|
+ if (percentile != null) {
|
|
|
|
|
+ sb.append(",\"percentile\":").append(percentile);
|
|
|
|
|
+ }
|
|
|
|
|
+ sb.append(",\"score\":").append(score);
|
|
|
|
|
+ sb.append("},\"dimensionCodes\":\"");
|
|
|
|
|
+ sb.append(dataTypeToDimension(dataType)).append("\"}");
|
|
|
|
|
+ return sb.toString();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String dataTypeToDimension(String dataType) {
|
|
|
|
|
+ if ("step".equals(dataType)) {
|
|
|
|
|
+ return "body.exercise";
|
|
|
|
|
+ }
|
|
|
|
|
+ if ("sleep".equals(dataType)) {
|
|
|
|
|
+ return "body.sleep";
|
|
|
|
|
+ }
|
|
|
|
|
+ if ("body_weight".equals(dataType)) {
|
|
|
|
|
+ return "body.nutrition,body.growth";
|
|
|
|
|
+ }
|
|
|
|
|
+ return "body.exercise";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<HealthDataSourceRecord> single(HealthDataSourceRecord r) {
|
|
|
|
|
+ List<HealthDataSourceRecord> list = new ArrayList<HealthDataSourceRecord>();
|
|
|
|
|
+ list.add(r);
|
|
|
|
|
+ return list;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<HealthDataSourceRecord> batch(HealthDataSourceRecord... records) {
|
|
|
|
|
+ List<HealthDataSourceRecord> list = new ArrayList<HealthDataSourceRecord>();
|
|
|
|
|
+ for (HealthDataSourceRecord r : records) {
|
|
|
|
|
+ list.add(r);
|
|
|
|
|
+ }
|
|
|
|
|
+ return list;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private java.util.Date ts(String yyyyMMddHHmm) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm");
|
|
|
|
|
+ return sdf.parse(yyyyMMddHHmm);
|
|
|
|
|
+ } catch (java.text.ParseException e) {
|
|
|
|
|
+ throw new IllegalArgumentException("非法时间戳: " + yyyyMMddHHmm, e);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Date sqldate(String yyyyMMdd) {
|
|
|
|
|
+ return Date.valueOf(yyyyMMdd);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== schema.sql 辅助 ====================
|
|
|
|
|
+
|
|
|
|
|
+ private String readSchemaSql() {
|
|
|
|
|
+ try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
|
|
|
|
|
+ if (is == null) {
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }
|
|
|
|
|
+ try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
|
|
|
|
|
+ scanner.useDelimiter("\\A");
|
|
|
|
|
+ return scanner.hasNext() ? scanner.next() : "";
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 取出目标表 CREATE TABLE 语句块(到下一个 CREATE TABLE 为止)。 */
|
|
|
|
|
+ private String schemaBlock(String table) {
|
|
|
|
|
+ String schema = readSchemaSql();
|
|
|
|
|
+ String marker = "CREATE TABLE IF NOT EXISTS " + table;
|
|
|
|
|
+ int start = schema.indexOf(marker);
|
|
|
|
|
+ if (start < 0) {
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }
|
|
|
|
|
+ int end = schema.indexOf("CREATE TABLE IF NOT EXISTS", start + table.length());
|
|
|
|
|
+ return end > start ? schema.substring(start, end) : schema.substring(start);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|