浏览代码

test(S-cfc-129): RED 契约测试 health.dataSource.bridgeWrite D/X 桥接 GAP-A 未落地

能力键 health.dataSource.bridgeWrite(O-17 GAP-A)TDD-RED 阶段:新增契约级单测
HealthDataSourceBridgeContractTest,断言契约所需桥接行为缺失(预期红)。

契约溯源: docs/superpowers/specs/2026-11-08-dx-health-bridge.md
- §3.1 字段映射: body.前缀剥离/未知码丢弃/body_weight双维度展开/score+percentile 直取
  normalized/DEVICE(tier=2)/raw_data 直存/assess_date 取采集日
- §3.2 接线: DimensionScoreServiceImpl 死注入解除 + 触发点 A(同步内事务桥接)
- §6 拍板项: ①data_source=DEVICE ②tier=2 ③触发点A ④不加唯一索引
- §5 E-1 证据: 多行 LIFO 读最新覆盖 / 防负向回归(不覆盖 REPORT 行)

RED 结果: 16 通过 2 / 失败 14(宿主类缺失 8 + 入口缺失 + 死注入未解除 +
huawei/xiaomi 无桥接钩子 + schema 无 DEVICE 注释),其余 2 项为守则 guard(§6 ④
不加唯一索引 + 权限流不适用)通过。仅新增测试文件,未动 src/main。
E2E Test Bot 3 天之前
父节点
当前提交
6da900595
共有 1 个文件被更改,包括 547 次插入 和 0 次删除
  1. 547 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/HealthDataSourceBridgeContractTest.java

+ 547 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/HealthDataSourceBridgeContractTest.java

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