|
|
@@ -0,0 +1,308 @@
|
|
|
+package com.payment.platform.module.payment.leshouying.config;
|
|
|
+
|
|
|
+import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
|
|
|
+import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper;
|
|
|
+import com.payment.platform.module.payment.leshouying.scheduler.LsyOrderPollScheduler;
|
|
|
+import com.payment.platform.module.payment.leshouying.scheduler.LsyReconcileScheduler;
|
|
|
+import com.payment.platform.module.payment.leshouying.service.LsyCallbackService;
|
|
|
+import com.payment.platform.module.payment.leshouying.service.LsyCancelService;
|
|
|
+import com.payment.platform.module.payment.leshouying.service.LsyOrderService;
|
|
|
+import com.payment.platform.module.payment.leshouying.service.LsyQueryService;
|
|
|
+import com.payment.platform.module.payment.leshouying.service.LsyRefundService;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.Mockito;
|
|
|
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
|
|
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
|
|
+import org.springframework.boot.context.properties.bind.Binder;
|
|
|
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
|
|
+import org.springframework.context.ApplicationContextInitializer;
|
|
|
+import org.springframework.context.ConfigurableApplicationContext;
|
|
|
+import org.springframework.context.annotation.Configuration;
|
|
|
+import org.springframework.core.env.StandardEnvironment;
|
|
|
+import org.springframework.core.env.SystemEnvironmentPropertySource;
|
|
|
+
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.nio.file.Files;
|
|
|
+import java.nio.file.Path;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.stream.Stream;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 乐收赢通道**总开关**({@code leshouying.enabled})的装配面守卫。
|
|
|
+ *
|
|
|
+ * <p>【它守的是什么】关掉开关必须做到两件事:<b>端点不再存在</b>(不是「存在但返回错误」)、
|
|
|
+ * <b>定时任务不再注册</b>。第二件尤其承重 —— 本模块的自动出站流量全部来自那两个
|
|
|
+ * {@code @Scheduled},而它们用的密钥在生产上曾经是**文档里公开的那个测试密钥**
|
|
|
+ * (见 {@link LeshouyingProperties} 的两道闸)。端点关掉而定时器还在跑,
|
|
|
+ * 等于「看不见但一直在发包」。
|
|
|
+ *
|
|
|
+ * <p>【为什么默认必须是 true】开关的默认值就是这条防线本身。默认 false 的话,
|
|
|
+ * {@code validate()} 那两道闸在生产上**永远不会执行** —— 那正是
|
|
|
+ * {@code SPRING_PROFILES_ACTIVE=prod,dev} 那个漏洞的形态(用一个配置把闸整个绕过去)。
|
|
|
+ * 所以本文件把「默认装配」也钉成一条臂:默认值被改成 false 就是红灯。
|
|
|
+ *
|
|
|
+ * <p>【两处量具,各补对方的盲区】
|
|
|
+ * <ul>
|
|
|
+ * <li>{@code 两个定时任务…} 两条臂跑**真容器**({@link ApplicationContextRunner}),
|
|
|
+ * 证的是「条件注解在真装配链上生效」;代价是只覆盖被点名的两个类。</li>
|
|
|
+ * <li>{@code 每个控制器与定时任务都必须带总开关} 走**源码扫描 + 反射**,
|
|
|
+ * 证的是「覆盖面」:新加一个 {@code @RestController} 或 {@code @Scheduled}
|
|
|
+ * 而忘了挂开关时它会红 —— 那是前一类的盲区。它的代价写在方法 javadoc 里。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p>★ 边界(本文件**不**声称的):它不验「开关打开时端点真的能收单」——
|
|
|
+ * 那由各控制器自己的测试与 {@code LeshouyingPropertiesStartupTest} 负责。
|
|
|
+ */
|
|
|
+class LeshouyingChannelSwitchWiringTest {
|
|
|
+
|
|
|
+ /** 模块根包 —— 源码扫描的根与反射用的类名前缀**同出一源**,免得两处各漂。 */
|
|
|
+ private static final String BASE_PACKAGE = "com.payment.platform.module.payment.leshouying";
|
|
|
+
|
|
|
+ /** 开关属性名:本文件里**只写这一处**,文本扫描与反射断言共用它。 */
|
|
|
+ private static final String SWITCH_KEY = "leshouying.enabled";
|
|
|
+
|
|
|
+ /** 开关缺省时的取值 —— 与 {@code @ConditionalOnProperty} 的 {@code havingValue} 同值。 */
|
|
|
+ private static final String SWITCH_ON = "true";
|
|
|
+
|
|
|
+ /** 源码里去空白后的开关注解开头,文本扫描用(注释已被剥掉,不会自我满足)。 */
|
|
|
+ private static final String SWITCH_ANNOTATION =
|
|
|
+ "@ConditionalOnProperty(name=\"" + SWITCH_KEY + "\"";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 让**两道启动闸都不参与**的一组配置:非空(过第一道)、且逐项不等于
|
|
|
+ * {@link LeshouyingProperties} 里那三个文档测试常量(不触发第二道),
|
|
|
+ * 于是本文件里唯一的自变量就是开关本身。
|
|
|
+ */
|
|
|
+ private static ApplicationContextRunner base() {
|
|
|
+ return new ApplicationContextRunner()
|
|
|
+ .withUserConfiguration(Cfg.class)
|
|
|
+ .withPropertyValues(
|
|
|
+ "leshouying.base-url=https://open.gdyfsk.com/yfpay",
|
|
|
+ "leshouying.inst-no=999999",
|
|
|
+ "leshouying.key=unit-test-key",
|
|
|
+ "leshouying.notify-url=https://example.com/api/v1/payment/collection/notify/pay")
|
|
|
+ // 两个调度器的构造依赖:全用 mock,本文件不碰库、不碰上游。
|
|
|
+ .withBean(LsyOrderMapper.class, () -> Mockito.mock(LsyOrderMapper.class))
|
|
|
+ .withBean(LsyRefundMapper.class, () -> Mockito.mock(LsyRefundMapper.class))
|
|
|
+ .withBean(LsyQueryService.class, () -> Mockito.mock(LsyQueryService.class))
|
|
|
+ .withBean(LsyCancelService.class, () -> Mockito.mock(LsyCancelService.class))
|
|
|
+ .withBean(LsyCallbackService.class, () -> Mockito.mock(LsyCallbackService.class))
|
|
|
+ .withBean(LsyOrderService.class, () -> Mockito.mock(LsyOrderService.class))
|
|
|
+ .withBean(LsyRefundService.class, () -> Mockito.mock(LsyRefundService.class))
|
|
|
+ .withUserConfiguration(LsyOrderPollScheduler.class, LsyReconcileScheduler.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Configuration(proxyBeanMethods = false)
|
|
|
+ @EnableConfigurationProperties(LeshouyingProperties.class)
|
|
|
+ static class Cfg {
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ <b>默认臂,也是「开关默认值」这条防线的钉子。</b>
|
|
|
+ *
|
|
|
+ * <p>不带任何 {@code leshouying.enabled} 属性 ⇒ 两个定时任务**必须**装配。
|
|
|
+ * 把注解的 {@code matchIfMissing} 改成 false(或字段初值改成 false)都会让本条变红:
|
|
|
+ * 那两种改法在生产上的后果是「通道静默消失」——应用照常启动、日志里什么都看不出来。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void 不给开关属性_两个定时任务照旧装配() {
|
|
|
+ base().run(context -> {
|
|
|
+ assertNull(context.getStartupFailure(), "基础配置必须能起得来,实际: "
|
|
|
+ + chain(context.getStartupFailure()));
|
|
|
+ assertTrue(context.getBeanNamesForType(LsyOrderPollScheduler.class).length > 0,
|
|
|
+ "开关缺省时必须装配(默认 true)—— 默认值改成 false 就是通道静默消失");
|
|
|
+ assertTrue(context.getBeanNamesForType(LsyReconcileScheduler.class).length > 0,
|
|
|
+ "开关缺省时必须装配(默认 true)");
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 关掉开关 ⇒ 两个定时任务**一个都不注册**。这是本文件的核心承诺:
|
|
|
+ * 「关掉」意味着不出站,而不只是「界面上点不到」。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void 开关关掉_两个定时任务都不装配() {
|
|
|
+ base().withPropertyValues(SWITCH_KEY + "=false").run(context -> {
|
|
|
+ assertNull(context.getStartupFailure(),
|
|
|
+ "关掉开关不得让容器起不来,实际: " + chain(context.getStartupFailure()));
|
|
|
+ assertEquals(0, context.getBeanNamesForType(LsyOrderPollScheduler.class).length,
|
|
|
+ "开关关掉后定时任务仍在注册 ⇒ 它会按 30 秒一次继续向上游发包");
|
|
|
+ assertEquals(0, context.getBeanNamesForType(LsyReconcileScheduler.class).length,
|
|
|
+ "开关关掉后对账任务仍在注册 ⇒ 它会按 5 分钟一次继续向上游发包");
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 覆盖面:模块里**每一个** {@code @RestController} / {@code @Scheduled} 类都必须挂总开关。
|
|
|
+ *
|
|
|
+ * <p>【为什么不能用上面两条臂代替】那两条只覆盖被点名的两个类;
|
|
|
+ * 新写一个控制器照抄邻类(邻类有开关、抄漏了)时它们**全绿**。
|
|
|
+ * 这里改用**源码扫描**而不是登记制清单:文件系统能枚举,所以新增的类自动进入射程,
|
|
|
+ * 不存在「忘了登记」这条缝({@code LeshouyingAdminWiringTest} 用登记制是因为反射枚举不了)。
|
|
|
+ *
|
|
|
+ * <p>【两重量具】扫描负责**谁该有**,反射负责**真的有、且长对了** ——
|
|
|
+ * 只扫文本的话,把注解写到某个方法上(而类上没有)也会被判成合规;
|
|
|
+ * 只反射的话,就退化成登记制。
|
|
|
+ *
|
|
|
+ * <p>★ 本文件里出现 {@code @ConditionalOnProperty(name = "leshouying.enabled"} 这行字
|
|
|
+ * **不会**让本方法自我满足:扫描前先逐行剥注释(同 {@code LeshouyingAdminWiringTest} 的做法),
|
|
|
+ * 而本行在 javadoc 里。
|
|
|
+ *
|
|
|
+ * <p>★ 扫描的已知边界:① 它看的是**源码文本**,所以「注解被拼在常量里」这种写法它认不出
|
|
|
+ * (本仓没有这种写法,且那样写会红 —— 假红方向安全);② 剥注释是按行的,
|
|
|
+ * 不看字符串字面量 —— 对这些文件安全:注解行里不含 {@code //}。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void 每个控制器与定时任务都必须带总开关() throws Exception {
|
|
|
+ Path root = Path.of("src/main/java", BASE_PACKAGE.replace('.', '/'));
|
|
|
+ assertTrue(Files.isDirectory(root), "找不到源码根 " + root.toAbsolutePath()
|
|
|
+ + "(cwd=" + Path.of("").toAbsolutePath() + ")—— 本用例须从模块根目录运行");
|
|
|
+
|
|
|
+ List<String> onSwitch = new ArrayList<>();
|
|
|
+ List<String> offSwitch = new ArrayList<>();
|
|
|
+ List<Path> sources;
|
|
|
+ try (Stream<Path> walk = Files.walk(root)) {
|
|
|
+ sources = walk.filter(p -> p.toString().endsWith(".java")).toList();
|
|
|
+ }
|
|
|
+
|
|
|
+ for (Path file : sources) {
|
|
|
+ String code = stripComments(Files.readString(file, StandardCharsets.UTF_8));
|
|
|
+ if (!code.contains("@RestController") && !code.contains("@Scheduled(")) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String name = file.getFileName().toString();
|
|
|
+ boolean textOk = code.replaceAll("\\s+", "").contains(SWITCH_ANNOTATION);
|
|
|
+ if (textOk && hasSwitchOnType(toFqcn(root, file))) {
|
|
|
+ onSwitch.add(name);
|
|
|
+ } else {
|
|
|
+ offSwitch.add(name);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 正对照:先证明「扫描确实在看这些文件」,否则下面那条空集断言是平凡真 ──────────
|
|
|
+ // ★ 对照必须落在**扫描结果**上(两袋之和),不能落在 onSwitch 上:
|
|
|
+ // 后者只在功能**已实现**时才可能非空 ⇒ 那样这条对照与「功能存在」是同一件事,
|
|
|
+ // 功能没写时它会先炸,把「量具失效」和「还没实现」报成同一条消息。
|
|
|
+ List<String> scanned = new ArrayList<>(onSwitch);
|
|
|
+ scanned.addAll(offSwitch);
|
|
|
+ assertTrue(sources.size() > 1, "一个源文件都没扫到 —— 扫描根写错了,此时 offSwitch 为空毫无意义");
|
|
|
+ assertTrue(scanned.contains("LeshouyingCashierController.java"),
|
|
|
+ "扫描没扫到已知成员(现金台控制器)—— 量具失效,不是代码合规。"
|
|
|
+ + "扫到的文件数=" + sources.size() + ",命中名单=" + scanned);
|
|
|
+ assertTrue(scanned.contains("LsyOrderPollScheduler.java"),
|
|
|
+ "扫描没扫到已知成员(被扫轮询任务)—— 量具失效,不是代码合规。命中名单=" + scanned);
|
|
|
+
|
|
|
+ assertTrue(offSwitch.isEmpty(),
|
|
|
+ "这些类没有总开关(或注解没挂在类上、参数不对)⇒ leshouying.enabled=false 时它们照旧装配,"
|
|
|
+ + "而 prod 上那份配置用的是文档公开的测试密钥: " + offSwitch);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ <b>用户实际要敲的那一行到底生不生效。</b>
|
|
|
+ *
|
|
|
+ * <p>上面那条臂注入的是**规范化**属性名 `leshouying.enabled`;而 compose 里那一行是
|
|
|
+ * {@code LESHOUYING_ENABLED: "${LESHOUYING_ENABLED:-true}"} —— **环境变量形态**。
|
|
|
+ * 两者之间隔着 Spring 的宽松绑定:绑不上的话,线上那句开关就是空操作,
|
|
|
+ * 而症状(应用照旧起不来)与「开关没写」一模一样,极难分辨。
|
|
|
+ * 所以「照文档敲这一行就行」这句话必须**实测**,不能靠 Spring 的常识推。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void 环境变量形态_真的被规范化成_leshouying_point_enabled() {
|
|
|
+ // ★ 量具的选择:本类**不能**直接 new SpringConfigurationPropertySource
|
|
|
+ // (那个类不是 public)。改用 Binder.get(Environment) —— 它内部走的就是
|
|
|
+ // ConfigurationPropertySources.get(environment),即生产上同一支适配器:
|
|
|
+ // SystemEnvironmentPropertySource 会被配上 SystemEnvironmentPropertyMapper。
|
|
|
+ // 下面这条断言因此证的是**那条适配链**,不是我自己手搓的映射。
|
|
|
+ StandardEnvironment env = new StandardEnvironment();
|
|
|
+ env.getPropertySources().addFirst(
|
|
|
+ new SystemEnvironmentPropertySource("compose-env", Map.of("LESHOUYING_ENABLED", "false")));
|
|
|
+ assertEquals(Boolean.FALSE,
|
|
|
+ Binder.get(env).bind("leshouying.enabled", Boolean.class).orElse(null),
|
|
|
+ "环境变量 LESHOUYING_ENABLED 没有被宽松绑定成 leshouying.enabled —— "
|
|
|
+ + "那 compose 里那一行就是空操作(@ConditionalOnProperty 读的是规范化名)");
|
|
|
+
|
|
|
+ // 正对照:同一支量具对**未被赋值**的键必须给不出值,否则上面那条可能只是「恒为 false」。
|
|
|
+ assertNull(Binder.get(env).bind("leshouying.never-set", Boolean.class).orElse(null),
|
|
|
+ "量具对不存在的键也给出了值 —— 上面那条断言不可信");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 同一条注入路径在**真容器**里过一遍:环境变量形态必须真的把定时任务摘掉。
|
|
|
+ *
|
|
|
+ * <p>★ 这里**不能**用 {@code withPropertyValues("LESHOUYING_ENABLED=false")}。
|
|
|
+ * 实测(2026-09-28):那样写开关**不生效** ——
|
|
|
+ * {@code Tests run: 5, Failures: 1 … expected: <0> but was: <1>}。
|
|
|
+ * 成因在栈里读得出来,不用推:{@code AbstractApplicationContextRunner.run} 把
|
|
|
+ * {@code withPropertyValues} 交给 {@code TestPropertyValues.applyToSystemProperties}
|
|
|
+ * (栈帧 {@code TestPropertyValues.java:188}),落成的是 **JVM 系统属性**;
|
|
|
+ * 而「去掉分隔符、全大写」这条宽松形态是 {@code SystemEnvironmentPropertySource}
|
|
|
+ * **专有**的,系统属性只认规范名。
|
|
|
+ *
|
|
|
+ * <p>⇒ 落到运维上是一条实打实的坑:{@code JAVA_OPTS=-DLESHOUYING_ENABLED=false}
|
|
|
+ * 关不掉开关,而症状(应用照旧被启动闸挡住)与「压根没写这一行」一模一样。
|
|
|
+ * compose 里那一行走的是容器环境变量,是通的;要关就用那条。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void 环境变量形态_真容器里也能关掉装配() {
|
|
|
+ // 必须是**环境变量源**:系统属性源在这里量的是另一件事(见上面的实测)。
|
|
|
+ ApplicationContextInitializer<ConfigurableApplicationContext> injectComposeEnv = ctx ->
|
|
|
+ ctx.getEnvironment().getPropertySources().addFirst(
|
|
|
+ new SystemEnvironmentPropertySource("compose-env",
|
|
|
+ Map.of("LESHOUYING_ENABLED", "false")));
|
|
|
+ base().withInitializer(injectComposeEnv).run(context -> {
|
|
|
+ assertNull(context.getStartupFailure(), "实际: " + chain(context.getStartupFailure()));
|
|
|
+ assertEquals(0, context.getBeanNamesForType(LsyOrderPollScheduler.class).length,
|
|
|
+ "以环境变量形态关开关时定时任务仍被注册 —— compose 那条注入路径不生效");
|
|
|
+ // 两个调度器都要摘 —— 只摘一个说明注解在某些类上没生效(覆盖面那条臂的另一半)。
|
|
|
+ assertEquals(0, context.getBeanNamesForType(LsyReconcileScheduler.class).length,
|
|
|
+ "对账任务也必须一并摘掉");
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 开关注解**在类上**且三个参数都对 —— {@code havingValue}/{@code matchIfMissing} 一个不对就是关不干净。 */
|
|
|
+ private static boolean hasSwitchOnType(String fqcn) throws ClassNotFoundException {
|
|
|
+ ConditionalOnProperty c = Class.forName(fqcn).getAnnotation(ConditionalOnProperty.class);
|
|
|
+ return c != null
|
|
|
+ && Arrays.asList(c.name()).contains(SWITCH_KEY)
|
|
|
+ && Arrays.asList(c.havingValue()).contains(SWITCH_ON)
|
|
|
+ && c.matchIfMissing();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String toFqcn(Path root, Path file) {
|
|
|
+ String rel = root.relativize(file).toString().replace('\\', '/');
|
|
|
+ return BASE_PACKAGE + "." + rel.substring(0, rel.length() - ".java".length()).replace('/', '.');
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 逐行剥注释:整行 `//`、块注释行(`*` / `/*` 开头)、行尾 `//` 之后的部分。 */
|
|
|
+ private static String stripComments(String text) {
|
|
|
+ StringBuilder out = new StringBuilder();
|
|
|
+ for (String line : text.split("\n", -1)) {
|
|
|
+ String t = line.trim();
|
|
|
+ if (t.startsWith("//") || t.startsWith("*") || t.startsWith("/*")) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ int cut = line.indexOf("//");
|
|
|
+ out.append(cut >= 0 ? line.substring(0, cut) : line).append('\n');
|
|
|
+ }
|
|
|
+ return out.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 把异常链上的消息拼起来 —— 根因常埋在 `BeanCreationException` 的 cause 里。 */
|
|
|
+ private static String chain(Throwable t) {
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ for (Throwable c = t; c != null; c = c.getCause()) {
|
|
|
+ sb.append(c.getClass().getSimpleName()).append(": ").append(c.getMessage()).append(" | ");
|
|
|
+ if (sb.length() > 4000) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+}
|