ソースを参照

feat(进件): 三-B 配置项与凭据闸(不进 validate,避免假值进仓库)

alphaH 3 週間 前
親
コミット
3e139fb06a

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

@@ -34,6 +34,28 @@ public class LeshouyingProperties {
     /** 后端回调地址(我们暴露给乐收赢的入口) */
     private String notifyUrl;
 
+    // ===== 三-B 进件参数(spec §6.3 的 1-5,由客户经理提供)=====
+    // ★ 五项**全是 String**,且**全都不给默认值**(Global Constraint 3「凭据不进仓库」/
+    //   4「不留测试用假值」)—— application.yml 里写成 `${ENV:}` 的空默认值,值只从环境变量进。
+    //   不用 Integer:空串绑 Integer 会抛转换异常,而那恰好发生在**凭据还没配**这个
+    //   正常场景里(第一次进件之前,这五个键本来就都是空的)。
+    //   五项都不进 validate(),理由见下方 missingOnboardingCredentials() 的 javadoc。
+
+    /** 进件通道 id(spec §6.3 #1,由客户经理提供)。**不给默认值。** */
+    private String channelId;
+
+    /** 进件业务经理 id(spec §6.3 #2,由客户经理提供)。**不给默认值。** */
+    private String bdId;
+
+    /** 行业类目接口的通道类型(spec §6.3 #5,由客户经理提供)。**不给默认值。** */
+    private String channelType;
+
+    /** 费率模板(spec §6.3 #3)。格式规则见文档:万分之四十五填 RT0045。归属见计划 §0.8。 */
+    private String rateCode;
+
+    /** 进件审核通知地址(我方固定地址,spec §6.5)。**与 notifyUrl 是两个不同的地址。** */
+    private String onboardingNotifyUrl;
+
     // ===== 下面三个是 application.yml 给出的「文档**测试**环境」默认值 =====
     // ★ 它们是测试环境的:base-url 指向乐收赢测试网关,inst-no/key 是文档给的测试机构。
     //   非 dev profile 下出现任意一个 ⇒ validate() 拒绝启动。
@@ -166,6 +188,63 @@ public class LeshouyingProperties {
                         + " 不是 dev/test/local,拒绝启动: " + String.join("; ", testDefaults));
     }
 
+    /**
+     * 列出三-B 进件所必需、当前又**没配**的那几个参数;全配齐时返回空表。
+     *
+     * <p>★ <b>一次性列出所有缺失项</b> —— 与 {@link #validate()} 是同一条理由,
+     * 也就是它 javadoc 的末句:「一次性列出**所有**缺失项:逐个抛会让人改一个重启一次,
+     * <b>进件当天每轮重启都是分钟级</b>」。这里连场景都是同一个(进件当天、反复重启),
+     * 只是闸的位置不同:一个在启动期,一个在进件服务入口。
+     *
+     * <p>★ <b>为什么不放进 {@code validate()}</b>(裁定:闸放服务入口,不放启动期)——
+     * 两条路都堵死:
+     * <ol>
+     *   <li><b>放进去会连坐整个容器。</b>本仓 {@code src/test/resources/} <b>不存在</b>
+     *       (2026-09-23 实测),{@code PaymentApplicationContextTest} 是
+     *       {@code @SpringBootTest},吃的是 {@code src/main/resources/application.yml}
+     *       那份线上配置。而按下面第 2 条,这五个键在那份 yml 里必须是空的 ⇒
+     *       {@code validate()} 会在 refresh 期抛 {@code IllegalStateException} ⇒
+     *       <b>全仓唯一一条真碰容器的测试跟着红</b>;更糟的是它的红灯与
+     *       「装配坏了」长得一模一样(异常都发生在进测试方法之前),
+     *       排障时会把矛头指向根本不存在的那类缺陷。</li>
+     *   <li><b>也不能靠填默认值绕开。</b>{@code Global Constraint 4} / spec §6.4 逐字禁止
+     *       「测试用假值」。而这几个值恰恰是**编不出来的**:{@code channel-id} /
+     *       {@code bd-id} / {@code rate-code} 的真实值只有客户经理能给(spec §6.3 #1/#2/#3),
+     *       随手填一个塞进 yml,等于把 {@code Global Constraint 3}「凭据不进仓库」也一起破了。</li>
+     * </ol>
+     *
+     * <p>★ <b>代价与前提</b>:凭据缺失不再是启动期失败,而是推迟到**第一次进件**时才失败。
+     * 所以调用方(进件服务入口)必须在发起任何上游请求**之前**调本方法、非空即拒 ——
+     * 这是本裁定成立的前提,不是可选项。少了这一句调用,凭据缺失就会退化成
+     * 「拿 null 去签名、被上游回一句签名错误」,也就是 {@code validate()} javadoc
+     * 里说的那种**静默失败**。
+     *
+     * <p>本方法只回答「缺哪些」,不决定「怎么拒绝」:异常类型与对外文案由调用方定。
+     *
+     * @return 给人看的配置键名(含对应环境变量名),形如
+     *         {@code leshouying.channel-id(环境变量 LESHOUYING_CHANNEL_ID)};
+     *         格式照 {@code validate()} 里 {@code missing.add(...)} 的既有写法,逐字同形。
+     */
+    public List<String> missingOnboardingCredentials() {
+        List<String> missing = new ArrayList<>();
+        if (StrUtil.isBlank(channelId)) {
+            missing.add("leshouying.channel-id(环境变量 LESHOUYING_CHANNEL_ID)");
+        }
+        if (StrUtil.isBlank(bdId)) {
+            missing.add("leshouying.bd-id(环境变量 LESHOUYING_BD_ID)");
+        }
+        if (StrUtil.isBlank(channelType)) {
+            missing.add("leshouying.channel-type(环境变量 LESHOUYING_CHANNEL_TYPE)");
+        }
+        if (StrUtil.isBlank(rateCode)) {
+            missing.add("leshouying.rate-code(环境变量 LESHOUYING_RATE_CODE)");
+        }
+        if (StrUtil.isBlank(onboardingNotifyUrl)) {
+            missing.add("leshouying.onboarding-notify-url(环境变量 LESHOUYING_ONBOARDING_NOTIFY_URL)");
+        }
+        return missing;
+    }
+
     /**
      * 是否开发/测试环境:只认 dev / test / local。
      * ★ 读不到 profile(blank)时返回 false —— **fail-closed**:

+ 10 - 0
java/src/main/resources/application.yml

@@ -161,3 +161,13 @@ leshouying:
   reconcile-delay-seconds: 60
   absolute-expire-hours: 24
   downstream-notify-max-retries: 8
+  # ── 三-B 进件参数(spec §6.3 的 1-5,由客户经理提供)──
+  # ★ 默认值必须留空:Global Constraint 3/4(spec §6.4 逐字)禁止「测试用假值」,
+  #   也不许把凭据提交进仓库。缺失由 LeshouyingProperties.missingOnboardingCredentials()
+  #   在进件服务入口挡下并**一次列全**,**不进 validate()**(理由见该方法 javadoc)。
+  channel-id: ${LESHOUYING_CHANNEL_ID:}
+  bd-id: ${LESHOUYING_BD_ID:}
+  channel-type: ${LESHOUYING_CHANNEL_TYPE:}
+  rate-code: ${LESHOUYING_RATE_CODE:}
+  # 进件审核通知地址(我方固定地址,spec §6.5)。★ 与上面 notify-url 不是同一个地址。
+  onboarding-notify-url: ${LESHOUYING_ONBOARDING_NOTIFY_URL:}

+ 89 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/config/LeshouyingPropertiesTest.java

@@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test;
 
 import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
+import java.util.List;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -126,6 +127,94 @@ class LeshouyingPropertiesTest {
         }
     }
 
+    // ---- ★ 三-B 进件凭据闸:missingOnboardingCredentials() ----
+
+    @Test
+    void missingOnboardingCredentials_listsAllFive_whenAllBlank() {
+        LeshouyingProperties p = new LeshouyingProperties();
+        List<String> missing = p.missingOnboardingCredentials();
+        assertEquals(5, missing.size(), "五项全缺时必须一次列全,不能只报第一个");
+    }
+
+    @Test
+    void missingOnboardingCredentials_isEmpty_whenAllSet() {
+        LeshouyingProperties p = new LeshouyingProperties();
+        p.setChannelId("96");
+        p.setBdId("288");
+        p.setChannelType("1");
+        p.setRateCode("RT0045");
+        p.setOnboardingNotifyUrl("https://example.invalid/notify");
+        assertTrue(p.missingOnboardingCredentials().isEmpty());
+    }
+
+    @Test
+    void missingOnboardingCredentials_listsOnlyTheMissingOne() {
+        LeshouyingProperties p = new LeshouyingProperties();
+        p.setChannelId("96");
+        p.setBdId("288");
+        p.setChannelType("1");
+        p.setRateCode("RT0045");
+        // onboardingNotifyUrl 留空
+        assertEquals(1, p.missingOnboardingCredentials().size());
+    }
+
+    /**
+     * 返回的字符串是**给人看的**:运维照它去设环境变量。键名或环境变量名写错一个字母,
+     * 人就照着设一个不存在的变量、再重启一轮(进件当天每轮重启都是分钟级)——
+     * 那正是 {@code validate()} javadoc 里「逐个抛会让人改一个重启一次」要消灭的浪费,
+     * 只是这里换成了「名字写错」这种形式。
+     *
+     * <p>★ 这条是 brief 给的四条之外的**补充**,理由是上面那三条负向用例只断言 {@code size()}:
+     * 把五个字符串全写成同一个、或把环境变量名全拼错,它们**仍然全绿** —— 断言是空的。
+     * 这里用 {@code List#contains}(按元素 {@code equals} 比,子串不算)逐个钉死字面值,
+     * 配合上面那条 {@code size()==5}:五个互不相同的字符串必须各占一个元素 ⇒ 列表内容被唯一确定。
+     */
+    @Test
+    void missingOnboardingCredentials_namesEachConfigKeyWithItsEnvVar() {
+        LeshouyingProperties p = new LeshouyingProperties();
+        List<String> missing = p.missingOnboardingCredentials();
+
+        assertEquals(5, missing.size(), "实际: " + missing);
+        assertTrue(missing.contains("leshouying.channel-id(环境变量 LESHOUYING_CHANNEL_ID)"),
+                "实际: " + missing);
+        assertTrue(missing.contains("leshouying.bd-id(环境变量 LESHOUYING_BD_ID)"),
+                "实际: " + missing);
+        assertTrue(missing.contains("leshouying.channel-type(环境变量 LESHOUYING_CHANNEL_TYPE)"),
+                "实际: " + missing);
+        assertTrue(missing.contains("leshouying.rate-code(环境变量 LESHOUYING_RATE_CODE)"),
+                "实际: " + missing);
+        assertTrue(missing.contains("leshouying.onboarding-notify-url(环境变量 LESHOUYING_ONBOARDING_NOTIFY_URL)"),
+                "实际: " + missing);
+    }
+
+    // ---- ★ 正对照:进件凭据**不得**进 validate() ----
+
+    /**
+     * ★ <b>正对照</b>:本用例钉住 §0.9 的裁定 —— 进件凭据**不得**进 {@code validate()}。
+     *
+     * <p>它只写 {@code assertDoesNotThrow},看着像「没断言」,实际是**挡后来者**:
+     * <ul>
+     *   <li>若有人把这五项顺手加进 {@code validate()},本仓的
+     *       {@code PaymentApplicationContextTest}({@code @SpringBootTest},吃
+     *       {@code src/main/resources/application.yml} —— {@code src/test/resources/} 不存在)
+     *       会连同整个应用启动一起失败;</li>
+     *   <li>而 §6.4 又禁止我们往那份 yml 填假值(凭据不进仓库)。</li>
+     * </ul>
+     * 两条路都堵死 ⇒ 闸放在进件服务入口,而不是启动期。
+     * <b>没有这条用例,上面那次「加进 validate()」全库不会有任何测试变红</b>
+     * —— 本类的另外几条只造缺项、不看「四必填齐备时是否放行」。
+     */
+    @Test
+    void validate_passesWhenOnboardingCredentialsAreAbsent() {
+        LeshouyingProperties p = new LeshouyingProperties();
+        p.setInstNo("real-inst");
+        p.setKey("real-key");
+        p.setBaseUrl("https://real.example.invalid/yfpay");
+        p.setNotifyUrl("https://real.example.invalid/notify");
+        p.setActiveProfiles("dev");
+        assertDoesNotThrow(p::validate);
+    }
+
     // ---- ★ 防漂移:Java 常量必须与 application.yml 里的默认值一致 ----
 
     @Test