Browse Source

S-cfc-245: ai.coach.proactive 契约级 RED 单测(SC-B-02 主动提醒 + SC-B-12 方案跟进)

- 覆盖 SC-B-02(用药/饮水/作息/测评到期/打卡主动提醒,配置→触发→推送,免打扰/去重/仅本家庭)
- 覆盖 SC-B-12(同步规划师建议主动跟进:激活同步/节奏推送/停滞提示/可暂停/未激活不触发)
- 纯反射 + schema.sql 结构契约探测,无 Spring/DB/外部服务;宿主未落地,24/25 红(RED 达成)
- 新增文件:cfc-backend/src/test/java/com/etotem/cfc/unit/CoachProactiveContractTest.java
Sisyphus 2 hours ago
parent
commit
8ebca36dd1

+ 452 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/CoachProactiveContractTest.java

@@ -0,0 +1,452 @@
+package com.etotem.cfc.unit;
+
+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.nio.charset.StandardCharsets;
+import java.util.Arrays;
+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;
+
+/**
+ * 契约级单测:AI 成长教练·主动成长提醒与方案主动跟进(能力键 {@code ai.coach.proactive})。
+ *
+ * <p><b>需求/设计溯源</b></p>
+ * <ul>
+ *   <li>场景:{@code tests/user-scenarios/应用场景总览.md} <b>SC-B-02</b>(主动成长提醒:
+ *       按用药/饮水/作息/测评到期/打卡主动提醒,流程 配置→触发→推送;边界 免打扰生效、重复去重、仅本家庭)
+ *       与 <b>SC-B-12</b>(同步规划师建议主动跟进:方案生效后教练主动跟进打卡/进度,
+ *       流程 同步→节奏推送→停滞提示;边界 未激活不触发、可暂停)。</li>
+ *   <li>派生用例:{@code tests/test-cases/butler.md} <b>TC-B-02</b>(正常,推送经
+ *       {@code /api/notify/*} + 孩子端只读)、<b>TC-B-02-E</b>(免打扰 22:00-08:00 不推、关键安全提醒仍推、
+ *       重复 3 次去重仅 1 条、推送通道故障落站内信兜底)、<b>TC-B-02-P</b>(家长为 F9 孩子配置 403、
+ *       孩子无配置权 403、查询他人提醒 403)、<b>TC-B-12</b>(激活同步、节奏推送、停滞 3 天提示、可暂停)、
+ *       <b>TC-B-12-E</b>(未激活不触发、重同步不残留、引用已删任务收敛、推送失败落站内信)。</li>
+ *   <li>接口:{@code docs/superpowers/api/API_REFERENCE.md} <b>§4.15</b>({@code /api/stats/*} 与
+ *       {@code /api/notification/list|read});提醒投递落点为已具备的站内通知通道
+ *       ({@code UserNotificationController} + {@code service/notification/InAppNotificationChannel}
+ *       → {@code user_notifications} 表),免打扰/去重/仅本家庭在「主动提醒调度」侧实现。</li>
+ * </ul>
+ *
+ * <p><b>实现现状</b>:本分支 src/main 中「主动提醒调度」(用药/饮水/作息/测评到期/打卡)、
+ * 「方案主动跟进」两套契约宿主均未落地(全库 0 引用,schema.sql 无对应建表)。因此本测试以
+ * 「反射结构契约 + schema.sql 快照」探测实现缺失:能编译通过、能运行、断言失败(红),
+ * 且失败断言原文清晰可贴。已具备的站内通知通道({@code UserNotificationController} /
+ * {@code InAppNotificationChannel})在本测试中以「落点已具备」说明性用例标注,不作缺失断言。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者创建 {@code ProactiveReminderService}(含 {@code deliverReminder}
+ * 投递入口、{@code isInDoNotDisturb} 免打扰、{@code isDuplicate} 去重)、{@code ProactiveReminderScheduledTask}
+ * (定时触发)、{@code CoachFollowUpService}(方案激活同步/暂停/停滞探测),建 {@code proactive_reminder} /
+ * {@code proactive_reminder_log} / {@code coach_follow_up_config} 表与实体后,本测试自动转绿;
+ * 届时可将结构探测升级为行为级 mock 验证({@code verify(...)})。</p>
+ *
+ * <p><b>权限流说明</b>:TC-B-02-P 的「仅本家庭」由 {@code proactive_reminder.family_id} 承载
+ * (提醒记录必须携带 familyId 才能在投递/查询侧做家庭域校验),故权限流以「实体携带 family_id 字段」
+ * 结构锚定,不写独立 403 行为用例(服务宿主缺失,行为流属 GREEN 阶段)。</p>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,无 Spring / 无 DB / 无外部服务依赖(纯反射 + schema.sql 读取)。</p>
+ */
+class CoachProactiveContractTest {
+
+    // ==================== 契约宿主类(实现未落地,Class.forName 探测) ====================
+
+    /** SC-B-02:主动提醒调度服务宿主。 */
+    private static final String HOST_REMINDER_SERVICE = "com.etotem.cfc.service.ProactiveReminderService";
+
+    /** SC-B-02:主动提醒定时任务宿主(遵循 task/ 包 XxxScheduledTask 命名约定)。 */
+    private static final String HOST_REMINDER_TASK = "com.etotem.cfc.task.ProactiveReminderScheduledTask";
+
+    /** SC-B-12:方案主动跟进服务宿主。 */
+    private static final String HOST_FOLLOWUP_SERVICE = "com.etotem.cfc.service.CoachFollowUpService";
+
+    /** SC-B-02:提醒配置实体。 */
+    private static final String ENTITY_REMINDER = "com.etotem.cfc.entity.ProactiveReminder";
+
+    /** SC-B-02:提醒投递日志实体(去重)。 */
+    private static final String ENTITY_REMINDER_LOG = "com.etotem.cfc.entity.ProactiveReminderLog";
+
+    /** SC-B-12:方案跟进配置实体。 */
+    private static final String ENTITY_FOLLOWUP = "com.etotem.cfc.entity.CoachFollowUpConfig";
+
+    // ==================== 契约常量 ====================
+
+    /** 免打扰窗口(TC-B-02-E):22:00 - 08:00。 */
+    private static final int DND_START_HOUR = 22;
+    private static final int DND_END_HOUR = 8;
+
+    /** 五类主动提醒(SC-B-02:用药/饮水/作息/测评到期/打卡)。 */
+    private static final String[] REMINDER_TYPES = {
+            "medication", "water", "sleep", "assessment_due", "checkin"
+    };
+
+    /** 关键安全提醒白名单(免打扰仍推送,TC-B-02-E):用药。 */
+    private static final String CRITICAL_TYPE_MEDICATION = "medication";
+
+    /** 停滞判定阈值(TC-B-12):3 天未打卡进度 → 主动提示家长。 */
+    private static final int STAGNATION_DAYS = 3;
+
+    /** 三张新表。 */
+    private static final String[] NEW_TABLES = {
+            "proactive_reminder", "proactive_reminder_log", "coach_follow_up_config"
+    };
+
+    // ==================== SC-B-02 正常流:配置→触发→推送 ====================
+
+    @Test
+    @DisplayName("SC-B-02 正常-1:ProactiveReminder 提醒配置实体应存在(表 proactive_reminder)")
+    void reminderConfig_entityExists() {
+        assertContractClass(ENTITY_REMINDER, "ai.coach.proactive",
+                "ProactiveReminder 实体未实现:SC-B-02 要求「配置→触发→推送」,"
+                        + "需 proactive_reminder 表承载用药/饮水/作息/测评到期/打卡提醒配置。");
+    }
+
+    @Test
+    @DisplayName("SC-B-02 正常-2:提醒配置实体应携带 familyId(仅本家庭数据基础)")
+    void reminderConfig_entityCarriesFamilyScope() {
+        assertContractField(ENTITY_REMINDER, "familyId", "ai.coach.proactive",
+                "ProactiveReminder 应含 familyId 字段(proactive_reminder.family_id):"
+                        + "提醒记录必须绑定家庭,才能实现「仅本家庭」域校验(TC-B-02-P)。");
+    }
+
+    @Test
+    @DisplayName("SC-B-02 正常-3:ProactiveReminderService 调度服务应存在")
+    void reminderService_classExists() {
+        assertContractClass(HOST_REMINDER_SERVICE, "ai.coach.proactive",
+                "ProactiveReminderService 未实现:SC-B-02 主动提醒调度(配置/触发/推送)需服务宿主。");
+    }
+
+    @Test
+    @DisplayName("SC-B-02 正常-4:调度服务应提供 deliverReminder 投递入口(推送)")
+    void reminderService_hasDeliverReminder() {
+        assertContractMethod(HOST_REMINDER_SERVICE, "deliverReminder", 8,
+                "ProactiveReminderService 应提供 deliverReminder(userId,familyId,type,refType,refId,title,content)"
+                        + " 投递入口,将到点提醒经站内通知通道(InAppNotificationChannel → user_notifications)推送。");
+    }
+
+    @Test
+    @DisplayName("SC-B-02 正常-5:调度服务应提供 triggerDueReminders 到点触发入口")
+    void reminderService_hasTriggerDue() {
+        assertContractMethod(HOST_REMINDER_SERVICE, "triggerDueReminders", 1,
+                "ProactiveReminderService 应提供 triggerDueReminders(nowEpochMs) 到点触发入口:"
+                        + "遍历已配置提醒项,命中当前时点即投递。");
+    }
+
+    @Test
+    @DisplayName("SC-B-02 正常-6:ProactiveReminderScheduledTask 定时任务宿主应存在")
+    void reminderTask_classExists() {
+        assertContractClass(HOST_REMINDER_TASK, "ai.coach.proactive",
+                "ProactiveReminderScheduledTask 未实现:主动提醒需定时调度宿主"
+                        + "(遵循 task/ 包 XxxScheduledTask 命名约定)。");
+    }
+
+    @Test
+    @DisplayName("SC-B-02 正常-7:定时任务应提供 runBatch 批次执行入口")
+    void reminderTask_hasRunBatch() {
+        assertContractMethod(HOST_REMINDER_TASK, "runBatch", 0,
+                "ProactiveReminderScheduledTask 应提供 runBatch() 批次执行入口"
+                        + "(@Scheduled 触发,返回处理条数)。");
+    }
+
+    @Test
+    @DisplayName("SC-B-02 正常-8:五类提醒类型应齐全(用药/饮水/作息/测评到期/打卡)")
+    void reminderTypes_coverFiveSpecTypes() {
+        Object host = contractHost(HOST_REMINDER_SERVICE, "ai.coach.proactive");
+        String[] types = callStringArray(host, "reminderTypes");
+        for (String t : REMINDER_TYPES) {
+            assertTrue(Arrays.asList(types).contains(t),
+                    "reminderTypes() 应包含「" + t + "」(用药/饮水/作息/测评到期/打卡,SC-B-02),"
+                            + "实际=" + Arrays.toString(types));
+        }
+    }
+
+    // ==================== SC-B-02 边界流:免打扰 / 去重 / 仅本家庭 ====================
+
+    @Test
+    @DisplayName("SC-B-02 边界-1:免打扰窗口应为 22:00 - 08:00(夜间)")
+    void dnd_window_nightly22to08() {
+        Object host = contractHost(HOST_REMINDER_SERVICE, "ai.coach.proactive");
+        assertEquals(DND_START_HOUR, callInt(host, "dndStartHour"),
+                "免打扰开始小时必须为 22(TC-B-02-E:免打扰 22:00-08:00)");
+        assertEquals(DND_END_HOUR, callInt(host, "dndEndHour"),
+                "免打扰结束小时必须为 8(TC-B-02-E:免打扰 22:00-08:00)");
+    }
+
+    @Test
+    @DisplayName("SC-B-02 边界-2:免打扰时段普通提醒应不推送(次晨合并投递)")
+    void dnd_suppressesNormalReminder() {
+        Object host = contractHost(HOST_REMINDER_SERVICE, "ai.coach.proactive");
+        // 23:00 在免打扰窗口(22:00-08:00)内,非关键普通提醒 → 不推
+        assertTrue(callBoolean(host, "isInDoNotDisturb", 23),
+                "23 点应在免打扰窗口内(22:00-08:00),普通提醒不推送(TC-B-02-E)");
+        assertTrue(callBoolean(host, "isInDoNotDisturb", 2),
+                "凌晨 2 点应在免打扰窗口内(22:00-08:00),普通提醒不推送(TC-B-02-E)");
+    }
+
+    @Test
+    @DisplayName("SC-B-02 边界-3:免打扰时段关键安全提醒仍应推送(白名单不生效)")
+    void dnd_stillPushesCriticalSafetyReminder() {
+        Object host = contractHost(HOST_REMINDER_SERVICE, "ai.coach.proactive");
+        // 23:00 免打扰窗口内、关键安全提醒(用药)→ 仍推
+        boolean pushed = callBoolean(host, "shouldPushNow", CRITICAL_TYPE_MEDICATION, true, true);
+        assertTrue(pushed,
+                "免打扰时段内关键安全提醒(用药)仍须推送(白名单不生效于免打扰,TC-B-02-E)");
+    }
+
+    @Test
+    @DisplayName("SC-B-02 边界-4:同一提醒重复触发应去重仅投递 1 条(isDuplicate)")
+    void dedup_sameReminderRepeated_deliversOnlyOnce() {
+        Object host = contractHost(HOST_REMINDER_SERVICE, "ai.coach.proactive");
+        long dayStart = 1_700_000_000_000L;
+        assertTrue(callBoolean(host, "isDuplicate", 10L, "medication", "task", 900L, dayStart),
+                "同一提醒(同 userId/type/refType/refId)当日重复触发应判定为重复(去重,TC-B-02-E:"
+                        + "重复 3 次仅投递 1 条)");
+    }
+
+    @Test
+    @DisplayName("SC-B-02 边界-5:去重投递日志实体应存在(表 proactive_reminder_log)")
+    void dedup_logEntityExists() {
+        assertContractClass(ENTITY_REMINDER_LOG, "ai.coach.proactive",
+                "ProactiveReminderLog 实体未实现:去重需 proactive_reminder_log 记录当日已投递"
+                        + "(userId/type/refType/refId/deliveredAt)判定重复(TC-B-02-E)。");
+    }
+
+    @Test
+    @DisplayName("SC-B-02 边界-6:三张新表应落地 schema.sql(proactive_reminder/_log/coach_follow_up_config)")
+    void schemaSql_containsThreeNewTables() {
+        String schema = readSchemaSql();
+        for (String table : NEW_TABLES) {
+            assertTrue(schema.contains(table),
+                    "schema.sql 缺少 " + table + " 建表(SC-B-02 提醒配置/去重日志、SC-B-12 方案跟进)。");
+        }
+    }
+
+    @Test
+    @DisplayName("SC-B-02 边界-7:投递提醒必须返回投递条数(≥0,推送落站内信)")
+    void deliverReminder_returnsDeliveredCount() {
+        Object host = contractHost(HOST_REMINDER_SERVICE, "ai.coach.proactive");
+        int delivered = callInt(host, "deliverReminder",
+                10L, 100L, "water", "task", 900L, "饮水提醒", "到点饮水啦");
+        assertTrue(delivered >= 0,
+                "deliverReminder 应返回本次投递条数(落站内通知,≥0),delivered=" + delivered);
+    }
+
+    @Test
+    @DisplayName("SC-B-02 边界-8:到点触发应返回触发条数(≥0)")
+    void triggerDue_returnsTriggeredCount() {
+        Object host = contractHost(HOST_REMINDER_SERVICE, "ai.coach.proactive");
+        int triggered = callInt(host, "triggerDueReminders", 1_700_000_000_000L);
+        assertTrue(triggered >= 0,
+                "triggerDueReminders(nowEpochMs) 应返回到点触发条数(≥0),triggered=" + triggered);
+    }
+
+    // ==================== SC-B-12 正常流:同步规划师建议主动跟进 ====================
+
+    @Test
+    @DisplayName("SC-B-12 正常-1:CoachFollowUpService 方案跟进服务应存在")
+    void followUpService_classExists() {
+        assertContractClass(HOST_FOLLOWUP_SERVICE, "ai.coach.proactive",
+                "CoachFollowUpService 未实现:SC-B-12 要求方案激活后教练主动跟进打卡/进度。");
+    }
+
+    @Test
+    @DisplayName("SC-B-12 正常-2:方案激活应触发教练同步(syncPlanFollowUp)")
+    void followUp_syncOnPlanActivation() {
+        Object host = contractHost(HOST_FOLLOWUP_SERVICE, "ai.coach.proactive");
+        int synced = callInt(host, "syncPlanFollowUp", 555L);
+        assertTrue(synced >= 1,
+                "方案激活事件必须触发教练同步(同步任务清单与方案任务一一对应,SC-B-12),synced=" + synced);
+    }
+
+    @Test
+    @DisplayName("SC-B-12 正常-3:跟进配置实体应存在(表 coach_follow_up_config)")
+    void followUpConfig_entityExists() {
+        assertContractClass(ENTITY_FOLLOWUP, "ai.coach.proactive",
+                "CoachFollowUpConfig 实体未实现:跟进开关/暂停态需 coach_follow_up_config 承载"
+                        + "(active/paused,SC-B-12 可暂停)。");
+    }
+
+    @Test
+    @DisplayName("SC-B-12 正常-4:跟进配置应携带 familyId(仅本家庭方案域校验)")
+    void followUpConfig_carriesFamilyScope() {
+        assertContractField(ENTITY_FOLLOWUP, "familyId", "ai.coach.proactive",
+                "CoachFollowUpConfig 应含 familyId 字段(coach_follow_up_config.family_id):"
+                        + "跟进仅绑定本家庭方案(TC-B-12-P 非本家庭不可见不可改)。");
+    }
+
+    // ==================== SC-B-12 边界流:未激活不触发 / 可暂停 / 停滞提示 ====================
+
+    @Test
+    @DisplayName("SC-B-12 边界-1:未激活方案不应触发跟进(isEligibleForFollowUp)")
+    void followUp_notActivated_noTrigger() {
+        Object host = contractHost(HOST_FOLLOWUP_SERVICE, "ai.coach.proactive");
+        assertFalse(callBoolean(host, "isEligibleForFollowUp", 555L),
+                "方案未激活(非 active)时不得触发跟进/推送(TC-B-12-E:未激活不触发)。");
+    }
+
+    @Test
+    @DisplayName("SC-B-12 边界-2:跟进应可暂停/恢复(setPaused)")
+    void followUp_pausable_setPaused() {
+        Object host = contractHost(HOST_FOLLOWUP_SERVICE, "ai.coach.proactive");
+        callVoid(host, "setPaused", 555L, true);
+        assertTrue(callBoolean(host, "isPaused", 555L),
+                "暂停后跟进不再推送、可随时恢复(TC-B-12:家长暂停教练跟进,可恢复)。");
+    }
+
+    @Test
+    @DisplayName("SC-B-12 边界-3:停滞 3 天应主动提示家长(detectStagnation)")
+    void followUp_stagnationAfter3Days() {
+        Object host = contractHost(HOST_FOLLOWUP_SERVICE, "ai.coach.proactive");
+        long now = 1_700_000_000_000L;
+        int stagnated = callInt(host, "detectStagnation", now);
+        assertTrue(stagnated >= 0,
+                "停滞检测应返回停滞跟进条数(≥0);方案节奏点超 3 天未打卡须主动提示家长"
+                        + "(TC-B-12:停滞时主动提示),stagnated=" + stagnated);
+    }
+
+    // ==================== 权限流(TC-B-02-P / TC-B-12-P 观察) ====================
+
+    @Test
+    @DisplayName("SC-B-02-P/SC-B-12-P 权限流:仅本家庭校验由 family_id 承载(说明性锚定)")
+    void permissionFlow_familyScopeAnchoredByFamilyId() {
+        // 权限流结构锚定:proactive_reminder / coach_follow_up_config 必须携带 family_id 才能在
+        // 投递/查询侧做家庭域校验(家长为 F9 孩子配置 403、孩子无配置权 403、查他人提醒 403)。
+        // 宿主服务未实现,403 行为级断言属 GREEN 阶段;此处以实体字段锚定三流覆盖。
+        assertContractField(ENTITY_REMINDER, "familyId", "ai.coach.proactive",
+                "仅本家庭校验数据基础:ProactiveReminder 必须携带 familyId(TC-B-02-P)。");
+        assertContractField(ENTITY_FOLLOWUP, "familyId", "ai.coach.proactive",
+                "仅本家庭校验数据基础:CoachFollowUpConfig 必须携带 familyId(TC-B-12-P)。");
+    }
+
+    // ==================== 落点说明:站内通知通道已具备(不缺失断言) ====================
+
+    @Test
+    @DisplayName("推送落点已具备:UserNotificationController / InAppNotificationChannel(说明性)")
+    void pushEndpoint_inAppChannelAlreadyPresent() {
+        // 主动提醒投递落点为已具备的站内通知通道(UserNotificationController /api/notification/list|read、
+        // InAppNotificationChannel → user_notifications 表,见 §4.15)。此通道已实现,故不作缺失断言,
+        // 仅标注「推送落点已具备」;RED 缺口集中在「主动提醒调度 / 方案主动跟进」契约宿主。
+        assertNotNull(loadClass("com.etotem.cfc.controller.UserNotificationController"),
+                "推送落点 UserNotificationController 应存在(已具备,§4.15)");
+        assertNotNull(loadClass("com.etotem.cfc.service.notification.InAppNotificationChannel"),
+                "推送落点 InAppNotificationChannel 应存在(已具备,站内通道)");
+    }
+
+    // ==================== 反射辅助(锚定契约宿主,避免编译期依赖不存在类) ====================
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private void assertContractClass(String className, String key, String why) {
+        assertNotNull(loadClass(className),
+                "RED[" + key + "] " + why + "(契约宿主 " + className + " 未实现)。");
+    }
+
+    private void assertContractField(String className, String fieldName, String key, String why) {
+        Class<?> clazz = loadClass(className);
+        assertNotNull(clazz, "RED[" + key + "] " + why + "(实体 " + className + " 未实现)。");
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().equals(fieldName)) {
+                return;
+            }
+        }
+        fail("RED[" + key + "] " + why + "(实体 " + className + " 缺字段 " + fieldName + ")。");
+    }
+
+    private void assertContractMethod(String className, String methodName, int paramCount, String why) {
+        Class<?> clazz = loadClass(className);
+        assertNotNull(clazz, "RED[ai.coach.proactive] " + why + "(契约宿主 " + className + " 未实现)。");
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(methodName) && m.getParameterCount() == paramCount) {
+                return;
+            }
+        }
+        fail("RED[ai.coach.proactive] " + why + "(契约方法 " + className + "#"
+                + methodName + "(" + paramCount + ") 未实现)。");
+    }
+
+    /** 实例化契约宿主(默认构造),类缺失即断言失败(红)。 */
+    private Object contractHost(String className, String key) {
+        Class<?> clazz = loadClass(className);
+        assertNotNull(clazz, "RED[" + key + "] 契约宿主 " + className + " 未实现(实现未落地)。");
+        try {
+            return clazz.getDeclaredConstructor().newInstance();
+        } catch (Exception e) {
+            fail("契约宿主 " + className + " 实例化失败:" + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        }
+    }
+
+    /** 按方法名 + 参数个数匹配并调用(宽松适配基本/包装类型),契约方法缺失即断言失败。 */
+    private Object call(Object host, String method, Object... args) {
+        for (Method m : host.getClass().getMethods()) {
+            if (m.getName().equals(method) && m.getParameterCount() == args.length) {
+                try {
+                    return m.invoke(host, args);
+                } catch (Exception e) {
+                    fail("契约方法 " + host.getClass().getName() + "#" + method + " 调用失败:" + e.getMessage());
+                    throw new IllegalStateException("unreachable");
+                }
+            }
+        }
+        fail("RED[ai.coach.proactive] 契约方法 " + host.getClass().getName() + "#" + method + " 未实现");
+        throw new IllegalStateException("unreachable");
+    }
+
+    private int callInt(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof Number) {
+            return ((Number) r).intValue();
+        }
+        fail("契约方法 " + method + " 应返回整数,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    private boolean callBoolean(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof Boolean) {
+            return (Boolean) r;
+        }
+        fail("契约方法 " + method + " 应返回布尔,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    private String[] callStringArray(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof String[]) {
+            return (String[]) r;
+        }
+        fail("契约方法 " + method + " 应返回 String[],实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    private void callVoid(Object host, String method, Object... args) {
+        call(host, method, args);
+    }
+
+    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 "";
+        }
+    }
+}