Explorar el Código

feat(乐收赢): 通道加总开关 leshouying.enabled,默认 true

该通道第一次随 jar 上机就被自己的启动闸挡在门外(2026-09-28 11:43 实测):
LeshouyingProperties 的第二道闸拒绝「非 dev profile + 文档测试机构号/密钥」启动,
而这条通道当时**没有真实凭据**(进件未完成,application.yml 的三项默认值就是文档测试值)
⇒ 一个还没上线的模块把整个应用挡住了。这个开关就是那条出口。

enabled=false 时做两件事,缺一不可:

  1. validate() 的两道闸一并放行(通道不起用,配置对不对就不重要了),并打 WARN;
  2. 7 个控制器 + 2 个调度器全部不装配(@ConditionalOnProperty)—— 端点直接 404,
     两个 @Scheduled 一次都不跑。

只做第 1 件等于「拿文档公开的测试密钥继续收单」:验签用的 key 在生产上曾等于文档里
那个公开常量,回调入口留着就是一个谁都能签得出来的伪造通道。

★ 默认 true,而这个默认值**就是防线本身**:默认 false 会让 validate() 在非 dev 环境
  永远不执行 —— 与 SPRING_PROFILES_ACTIVE=prod,dev 那个漏洞同形(用一个配置把闸绕过去)。
  两条测试钉着它:LeshouyingPropertiesStartupTest 的臂一(不带 enabled 仍必须炸)、
  LeshouyingChannelSwitchWiringTest 的默认臂(不带 enabled 仍必须装配)。

docker-compose.yml 加 LESHOUYING_ENABLED 透传行:本文件没有 env_file,
宿主机 export 了也进不了容器,没有这一行开关根本用不上。
用法:LESHOUYING_ENABLED=false docker compose up -d

量具与两向读数:

- 新增 LeshouyingChannelSwitchWiringTest(真容器 ApplicationContextRunner + mock 依赖,
  不起库不起 Redis)。两向:实现前「关掉开关」那条臂实测仍注册
  (expected: <0> but was: <1>);实现后 3/3 绿。
- 覆盖面用**源码扫描 + 反射**而不是登记制:文件系统能枚举,新增控制器自动进入射程,
  没有「忘了登记」这条缝(反射版的两个调度器臂只覆盖被点名的类,那正是它的盲区)。
  量具先自证:扫描恰好命中 9 个文件,与手工清点逐字一致。
- 启动闸那两条新臂各证一道闸:不注 key 时红在「配置缺失」,用文档测试配置时红在
  「文档测试…拒绝启动」—— 不是同一条的重复。
- 变异两向:① 摘掉 Merchant 控制器的注解 ⇒ 扫描点名它;
  ② 把 matchIfMissing 改成 false ⇒ 默认臂红、扫描同时点名该调度器;
  还原后两个文件 sha256 逐字节回基线。
- 全量 898 绿(此前 893,新增 5 条 = 2 + 3);PaymentApplicationContextTest 仍绿。

未做(如实记):

- isDevProfile() 的 `prod,dev` 漏洞**未**收紧(fail-closed 被混合列表打穿)——
  那是另一处改动,混进来会让本次的 diff 难以单独回退。
- 摘的是**装配面**,不是文件:69 个 leshouying 文件仍在本分支上,通道靠开关关。
- 服务层与客户端 bean 仍装配。已实测模块内除 validate() 外零 @PostConstruct、
  无 runner/listener ⇒ 它们是惰性的,不产生出站流量。
- 前端乐收赢页面与已执行的菜单 SQL 会指向 404(通道关掉的预期代价)。
alphaH hace 2 semanas
padre
commit
b6da54ad5a
Se han modificado 13 ficheros con 364 adiciones y 1 borrados
  1. 6 0
      docker-compose.yml
  2. 33 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/config/LeshouyingProperties.java
  3. 3 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCashierController.java
  4. 3 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingMerchantController.java
  5. 5 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingNotifyController.java
  6. 3 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingController.java
  7. 3 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOrderController.java
  8. 3 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingRefundController.java
  9. 3 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenController.java
  10. 3 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyOrderPollScheduler.java
  11. 3 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyReconcileScheduler.java
  12. 240 0
      java/src/test/java/com/payment/platform/module/payment/leshouying/config/LeshouyingChannelSwitchWiringTest.java
  13. 56 1
      java/src/test/java/com/payment/platform/module/payment/leshouying/config/LeshouyingPropertiesStartupTest.java

+ 6 - 0
docker-compose.yml

@@ -8,6 +8,12 @@ services:
     environment:
       TZ: "Asia/Shanghai"
       SPRING_PROFILES_ACTIVE: prod
+      # 乐收赢通道总开关,默认 true(不写就是开,与代码里的默认值一致)。
+      # 本文件没有 env_file ⇒ ★ 宿主机上 export 了也进不了容器,必须靠这一行透传。
+      # 通道还没有真实凭据时置 false:该模块的 7 个控制器与 2 个定时任务都不装配,
+      # 应用照常启动(否则会被 LeshouyingProperties 的文档测试配置闸挡住,起不来)。
+      # 用法:LESHOUYING_ENABLED=false docker compose up -d
+      LESHOUYING_ENABLED: "${LESHOUYING_ENABLED:-true}"
       JAVA_OPTS: "-Xms256m -Xmx512m"
       SPRING_DATASOURCE_DRUID_URL: jdbc:postgresql://postgres:5432/payment_platform_java?currentSchema=public
       SPRING_DATASOURCE_DRUID_USERNAME: admin

+ 33 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/config/LeshouyingProperties.java

@@ -72,6 +72,30 @@ public class LeshouyingProperties {
     @Value("${spring.profiles.active:}")
     private String activeProfiles;
 
+    /**
+     * 通道总开关。**默认开。**
+     *
+     * <p>置 false 时:{@link #validate()} 的两道闸一并放行(通道不起用,配置对不对就不重要了),
+     * 且本模块的**全部对外入口**(7 个控制器)与**全部定时任务**(2 个调度器)都不装配
+     * (`@ConditionalOnProperty`,见各自的类注解)。关掉之后本模块不产生任何出站流量,
+     * 端点直接 404。
+     *
+     * <p>★ <b>默认值必须是 true —— 它就是防线本身。</b>默认 false 的话,
+     * {@code validate()} 在非 dev 环境下**永远不会执行**,那正是「用一个配置把闸整个绕过去」,
+     * 与 `SPRING_PROFILES_ACTIVE=prod,dev` 那个漏洞同形(后者见 {@link #isDevProfile()})。
+     * 所以「忘设」必须仍走闸,只有**显式**写 false 才放行。有两条测试钉着这个默认值:
+     * {@code LeshouyingPropertiesStartupTest} 的臂一(不带 enabled 仍必须炸)与
+     * {@code LeshouyingChannelSwitchWiringTest} 的默认臂(不带 enabled 仍必须装配)。
+     *
+     * <p>★ <b>它存在的理由</b>:第一次上机(2026-09-28)时通道**没有真实凭据**
+     * (文档测试值是 application.yml 的默认值),于是第二道闸把整个应用挡在生产门外 ——
+     * 而它本可以只是不干活。这个开关就是那条「不起用、也别挡路」的出口。
+     *
+     * <p>它不是 {@code missingOnboardingCredentials()} 那类进件参数:那类「没配就没法进件」,
+     * 本项「关掉就整个模块不参与」,粒度不同,故不进 {@link #missingOnboardingCredentials()}。
+     */
+    private boolean enabled = true;
+
     /** 被扫同步轮询次数(用户定:3 次) */
     private int syncPollTimes = 3;
 
@@ -138,9 +162,18 @@ public class LeshouyingProperties {
      * 键名打错也只是退回默认值,不会造成状态不收敛。
      *
      * 一次性列出**所有**缺失项:逐个抛会让人改一个重启一次,进件当天每轮重启都是分钟级。
+     *
+     * <p>★ <b>总开关关掉时两道闸都不跑</b>({@link #enabled},理由见其 javadoc):
+     * 通道不起用,配置对不对就不重要了。反过来,**开关开着**时这里仍然是 fail-closed ——
+     * 忘设环境变量不会走到「放行」,只会走到「起不来」。
      */
     @PostConstruct
     void validate() {
+        if (!enabled) {
+            log.warn("★ 乐收赢通道已由 leshouying.enabled=false 关闭:本模块的控制器与定时任务都不装配,"
+                    + "配置缺失 / 文档测试配置两道闸一并放行。要恢复通道,置回 true 并注入真实进件参数。");
+            return;
+        }
         List<String> missing = new ArrayList<>();
         if (StrUtil.isBlank(instNo)) {
             missing.add("leshouying.inst-no(环境变量 LESHOUYING_INST_NO)");

+ 3 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCashierController.java

@@ -25,6 +25,7 @@ import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.context.SecurityContextHolder;
@@ -113,6 +114,8 @@ import java.util.Map;
  * {@code LeshouyingAdminWiringTest} 对撞(本类已登记进它的 {@code ADMIN_CONTROLLERS})。
  */
 @Slf4j
+// 总开关:leshouying.enabled=false 时本类不装配(端点不存在),见 LeshouyingProperties#enabled
+@ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
 @RestController
 @RequestMapping("/payment/collection/cashier")
 @RequiredArgsConstructor

+ 3 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingMerchantController.java

@@ -5,6 +5,7 @@ import com.payment.platform.common.response.Result;
 import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
 import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
 import lombok.RequiredArgsConstructor;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
@@ -15,6 +16,8 @@ import org.springframework.web.bind.annotation.*;
  * <p>权限串与 Task 18 的 sys_menu.permission 是**跨任务契约**,须逐字一致
  * (PermissionCheck.hasAny 是全等匹配,差一个字符即 403)。
  */
+// 总开关:leshouying.enabled=false 时本类不装配(端点不存在),见 LeshouyingProperties#enabled
+@ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
 @RestController
 @RequestMapping("/payment/collection/merchant")
 @RequiredArgsConstructor

+ 5 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingNotifyController.java

@@ -5,6 +5,7 @@ import com.payment.platform.module.payment.leshouying.service.LsyOnboardingNotif
 import com.payment.platform.module.payment.leshouying.util.LsyLogSanitizer;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.http.MediaType;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
@@ -34,6 +35,10 @@ import java.util.Map;
  * (CWE-117:{@code \r\n} 能伪造出一整行日志),且这几行日志<b>刻意打在验签之前</b>。
  */
 @Slf4j
+// 总开关:leshouying.enabled=false 时本类不装配 —— 回调入口一并消失。
+// 这不只是「省事」:验签用的密钥在 prod 上曾等于文档公开的那个测试密钥,留着入口等于留着一个
+// 「谁都能签得出来」的伪造回调通道(见 LeshouyingProperties#enabled)。
+@ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
 @RestController
 @RequestMapping("/payment/collection/notify")
 @RequiredArgsConstructor

+ 3 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingController.java

@@ -20,6 +20,7 @@ import lombok.Data;
 import lombok.EqualsAndHashCode;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.context.SecurityContextHolder;
@@ -104,6 +105,8 @@ import java.util.Set;
  * 一个都测不出写错的权限串)。
  */
 @Slf4j
+// 总开关:leshouying.enabled=false 时本类不装配(端点不存在),见 LeshouyingProperties#enabled
+@ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
 @RestController
 @RequestMapping("/payment/collection/onboarding")
 @RequiredArgsConstructor

+ 3 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOrderController.java

@@ -11,6 +11,7 @@ import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
 import com.payment.platform.module.payment.leshouying.mapper.LsyNotifyLogMapper;
 import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
 import lombok.RequiredArgsConstructor;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
@@ -29,6 +30,8 @@ import java.util.List;
  * <p>权限串与 Task 18 的 sys_menu.permission 是**跨任务契约**,须逐字一致 ——
  * 上面那个测试同时会拿 {@code db/lsy-menu.sql} 里的 permission 清单比对注解里的串。
  */
+// 总开关:leshouying.enabled=false 时本类不装配(端点不存在),见 LeshouyingProperties#enabled
+@ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
 @RestController
 @RequestMapping("/payment/collection/order")
 @RequiredArgsConstructor

+ 3 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingRefundController.java

@@ -8,6 +8,7 @@ import com.payment.platform.common.response.Result;
 import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
 import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper;
 import lombok.RequiredArgsConstructor;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
@@ -17,6 +18,8 @@ import org.springframework.web.bind.annotation.*;
  *
  * <p>权限串与 Task 18 的 sys_menu.permission 是**跨任务契约**,须逐字一致。
  */
+// 总开关:leshouying.enabled=false 时本类不装配(端点不存在),见 LeshouyingProperties#enabled
+@ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
 @RestController
 @RequestMapping("/payment/collection/refund")
 @RequiredArgsConstructor

+ 3 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenController.java

@@ -21,6 +21,7 @@ import jakarta.servlet.http.HttpServletRequest;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.converter.HttpMessageNotReadableException;
 import org.springframework.web.bind.annotation.*;
@@ -42,6 +43,8 @@ import java.util.function.Supplier;
  * 租户身份从 request attribute 取(由 filter 注入),不信任请求体里的 tenant_id。
  */
 @Slf4j
+// 总开关:leshouying.enabled=false 时本类不装配(端点不存在),见 LeshouyingProperties#enabled
+@ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
 @RestController
 @RequestMapping("/payment/openapi/pay")
 @RequiredArgsConstructor

+ 3 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyOrderPollScheduler.java

@@ -11,6 +11,7 @@ import com.payment.platform.module.payment.leshouying.service.LsyOrderService;
 import com.payment.platform.module.payment.leshouying.service.LsyQueryService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
@@ -101,6 +102,8 @@ import java.util.Map;
  *   清册在 `LsyReconcileScheduler` 的类 javadoc 里,以那处为准。)
  */
 @Slf4j
+// 总开关:leshouying.enabled=false 时本类不装配 ⇒ 一次出站轮询都不会发生(见 LeshouyingProperties#enabled)
+@ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
 @Component
 @RequiredArgsConstructor
 public class LsyOrderPollScheduler {

+ 3 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyReconcileScheduler.java

@@ -16,6 +16,7 @@ import com.payment.platform.module.payment.leshouying.service.LsyQueryService;
 import com.payment.platform.module.payment.leshouying.service.LsyRefundService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
@@ -105,6 +106,8 @@ import java.util.Map;
  *   ⇒ 「退款推送失败会被重试」这句话**现在成立**(此前不成立,本条是那次修复后的订正)。
  */
 @Slf4j
+// 总开关:leshouying.enabled=false 时本类不装配 ⇒ 一次出站对账都不会发生(见 LeshouyingProperties#enabled)
+@ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
 @Component
 @RequiredArgsConstructor
 public class LsyReconcileScheduler {

+ 240 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/config/LeshouyingChannelSwitchWiringTest.java

@@ -0,0 +1,240 @@
+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.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Configuration;
+
+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.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);
+    }
+
+    /** 开关注解**在类上**且三个参数都对 —— {@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();
+    }
+}

+ 56 - 1
java/src/test/java/com/payment/platform/module/payment/leshouying/config/LeshouyingPropertiesStartupTest.java

@@ -6,6 +6,7 @@ import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.context.annotation.Configuration;
 
 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.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -33,9 +34,14 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
  * 「本 bean 真的在 `@ComponentScan` 范围内」这件事 —— 那一条仍由
  * `PaymentApplicationContextTest` 负责,两者不互相替代。
  *
- * <p>★ <b>三条臂是一次单变量对照</b>:臂一与臂二**只差** `spring.profiles.active` 一格,
+ * <p>★ <b>前三条臂是一次单变量对照</b>:臂一与臂二**只差** `spring.profiles.active` 一格,
  * 其余属性逐字相同 ⇒ 臂一的失败只能是 profile 闸造成的,而不是「属性没绑上」。
  * 臂二的断言就是为这个「只差一格」作证的。
+ *
+ * <p>★ 后两条臂(2026-09-28 追加)问的是**反方向**的问题:不是「什么情况下必须起不来」,
+ * 而是「明确说了不起用时,怎样才起得来」。它们与「装配面」成对 ——
+ * 放行的那一半在这里,摘掉端点与定时任务的那一半在
+ * {@code LeshouyingChannelSwitchWiringTest}。
  */
 class LeshouyingPropertiesStartupTest {
 
@@ -139,4 +145,53 @@ class LeshouyingPropertiesStartupTest {
                             "第一道闸必须先炸,不该报成「文档测试配置」把人指向错误方向。实际链: " + all);
                 });
     }
+
+    /**
+     * ★ <b>总开关关掉时,两道闸一并放行</b> —— 这是「本批通道不上线」的出口。
+     *
+     * <p>【为什么需要它】臂一证明了「拿文档测试配置上 prod 就起不来」,而这条防线在
+     * 2026-09-28 那天的实际后果是:通道**没有任何真实凭据**(进件未完成),于是它凭一己之力
+     * 把整个应用挡在生产门外 —— 而它本可以只是不干活。本臂给出的就是那条
+     * 「不起用、但也别挡路」的路。
+     *
+     * <p>【为什么这不算打穿 fail-closed】`enabled` **默认 true**(字段初值与
+     * `@ConditionalOnProperty` 的 `matchIfMissing` 两处都是),所以「忘设」仍旧走闸 ——
+     * 臂一就是这条的正对照(它不带 enabled,仍然必须炸)。只有**显式**写 false 才放行,
+     * 且 {@link LeshouyingProperties#validate()} 会打一条 WARN,不是静默。
+     *
+     * <p>【这一臂与装配面正交】它只证「起得来」;「起得来的同时真的不装配任何端点与定时任务」
+     * 由 {@code LeshouyingChannelSwitchWiringTest} 负责。两条缺一不可:
+     * 只放行而不摘装配 = 拿公开的测试密钥继续收单。
+     */
+    @Test
+    void prodProfile_withDocTestConfig_butChannelExplicitlyDisabled_startsContext() {
+        docTestConfig()
+                .withPropertyValues("spring.profiles.active=prod", "leshouying.enabled=false")
+                .run(context -> {
+                    assertNull(context.getStartupFailure(),
+                            "enabled=false 是「本批通道不上线」的出口,必须起得来,实际: "
+                                    + chain(context.getStartupFailure()));
+                    assertFalse(context.getBean(LeshouyingProperties.class).isEnabled(),
+                            "正对照:leshouying.enabled 没绑上 —— 那样本臂证明的就不是开关放行");
+                });
+    }
+
+    /**
+     * 开关关掉时,**第一道闸(配置缺失)也一并放行**。
+     *
+     * <p>这是生产上更容易撞到的那种:compose 里写成
+     * {@code LESHOUYING_KEY: "${LESHOUYING_KEY:-}"} 这类空值注入,或者压根没注入。
+     * 只放行第二道闸的话,启动仍会被「配置缺失」挡住 —— 出口只开了一半,
+     * 而症状(起不来)与没加开关时**一模一样**,排查时极易误判成「开关没生效」。
+     */
+    @Test
+    void prodProfile_withBlankKey_butChannelExplicitlyDisabled_startsContext() {
+        docTestConfig()
+                .withPropertyValues(
+                        "spring.profiles.active=prod",
+                        "leshouying.key=",
+                        "leshouying.enabled=false")
+                .run(context -> assertNull(context.getStartupFailure(),
+                        "开关关掉时两道闸都应放行,实际: " + chain(context.getStartupFailure())));
+    }
 }