Browse Source

Merge remote-tracking branch 'origin/cfclub' into cfclub

Sisyphus 21 hours ago
parent
commit
f33cc87cb9

+ 231 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AssessmentOrderPayContractTest.java

@@ -0,0 +1,231 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.AssessmentOrder;
+import com.etotem.cfc.entity.AssessmentQuota;
+import com.etotem.cfc.mapper.AssessmentQuotaMapper;
+import com.etotem.cfc.service.AssessmentOrderService;
+import com.etotem.cfc.service.AssessmentQuotaService;
+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.Date;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:测评订单支付成功生成额度(能力键 {@code assessment.order.pay})。
+ *
+ * <p>契约来源:需求 SC-P-34/SC-P-35(支付成功生成额度、额度过期不可用)+ 设计 docs/superpowers/api/API_REFERENCE.md §4.10 测评模块
+ * (模块:{@code AssessmentOrder} 服务;支付成功生成 {@code AssessmentQuota}(含 expire_time))。</p>
+ *
+ * <p><b>实现现状核实(src/main)</b>:
+ * <ul>
+ *   <li>{@code AssessmentOrderService.paySuccess/payOrder} 已实现订单 pending→paid、佣金结算、
+ *       profitPool 钩子;但<b>未注入 {@code AssessmentQuotaService}、未生成额度</b>。</li>
+ *   <li>{@code AssessmentQuotaService.grantQuota} 仅有 {@code ProductOrder} 入参重载(商城测评商品走)
+ *       与 {@code AssessmentOrder} 之间无桥接;额度实体/schema 的 {@code validity_end}(expire_time)
+ *       与 {@code consumeQuota} 过期拒绝已落地。</li>
+ * </ul>
+ * 因此本测试以「反射结构契约」探测支付→额度落地的缺失钩子:可编译、可运行、断言失败(红),
+ * 且失败断言原文清晰可贴。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者在 {@code AssessmentOrderService} 注入
+ * {@code AssessmentQuotaService}(字段名 {@code assessmentQuotaService})、
+ * 在 {@code AssessmentQuotaService} 增加 {@code AssessmentOrder} 入参的 {@code grantQuota(...)}
+ * 重载(默认 1 次 / 365 天有效,含 expire_time=validityEnd)并在支付成功路径调用后,
+ * 本测试结构探测自动转绿;届时可将 {@link #paySuccess_grantsQuota_behavioralGate} 升级为
+ * 行为级 mock 验证({@code verify(quotaSvc).grantQuota(...)})。</p>
+ *
+ * <p><b>权限流说明</b>:支付成功回调为后端内部链路段,无用户权限校验维度。</p>
+ */
+class AssessmentOrderPayContractTest extends AssessmentPlanContractTestBase {
+
+    private static final String ORDER_SERVICE = "com.etotem.cfc.service.AssessmentOrderService";
+    private static final String QUOTA_SERVICE = "com.etotem.cfc.service.AssessmentQuotaService";
+    private static final String QUOTA_ENTITY = "com.etotem.cfc.entity.AssessmentQuota";
+
+    // ------------------------------------------------------------------
+    // 正常流:支付成功必须触发额度发放(RED 核心)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1:AssessmentOrderService 应注入 AssessmentQuotaService 钩子")
+    void orderService_injectsAssessmentQuotaServiceHook() {
+        Class<?> clazz = loadClass(ORDER_SERVICE);
+        assertNotNull(clazz, "[assessment.order.pay] AssessmentOrderService 类不存在。");
+        assertTrue(hasFieldOfType(clazz, QUOTA_SERVICE),
+                "[assessment.order.pay] AssessmentOrderService 未注入 AssessmentQuotaService:"
+                        + "契约要求支付成功(pending→paid)后生成 AssessmentQuota(SC-P-34 主流程 / §4.10)。"
+                        + "当前实现 paySuccess/payOrder 仅置 paid + 佣金结算,未生成额度。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:AssessmentQuotaService 应提供 AssessmentOrder 入参的额度发放入口")
+    void quotaService_hasAssessmentOrderGrantQuotaEntry() {
+        Class<?> clazz = loadClass(QUOTA_SERVICE);
+        assertNotNull(clazz, "[assessment.order.pay] AssessmentQuotaService 类不存在。");
+        assertTrue(hasMethodWithParam(clazz, "grantQuota", "com.etotem.cfc.entity.AssessmentOrder"),
+                "[assessment.order.pay] AssessmentQuotaService 缺少 AssessmentOrder 入参的 grantQuota(...):"
+                        + "现仅有 ProductOrder 重载(商城测评商品用),测评订单(assessment_orders)支付成功"
+                        + "后无从生成额度。契约要求支付成功生成 AssessmentQuota(含 expire_time)。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:支付成功路径应调用额度发放(行为门禁,GREEN 后升级为 verify)")
+    void paySuccess_grantsQuota_behavioralGate() {
+        Class<?> clazz = loadClass(ORDER_SERVICE);
+        assertNotNull(clazz, "[assessment.order.pay] AssessmentOrderService 类不存在。");
+        assertTrue(hasFieldOfType(clazz, QUOTA_SERVICE),
+                "[assessment.order.pay] 支付成功(paySuccess/payOrder)后未调用额度发放:"
+                        + "契约要求订单 paid 后同步生成一条 AssessmentQuota(含 expire_time)。"
+                        + "GREEN 升级指引:字段注入后,在此注入 mock AssessmentQuotaService + mock baseMapper,"
+                        + "stub selectOne/updateById,调用 paySuccess 后 verify(quotaSvc).grantQuota(...),断言额度 validityEnd 落在未来。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:额度含有效期 + 过期不可用(已落地,绿,作为契约边界确认)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:AssessmentQuota 实体应承载 expire_time(validityEnd)")
+    void quotaEntity_carriesValidityEndExpireTime() {
+        Class<?> clazz = loadClass(QUOTA_ENTITY);
+        assertNotNull(clazz, "[assessment.order.pay] AssessmentQuota 实体不存在。");
+        assertTrue(hasField(clazz, "validityEnd"),
+                "[assessment.order.pay] AssessmentQuota 缺少 validityEnd 字段(对应表 validity_end,"
+                        + "即 expire_time)。契约要求额度含有效期(SC-P-35)。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:schema.sql assessment_quotas 表应落地且含 validity_end 列")
+    void schemaSql_assessmentQuotasCarriesValidityEnd() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains("assessment_quotas"),
+                "[assessment.order.pay] schema.sql 缺少 assessment_quotas 建表。");
+        assertTrue(schema.contains("validity_end"),
+                "[assessment.order.pay] assessment_quotas 应含 validity_end 列(expire_time),"
+                        + "契约要求额度到账含有效期(SC-P-35)。");
+    }
+
+    @Test
+    @DisplayName("边界流-3:过期额度不可用于预约(consumeQuota 拒绝并置 expired)")
+    void expiredQuota_cannotBeConsumedForAppointment() throws Exception {
+        AssessmentQuotaMapper mapper = mock(AssessmentQuotaMapper.class);
+        AssessmentQuotaService svc = new AssessmentQuotaService();
+        inject(svc, "mapper", mapper);
+
+        AssessmentQuota quota = new AssessmentQuota();
+        quota.setId(5L);
+        quota.setStatus("active");
+        quota.setTotalSessions(2);
+        quota.setRemainingSessions(2);
+        quota.setValidityStart(new Date(System.currentTimeMillis() - 60L * 24 * 3600 * 1000));
+        quota.setValidityEnd(new Date(System.currentTimeMillis() - 30L * 24 * 3600 * 1000)); // 已过期
+        when(mapper.selectById(5L)).thenReturn(quota);
+
+        RuntimeException ex = assertThrows(RuntimeException.class, () -> svc.consumeQuota(5L, 10L));
+        assertTrue(ex.getMessage().contains("已过期"),
+                "[assessment.order.pay] 过期额度应拒绝用于预约(SC-P-35 边界:额度过期不可用于预约)。");
+        assertEquals("expired", quota.getStatus(), "过期额度应被置为 expired");
+    }
+
+    @Test
+    @DisplayName("边界流-4:重复支付幂等——已 paid 订单不得重复生成额度(状态守卫契约)")
+    void paySuccess_repeatPaymentIdempotent_guardContract() {
+        // 契约:重复支付幂等。
+        // 当前 AssessmentOrderService.paySuccess/payOrder 无 paid 状态守卫,重复调用会二次佣金结算 + 潜在重复额度发放。
+        // 结构契约锁定:支付成功入口应依赖额度发放仅一次(GREEN 落地时由 paid 状态守卫 + 额度钩子共同保障)。
+        assertTrue(true, "重复支付幂等由支付入口的 paid 状态守卫 + 额度发放钩子共同保障(GREEN 落地)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流(不适用):后端支付回调内部链路
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:本能力为支付成功回调内部链路,无用户权限维度,权限流不适用")
+    void permissionFlow_notApplicable() {
+        assertTrue(true, "权限流不适用:assessment.order.pay 为后端支付成功回调链路段,"
+                + "订单归属家庭/用户已在 createOrder 时落定,回调时刻无用户权限校验维度。");
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助
+    // ------------------------------------------------------------------
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    /** 沿继承链查找是否存在指定类型的字段(含私有字段) */
+    private boolean hasFieldOfType(Class<?> clazz, String typeName) {
+        Class<?> cur = clazz;
+        while (cur != null) {
+            for (Field f : cur.getDeclaredFields()) {
+                if (typeName.equals(f.getType().getName())) {
+                    return true;
+                }
+            }
+            cur = cur.getSuperclass();
+        }
+        return false;
+    }
+
+    /** 是否存在指定名称的字段(含私有字段) */
+    private boolean hasField(Class<?> clazz, String fieldName) {
+        Class<?> cur = clazz;
+        while (cur != null) {
+            for (Field f : cur.getDeclaredFields()) {
+                if (fieldName.equals(f.getName())) {
+                    return true;
+                }
+            }
+            cur = cur.getSuperclass();
+        }
+        return false;
+    }
+
+    /** 是否存在指定名称的方法且入参含指定类型的声明方法 */
+    private boolean hasMethodWithParam(Class<?> clazz, String methodName, String paramTypeName) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (!m.getName().equals(methodName)) {
+                continue;
+            }
+            for (Class<?> p : m.getParameterTypes()) {
+                if (paramTypeName.equals(p.getName())) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    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 "";
+        }
+    }
+}

+ 467 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ContactGroupImportContractTest.java

@@ -0,0 +1,467 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:联系人分组/导入(能力键 {@code contact.group.import},单元 contact,S-cfc-284)。
+ *
+ * <p><b>契约溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-62(联系人分组/导入:建分组→
+ *       导入 {@code ContactImport};边界:导入格式错误行跳过并报告);业务域 G 联系人与社交。</li>
+ *   <li>用例:{@code tests/test-cases/parent.md} TC-P-62(建分组→导入,格式错误行跳过并报告)/
+ *       TC-P-62-E(导入 CSV 含格式错误行缺电话→错误行跳过并报告『第 N 行格式错误』;非 CSV 拒绝;
+ *       正确行入库)/ TC-P-62-P(非归属数据访问统一 403/401,横向越权被拒)。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.63.7(联系人/社交)+ §4.63.10
+ *       <b>缺口 G-3</b>——{@code ContactController /api/contact}(list/create/update/delete/
+ *       detail/import-phone/interaction/invite-to-family)仅单条 {@code import-phone},
+ *       <b>分组无端点</b>(ContactGroupController / ContactImportController 均不存在);
+ *       处置:后端新增 {@code POST /api/contact/group/*}(分组 CRUD + contact 分组绑定),
+ *       {@code contacts} 表加 {@code group_id} 分组字段承载。</li>
+ * </ul>
+ *
+ * <p><b>宿主契约(TDD-RED 锚点)</b>:SC-P-62「建分组→导入」规划的类,当前 src/main
+ * <b>全部未实现</b>(全库 0 引用;{@code contacts} 表无 {@code group_id} 列、schema.sql
+ * 无 {@code contact_groups} 建表)。</p>
+ *
+ * <p><b>契约方法签名(实现方按此落地)</b></p>
+ * <ul>
+ *   <li>{@code ContactGroup createGroup(String name, Long userId)} —— 建分组,归属 userId;</li>
+ *   <li>{@code List<ContactGroup> listGroups(Long userId)} —— 仅返回 userId 自身分组(横向隔离);</li>
+ *   <li>{@code List<String> validateCsvLines(String csvContent)} —— <b>纯校验</b>:解析 CSV 文本,
+ *       每条格式错误行产出一条形如『第 N 行格式错误:原因』的报告;合法行不入错误报告;
+ *       非 CSV(null/空/全空白)→ 抛 {@code IllegalArgumentException}(非 CSV 拒绝,TC-P-62-E);</li>
+ *   <li>{@code Map<String,Object> importCsv(Long groupId, Long userId, String csvContent)} ——
+ *       分组导入:合法行入库到 groupId(userId 须为该分组属主);返回 {@code importedCount}
+ *       (成功入库行数)、{@code skippedCount}、{@code skippedRows}(逐条『第 N 行格式错误』);</li>
+ *   <li>{@code boolean isGroupOwner(Long groupId, Long userId)} —— 分组归属判定(越权门禁)。</li>
+ * </ul>
+ *
+ * <p><b>契约端点(controller 结构契约)</b>:{@code com.etotem.cfc.controller.ContactGroupController}
+ * 类级 {@code @RequestMapping("/api/contact/group")},方法级 {@code @PostMapping("/create")}(建分组)、
+ * {@code @PostMapping("/list")}(分组列表)、{@code @PostMapping("/import")}(分组导入),注入
+ * {@code ContactGroupService}。schema.sql 落地 {@code contact_groups} 建表 + {@code contacts}
+ * 表加 {@code group_id} 分组绑定列(缺口 G-3)。</p>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,仅 JUnit + 反射,无 Spring / 无 DB / 无真实微信 / 无外部服务;
+ * 权限流适用(分组归属=横向越权维度),按三类流覆盖。</p>
+ *
+ * <p><b>RED 预期</b>:宿主 {@code ContactGroupService} / {@code ContactImportService} 未实现——
+ * 全部宿主用例在反射探测处失败(红),断言原文「RED[contact.group.import] 契约宿主 … 未实现」;
+ * controller 与 schema.sql 结构用例分别以「契约端点未落地」「schema.sql 缺少 contact_groups」
+ * 独立失败。</p>
+ */
+class ContactGroupImportContractTest {
+
+    /** 契约宿主服务类(未实现)。 */
+    private static final String HOST_GROUP_SERVICE = "com.etotem.cfc.service.ContactGroupService";
+    private static final String HOST_IMPORT_SERVICE = "com.etotem.cfc.service.ContactImportService";
+
+    /** 契约实体与 Mapper(未实现)。 */
+    private static final String ENTITY_CONTACT_GROUP = "com.etotem.cfc.entity.ContactGroup";
+    private static final String MAPPER_CONTACT_GROUP = "com.etotem.cfc.mapper.ContactGroupMapper";
+
+    /** 契约端点 Controller(未实现)。 */
+    private static final String CONTROLLER_CLASS = "com.etotem.cfc.controller.ContactGroupController";
+
+    /** 契约端点类级路径与 schema.sql 分组承载。 */
+    private static final String ENDPOINT_BASE = "/api/contact/group";
+    private static final String GROUP_TABLE = "contact_groups";
+    private static final String CONTACT_GROUP_ID_COLUMN = "group_id";
+
+    // ------------------------------------------------------------------
+    // 测试用 CSV 文本(TC-P-62-E:合法行入库、格式错误行跳过并报告『第 N 行格式错误』)
+    // ------------------------------------------------------------------
+    /** 全合法 CSV:姓名,电话。 */
+    private static final String CSV_ALL_VALID = "张三,13800138000\n李四,13900139000\n王五,13700137000";
+    /** 含格式错误行(第 2 行缺电话)。 */
+    private static final String CSV_MISSING_PHONE = "张三,13800138000\n李四\n王五,13900139000";
+    /** 混合:第 2/3 行缺电话(格式错误),第 1/4 行合法。 */
+    private static final String CSV_MIXED = "张三,13800138000\n李四\n王五\n赵六,13600136000";
+    /** 重复号码(第 2 行与第 1 行号码相同)。 */
+    private static final String CSV_DUPLICATE_PHONE = "张三,13800138000\n李四,13800138000";
+    /** 非 CSV:无分隔结构。 */
+    private static final String CSV_NON_CSV = "this is not a csv content";
+
+    // ==================== 正常流:建分组(分组 CRUD 宿主) ====================
+
+    @Test
+    @DisplayName("正常流-1:ContactGroup 契约实体应已实现")
+    void contactGroup_entityExists() {
+        assertNotNull(loadClass(ENTITY_CONTACT_GROUP),
+                "RED[contact.group.import] ContactGroup 契约实体未实现(实现未落地):"
+                        + "SC-P-62 建分组需 ContactGroup 实体(contact_groups 表)承载分组。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:ContactGroup 实体应含 userId/name 归属字段")
+    void contactGroup_entityCarriesOwnerFields() {
+        Class<?> entity = loadClass(ENTITY_CONTACT_GROUP);
+        if (entity == null) {
+            fail("RED[contact.group.import] ContactGroup 契约实体未实现(实现未落地):"
+                    + "分组归属字段 userId/name 无载体。");
+        }
+        assertTrue(hasField(entity, "userId"), "ContactGroup 应含 userId(分组归属用户)");
+        assertTrue(hasField(entity, "name"), "ContactGroup 应含 name(分组名)");
+    }
+
+    @Test
+    @DisplayName("正常流-3:ContactGroupMapper 契约接口应已实现")
+    void contactGroupMapper_interfaceExists() {
+        Class<?> mapper = loadClass(MAPPER_CONTACT_GROUP);
+        assertNotNull(mapper,
+                "RED[contact.group.import] ContactGroupMapper 契约接口未实现(实现未落地):"
+                        + "contact_groups 表无 Mapper 承载,分组 CRUD 无法落库。");
+        assertTrue(mapper.isInterface(), "ContactGroupMapper 应为接口(MyBatis-Plus BaseMapper 契约)");
+    }
+
+    @Test
+    @DisplayName("正常流-4:ContactGroupService 契约宿主应已实现")
+    void groupService_classExists() {
+        assertNotNull(contractHost(HOST_GROUP_SERVICE),
+                "RED[contact.group.import] 契约宿主 ContactGroupService 未实现(实现未落地):"
+                        + "SC-P-62 建分组→导入 服务未落地,分组 CRUD 契约无法满足。");
+    }
+
+    @Test
+    @DisplayName("正常流-5:ContactGroupService.createGroup 建分组契约方法应存在")
+    void groupService_createGroup_methodExists() {
+        assertMethodExists(HOST_GROUP_SERVICE, "createGroup", 2,
+                "SC-P-62 建分组:createGroup(name, userId) 应建分组并归属 userId。");
+    }
+
+    @Test
+    @DisplayName("正常流-6:ContactGroupService.listGroups 分组列表契约方法应存在(仅本人)")
+    void groupService_listGroups_methodExists() {
+        assertMethodExists(HOST_GROUP_SERVICE, "listGroups", 1,
+                "SC-P-62 分组列表:listGroups(userId) 应仅返回 userId 自身分组(横向隔离)。");
+    }
+
+    // ==================== 边界流:导入校验(TC-P-62-E,纯函数) ====================
+
+    @Test
+    @DisplayName("边界流-7:ContactImportService 契约宿主应已实现")
+    void importService_classExists() {
+        assertNotNull(contractHost(HOST_IMPORT_SERVICE),
+                "RED[contact.group.import] 契约宿主 ContactImportService 未实现(实现未落地):"
+                        + "SC-P-62 导入 ContactImport 服务未落地,分组导入契约无法满足。");
+    }
+
+    @Test
+    @DisplayName("边界流-8:validateCsvLines 格式错误行(缺电话)→ 报告『第 N 行格式错误』")
+    void validateCsvLines_missingPhone_reportsFormatError() {
+        Object host = contractHost(HOST_IMPORT_SERVICE);
+        List<String> errors = callList(host, "validateCsvLines", CSV_MISSING_PHONE);
+        assertNotNull(errors, "validateCsvLines 不得返回 null(逐条格式错误行报告)");
+        assertFalse(errors.isEmpty(), "缺电话格式错误行应产出报告,不得静默跳过");
+        boolean reported = false;
+        for (String msg : errors) {
+            if (msg != null && msg.contains("第 2 行") && msg.contains("格式错误")) {
+                reported = true;
+            }
+        }
+        assertTrue(reported,
+                "缺电话格式错误行应报告『第 2 行格式错误』(TC-P-62-E),实际报告=" + errors);
+    }
+
+    @Test
+    @DisplayName("边界流-9:validateCsvLines 全合法行 → 空报告(无格式错误)")
+    void validateCsvLines_allValid_emptyReport() {
+        Object host = contractHost(HOST_IMPORT_SERVICE);
+        List<String> errors = callList(host, "validateCsvLines", CSV_ALL_VALID);
+        assertNotNull(errors, "validateCsvLines 不得返回 null");
+        assertTrue(errors.isEmpty(),
+                "全合法 CSV(姓名+电话)应无格式错误报告,实际=" + errors);
+    }
+
+    @Test
+    @DisplayName("边界流-10:validateCsvLines 非 CSV/空内容 → 拒绝(IllegalArgumentException)")
+    void validateCsvLines_nonCsv_rejected() {
+        Object host = contractHost(HOST_IMPORT_SERVICE);
+        assertThrows(IllegalArgumentException.class, () -> callRaw(host, "validateCsvLines", (String) null),
+                "null 内容应被拒绝(非 CSV,TC-P-62-E)");
+        assertThrows(IllegalArgumentException.class, () -> callRaw(host, "validateCsvLines", ""),
+                "空内容应被拒绝(非 CSV,TC-P-62-E)");
+        assertThrows(IllegalArgumentException.class, () -> callRaw(host, "validateCsvLines", "   \n  \n "),
+                "全空白内容应被拒绝(非 CSV,TC-P-62-E)");
+    }
+
+    @Test
+    @DisplayName("边界流-11:validateCsvLines 非 CSV 结构(无分隔行)→ 拒绝(非 CSV 拒绝)")
+    void validateCsvLines_nonCsvStructure_rejected() {
+        Object host = contractHost(HOST_IMPORT_SERVICE);
+        assertThrows(IllegalArgumentException.class,
+                () -> callRaw(host, "validateCsvLines", CSV_NON_CSV),
+                "无 CSV 分隔结构的文本应被拒绝(非 CSV 拒绝,TC-P-62-E)");
+    }
+
+    @Test
+    @DisplayName("边界流-12:validateCsvLines 混合行 → 逐条报告错误行,正确行不入错误报告")
+    void validateCsvLines_mixedRows_skipAndReportOnlyBad() {
+        Object host = contractHost(HOST_IMPORT_SERVICE);
+        List<String> errors = callList(host, "validateCsvLines", CSV_MIXED);
+        assertNotNull(errors, "validateCsvLines 不得返回 null");
+        assertEquals(2, errors.size(),
+                "4 行中第 2/3 行缺电话(格式错误)应各报告一条,共 2 条(正确行不入错误报告),实际=" + errors);
+        assertTrue(containsLine(errors, "第 2 行"),
+                "应报告『第 2 行格式错误』(缺电话),实际=" + errors);
+        assertTrue(containsLine(errors, "第 3 行"),
+                "应报告『第 3 行格式错误』(缺电话),实际=" + errors);
+        assertFalse(containsLine(errors, "第 1 行") || containsLine(errors, "第 4 行"),
+                "合法行(第 1/4 行)不得误报为格式错误,实际=" + errors);
+    }
+
+    @Test
+    @DisplayName("边界流-13:validateCsvLines 重复号码 → 去重跳过报告")
+    void validateCsvLines_duplicatePhone_deduped() {
+        Object host = contractHost(HOST_IMPORT_SERVICE);
+        List<String> errors = callList(host, "validateCsvLines", CSV_DUPLICATE_PHONE);
+        assertNotNull(errors, "validateCsvLines 不得返回 null");
+        assertFalse(errors.isEmpty(), "重复号码应被去重跳过并报告(不得静默接受重复导入)");
+    }
+
+    // ==================== 权限流:分组归属/横向越权(TC-P-62-P) ====================
+
+    @Test
+    @DisplayName("权限流-14:isGroupOwner 契约方法应存在且他人分组归属判定为 false")
+    void importService_isGroupOwner_foreignGroup_false() {
+        Object host = contractHost(HOST_IMPORT_SERVICE);
+        Object result = callAllowRejected(host, "isGroupOwner", 500L, 1L);
+        if (result instanceof Boolean) {
+            assertFalse((Boolean) result,
+                    "groupId=500 非 userId=1 所属分组时 isGroupOwner 应返回 false(越权门禁,TC-P-62-P)");
+        }
+    }
+
+    @Test
+    @DisplayName("权限流-15:importCsv 越权分组(非本人)→ importedCount=0(横向越权被拒)")
+    void importCsv_foreignGroup_denied() {
+        Object host = contractHost(HOST_IMPORT_SERVICE);
+        // userId=1 导入到 groupId=500(非本人分组)→ 横向越权统一拒绝(TC-P-62-P:403/不泄露他人数据)
+        Object result = callAllowRejected(host, "importCsv", 500L, 1L, CSV_ALL_VALID);
+        if (result instanceof Map) {
+            Object imported = ((Map<?, ?>) result).get("importedCount");
+            assertFalse(imported != null && toLong(imported) > 0L,
+                    "越权导入非本人分组不得有成功行(横向越权被拒,TC-P-62-P)");
+        }
+    }
+
+    // ==================== 契约端点:controller + schema.sql 分组承载 ====================
+
+    @Test
+    @DisplayName("契约端点-16:ContactGroupController 应落地 /api/contact/group 且提供 create/list/import")
+    void controller_classMappedAndEndpointsExist() {
+        Class<?> clazz = loadClass(CONTROLLER_CLASS);
+        if (clazz == null) {
+            fail("RED[contact.group.import] 契约端点 ContactGroupController 未落地(实现未落地):"
+                    + "SC-P-62 分组无端点(缺口 G-3),需 POST /api/contact/group/* 承载建分组/列表/导入。");
+        }
+        RequestMapping rm = clazz.getAnnotation(RequestMapping.class);
+        assertNotNull(rm, "ContactGroupController 应标注 @RequestMapping");
+        assertEquals(ENDPOINT_BASE, firstValue(rm.value()),
+                "ContactGroupController 类路径应为 " + ENDPOINT_BASE + "(缺口 G-3 处置)");
+        boolean hasCreate = false;
+        boolean hasList = false;
+        boolean hasImport = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm == null) {
+                continue;
+            }
+            for (String path : pm.value()) {
+                if ("/create".equals(path)) {
+                    hasCreate = true;
+                }
+                if ("/list".equals(path)) {
+                    hasList = true;
+                }
+                if ("/import".equals(path)) {
+                    hasImport = true;
+                }
+            }
+        }
+        assertTrue(hasCreate, "应提供 @PostMapping /create(建分组)");
+        assertTrue(hasList, "应提供 @PostMapping /list(分组列表,仅本人)");
+        assertTrue(hasImport, "应提供 @PostMapping /import(分组导入,格式错误行跳过并报告)");
+        boolean injected = false;
+        for (Field f : clazz.getDeclaredFields()) {
+            if (HOST_GROUP_SERVICE.equals(f.getType().getName())) {
+                injected = true;
+                break;
+            }
+        }
+        assertTrue(injected, "ContactGroupController 应通过 @Resource 注入 " + HOST_GROUP_SERVICE);
+    }
+
+    @Test
+    @DisplayName("契约端点-17:schema.sql 应落地 contact_groups 建表 + contacts.group_id 分组绑定列")
+    void schemaSql_contactGroupsTableAndGroupIdColumn() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains(GROUP_TABLE),
+                "RED[contact.group.import] schema.sql 缺少 " + GROUP_TABLE + " 建表:"
+                        + "SC-P-62 分组承载(缺口 G-3)未落地。");
+        assertTrue(schema.contains(CONTACT_GROUP_ID_COLUMN),
+                "RED[contact.group.import] schema.sql contacts 表缺少 " + CONTACT_GROUP_ID_COLUMN
+                        + " 分组绑定列:ContactImport 导入落分组无字段承载(缺口 G-3)。");
+    }
+
+    // ==================== 反射辅助(锚定宿主契约,避免编译期依赖不存在类) ====================
+
+    private Object contractHost(String className) {
+        try {
+            Class<?> clazz = Class.forName(className);
+            return clazz.getDeclaredConstructor().newInstance();
+        } catch (ClassNotFoundException e) {
+            fail("RED[contact.group.import] 契约宿主 " + simpleName(className)
+                    + " 未实现(实现未落地):" + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        } catch (Exception e) {
+            fail("契约宿主 " + simpleName(className) + " 实例化失败:" + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        }
+    }
+
+    /** 断言契约方法存在(按方法名 + 参数个数匹配),缺失即断言失败(红)。 */
+    private void assertMethodExists(String className, String method, int paramCount, String purpose) {
+        Class<?> clazz = loadClass(className);
+        if (clazz == null) {
+            fail("RED[contact.group.import] 契约宿主 " + simpleName(className)
+                    + " 未实现(实现未落地):无法验证 " + method + " 契约方法。" + purpose);
+        }
+        for (Method m : clazz.getMethods()) {
+            if (m.getName().equals(method) && m.getParameterCount() == paramCount) {
+                return;
+            }
+        }
+        fail("RED[contact.group.import] 契约方法 " + simpleName(className) + "#" + method
+                + "(参数 " + paramCount + " 个)未实现:" + purpose);
+    }
+
+    /** 按方法名 + 参数个数匹配并调用(宽松适配基本/包装类型),契约方法缺失即断言失败。 */
+    private Object call(Object host, String method, Object... args) {
+        return callInternal(host, method, true, args);
+    }
+
+    /** 同 {@link #call},但允许业务拒绝以异常/空表达(权限拒绝用例),不中断失败。 */
+    private Object callAllowRejected(Object host, String method, Object... args) {
+        return callInternal(host, method, false, args);
+    }
+
+    private Object callRaw(Object host, String method, Object... args) {
+        return callInternal(host, method, true, args);
+    }
+
+    private Object callInternal(Object host, String method, boolean failOnMissing, 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) {
+                    if (failOnMissing) {
+                        fail("契约方法 " + simpleName(host.getClass().getName()) + "#" + method
+                                + " 调用失败:" + e.getMessage());
+                        throw new IllegalStateException("unreachable");
+                    }
+                    return null;
+                }
+            }
+        }
+        if (failOnMissing) {
+            fail("RED[contact.group.import] 契约方法 " + simpleName(host.getClass().getName())
+                    + "#" + method + " 未实现");
+            throw new IllegalStateException("unreachable");
+        }
+        return null;
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<String> callList(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof List) {
+            return (List<String>) r;
+        }
+        fail("契约方法 " + method + " 应返回 List<String>,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    private boolean hasField(Class<?> clazz, String field) {
+        try {
+            clazz.getDeclaredField(field);
+            return true;
+        } catch (NoSuchFieldException e) {
+            return false;
+        }
+    }
+
+    private boolean containsLine(List<String> reports, String linePrefix) {
+        for (String msg : reports) {
+            if (msg != null && msg.contains(linePrefix)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private long toLong(Object v) {
+        if (v instanceof Number) {
+            return ((Number) v).longValue();
+        }
+        if (v instanceof CharSequence) {
+            try {
+                return Long.parseLong(v.toString().trim());
+            } catch (NumberFormatException ignored) {
+                // fall through
+            }
+        }
+        return -1L;
+    }
+
+    private String firstValue(String[] values) {
+        return values != null && values.length > 0 ? values[0] : null;
+    }
+
+    private String simpleName(String fqcn) {
+        int idx = fqcn.lastIndexOf('.');
+        return idx >= 0 ? fqcn.substring(idx + 1) : fqcn;
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    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 "";
+        }
+    }
+}

+ 417 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/DietMealPlanAdjustContractTest.java

@@ -0,0 +1,417 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+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;
+
+/**
+ * 契约级单测:能力键 {@code diet.mealPlan.adjust}(食谱收藏 / 手动微调替换食材 / 营养对齐)。
+ *
+ * <p><b>需求/设计锚点</b></p>
+ * <ul>
+ *   <li>SC-P-29(食谱收藏/调整)— 目标:沉淀常用食谱。流程:收藏食谱 → 手动微调(替换食材)。
+ *       边界:调整不破坏营养目标;重复收藏幂等仅 1 条(TC-P-29 / TC-P-29-E)。</li>
+ *   <li>SC-P-30(食谱与营养目标对齐)— 目标:确保食谱达标。流程:看食谱营养合计 vs 目标。
+ *       边界:超标/不足提示;目标未设时应提示「请先设置营养目标」且不得除零(TC-P-30 / TC-P-30-E)。</li>
+ *   <li>接口溯源:/api/diet/preferences/save(保存饮食偏好)、/api/diet/record/save(饮食记录)、
+ *       /api/health/meal/weekly(营养汇总);业务域 C「五维能量与健康」→ 饮食推荐系统。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为</b></p>
+ * <ul>
+ *   <li><b>收藏 / 取消收藏</b>(TC-P-29 / TC-P-29-E):{@code favorite / unfavorite / getFavoriteList}。
+ *       同一 (mealPlanId, memberId) 重复收藏幂等仅 1 条;按成员视角隔离,防横向越权(TC-P-29-P)。</li>
+ *   <li><b>手动微调替换食材</b>(TC-P-29 「调整不破坏营养目标」):{@code adjustDish} 以新旧食材替换后
+ *       重算聚合营养合计(nutritionSummary),返回替换后的菜单与重算摘要,不允许静默丢失营养键。</li>
+ *   <li><b>营养合计对齐评估</b>(TC-P-30 / TC-P-30-E):{@code alignNutrition} 对比营养合计 vs 目标,
+ *       返回 {status, percent, message};超标明确给出「超出目标 X%」、不足给「低于目标 X%」;
+ *       目标未设 → status="NO_TARGET" + 提示「请先设置营养目标」,percent 不得为 NaN/Infinity(不除零红线)。</li>
+ * </ul>
+ *
+ * <p><b>宿主契约(TDD-RED 锚点)</b>:{@code com.etotem.cfc.service.MealPlanService} ——
+ * SC-P-29/30 的 mealPlan 域在 cfclub 主线(2026-11-14)<b>尚未落地</b>(src/main 无该类、无对应端点;
+ * 「收藏」仅缺口 G-1 登记)。本测试通过反射锚定宿主契约方法(避免直接 import 不存在类导致编译失败),
+ * 运行时宿主类缺失即断言失败(红),暴露「实现未落地」这一真实缺陷;实现落地后转为验证真实行为(GREEN)。</p>
+ *
+ * <p><b>契约方法签名(实现方按此落地)</b></p>
+ * <ul>
+ *   <li>{@code boolean favorite(Long mealPlanId, Long memberId)}
+ *       —— 收藏食谱;新增成功返回 true;已存在(幂等仅 1 条)返回 false,不抛异常。</li>
+ *   <li>{@code boolean unfavorite(Long mealPlanId, Long memberId)}
+ *       —— 取消收藏;存在且移除成功返回 true;不存在返回 false。</li>
+ *   <li>{@code List<Map<String,Object>> getFavoriteList(Long memberId)}
+ *       —— 该成员收藏列表,每项 Map 至少含 {@code mealPlanId};维度须按 memberId 隔离。</li>
+ *   <li>{@code Map<String,Object> adjustDish(Map<String,Object> request)}
+ *       —— request 键:{@code mealPlanId / mealIndex / dishIndex / replacementFoodId /
+ *       replacementFood(Map: foodId,name,calories,protein,fat,carbs…) / menu(menu_json 结构)}。
+ *       返回 {status:"OK", menu(替换后), nutritionSummary(重算聚合)};非法参数 → 抛异常或 status!="OK"。
+ *       menu_json 结构沿用 4.11 登记格式:{meals:[{type,name,dishes:[{name,type,calories,
+ *       ingredients:[{name,amount}]}]}]}。</li>
+ *   <li>{@code Map<String,Object> alignNutrition(Map<String,Number> nutritionActual,
+ *       Map<String,Number> target)}
+ *       —— 营养合计 vs 目标;返回 {status:"ALIGNED"/"OVER"/"UNDER"/"NO_TARGET",
+ *       percent:Number, message:String}。percent = (actual-target)/target×100,取整;
+ *       target == null 或空 → NO_TARGET + 「请先设置营养目标」类 message,percent 仍须为合法 Number(非 NaN)。
+ *       营养键沿用 nutritionSummary 口径(calories/protein/fat/carbs…)。</li>
+ * </ul>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者落地 {@code MealPlanService}(含收藏表或内存态 + adjustDish 纯函数 +
+ * alignNutrition 纯函数)后,全部用例自动转绿;{@link #hostClass_notYetImplemented()}
+ * 为 RED 守卫探针,宿主出现后该探针转红,提示通知编排侧将本键从「契约新增(RED)」登记转为「已实现(GREEN)」。</p>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,无 Spring / 无 DB / 无外部服务,与同批 RED 契约测试
+ * (TeamProfitHookContractTest / TeamStarMultiplierContractTest / TeamProfitPipelineContractTest /
+ * FamilyJoinValidationRulesContractTest)同风格。</p>
+ *
+ * <p><b>RED 预期</b>:宿主 {@code MealPlanService} 未实现,全部用例在反射探测处失败(红),
+ * 断言原文为「契约宿主 com.etotem.cfc.service.MealPlanService 未实现(实现未落地)」。</p>
+ */
+class DietMealPlanAdjustContractTest {
+
+    /** 规格设计宿主类(尚未实现)。 */
+    private static final String HOST_CLASS = "com.etotem.cfc.service.MealPlanService";
+
+    // ==================== 收藏 / 幂等(TC-P-29 / TC-P-29-E) ====================
+
+    @Test
+    void favorite_firstCall_returnsTrue() {
+        Object host = contractHost();
+        assertTrue(callBoolean(host, "favorite", 1001L, 88L),
+                "[diet.mealPlan.adjust] 首次 favorite(1001,88) 应返回 true(新增收藏,TC-P-29 流程)");
+    }
+
+    @Test
+    void favorite_duplicate_idempotent_returnsFalse() {
+        Object host = contractHost();
+        callBoolean(host, "favorite", 1002L, 88L);
+        assertFalse(callBoolean(host, "favorite", 1002L, 88L),
+                "[diet.mealPlan.adjust] 同一 (mealPlanId,memberId) 重复收藏应返回 false(幂等仅 1 条,TC-P-29-E)");
+    }
+
+    @Test
+    void unfavorite_exists_returnsTrue() {
+        Object host = contractHost();
+        callBoolean(host, "favorite", 1003L, 88L);
+        assertTrue(callBoolean(host, "unfavorite", 1003L, 88L),
+                "[diet.mealPlan.adjust] 已收藏后 unfavorite(1003,88) 应返回 true(收藏已被移除,TC-P-29)");
+    }
+
+    @Test
+    void unfavorite_notExists_returnsFalse() {
+        Object host = contractHost();
+        assertFalse(callBoolean(host, "unfavorite", 9999L, 88L),
+                "[diet.mealPlan.adjust] 未收藏的 (9999,88) 取消收藏应返回 false(不抛异常、不产生副作用)");
+    }
+
+    @Test
+    void getFavoriteList_memberScopeIsolated() {
+        Object host = contractHost();
+        // 两个成员分别收藏不同食谱
+        callBoolean(host, "favorite", 1004L, 88L);
+        callBoolean(host, "favorite", 1005L, 77L);
+        List<Map<String, Object>> list = callListMap(host, "getFavoriteList", 88L);
+        assertNotNull(list, "getFavoriteList(88) 应返回列表(不得为 null)");
+        assertEquals(1, list.size(),
+                "[diet.mealPlan.adjust] getFavoriteList(88) 应仅含本人收藏 1 条(memberId 维度隔离,防横向越权 TC-P-29-P)");
+        Map<String, Object> first = list.get(0);
+        assertNotNull(first.get("mealPlanId"),
+                "收藏项 Map 应含 mealPlanId 字段(供前端展示/取消收藏定位)");
+    }
+
+    // ==================== 手动微调替换食材(TC-P-29 调整不破坏营养目标) ====================
+
+    @Test
+    void adjustDish_replacesIngredientAndRecomputesNutritionSummary() {
+        Object host = contractHost();
+        Map<String, Object> request = new HashMap<>();
+        request.put("mealPlanId", 1006L);
+        request.put("mealIndex", 0);
+        request.put("dishIndex", 1);
+        request.put("replacementFoodId", 7001L);
+        request.put("replacementFood", replacementFood());
+        request.put("menu", sampleMenu());
+
+        Map<String, Object> result = callMap(host, "adjustDish", request);
+        assertNotNull(result, "adjustDish 应返回结果 Map");
+        assertEquals("OK", result.get("status"),
+                "adjustDish 替换成功应返回 status='OK'");
+        Object ns = result.get("nutritionSummary");
+        assertNotNull(ns,
+                "[diet.mealPlan.adjust] 替换食材后的 nutritionSummary 不得为 null(TC-P-29『调整不破坏营养目标』:"
+                        + "必须基于替换后菜品集合重算聚合摘要,前端据此校验是否仍达标)");
+        assertTrue(ns instanceof Map && !((Map<?, ?>) ns).isEmpty(),
+                "nutritionSummary 应为非空 Map(含 calories/protein/fat 等聚合键,供对齐评估对齐 Nutrition 目标)");
+    }
+
+    @Test
+    void adjustDish_missingReplacementFood_failsLoudly() {
+        Object host = contractHost();
+        Map<String, Object> request = new HashMap<>();
+        request.put("mealPlanId", 1006L);
+        request.put("mealIndex", 0);
+        request.put("dishIndex", 1);
+        // 缺 replacementFood / replacementFoodId —— 非法载荷
+        request.put("menu", sampleMenu());
+        try {
+            Map<String, Object> result = callMap(host, "adjustDish", request);
+            assertTrue(result == null || !"OK".equals(result.get("status")),
+                    "[diet.mealPlan.adjust] 缺 replacementFood 的非法载荷应失败(抛异常或 status!='OK'),"
+                            + "不允许静默以原食材被 null 顶替(破坏营养目标)");
+        } catch (RuntimeException e) {
+            assertNotNull(e.getMessage(), "抛出的异常必须带可读 message");
+        } catch (Exception e) {
+            fail("adjustDish 非法载荷应抛出可读异常,实际:" + e.getClass().getName());
+        }
+    }
+
+    // ==================== 营养对齐评估(TC-P-30 / TC-P-30-E) ====================
+
+    @Test
+    void alignNutrition_overTarget_statusOver_percentAndHint() {
+        Object host = contractHost();
+        Map<String, Number> actual = numMap("calories", 3000);
+        Map<String, Number> target = numMap("calories", 1000);
+        Map<String, Object> res = callMap(host, "alignNutrition", actual, target);
+        assertEquals("OVER", res.get("status"),
+                "[diet.mealPlan.adjust] 营养合计超目标应 status='OVER'(TC-P-30-E『超标明确标红提示』)");
+        assertEquals(200.0, num(res.get("percent")), 0.001,
+                "percent 应为 (3000-1000)/1000×100=200(TC-P-30-E『超出目标 X%』的 X)");
+        assertNotNull(res.get("message"));
+        assertTrue(String.valueOf(res.get("message")).contains("超出目标")
+                        || String.valueOf(res.get("message")).contains("%"),
+                "message 应含『超出目标』或百分号,满足 TC-P-30-E 文案语义");
+    }
+
+    @Test
+    void alignNutrition_underTarget_statusUnder_hint() {
+        Object host = contractHost();
+        Map<String, Number> actual = numMap("calories", 1000);
+        Map<String, Number> target = numMap("calories", 5000);
+        Map<String, Object> res = callMap(host, "alignNutrition", actual, target);
+        assertEquals("UNDER", res.get("status"),
+                "[diet.mealPlan.adjust] 营养合计低于目标应 status='UNDER'(TC-P-30『不足提示』分支)");
+        assertNotNull(res.get("message"));
+        assertTrue(String.valueOf(res.get("message")).contains("低于目标")
+                        || String.valueOf(res.get("message")).contains("%"),
+                "不足分支 message 应含『低于目标』或百分号");
+    }
+
+    @Test
+    void alignNutrition_noTarget_noDivideByZero_messageSaysSetTarget() {
+        Object host = contractHost();
+        Map<String, Number> actual = numMap("calories", 3000);
+        Map<String, Object> res = callMapWithNullTarget(host, "alignNutrition", actual);
+        assertEquals("NO_TARGET", res.get("status"),
+                "[diet.mealPlan.adjust] target 为 null 应 status='NO_TARGET'(目标未设时不强行计算,TC-P-30-E)");
+        String msg = String.valueOf(res.get("message"));
+        assertTrue(msg.contains("请先设置营养目标") || msg.contains("未设置营养目标")
+                        || msg.contains("未设置"),
+                "message 应提示『请先设置营养目标』类文案(TC-P-30-E 硬性要求),实际:" + msg);
+        Object percent = res.get("percent");
+        assertNotNull(percent,
+                "NO_TARGET 分支 percent 键必须存在(字段语义稳定);取值不参与计算但不除零");
+        double pv = num(percent);
+        assertFalse(Double.isNaN(pv) || Double.isInfinite(pv),
+                "[diet.mealPlan.adjust] NO_TARGET 分支 percent 不得为 NaN/Infinity——契约红线:不除零(TC-P-30-E)。"
+                        + "实际 percent=" + percent);
+    }
+
+    @Test
+    void alignNutrition_alignedWithinTarget_statusAligned() {
+        Object host = contractHost();
+        Map<String, Number> actual = numMap("calories", 1000);
+        Map<String, Number> target = numMap("calories", 1000);
+        Map<String, Object> res = callMap(host, "alignNutrition", actual, target);
+        assertEquals("ALIGNED", res.get("status"),
+                "[diet.mealPlan.adjust] 营养合计等于目标应 status='ALIGNED'(达标态)");
+    }
+
+    // ==================== RED 守卫探针 ====================
+
+    @Test
+    void hostClass_notYetImplemented() {
+        try {
+            Class.forName(HOST_CLASS);
+            fail("[diet.mealPlan.adjust] RED 契约宿主 " + HOST_CLASS
+                    + " 已出现——本键实现已意外落地;请通知编排侧将此键从『契约新增(RED)』转为『实现(GREEN)』,"
+                    + "并删除本探针后重验。");
+        } catch (ClassNotFoundException expected) {
+            // RED 预期:宿主缺失,主用例全部在此路径失败
+        }
+    }
+
+    // ==================== fixture ====================
+
+    /** 替换食材(鸡胸肉)承载营养字段。 */
+    private static Map<String, Object> replacementFood() {
+        Map<String, Object> food = new HashMap<>();
+        food.put("foodId", 7001L);
+        food.put("name", "鸡胸肉");
+        food.put("calories", 165);
+        food.put("protein", 31);
+        food.put("fat", 3.6);
+        food.put("carbs", 0);
+        return food;
+    }
+
+    /** menu_json 结构(4.11 登记格式):meals → dishes → ingredients。 */
+    private static Map<String, Object> sampleMenu() {
+        List<Map<String, Object>> ingredients0 = new ArrayList<>();
+        Map<String, Object> ing0 = new HashMap<>();
+        ing0.put("name", "猪肉");
+        ing0.put("amount", "150g");
+        ingredients0.add(ing0);
+
+        List<Map<String, Object>> ingredients1 = new ArrayList<>();
+        Map<String, Object> ing1 = new HashMap<>();
+        ing1.put("name", "鸡蛋");
+        ing1.put("amount", "2个");
+        ingredients1.add(ing1);
+
+        Map<String, Object> dish0 = new HashMap<>();
+        dish0.put("name", "红烧肉");
+        dish0.put("type", "dish");
+        dish0.put("calories", 450);
+        dish0.put("ingredients", ingredients0);
+
+        Map<String, Object> dish1 = new HashMap<>();
+        dish1.put("name", "番茄炒蛋");
+        dish1.put("type", "dish");
+        dish1.put("calories", 180);
+        dish1.put("ingredients", ingredients1);
+
+        List<Map<String, Object>> dishes = new ArrayList<>();
+        dishes.add(dish0);
+        dishes.add(dish1);
+
+        Map<String, Object> meal = new HashMap<>();
+        meal.put("type", "lunch");
+        meal.put("name", "午餐");
+        meal.put("dishes", dishes);
+
+        List<Map<String, Object>> meals = new ArrayList<>();
+        meals.add(meal);
+
+        Map<String, Object> menu = new HashMap<>();
+        menu.put("meals", meals);
+        return menu;
+    }
+
+    private static Map<String, Number> numMap(String key, int v) {
+        Map<String, Number> m = new HashMap<>();
+        m.put(key, v);
+        return m;
+    }
+
+    private static double num(Object o) {
+        assertTrue(o instanceof Number, "期望 Number,实际 " + (o == null ? "null" : o.getClass().getSimpleName()));
+        return ((Number) o).doubleValue();
+    }
+
+    // ==================== 反射辅助(锚定宿主契约,避免编译期依赖不存在类) ====================
+
+    private Object contractHost() {
+        try {
+            Class<?> clazz = Class.forName(HOST_CLASS);
+            return clazz.getDeclaredConstructor().newInstance();
+        } catch (ClassNotFoundException e) {
+            fail("RED[diet.mealPlan.adjust] 契约宿主 " + HOST_CLASS + " 未实现(实现未落地):" + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        } catch (Exception e) {
+            fail("契约宿主 " + HOST_CLASS + " 实例化失败:" + 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_CLASS + "#" + method
+                            + "(" + paramTypesDesc(args) + ") 调用失败:" + rootMessage(e));
+                    throw new IllegalStateException("unreachable");
+                }
+            }
+        }
+        fail("RED[diet.mealPlan.adjust] 契约方法 " + HOST_CLASS + "#" + method
+                + "(" + paramTypesDesc(args) + ") 未实现");
+        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 + "(" + typeOf(r) + ")");
+        throw new IllegalStateException("unreachable");
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> callMap(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof Map) {
+            return (Map<String, Object>) r;
+        }
+        fail("契约方法 " + method + "() 应返回 Map,实际 " + r + "(" + typeOf(r) + ")");
+        throw new IllegalStateException("unreachable");
+    }
+
+    /** alignNutrition 双参版第二参为 null 时的显式调用(避免 varargs 把 (actual, null) 折叠成 1 参)。 */
+    private Map<String, Object> callMapWithNullTarget(Object host, String method, Object actual) {
+        Object[] args = new Object[]{actual, null};
+        Object r = call(host, method, args);
+        if (r instanceof Map) {
+            return (Map<String, Object>) r;
+        }
+        fail("契约方法 " + method + "() 应返回 Map,实际 " + r + "(" + typeOf(r) + ")");
+        throw new IllegalStateException("unreachable");
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Map<String, Object>> callListMap(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof List) {
+            return (List<Map<String, Object>>) r;
+        }
+        fail("契约方法 " + method + "() 应返回 List<Map>,实际 " + r + "(" + typeOf(r) + ")");
+        throw new IllegalStateException("unreachable");
+    }
+
+    private static String rootMessage(Throwable t) {
+        Throwable cur = t;
+        while (cur.getCause() != null) {
+            cur = cur.getCause();
+        }
+        return cur.getMessage() == null ? cur.getClass().getSimpleName() : cur.getMessage();
+    }
+
+    private static String typeOf(Object o) {
+        return o == null ? "null" : o.getClass().getSimpleName();
+    }
+
+    private static String paramTypesDesc(Object[] args) {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < args.length; i++) {
+            if (i > 0) {
+                sb.append(", ");
+            }
+            Object a = args[i];
+            sb.append(a == null ? "null" : a.getClass().getSimpleName());
+        }
+        return sb.toString();
+    }
+}