Quellcode durchsuchen

Merge origin/cfclub into cfclub: 同步并发合入的 S-cfc-291/295

Sisyphus vor 2 Tagen
Ursprung
Commit
5b7842bca6

+ 276 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ActivityCheckinBoostContractTest.java

@@ -0,0 +1,276 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:活动打卡/助力(营销)进度累计与反刷量(能力键 {@code activity.checkin.boost},单元 activity)。
+ *
+ * <p><b>需求/设计锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-77(活动打卡/助力·营销)——
+ *       主流程「打卡 → 进度累计 → 助力」;异常/边界「重复打卡幂等;刷量/机器行为拦截」;
+ *       CF 值预估「打卡/助力为平台 CF 值产出点,操作入口须按 SC-X-22 展示『本操作预计 +N CF』」</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.7 健康打卡(/api/daily/checkin、
+ *       /api/mind/checkin 情绪日记同步)+ 活动打卡(AdminActivityCheckinController
+ *       /api/admin/activity-checkins);模块:活动({@code ActivityService.checkin} 现状为
+ *       一次性签到任务,无进度累计/助力/反刷量/CF 预估)</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为</b></p>
+ * <ul>
+ *   <li>打卡 → 进度累计:同一活动下某成员打卡一次,进度 +1;连续打卡逐次累计</li>
+ *   <li>助力 → 进度累计:他人为目标成员助力一次,目标进度 +1;打卡后再助力继续累计</li>
+ *   <li>重复打卡幂等:同一 (activityId, memberId) 重复打卡,进度不重复累计,返回既有进度</li>
+ *   <li>刷量/机器行为拦截:窗口内高频打卡判为刷量应拒绝;同一设备短时关联多账号判为机器行为应拒绝</li>
+ *   <li>CF 值预估(SC-X-22):打卡/助力入口应返回「本操作预计 +N CF」的预估(非负,规则未配置可降级为 0)</li>
+ * </ul>
+ *
+ * <p><b>宿主契约(TDD-RED 锚点)</b>:{@code com.etotem.cfc.service.ActivityCheckinBoostService} ——
+ * 承载活动打卡/助力营销进度的服务类,当前(2026-11-14)<b>尚未实现</b>。本测试通过反射锚定该
+ * 宿主契约方法(避免直接 import 不存在类导致编译失败),运行时宿主类缺失即断言失败(红),
+ * 暴露"实现未落地"这一真实缺陷;实现落地后转为验证真实行为(GREEN)。</p>
+ *
+ * <p><b>契约方法签名(实现方按此落地)</b></p>
+ * <ul>
+ *   <li>{@code int checkin(long activityId, long userId, long memberId, String deviceId)}
+ *       —— 打卡一次,进度 +1,返回该成员本次活动累计进度</li>
+ *   <li>{@code int boost(long activityId, long targetUserId, long helperUserId, String deviceId)}
+ *       —— 助力目标成员,目标进度 +1,返回目标成员累计进度</li>
+ *   <li>{@code int checkinProgress(long activityId, long memberId)}
+ *       —— 查询该成员本次活动累计进度</li>
+ *   <li>{@code int expectedCf(long activityId, String actionType)}
+ *       —— SC-X-22 CF 值预估:actionType ∈ {@code checkin}/{@code boost},返回本操作预计获得 CF
+ *       (规则未配置可降级为 0,不得为负)</li>
+ *   <li>{@code boolean shouldRejectCheckin(int checkinsInWindow, int windowSeconds)}
+ *       —— 刷量拦截判定(纯函数):窗口内打卡次数超过阈值(如 60s 内 ≥ 5 次)应返回 true</li>
+ *   <li>{@code boolean shouldRejectMachineAbuse(int accountsOnDeviceInWindow, int windowHours)}
+ *       —— 机器行为拦截判定(纯函数):同一设备窗口内关联账号数超过阈值(如 1h 内 ≥ 3 账号)应返回 true</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,无 Spring / 无 DB / 无外部服务依赖(纯 Mockito/反射风格,
+ * 与同批已合入主链的 {@code TeamStarMultiplierContractTest} / {@code TeamProfitHookContractTest} 一致)。</p>
+ *
+ * <p><b>权限流说明</b>:SC-P-77 营销打卡/助力面向已报名活动成员,本契约聚焦进度累计/幂等/反刷量
+ * 与 CF 预估行为,无独立角色权限分支(权限校验由活动报名与身份门禁承载,属 {@code activity.enroll.join}
+ * 范围),故权限流以说明性用例标注不适用。</p>
+ *
+ * <p><b>RED 预期</b>:宿主 {@code ActivityCheckinBoostService} 未实现,全部用例在反射探测处失败(红),
+ * 断言原文为「RED[activity.checkin.boost] 契约宿主 ActivityCheckinBoostService 未实现(实现未落地)」。</p>
+ */
+class ActivityCheckinBoostContractTest {
+
+    /** 规格设计宿主类(未实现)。 */
+    private static final String HOST_CLASS = "com.etotem.cfc.service.ActivityCheckinBoostService";
+
+    // ==================== 契约常量(SC-P-77 边界) ====================
+
+    /** 示例活动 ID(营销活动)。 */
+    private static final long ACTIVITY_ID = 9001L;
+    /** 打卡成员(memberId)。 */
+    private static final long MEMBER_ID = 70001L;
+    /** 打卡操作账号(userId)。 */
+    private static final long USER_ID = 60001L;
+    /** 助力人账号(helperUserId)。 */
+    private static final long HELPER_USER_ID = 60002L;
+    /** 目标成员(targetUserId 对应的 memberId)。 */
+    private static final long TARGET_MEMBER_ID = 70002L;
+    /** 设备指纹(刷量/机器行为拦截维度)。 */
+    private static final String DEVICE_ID = "device-a1b2c3";
+
+    /** 刷量阈值锚点:60 秒窗口内 5 次及以上判为刷量(SC-P-77 反刷量)。 */
+    private static final int ABUSE_CHECKINS = 5;
+    private static final int ABUSE_WINDOW_SECONDS = 60;
+    /** 机器行为阈值锚点:1 小时窗口内同设备 3 个及以上账号判为机器行为。 */
+    private static final int MACHINE_ACCOUNTS = 3;
+    private static final int MACHINE_WINDOW_HOURS = 1;
+
+    // ==================== 正常流:打卡 → 进度累计 ====================
+
+    @Test
+    @DisplayName("正常流-1:首次打卡进度累计为 1")
+    void checkin_firstTime_progressIsOne() {
+        Object host = contractHost();
+        int progress = callInt(host, "checkin", ACTIVITY_ID, USER_ID, MEMBER_ID, DEVICE_ID);
+        assertEquals(1, progress, "首次打卡后累计进度应为 1");
+    }
+
+    @Test
+    @DisplayName("正常流-2:连续打卡进度逐次累计")
+    void checkin_consecutive_progressAccumulates() {
+        Object host = contractHost();
+        callInt(host, "checkin", ACTIVITY_ID, USER_ID, MEMBER_ID, DEVICE_ID);
+        callInt(host, "checkin", ACTIVITY_ID, USER_ID, MEMBER_ID, DEVICE_ID);
+        int progress = callInt(host, "checkin", ACTIVITY_ID, USER_ID, MEMBER_ID, DEVICE_ID);
+        assertEquals(3, progress, "连续打卡三次后累计进度应为 3");
+    }
+
+    @Test
+    @DisplayName("正常流-3:checkinProgress 查询返回累计值")
+    void checkinProgress_reflectsAccumulatedValue() {
+        Object host = contractHost();
+        callInt(host, "checkin", ACTIVITY_ID, USER_ID, MEMBER_ID, DEVICE_ID);
+        callInt(host, "checkin", ACTIVITY_ID, USER_ID, MEMBER_ID, DEVICE_ID);
+        int progress = callInt(host, "checkinProgress", ACTIVITY_ID, MEMBER_ID);
+        assertEquals(2, progress, "查询进度应与两次打卡累计一致(2)");
+    }
+
+    // ==================== 正常流:助力 → 目标进度累计 ====================
+
+    @Test
+    @DisplayName("正常流-4:助力使目标成员进度 +1")
+    void boost_addsOneToTargetProgress() {
+        Object host = contractHost();
+        callInt(host, "checkin", ACTIVITY_ID, USER_ID, TARGET_MEMBER_ID, DEVICE_ID);
+        int progress = callInt(host, "boost", ACTIVITY_ID, USER_ID, HELPER_USER_ID, DEVICE_ID);
+        assertEquals(2, progress, "目标打卡 1 次后再被助力 1 次,目标进度应为 2");
+    }
+
+    @Test
+    @DisplayName("正常流-5:打卡后再助力进度继续累计")
+    void checkinThenBoost_continuesAccumulation() {
+        Object host = contractHost();
+        callInt(host, "checkin", ACTIVITY_ID, USER_ID, MEMBER_ID, DEVICE_ID);
+        callInt(host, "checkin", ACTIVITY_ID, USER_ID, MEMBER_ID, DEVICE_ID);
+        int progress = callInt(host, "boost", ACTIVITY_ID, USER_ID, HELPER_USER_ID, DEVICE_ID);
+        assertEquals(3, progress, "打卡两次后再助力一次,累计进度应为 3");
+    }
+
+    // ==================== 边界流:重复打卡幂等 ====================
+
+    @Test
+    @DisplayName("边界流-1:重复打卡幂等,进度不重复累计")
+    void duplicateCheckin_idempotent_progressNotDoubleCounted() {
+        Object host = contractHost();
+        callInt(host, "checkin", ACTIVITY_ID, USER_ID, MEMBER_ID, DEVICE_ID);
+        int second = callInt(host, "checkin", ACTIVITY_ID, USER_ID, MEMBER_ID, DEVICE_ID);
+        assertEquals(1, second, "同一 (activityId, memberId) 重复打卡不得重复累计进度(幂等)");
+    }
+
+    // ==================== 边界流:刷量 / 机器行为拦截 ====================
+
+    @Test
+    @DisplayName("边界流-2:窗口内高频打卡判为刷量,应拒绝")
+    void highFrequencyCheckin_shouldReject() {
+        Object host = contractHost();
+        boolean reject = callBoolean(host, "shouldRejectCheckin", ABUSE_CHECKINS, ABUSE_WINDOW_SECONDS);
+        assertTrue(reject, "60 秒窗口内 5 次打卡应判为刷量并拒绝(SC-P-77 反刷量)");
+    }
+
+    @Test
+    @DisplayName("边界流-3:正常低频打卡不判刷量")
+    void normalFrequencyCheckin_notRejected() {
+        Object host = contractHost();
+        boolean reject = callBoolean(host, "shouldRejectCheckin", 1, ABUSE_WINDOW_SECONDS);
+        assertFalse(reject, "60 秒窗口内 1 次打卡属正常行为,不应拒绝");
+    }
+
+    @Test
+    @DisplayName("边界流-4:同设备短时多账号判为机器行为,应拒绝")
+    void sameDeviceMultiAccount_shouldReject() {
+        Object host = contractHost();
+        boolean reject = callBoolean(host, "shouldRejectMachineAbuse", MACHINE_ACCOUNTS, MACHINE_WINDOW_HOURS);
+        assertTrue(reject, "1 小时窗口内同设备 3 个账号应判为机器行为并拒绝");
+    }
+
+    @Test
+    @DisplayName("边界流-5:同设备单账号不判机器行为")
+    void sameDeviceSingleAccount_notRejected() {
+        Object host = contractHost();
+        boolean reject = callBoolean(host, "shouldRejectMachineAbuse", 1, MACHINE_WINDOW_HOURS);
+        assertFalse(reject, "1 小时窗口内同设备 1 个账号属正常,不应判机器行为");
+    }
+
+    // ==================== CF 值预估流(SC-X-22) ====================
+
+    @Test
+    @DisplayName("CF预估-1:打卡入口应返回预计获得 CF(非负)")
+    void expectedCf_checkin_nonNegative() {
+        Object host = contractHost();
+        int cf = callInt(host, "expectedCf", ACTIVITY_ID, "checkin");
+        assertTrue(cf >= 0, "打卡入口应返回『本操作预计 +N CF』预估(SC-X-22),cf=" + cf);
+    }
+
+    @Test
+    @DisplayName("CF预估-2:助力入口应返回预计获得 CF(非负)")
+    void expectedCf_boost_nonNegative() {
+        Object host = contractHost();
+        int cf = callInt(host, "expectedCf", ACTIVITY_ID, "boost");
+        assertTrue(cf >= 0, "助力入口应返回『本操作预计 +N CF』预估(SC-X-22),cf=" + cf);
+    }
+
+    @Test
+    @DisplayName("CF预估-3:未知动作类型不得为负(降级为 0)")
+    void expectedCf_unknownAction_notNegative() {
+        Object host = contractHost();
+        int cf = callInt(host, "expectedCf", ACTIVITY_ID, "unknown");
+        assertTrue(cf >= 0, "未知动作类型 CF 预估不得为负(SC-X-22 降级口径),cf=" + cf);
+    }
+
+    // ==================== 权限流(不适用) ====================
+
+    @Test
+    @DisplayName("权限流:营销打卡/助力无独立角色权限分支,权限流不适用")
+    void permissionFlow_notApplicable() {
+        // 说明性用例:SC-P-77 权限校验由活动报名与身份门禁承载(activity.enroll.join),
+        // 本能力聚焦进度累计/幂等/反刷量/CF 预估,无独立权限分支。不产生断言失败。
+        assertTrue(true, "权限流不适用:activity.checkin.boost 无独立角色权限分支。");
+    }
+
+    // ==================== 反射辅助(锚定宿主契约,避免编译期依赖不存在类) ====================
+
+    private Object contractHost() {
+        try {
+            Class<?> clazz = Class.forName(HOST_CLASS);
+            return clazz.getDeclaredConstructor().newInstance();
+        } catch (ClassNotFoundException e) {
+            fail("RED[activity.checkin.boost] 契约宿主 ActivityCheckinBoostService 未实现(实现未落地):"
+                    + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        } catch (Exception e) {
+            fail("契约宿主 ActivityCheckinBoostService 实例化失败:" + 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 + " 调用失败:" + e.getMessage());
+                    throw new IllegalStateException("unreachable");
+                }
+            }
+        }
+        fail("RED[activity.checkin.boost] 契约方法 " + HOST_CLASS + "#" + 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");
+    }
+}

+ 400 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/DataExportGdprContractTest.java

@@ -0,0 +1,400 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.AccountCleanupService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:个人/家庭数据导出(GDPR)(能力键 {@code data.export.gdpr},SC-P-72,TDD-RED 阶段)。
+ *
+ * <p><b>契约溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-72「个人/家庭数据导出(GDPR)」(业务域 J:账号与数据主权)——
+ *       <b>流程:申请导出 → 生成加密包 → 下载</b>;<b>边界:敏感字段脱敏;频次限制;越权导出被拒</b>;
+ *       模块:数据导出(GDPR)。用例台账 GAP-028(TC-P-72 正常流 / TC-P-72-E 异常流 / TC-P-72-P 权限流,
+ *       见 {@code docs/tdd/test/scenario-gap-backlog.csv});sc-key-map 台账登记 SC-P-72 → {@code data.export.gdpr}(RED/未跑通)。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.61.5——数据主权(GDPR)能力<b>无独立端点</b>,
+ *       需复用 {@code /api/user}、{@code /api/family/member/list} 汇集导出;
+ *       「仅导出登录用户本人;无 memberId 入参 → 无『家长代导出孩子』(G-05)」;
+ *       {@code docs/superpowers/specs/2026-11-07-child-zone-contract.md} §5 G-05:
+ *       {@code exportUserData(userId)} 不接受 memberId,无家长代导出指定成员。</li>
+ * </ul>
+ *
+ * <p><b>宿主契约(本 RED 测试定稿,GREEN 实现方按此落地)</b>:{@code com.etotem.cfc.service.DataExportService}
+ * ——「数据导出(GDPR)」模块服务(申请→加密包→下载 流程宿主)。origin/cfclub @ d6dac931a 实测 <b>不存在</b>
+ * (实现未落地),测试以反射结构契约探测缺失,失败断言原文红色清晰可贴。</p>
+ *
+ * <p><b>建议契约方法签名(实现方可按仓库惯例微调命名)</b></p>
+ * <ul>
+ *   <li>{@code applyExport(Long userId, Long scopeMemberId)}——申请导出入口:频次窗口校验(如 24h)+ 归属校验
+ *       (越权拒)后创建导出工单(pending);</li>
+ *   <li>{@code buildExportPackage(...)}(或 generatePackage)——汇集数据(复用 user / family-member 数据)+
+ *       敏感字段脱敏(复用 {@code DataMaskUtil})+ 加密 → 生成加密包;</li>
+ *   <li>{@code downloadPackage(...)}——下载入口:本人/家庭归属校验 → 返回加密包(含密钥交接);</li>
+ *   <li>配套实体/表:{@code DataExportTask}({@code data_export_task})承载 status / packagePath / packageKey /
+ *       expiresAt 等工单状态字段。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯 JUnit + Mockito + 反射(无 Spring / 无 DB / 无外部服务,同批
+ * {@code TeamProfitHookContractTest} / {@code TeacherExportContractTest} 风格);
+ * 行为断言锚定现有数据导出载体 {@code AccountCleanupService.exportUserData}(设计指定复用的 /api/user 载体)。</p>
+ *
+ * <p><b>RED 预期</b>:宿主 {@code DataExportService} 未实现(反射结构用例全红);且现有载体
+ * {@code AccountCleanupService.exportUserData} 明文导出家庭成员 phone、无 memberId 范围入参(行为/签名用例红),
+ * GREEN 阶段按上述契约落地后转绿。</p>
+ */
+class DataExportGdprContractTest {
+
+    /** 宿主服务类(SC-P-72 数据导出(GDPR) 模块,设计锚点;当前实现未落地)。 */
+    private static final String HOST_CLASS = "com.etotem.cfc.service.DataExportService";
+
+    /** 配套导出工单实体(申请→加密包→下载 状态载体)。 */
+    private static final String TASK_ENTITY = "com.etotem.cfc.entity.DataExportTask";
+
+    /** 配套导出工单表(schema.sql 建表快照)。 */
+    private static final String TASK_TABLE = "data_export_task";
+
+    /** 现有数据导出载体(设计指定复用 /api/user,UserController POST /api/user/data-export → exportUserData)。 */
+    private static final String EXPORT_CARRIER = "com.etotem.cfc.service.AccountCleanupService";
+
+    private static final Long PARENT_USER_ID = 1001L;
+    private static final Long FAMILY_ID = 5001L;
+    private static final Long CHILD_MEMBER_ID = 2001L;
+    private static final String RAW_PHONE = "13800138000";
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Mock
+    private DanAssessmentResultMapper danAssessmentResultMapper;
+
+    @Mock
+    private HealthReportMapper healthReportMapper;
+
+    @Mock
+    private TaskMapper taskMapper;
+
+    @InjectMocks
+    private AccountCleanupService service;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ==================== 正常流(TC-P-72):申请导出 → 加密包 → 下载 ====================
+
+    @Test
+    @DisplayName("正常流-1:DataExportService 数据导出(GDPR) 服务类应存在(宿主锚点)")
+    void dataExportService_classExists() {
+        assertNotNull(loadClass(HOST_CLASS),
+                "[data.export.gdpr] DataExportService 类不存在:SC-P-72 个人/家庭数据导出(GDPR)"
+                        + "要求「申请导出 → 生成加密包 → 下载」流程宿主(模块:数据导出(GDPR),复用 /api/user、"
+                        + "/api/family/member/list 汇集导出)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:DataExportService 应提供 applyExport 申请导出入口")
+    void dataExportService_hasApplyExportEntry() {
+        Method apply = findMethodContains(hostClass(), "apply");
+        assertNotNull(apply,
+                "[data.export.gdpr] DataExportService 应提供 applyExport(...) 申请导出入口"
+                        + "(申请 → 生成加密包 → 下载 三阶段之首,SC-P-72 正常流)。实现未落地:宿主类不存在。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:DataExportService 应提供加密包生成入口(package/generate/encrypt)")
+    void dataExportService_hasPackageGenerateEntry() {
+        Class<?> clazz = hostClass();
+        boolean hasPackageBuilder = methodNameContains(clazz, "package") && (
+                methodNameContains(clazz, "generate")
+                        || methodNameContains(clazz, "build")
+                        || methodNameContains(clazz, "create")
+                        || methodNameContains(clazz, "encrypt"));
+        assertTrue(hasPackageBuilder,
+                "[data.export.gdpr] DataExportService 应提供加密包生成入口(方法名含 package 且 "
+                        + "generate/build/create/encrypt,如 buildExportPackage):契约要求「生成加密包」"
+                        + "而非同步裸返回明文 Map(SC-P-72 正常流)。实现未落地:宿主类不存在。");
+    }
+
+    @Test
+    @DisplayName("正常流-4:DataExportService 应提供 downloadPackage 下载入口")
+    void dataExportService_hasDownloadEntry() {
+        Method download = findMethodContains(hostClass(), "download");
+        assertNotNull(download,
+                "[data.export.gdpr] DataExportService 应提供 downloadPackage(...) 下载入口"
+                        + "(申请导出 → 生成加密包 → 下载 三阶段之末,SC-P-72 正常流)。实现未落地:宿主类不存在。");
+    }
+
+    // ==================== 边界/异常流(TC-P-72-E):频次限制 + 脱敏 + 加密包字段 ====================
+
+    @Test
+    @DisplayName("边界流-1:DataExportService 应有频次限制守卫(RATE_LIMIT 常量或 rateLimit/checkFrequency 方法)")
+    void applyExport_hasRateLimitGuard() {
+        Class<?> clazz = hostClass();
+        boolean hasRateLimit = fieldNameContains(clazz, "rate_limit")
+                || fieldNameContains(clazz, "frequency")
+                || methodNameContains(clazz, "ratelimit")
+                || methodNameContains(clazz, "checkfrequency")
+                || methodNameContains(clazz, "assertfrequency");
+        assertTrue(hasRateLimit,
+                "[data.export.gdpr] applyExport 应含频次限制守卫:SC-P-72 边界「频次限制」"
+                        + "(建议 24h 窗口,RATE_LIMIT_WINDOW_HOURS 常量或 checkRateLimit 方法),"
+                        + "超限申请应被拒。实现未落地:宿主类不存在。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:DataExportTask 实体与 data_export_task 表应存在(申请→加密包→下载 工单载体)")
+    void dataExportTask_entityAndTableExist() {
+        String schema = readSchemaSql();
+        assertNotNull(loadClass(TASK_ENTITY),
+                "[data.export.gdpr] DataExportTask 实体不存在:SC-P-72 要求导出为「申请 → 生成加密包 → 下载」"
+                        + "有状态流程,需 data_export_task 工单承载。实现未落地。");
+        assertTrue(schema.contains(TASK_TABLE),
+                "[data.export.gdpr] schema.sql 缺少 " + TASK_TABLE + " 建表(导出工单,SC-P-72 流程载体)。");
+    }
+
+    @Test
+    @DisplayName("边界流-3:DataExportTask 应含工单状态与加密包字段(status/packagePath/packageKey/expiresAt)")
+    void dataExportTask_hasPackageStateFields() {
+        Class<?> entity = loadClass(TASK_ENTITY);
+        if (entity == null) {
+            fail("[data.export.gdpr] DataExportTask 实体不存在,无法校验工单状态字段(SC-P-72 加密包流程)。");
+        }
+        for (String required : new String[]{"status", "packagePath", "packageKey", "expiresAt"}) {
+            boolean has = fieldNameEquals(entity, required);
+            assertTrue(has,
+                    "[data.export.gdpr] DataExportTask 应含 " + required + " 字段:加密包流程工单"
+                            + "(status=导出状态、packagePath=加密包路径、packageKey=加密密钥、expiresAt=有效/频次窗口)。"
+                            + "当前实体缺失。");
+        }
+    }
+
+    @Test
+    @DisplayName("边界流-4:DataExportService 应暴露敏感字段脱敏方法(mask/desensitize,复用 DataMaskUtil)")
+    void dataExportService_masksSensitiveFields() {
+        Method mask = findMethodContains(hostClass(), "mask");
+        assertNotNull(mask,
+                "[data.export.gdpr] DataExportService 应提供敏感字段脱敏方法(如 maskSensitiveFields,"
+                        + "复用 DataMaskUtil.maskPhone/maskName):SC-P-72 边界「敏感字段脱敏」(对齐 SC-T-29/"
+                        + "SC-S-11 合规口径,phone/realName/idCard 强制脱敏)。实现未落地:宿主类不存在。");
+    }
+
+    @Test
+    @DisplayName("边界流-5:现有导出载体 exportUserData 的家庭成员块 phone 应脱敏(当前明文,红)")
+    void exportUserData_familyMemberPhone_shouldBeMasked() {
+        // Given:家庭内有一名成员(phone 明文),其余导出区块无数据
+        User parent = new User();
+        parent.setId(PARENT_USER_ID);
+        parent.setFamilyId(FAMILY_ID);
+        when(userMapper.selectById(PARENT_USER_ID)).thenReturn(parent);
+
+        FamilyMember member = new FamilyMember();
+        member.setId(CHILD_MEMBER_ID);
+        member.setFamilyId(FAMILY_ID);
+        member.setPhone(RAW_PHONE);
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(member));
+        when(danAssessmentResultMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(healthReportMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(taskMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        // When:导出个人数据包(复用 /api/user 载体,设计指定汇集导出)
+        Map<String, Object> data = service.exportUserData(PARENT_USER_ID);
+
+        // Then:家庭成员(他人数据)的敏感字段 phone 必须脱敏,不得明文
+        Object children = data.get("children");
+        assertNotNull(children,
+                "[data.export.gdpr] 导出包应包含家庭成员 children 区块(SC-P-72 个人/家庭数据导出)。");
+        @SuppressWarnings("unchecked")
+        List<FamilyMember> members = (List<FamilyMember>) children;
+        assertFalse(members.isEmpty(),
+                "[data.export.gdpr] 家庭成员区块不应为空(SC-P-72 家庭数据导出汇集 /api/family/member/list)。");
+        for (FamilyMember fm : members) {
+            String phone = fm.getPhone();
+            assertFalse(phone != null && phone.contains(RAW_PHONE),
+                    "[data.export.gdpr] 导出包家庭成员 phone 应脱敏(如 138****8000,复用 DataMaskUtil):"
+                            + "SC-P-72 边界「敏感字段脱敏」。当前实现将 family_members 全量实体裸导出"
+                            + "(含 phone 明文),脱敏契约未落地。");
+        }
+    }
+
+    // ==================== 权限流(TC-P-72-P):越权导出被拒 ====================
+
+    @Test
+    @DisplayName("权限流-1:applyExport/downloadPackage 应携带显式 memberId 范围入参(G-05 家长代导出指定成员)")
+    void applyExport_carriesScopeMemberId() {
+        Class<?> clazz = hostClass();
+        Method apply = findMethodContains(clazz, "apply");
+        assertNotNull(apply,
+                "[data.export.gdpr] 申请导出入口缺失(宿主未实现),无法校验范围入参契约。");
+        boolean scopeAware = hasNumberOrMapParam(apply);
+        assertTrue(scopeAware,
+                "[data.export.gdpr] applyExport 应携带显式导出范围(scopeMemberId/memberId 数值入参或 Map 载体),"
+                        + "支撑「家长代导出指定成员」(G-05,API_REFERENCE §4.61.5:无 memberId 入参 → 无代导出)"
+                        + "与「越权导出被拒」(TC-P-72-P 权限流)。");
+    }
+
+    @Test
+    @DisplayName("权限流-2:DataExportService 应有归属校验守卫(assertCanExport/checkExportPermission,越权拒)")
+    void dataExportService_hasOwnershipGuard() {
+        Class<?> clazz = hostClass();
+        boolean guarded = methodNameContains(clazz, "assert")        // assertCanExport
+                || methodNameContains(clazz, "check")               // checkExportPermission
+                || methodNameContains(clazz, "permission")
+                || methodNameContains(clazz, "ownership")
+                || methodNameContains(clazz, "authorize");
+        assertTrue(guarded,
+                "[data.export.gdpr] applyExport/downloadPackage 须有归属校验守卫(如 assertCanExport/"
+                        + "checkExportPermission):非本家庭成员/非绑定家庭不得导出他人数据(SC-P-72 边界"
+                        + "「越权导出被拒」,TC-P-72-P 权限流)。实现未落地:宿主类不存在。");
+    }
+
+    @Test
+    @DisplayName("权限流-3:现有导出载体 exportUserData 应支持 memberId 范围入参(G-05;当前仅 1 参,红)")
+    void exportUserData_shouldCarryScopeMemberId() {
+        Class<?> carrier = loadClass(EXPORT_CARRIER);
+        if (carrier == null) {
+            fail("[data.export.gdpr] AccountCleanupService 载体类不存在(异常状态)。");
+        }
+        boolean hasScopeParam = false;
+        for (Method m : carrier.getDeclaredMethods()) {
+            if (m.getName().equals("exportUserData") && m.getParameterCount() >= 2) {
+                hasScopeParam = true;
+                break;
+            }
+        }
+        assertTrue(hasScopeParam,
+                "[data.export.gdpr] " + EXPORT_CARRIER + ".exportUserData 应支持 memberId 范围入参"
+                        + "(如 exportUserData(Long userId, Long scopeMemberId)):G-05 缺口——无 memberId 入参则"
+                        + "无「家长代导出指定成员」、也无越权归属校验载体(TC-P-72-P 权限流)。"
+                        + "当前仅 exportUserData(Long) 1 参,实现未落地。");
+    }
+
+    // ==================== 反射辅助(锚定宿主契约,避免编译期依赖不存在类) ====================
+
+    /** 解析宿主类;不存在即断言失败(RED 信号:实现未落地),并清晰贴出缺口。 */
+    private Class<?> hostClass() {
+        try {
+            return Class.forName(HOST_CLASS);
+        } catch (ClassNotFoundException e) {
+            fail("RED[data.export.gdpr] 契约宿主 " + HOST_CLASS + " 未实现(实现未落地):"
+                    + "SC-P-72 个人/家庭数据导出(GDPR)「申请导出 → 生成加密包 → 下载」流程宿主缺失"
+                    + "(模块:数据导出(GDPR);复用 /api/user、/api/family/member/list 汇集导出;"
+                    + "现有 UserController /api/user/data-export 仅同步返回明文 Map,无加密包/频次/越权校验)。"
+                    + "缺失详情:" + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        }
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    /** 方法名包含关键字的首个匹配;无则 null。 */
+    private Method findMethodContains(Class<?> clazz, String keywordLower) {
+        if (clazz == null) {
+            return null;
+        }
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().toLowerCase().contains(keywordLower)) {
+                return m;
+            }
+        }
+        return null;
+    }
+
+    private boolean methodNameContains(Class<?> clazz, String keywordLower) {
+        if (clazz == null) {
+            return false;
+        }
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().toLowerCase().contains(keywordLower)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean fieldNameContains(Class<?> clazz, String keywordLower) {
+        if (clazz == null) {
+            return false;
+        }
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().toLowerCase().contains(keywordLower)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean fieldNameEquals(Class<?> clazz, String name) {
+        if (clazz == null) {
+            return false;
+        }
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().equals(name)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** 方法参数含数值 ID(Long/memberId 载体)或 Map(scopeMemberId 键)→ 支持显式导出范围。 */
+    private boolean hasNumberOrMapParam(Method m) {
+        for (Class<?> p : m.getParameterTypes()) {
+            if (Number.class.isAssignableFrom(p) || p.isPrimitive()
+                    || Map.class.isAssignableFrom(p)) {
+                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 "";
+        }
+    }
+}