Prechádzať zdrojové kódy

Merge branch 'task/S-cfc-284' into cfclub

Sisyphus 1 deň pred
rodič
commit
cbfeb60311

+ 179 - 363
cfc-backend/src/test/java/com/etotem/cfc/unit/ContactGroupImportContractTest.java

@@ -2,455 +2,271 @@ 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)。
+ * 契约级单测:联系人分组/导入(能力键 {@code contact.group.import},单元 contact,TDD-RED 阶段)。
  *
- * <p><b>契约溯源</b></p>
+ * <p>契约溯源(需求/设计):</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>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-62(联系人分组/导入)——
+ *       「建分组 → 导入({@code ContactImport})」,边界「导入格式错误行跳过并报告」,模块 {@code ContactImport};
+ *       场景细节见 {@code tests/user-scenarios/02-parent.md} 场景 P-62(角色:家长;前置:已登录;
+ *       主流程:建分组 → 导入;异常/边界:导入格式错误行跳过并报告)。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} —— {@code ContactController /api/contact}
+ *       (CRUD/互动/邀请入家庭)+ {@code ContactMatchController /api/contact/match}(联系人对接);
+ *       联系人导入登记为 {@code POST /api/contact/import-phone}(SC-P-62),但
+ *       <b>候选 {@code ContactImportController} 不存在;分组无端点 → 缺口 G-3</b>;
+ *       契约缺口表标注「导入已存在(/api/contact/import-phone);分组无端点」。</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>
+ * <p><b>契约范围</b>(SC-P-62 主流程 + 边界):</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>
+ *   <li><b>正常流-建分组</b>:分组能力应有实体/表载体({@code ContactGroup} 实体 + {@code contact_group} 表),
+ *       联系人可归属分组({@code contacts.group_id});</li>
+ *   <li><b>正常流-导入</b>:批量导入入口应存在({@code ContactImport} 模块:{@code ContactImportController} /
+ *       {@code ContactImportService} 或 {@code ContactController} 批量导入方法),导入时按 {@code groupId} 归组;</li>
+ *   <li><b>边界流-格式错误行跳过并报告</b>:批量导入对格式错误行(缺姓名/手机号格式非法/重复号码)应
+ *       <b>跳过并报告</b>——导入结果须返回 {@code successCount / skippedCount / skippedRows} 统计,不整批失败;</li>
+ *   <li><b>权限流</b>:导入的联系人归属当前登录用户({@code 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>:本分支 src/main 中 {@code ContactGroup} 实体、{@code contact_group} 表、
+ * {@code contacts.group_id} 列、{@code ContactImportController}/{@code ContactImportService} 批量导入
+ * 与「格式错误行跳过并报告」结果载体均<b>未落地</b>(全仓 0 引用,schema.sql 无 contact_group 建表)。
+ * 现仅 {@code ContactController#importPhone} 做<b>单条</b>电话导入(无分组、无批量、无错误行报告),
+ * 与 SC-P-62「建分组 → 导入,格式错误行跳过并报告」契约不符。因此本测试以「反射结构契约 + schema.sql
+ * 探测」验证实现缺失:能编译通过、能运行、断言失败(红),且失败断言原文清晰可贴。</p>
  *
- * <p><b>测试范围</b>:纯契约级单测,仅 JUnit + 反射,无 Spring / 无 DB / 无真实微信 / 无外部服务;
- * 权限流适用(分组归属=横向越权维度),按三类流覆盖。</p>
+ * <p><b>GREEN 升级指引</b>:实现者落地 {@code ContactGroup} 实体 + {@code contact_group} 表 +
+ * {@code contacts.group_id} 列(或分组关联表)、{@code ContactImportController}/{@code ContactImportService}
+ * 批量导入入口(含 {@code groupId} 归组)、导入结果 DTO({@code successCount/skippedCount/skippedRows},
+ * 格式错误行跳过并报告)后,本测试自动转绿;届时可将结构探测升级为行为级 mock 验证。</p>
  *
- * <p><b>RED 预期</b>:宿主 {@code ContactGroupService} / {@code ContactImportService} 未实现——
- * 全部宿主用例在反射探测处失败(红),断言原文「RED[contact.group.import] 契约宿主 … 未实现」;
- * controller 与 schema.sql 结构用例分别以「契约端点未落地」「schema.sql 缺少 contact_groups」
- * 独立失败。</p>
+ * <p><b>RED 预期</b>:契约宿主(分组实体/表、批量导入入口、跳过报告结果载体)未实现,全部用例在
+ * 反射/schema 探测处失败(红),断言原文为「契约宿主未实现:...」。</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";
+    /** SC-P-62 需求锚点:ContactImport 模块批量导入宿主控制器(设计标注候选不存在)。 */
+    private static final String IMPORT_CONTROLLER = "com.etotem.cfc.controller.ContactImportController";
+
+    /** SC-P-62 批量导入宿主服务(ContactImport 模块)。 */
+    private static final String 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";
+    /** 建分组能力:ContactGroup 实体(分组载体)。 */
+    private static final String GROUP_ENTITY = "com.etotem.cfc.entity.ContactGroup";
 
-    /** 契约端点 Controller(未实现)。 */
-    private static final String CONTROLLER_CLASS = "com.etotem.cfc.controller.ContactGroupController";
+    /** 建分组能力:分组 Mapper。 */
+    private static final String GROUP_MAPPER = "com.etotem.cfc.mapper.ContactGroupMapper";
 
-    /** 契约端点类级路径与 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";
+    /** 导入结果载体:格式错误行跳过并报告(successCount/skippedCount/skippedRows)。 */
+    private static final String IMPORT_RESULT_VO = "com.etotem.cfc.dto.ContactImportResultVO";
+
+    /** 分组表名(SC-P-62 建分组)。 */
+    private static final String GROUP_TABLE = "contact_group";
 
     // ------------------------------------------------------------------
-    // 测试用 CSV 文本(TC-P-62-E:合法行入库、格式错误行跳过并报告『第 N 行格式错误』)
+    // 正常流-建分组:ContactGroup 实体 + contact_group 表 + contacts.group_id
     // ------------------------------------------------------------------
-    /** 全合法 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 表)承载分组。");
+    @DisplayName("正常流-建分组:ContactGroup 实体应存在(分组载体)")
+    void groupEntity_shouldExist() {
+        assertNotNull(loadClass(GROUP_ENTITY),
+                "[contact.group.import] ContactGroup 实体不存在:SC-P-62 主流程「建分组」要求分组载体。"
+                        + "实现未落地。");
     }
 
     @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(分组名)");
+    @DisplayName("正常流-建分组:ContactGroupMapper 应存在(分组持久化)")
+    void groupMapper_shouldExist() {
+        assertNotNull(loadClass(GROUP_MAPPER),
+                "[contact.group.import] ContactGroupMapper 不存在:SC-P-62「建分组」需分组持久化。"
+                        + "实现未落地。");
     }
 
     @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 契约)");
+    @DisplayName("正常流-建分组:contact_group 表应落地 schema.sql")
+    void schemaSql_shouldContainGroupTable() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains(GROUP_TABLE),
+                "[contact.group.import] schema.sql 缺少 " + GROUP_TABLE
+                        + " 建表:SC-P-62「建分组」需要分组表(缺口 G-3)。");
     }
 
     @Test
-    @DisplayName("正常流-4:ContactGroupService 契约宿主应已实现")
-    void groupService_classExists() {
-        assertNotNull(contractHost(HOST_GROUP_SERVICE),
-                "RED[contact.group.import] 契约宿主 ContactGroupService 未实现(实现未落地):"
-                        + "SC-P-62 建分组→导入 服务未落地,分组 CRUD 契约无法满足。");
+    @DisplayName("正常流-建分组:contacts 表应含 group_id 列(联系人归组)")
+    void contacts_shouldCarryGroupIdColumn() {
+        String schema = readSchemaSql();
+        int contactsIdx = schema.indexOf("CREATE TABLE IF NOT EXISTS contacts");
+        if (contactsIdx < 0) {
+            fail("[contact.group.import] schema.sql 缺少 contacts 建表。");
+        }
+        // 只扫 contacts 建表块:避免其他表恰有 group_id 列造成误判
+        int blockEnd = schema.indexOf("ENGINE=InnoDB", contactsIdx);
+        String contactsBlock = blockEnd > contactsIdx ? schema.substring(contactsIdx, blockEnd) : schema.substring(contactsIdx);
+        assertTrue(contactsBlock.contains("group_id"),
+                "[contact.group.import] contacts 表缺少 group_id 列:导入到分组需将联系人归属 groupId"
+                        + "(缺口 G-3)。");
     }
 
-    @Test
-    @DisplayName("正常流-5:ContactGroupService.createGroup 建分组契约方法应存在")
-    void groupService_createGroup_methodExists() {
-        assertMethodExists(HOST_GROUP_SERVICE, "createGroup", 2,
-                "SC-P-62 建分组:createGroup(name, userId) 应建分组并归属 userId。");
-    }
+    // ------------------------------------------------------------------
+    // 正常流-导入:ContactImport 模块批量导入入口(含 groupId 归组)
+    // ------------------------------------------------------------------
 
     @Test
-    @DisplayName("正常流-6:ContactGroupService.listGroups 分组列表契约方法应存在(仅本人)")
-    void groupService_listGroups_methodExists() {
-        assertMethodExists(HOST_GROUP_SERVICE, "listGroups", 1,
-                "SC-P-62 分组列表:listGroups(userId) 应仅返回 userId 自身分组(横向隔离)。");
+    @DisplayName("正常流-导入:ContactImportController 应存在(批量导入入口)")
+    void importController_shouldExist() {
+        assertNotNull(loadClass(IMPORT_CONTROLLER),
+                "[contact.group.import] ContactImportController 不存在:SC-P-62「导入(ContactImport)」"
+                        + "要求批量导入入口(设计标注候选 ContactImportController 不存在,缺口 G-3)。");
     }
 
-    // ==================== 边界流:导入校验(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 服务未落地,分组导入契约无法满足。");
+    @DisplayName("正常流-导入:ContactImportService 应存在(批量导入业务)")
+    void importService_shouldExist() {
+        assertNotNull(loadClass(IMPORT_SERVICE),
+                "[contact.group.import] ContactImportService 不存在:SC-P-62「导入」需批量导入业务逻辑"
+                        + "(建分组 → 导入)。实现未落地。");
     }
 
     @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;
+    @DisplayName("正常流-导入:ContactController 应暴露带 groupId 的分组导入端点")
+    void contactController_shouldExposeGroupImportMapping() {
+        Class<?> clazz = loadClass("com.etotem.cfc.controller.ContactController");
+        assertNotNull(clazz, "[contact.group.import] ContactController 类不存在。");
+        boolean hasGroupImport = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals("importPhone") || m.getName().equals("importGroup")
+                    || m.getName().equals("importBatch")) {
+                // 单条 importPhone 现状不含 groupId 参数 → 契约要求分组导入(参数含 groupId)
+                for (Class<?> p : m.getParameterTypes()) {
+                    if (p.getName().endsWith("List")) {
+                        hasGroupImport = true;
+                        break;
+                    }
+                }
+                // 若方法名即带 group 语义,也视为分组导入入口
+                if (m.getName().equals("importGroup") || m.getName().equals("importBatch")) {
+                    hasGroupImport = 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)");
+        assertTrue(hasGroupImport,
+                "[contact.group.import] ContactController 无分组导入端点:SC-P-62「建分组 → 导入」"
+                        + "要求批量导入入口按 groupId 归组(现有 import-phone 仅单条、无分组,缺口 G-3)。");
     }
 
-    @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) ====================
+    // ------------------------------------------------------------------
+    // 边界流-导入:格式错误行跳过并报告(successCount / skippedCount / skippedRows)
+    // ------------------------------------------------------------------
 
     @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)");
+    @DisplayName("边界流-导入:导入结果应含 successCount 字段(成功统计)")
+    void importResult_shouldCarrySuccessCount() {
+        Class<?> vo = loadClass(IMPORT_RESULT_VO);
+        if (vo == null) {
+            fail("[contact.group.import] ContactImportResultVO 不存在:SC-P-62 边界「导入格式错误行"
+                    + "跳过并报告」要求结果载体携带成功/跳过统计。");
         }
+        assertTrue(hasField(vo, "successCount"),
+                "[contact.group.import] ContactImportResultVO 缺 successCount:导入应报告成功行数。");
     }
 
     @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)");
+    @DisplayName("边界流-导入:导入结果应含 skippedCount 字段(跳过统计)")
+    void importResult_shouldCarrySkippedCount() {
+        Class<?> vo = loadClass(IMPORT_RESULT_VO);
+        if (vo == null) {
+            fail("[contact.group.import] ContactImportResultVO 不存在:SC-P-62 边界「导入格式错误行"
+                    + "跳过并报告」要求结果载体携带成功/跳过统计。");
         }
+        assertTrue(hasField(vo, "skippedCount"),
+                "[contact.group.import] ContactImportResultVO 缺 skippedCount:导入应报告跳过行数。");
     }
 
-    // ==================== 契约端点: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;
-                }
-            }
+    @DisplayName("边界流-导入:导入结果应含 skippedRows 字段(错误行明细)")
+    void importResult_shouldCarrySkippedRows() {
+        Class<?> vo = loadClass(IMPORT_RESULT_VO);
+        if (vo == null) {
+            fail("[contact.group.import] ContactImportResultVO 不存在:SC-P-62 边界「导入格式错误行"
+                    + "跳过并报告」要求结果载体携带错误行明细。");
         }
-        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);
+        assertTrue(hasField(vo, "skippedRows"),
+                "[contact.group.import] ContactImportResultVO 缺 skippedRows:导入应返回被跳过的错误行明细"
+                        + "(行号+原因),供前端报告。");
     }
 
-    @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");
-        }
-    }
+    // ------------------------------------------------------------------
+    // 权限流:分组导入归属校验(userId 隔离 / 横向越权)
+    // ------------------------------------------------------------------
 
-    /** 断言契约方法存在(按方法名 + 参数个数匹配),缺失即断言失败(红)。 */
-    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;
-            }
+    @Test
+    @DisplayName("权限流:批量导入方法应含 groupId+userId 归属参数(防跨用户写入)")
+    void importBatch_carriesOwnerParams() {
+        // 契约要求 ContactImport(模块名)批量导入入口以 groupId+userId 双参数承载归属:
+        // 导入时校验 userId 为分组属主,禁止跨用户写入(SC-P-62 权限流,横向越权维度)。
+        // 宿主 ContactImportService 未实现 → 反射探测不到 importBatch/importCsv → 红。
+        Class<?> svc = loadClass(IMPORT_SERVICE);
+        if (svc == null) {
+            fail("[contact.group.import] ContactImportService 不存在:SC-P-62 权限流要求"
+                    + "批量导入入口以 groupId+userId 双参数承载归属校验。实现未落地。");
         }
-        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");
+        boolean hasOwnerScopedMethod = false;
+        for (Method m : svc.getDeclaredMethods()) {
+            if (m.getName().equals("importBatch") || m.getName().equals("importCsv")) {
+                Class<?>[] pts = m.getParameterTypes();
+                // 归属双参:第一个参数为 group 引用(Long groupId / String csv),第二个参数为 Long userId
+                if (pts.length >= 2) {
+                    boolean hasUserId = pts[1].getName().equals("java.lang.Long")
+                            || pts[1].getName().equals("long");
+                    boolean hasGroupRef = pts[0].getName().equals("java.lang.Long")
+                            || pts[0].getName().equals("long")
+                            || pts[0].getName().equals("java.lang.String");
+                    if (hasUserId && hasGroupRef) {
+                        hasOwnerScopedMethod = true;
+                        break;
                     }
-                    return null;
                 }
             }
         }
-        if (failOnMissing) {
-            fail("RED[contact.group.import] 契约方法 " + simpleName(host.getClass().getName())
-                    + "#" + method + " 未实现");
-            throw new IllegalStateException("unreachable");
-        }
-        return null;
+        assertTrue(hasOwnerScopedMethod,
+                "[contact.group.import] 批量导入方法应以 groupId+userId 双参数归属校验:"
+                        + "导入联系人归属当前登录用户,越权分组被拒(横向越权,SC-P-62 权限流)。");
     }
 
-    @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) {
+    private Class<?> loadClass(String name) {
         try {
-            clazz.getDeclaredField(field);
-            return true;
-        } catch (NoSuchFieldException e) {
-            return false;
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
         }
     }
 
-    private boolean containsLine(List<String> reports, String linePrefix) {
-        for (String msg : reports) {
-            if (msg != null && msg.contains(linePrefix)) {
+    private boolean hasField(Class<?> clazz, String fieldName) {
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().equals(fieldName)) {
                 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) {
@@ -464,4 +280,4 @@ class ContactGroupImportContractTest {
             return "";
         }
     }
-}
+}