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