Forráskód Böngészése

feat(进件): 创建商户的组装上送与取号(Task 3)

把 Task 2 的必填矩阵与 DTO 接到服务层:/api/merchant/create 的报文组装、上送、取号。
新写三条对外文案,全部按 GC 1「文案零上游痕迹」处理 —— 它们经 GlobalExceptionHandler
是 Result.of(code, message),message 原样回给调用方。

LsyOnboardingService.createMerchant(dto) —— 四步顺序固定,不许换:
  ① 凭据闸 → ② 必填矩阵 → ③ 组装 → ④ 上送 + 取号。
  ① 在 ② 之前是硬的:凭据缺失时连报文都补不全(channel_id / bd_id / notify_url / rate_code
  都是报文的一部分),先报必填的话操作者会填完一整屏再被告知「服务端配置没配」,
  而那个配置他改不了。凭据缺失的对外文案 = 中文固定文案 + 缺失项数,明细(键名 + 环境变量名)
  只进日志 —— 那串逐条含上游名号的罗马字(LESHOUYING_*),拼进 message 就是越线(R23)。
  必填缺失的 400 报**中文标签**(逐名过 LABELS,唯一翻译处),不报上游参数名。
  组装:merchant_type / account_type 从 LsyMerchantCategory 派生(类别为空在 ② 已被拒);
  notify_url 取 onboardingNotifyUrl(不是 notifyUrl,spec §6.5 两个地址不同);
  rate_code 以 DTO 为准、DTO 空则取配置(计划 §0.8)。
  取号:merchant_no 与 shoubei_merchant_no **两个都读**(§6.2(c):返回参数表与返回示例
  写的是两个不同的键,只有真跑一次才知道哪个准),都没有则 502 + 「不得当作失败直接重试」
  —— 这条腿重试一次就是多一个商户。
  ★ 新 502 文案不含上游专有字段名:merchant_no / shoubei_merchant_no 两查都不成立
  (建表语句无此列、也没有对外 DTO 把这一串回给租户),所以这两个键名只活在解析代码里。

channel_id / bd_id 按上游声明的 Integer(11) 形态上送(请求体是 JSON,Integer 与 String 在报文上不同)。
配置值只接受**规范十进制**写法:这一步是唯一会把配置文本改写成另一个文本的地方,
"001001" 若被静默接受,发出去的就是 1001,而通道 id 决定商户开到哪条通道上 ——
后果是「开到别的通道」且不报错。拒绝时只报键名与长度、不报值(GC 5;也不让
NumberFormatException 逃出去,它的 message 会把原值整串带出来)。

LsyMerchantReviewStatus —— pay_lsy_merchant.review_status 的本地词表
(PENDING / APPROVED / REJECTED),跨任务接口:本任务建,进件写入口与审核通知回写用。
三个词各有出处(DDL 默认值里的 APPROVED、process_status 已在用的 REJECTED、
本批次新引入的 PENDING),类 javadoc 写明**这不是** spec §6.4 禁的上游值域归一化 ——
解上游值域的是另一个枚举(LsyOnboardingReviewStatus),两者不得合并。

测试 +12(LsyOnboardingServiceTest +11、新增 LsyMerchantReviewStatusTest +1):
  8 条按 brief 的用例名;另 3 条钉本任务新增的行为(凭据明细进日志的正对照、
  非规范十进制配置、trace_no 每次新生成)。
  ★ DTO 夹具里每个字段的值就是它自己的上游键名(每个字段一个独特值)⇒「A 字段取了 B 的
  getter」必然红;期望键集是**手写的 59 项字面表**(含 18 个只带不判的字段),不是反射生成的。
  400 文案里的中文标签写字面中文,不是 LABELS.get(...)(那是恒真式)。

变异实测(两向都做,读数与字节级还原证据见 task-3-report.md):
  contact_phone 取到 getContactService        ⇒ 1 红(值 diff 精确指出那一项)
  凭据明细拼进 message                        ⇒ 1 红(GC 1 越线)
  去掉 shoubei_merchant_no 回退               ⇒ 1 红
  无害对照:对调两个 put 的先后                ⇒ 0 红(刻意钝感,证明不押 map 顺序)

测试:全量 526 → 538(本类 57,新增 11),0 fail / 0 error / 0 skipped,BUILD SUCCESS。
alphaH 3 hete
szülő
commit
3993578e1b

+ 53 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/enums/LsyMerchantReviewStatus.java

@@ -0,0 +1,53 @@
+package com.payment.platform.module.payment.leshouying.enums;
+
+/**
+ * {@code pay_lsy_merchant.review_status} 的**本地词表** —— 我们自己那一列的取值,**不是**上游报文的值域。
+ *
+ * <p><b>三个字面值的来源,逐条可查(都不是新造词)</b>
+ * <ol>
+ *   <li>{@link #PENDING} —— <b>本批次(三-B)新引入</b>:进件写入口用它,让刚提交的商户在管理端显示
+ *       「待审核」而不是「已通过」。<b>不得</b>让新行落到建表语句的默认值 {@code 'APPROVED'} 上:
+ *       {@code db/migration/V1.8__create_pay_lsy_tables.sql} 逐字写着
+ *       {@code review_status VARCHAR(32) NOT NULL DEFAULT 'APPROVED'},那是 A 阶段手工维护存量行的口径
+ *       (列注释:{@code 进件审核状态(A 阶段默认 APPROVED;B 阶段由进件写入口与上游审核通知回写)});
+ *       新行默认「已通过」等于把「还没审核」读成「审核过了」。</li>
+ *   <li>{@link #APPROVED} —— 沿用**同一份 DDL 的默认值**里的那个词(逐字 {@code 'APPROVED'})。</li>
+ *   <li>{@link #REJECTED} —— 沿用同模块 {@code pay_lsy_notify_log.process_status} **已在用的**那个词:
+ *       该列注释逐字列着 {@code RECEIVED/APPLIED/DUPLICATE/REJECTED/...},而
+ *       {@code LsyNotifyService} 在验签失败分支上写的就是字面量 {@code "REJECTED"}。
+ *       注意它沿用的是**这个词**,不是那个列的含义 —— 两列语义不同,只是不另造拼写。</li>
+ * </ol>
+ *
+ * <p>★ <b>这不是 spec §6.4 禁止的那种「上游值域归一化」。</b>那条禁的是把上游**三套**
+ * {@code reviewed_status} 值域(异步通知 page 196236556、入网状态 page 196236566、主动查询
+ * page 196236567)猜着合并成一个枚举 —— 三套各自实现、各按各的解析,**不做归一化猜测**。
+ * 本枚举解的不是上游报文:它是**我们自己一列**的词表,取值只从 196236556 那一套推导。
+ * 解上游值域的那个枚举({@code LsyOnboardingReviewStatus},审核通知那一侧的产物)与本枚举
+ * <b>是两件事,不得合并</b>:一个是「上游说了什么」,一个是「我们库里写什么」。
+ *
+ * <p>★ <b>写库时用 {@link #getValue()},不要用 {@code name()}</b>:字面值是**列契约**的一部分
+ * (列宽 {@code VARCHAR(32)}、管理端按它显示、上游通知回写时按它比对),改常量名<b>不得</b>
+ * 顺带改掉落进列里的那个词。三个字面值有测试钉着({@code LsyMerchantReviewStatusTest})。
+ */
+public enum LsyMerchantReviewStatus {
+
+    /** 进件已提交、等待上游审核 —— 三-B 的进件写入口写这个值。 */
+    PENDING("PENDING"),
+
+    /** 审核通过 —— 沿用建表语句默认值 {@code 'APPROVED'} 里的那个词。 */
+    APPROVED("APPROVED"),
+
+    /** 审核驳回 —— 沿用 {@code pay_lsy_notify_log.process_status} 已在用的那个词。 */
+    REJECTED("REJECTED");
+
+    private final String value;
+
+    LsyMerchantReviewStatus(String value) {
+        this.value = value;
+    }
+
+    /** 落进 {@code pay_lsy_merchant.review_status}({@code VARCHAR(32)})的那个词。 */
+    public String getValue() {
+        return value;
+    }
+}

+ 265 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingService.java

@@ -7,6 +7,7 @@ import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
 import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
 import com.payment.platform.module.payment.leshouying.dto.LsyAreaItemDTO;
+import com.payment.platform.module.payment.leshouying.dto.LsyMerchantCreateDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyReviewStatusDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyShopCreateDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyShopItemDTO;
@@ -293,6 +294,120 @@ public class LsyOnboardingService {
         return shopNo;
     }
 
+    /**
+     * 创建商户(上游 page 196236555,路径 {@code /api/merchant/create})—— 三-B 的进件入口。
+     *
+     * <p>上游入参 = 信封({@code inst_no} / {@code trace_no} / {@code sign};后两个里的
+     * {@code inst_no} 与 {@code sign} 由 {@link LeshouyingClient} 在出站链路上补,
+     * {@code trace_no} 由本方法**每次新生成**)+ 配置四项({@code notify_url} / {@code channel_id} /
+     * {@code bd_id} / {@code rate_code})+ 类别派生两项({@code merchant_type} / {@code account_type})
+     * + {@link LsyMerchantCreateDTO} 的 54 个业务字段。
+     * 出参:{@code result_code} / {@code result_msg} / 商户号 / {@code sign}
+     * (信封非 SUCCESS 一律抛 {@code LeshouyingApiException} ⇒ 走得下去就说明上游**认了**这次进件)。
+     *
+     * <p><b>【四步,顺序不许换】</b>① 凭据闸 → ② 必填矩阵 → ③ 组装 → ④ 上送 + 取号。
+     * ① 排在 ② 前面是**硬的**,不是偏好:凭据缺失时**连报文都补不全**
+     * ({@code channel_id} / {@code bd_id} / {@code notify_url} / {@code rate_code} 四项里缺的任何一项
+     * 都是报文的一部分)。反过来先报必填的话,操作者会先把一整屏字段填完,再被告知「服务端配置没配」
+     * —— 而**那个配置他改不了**,能改的人不在这个页面上。
+     *
+     * <p><b>【① 凭据闸:明细只进日志,对外只给中文固定文案 + 缺失项数】</b>
+     * {@link LeshouyingProperties#missingOnboardingCredentials()} 返回的每一串都含**环境变量名**
+     * ({@code LESHOUYING_CHANNEL_ID} 这种),也就是**上游名号的罗马字** ⇒ 拼进 message 就是越线
+     * (Global Constraint 1「文案零上游痕迹」/ spec §8.5 第 5 条)。所以:
+     * <ul>
+     *   <li>明细(键名 + 环境变量名)只进日志;日志里打的是**键名不是值**,不触 Global Constraint 5;</li>
+     *   <li>对外 message = 中文固定文案 + **缺失项数**;</li>
+     *   <li><b>不得</b>把环境变量名换成中文标签再发出去 —— 那会造出第二张翻译表,
+     *       且仍在教调用方猜服务端配置。</li>
+     * </ul>
+     * <b>代价若判错</b>:调用方少一条自助定位的线索(改看服务端日志)。这是**故意**的取舍:
+     * Global Constraint 1 是持续约束,而这条文案经 {@code GlobalExceptionHandler} 是
+     * {@code Result.of(code, message)},**原样**回给调用方。补偿机制由
+     * {@code createMerchant_missingCredentials_detailGoesToTheLog} 钉着。
+     *
+     * <p><b>【② 必填矩阵:报中文标签,不报键名】</b>
+     * {@link LsyMerchantRequiredFields#missingRequiredFields} 返回的是**上游参数名**(机器可读),
+     * 渲染前逐名过 {@link LsyMerchantRequiredFields#LABELS}(Task 2 产,是**唯一**的翻译处)。
+     * 两条依据:① 人话 —— 见 {@link #requireText} 的 javadoc(报键名的话操作者得在文档里做一次心算);
+     * ② Global Constraint 1 —— {@code fSettle_account_type} 这种键名出现在对外文案里就是暴露上游词汇。
+     * 类别为 {@code null} 时矩阵报的是两个派生字段({@code merchant_type} / {@code account_type}),
+     * 同样是 400,**不是** 500。
+     *
+     * <p><b>【③ 组装:三处取值不属于调用方】</b>
+     * <ul>
+     *   <li>{@code merchant_type} / {@code account_type} <b>从类别派生</b>({@code dto.getCategory()},
+     *       DTO 上不存这两个码值);★ 走到这一步类别**必然非 null** —— 类别为空在 ② 就被拒了
+     *       (矩阵把这两个派生字段报成缺失);</li>
+     *   <li>{@code notify_url} 取 {@code onboardingNotifyUrl},<b>不是</b> {@code notifyUrl}
+     *       (spec §6.5:进件审核与收款是**两个**地址);</li>
+     *   <li>{@code rate_code}:DTO 有值以 DTO 为准,没有则取配置(计划 §0.8 的裁定 ——
+     *       该字段归属未定,先按平台级实现、把逐商户覆盖的口子留在 DTO 上)。</li>
+     * </ul>
+     * 其余 54 个字段**原样透传**:{@code null} 照常放进 map({@code LeshouyingClient} 按既有规则
+     * 剔 null,服务层不重复一条口径 —— 两份「什么算没填」分叉的症状是「有的字段被剔了、有的没有」),
+     * 空串则原样上送(与本仓签名规则「null 不参与、**空串参与**」同源)。
+     * ⇒ <b>未填的非必填字段应当传 {@code null}(缺键),不要传空串</b>:空串是一个参与签名的真实值。
+     *
+     * <p><b>【④ 取号:两个键都读,都读不到才 502】</b>
+     * 上游返回参数表写 {@code shoubei_merchant_no}、同页返回示例写 {@code merchant_no}
+     * (spec §6.2(c) —— **只有真跑一次才知道**哪个是准的)⇒ 两个都读,谁有值用谁。
+     * 都没有(或只有空白)⇒ {@link BusinessException}(502),不返回 null、不静默:
+     * 理由与 {@link #createShop} 缺 {@code shop_no} 时**完全同源**(那里写得细,不复述),
+     * 而文案里那句「不得当作失败直接重试」在这里更重 —— 重试一次就是**多一个商户**。
+     * ★ 文案既不含上游名号、也**不含上游专有字段名**(那两个键名两查都不成立:DDL 里没有、
+     * 也没有对外 DTO 把这一串回给租户),所以它们只活在下面这段**解析代码**里
+     * (读的是上游应答,不触 Global Constraint 1)。
+     *
+     * <p>★ 租户缺口(R-4)对本法同样成立,且更重:{@code dto} 里没有任何租户标识,
+     * 上送用的 {@code channel_id} / {@code bd_id} 是**服务端配置** ⇒ 三-B 的端点必须先确定
+     * 「谁可以发起进件」,否则任何调用方都能拿平台的通道资质去开商户。见类 javadoc。
+     *
+     * @param dto 类别必填(七类之一),其余按 {@code LsyMerchantRequiredFields} 的必填矩阵;
+     *            缺项 ⇒ {@link BusinessException}(400),**不发上游**
+     * @return 上游给的商户号,**保证非空白**
+     * @throws BusinessException 400 服务端进件凭据缺失 / 配置项不是 Integer(11) 的规范写法 / 必填项缺失;
+     *                           502 上游返回成功但没给可用的商户号
+     */
+    public String createMerchant(LsyMerchantCreateDTO dto) {
+        // ① 凭据闸
+        List<String> missingCredentials = props.missingOnboardingCredentials();
+        if (!missingCredentials.isEmpty()) {
+            // 明细只进日志(Global Constraint 1):这里打的是**键名**不是值 ⇒ 不触 Global Constraint 5。
+            log.error("进件凭据缺失(共 {} 项),拒绝上送创建商户请求:{}",
+                    missingCredentials.size(), String.join("; ", missingCredentials));
+            throw new BusinessException(400, "服务端进件配置缺失(共 " + missingCredentials.size()
+                    + " 项),明细见服务端日志");
+        }
+
+        // ② 必填矩阵
+        List<String> missingFields = LsyMerchantRequiredFields.missingRequiredFields(dto);
+        if (!missingFields.isEmpty()) {
+            throw new BusinessException(400, "进件必填项缺失(共 " + missingFields.size()
+                    + " 项):" + labelsOf(missingFields));
+        }
+
+        // ③ 组装 + ④ 上送
+        Map<String, Object> response = client.postApi("/api/merchant/create", merchantParams(dto));
+
+        // 取号的键**两个都读**(spec §6.2(c):返回参数表写 shoubei_merchant_no、返回示例写
+        // merchant_no,只有真跑一次才知道哪个是准的)⇒ 两个都读、谁有值用谁。
+        // 判据是**空白**而不是「键在不在」:给了空串与没给一样不可用。
+        String merchantNo = asText(response.get("merchant_no"));
+        if (StrUtil.isBlank(merchantNo)) {
+            merchantNo = asText(response.get("shoubei_merchant_no"));
+        }
+        if (StrUtil.isBlank(merchantNo)) {
+            // 文案**不含上游名号、也不含上游专有字段名**(Global Constraint 1):它经
+            // GlobalExceptionHandler 原样回给调用方。结构照 createShop 那条 502
+            // (成功但缺号 ⇒ 502 + 行为指令),而「不得当作失败直接重试」在这里更重
+            // —— 重试一次就是多一个商户。
+            throw new BusinessException(502,
+                    "商户创建成功但未返回商户号(商户可能已创建,不得当作失败直接重试)");
+        }
+        return merchantNo;
+    }
+
     /**
      * 获取门店列表(上游 page 196236560,路径 {@code /api/shop/list})。
      *
@@ -495,6 +610,156 @@ public class LsyOnboardingService {
         return p;
     }
 
+    /**
+     * 创建商户的出站参数:{@code merchant_type} / {@code account_type} 由类别派生,
+     * {@code notify_url} / {@code channel_id} / {@code bd_id} / {@code rate_code} 取自配置,
+     * 其余 54 个字段从 DTO **原样透传** —— 全模块唯一的一处「驼峰字段 → 上游下划线键名」映射
+     * 就在下面这段(映射表本身见 {@link LsyMerchantCreateDTO} 的类 javadoc)。
+     *
+     * <p>★ <b>键名为什么字面写在这里、不另立 54 个常量</b>:{@code KEY_MANAGER_NAME} 那一族常量
+     * 存在的原因是**文档与示例互相矛盾、要留一个可翻转的单点**;这 54 个键没有这种冲突,
+     * 它们的权威是 DTO 的那份映射表与 {@code createMerchant_sendsExactlyTheExpectedParams} 里
+     * 那张**字面**期望表 —— 为每个键再立一个常量只是把同一份映射写两遍。
+     * 反之,{@code fSettle_account_type}(首段自带大写)与 {@code picture_idcard_hander}
+     * (上游自己拼错的 {@code hander})这两个键**不许**「顺手规整」:键名进**签名原文**,
+     * 改一个字母就换签名。
+     *
+     * <p>★ {@code trace_no} 每次调用**重新生成**({@code IdUtil.fastSimpleUUID()},32 位),
+     * 理由与 {@link #mchNoParams} 里那段同源(不复述第二份):写成常量或缓存在字段里,
+     * 会有两笔请求撞同一个流水号。
+     *
+     * <p>★ {@code rate_code} 单独判一次(DTO 有值以 DTO 为准),其余字段不带任何条件 ——
+     * 「哪些字段必填」由 {@link LsyMerchantRequiredFields} 一张表说了算,
+     * 组装层不再做第二次判断(两处口径分叉的症状是「校验说齐了、发出去却是空的」)。
+     */
+    private Map<String, Object> merchantParams(LsyMerchantCreateDTO dto) {
+        Map<String, Object> p = new LinkedHashMap<>();
+        p.put("trace_no", IdUtil.fastSimpleUUID());
+        p.put("notify_url", props.getOnboardingNotifyUrl());
+        // 配置项是字符串,而上游把这两个声明成 Integer(11)(判据见 upstreamInteger)
+        p.put("channel_id", upstreamInteger(props.getChannelId(), "channel_id"));
+        p.put("bd_id", upstreamInteger(props.getBdId(), "bd_id"));
+        // 类别派生。走到这里 category 必然非 null:类别为空在必填矩阵那一步已被拒(矩阵把这两个派生字段报成缺失)
+        p.put("merchant_type", dto.getCategory().getMerchantType());
+        p.put("account_type", dto.getCategory().getAccountType());
+        p.put("merchant_name", dto.getMerchantName());
+        p.put("merchant_name_short", dto.getMerchantNameShort());
+        p.put("merchant_name_business", dto.getMerchantNameBusiness());
+        p.put("merchant_mcc_code", dto.getMerchantMccCode());
+        p.put("contact_name", dto.getContactName());
+        p.put("contact_phone", dto.getContactPhone());
+        p.put("contact_email", dto.getContactEmail());
+        p.put("contact_service", dto.getContactService());
+        p.put("address_province", dto.getAddressProvince());
+        p.put("address_city", dto.getAddressCity());
+        p.put("address_county", dto.getAddressCounty());
+        p.put("address_street", dto.getAddressStreet());
+        p.put("license_type", dto.getLicenseType());
+        p.put("license_no", dto.getLicenseNo());
+        p.put("license_stt", dto.getLicenseStt());
+        p.put("license_expire", dto.getLicenseExpire());
+        p.put("legal_name", dto.getLegalName());
+        p.put("legal_cert_no", dto.getLegalCertNo());
+        p.put("legal_cert_stt", dto.getLegalCertStt());
+        p.put("legal_cert_expire", dto.getLegalCertExpire());
+        p.put("account_idcard_num", dto.getAccountIdcardNum());
+        p.put("account_idcard_stt", dto.getAccountIdcardStt());
+        p.put("account_idcard_expire", dto.getAccountIdcardExpire());
+        p.put("account_card_name", dto.getAccountCardName());
+        p.put("account_card_num", dto.getAccountCardNum());
+        p.put("account_card_phone", dto.getAccountCardPhone());
+        p.put("account_bank_code", dto.getAccountBankCode());
+        p.put("reg_capital", dto.getRegCapital());
+        // ★ 本类唯一一个「键名不是机械驼峰」的字段:上游键首段自带大写,别规整成别的形状
+        p.put("fSettle_account_type", dto.getFSettleAccountType());
+        p.put("actual_addr", dto.getActualAddr());
+        p.put("business_scope", dto.getBusinessScope());
+        p.put("check_code", dto.getCheckCode());
+        // §0.8:DTO 有值以 DTO 为准,没有则用平台级配置
+        p.put("rate_code", StrUtil.isBlank(dto.getRateCode()) ? props.getRateCode() : dto.getRateCode());
+        p.put("picture_license", dto.getPictureLicense());
+        p.put("picture_idcard_a", dto.getPictureIdcardA());
+        p.put("picture_idcard_b", dto.getPictureIdcardB());
+        p.put("picture_bankcard_a", dto.getPictureBankcardA());
+        p.put("picture_bankcard_b", dto.getPictureBankcardB());
+        p.put("picture_outside", dto.getPictureOutside());
+        p.put("picture_inside", dto.getPictureInside());
+        p.put("lease_contract", dto.getLeaseContract());
+        p.put("picture_tripartite", dto.getPictureTripartite());
+        // ★ 上游把 handler 拼成了 hander,照抄(键名进签名原文)
+        p.put("picture_idcard_hander", dto.getPictureIdcardHander());
+        p.put("picture_open_account", dto.getPictureOpenAccount());
+        p.put("picture_organization", dto.getPictureOrganization());
+        p.put("picture_tax_registration", dto.getPictureTaxRegistration());
+        p.put("picture_unincorporated", dto.getPictureUnincorporated());
+        p.put("picture_bank_idcard_a", dto.getPictureBankIdcardA());
+        p.put("picture_bank_idcard_b", dto.getPictureBankIdcardB());
+        p.put("picture_other", dto.getPictureOther());
+        p.put("share_scene", dto.getShareScene());
+        p.put("share_certificate", dto.getShareCertificate());
+        p.put("tripartite_flow_certificate_url", dto.getTripartiteFlowCertificateUrl());
+        p.put("p_cashier", dto.getPCashier());
+        return p;
+    }
+
+    /**
+     * 缺失清单(**上游参数名**)→ 对外文案里的中文标签串,用「、」连接。
+     *
+     * <p>★ 标签只从 {@link LsyMerchantRequiredFields#LABELS} 取(Task 2 产,是**唯一**的翻译处):
+     * 这里另写一张表的那天,两张表就会分叉,而症状是「同一个字段在提示里有两个名字」。
+     *
+     * <p>★ 标签没登记时抛 {@link IllegalStateException} 而**不是**退回键名:退回键名会让上游词汇
+     * 出现在对外文案里(Global Constraint 1)—— 那是**静默**越线,比一声响亮的 500 坏得多。
+     * 这条分支由 Task 2 的 {@code LABELS_definesEveryFieldTheMatrixCanReport} 保证不可达。
+     */
+    private static String labelsOf(List<String> names) {
+        List<String> labels = new ArrayList<>(names.size());
+        for (String name : names) {
+            String label = LsyMerchantRequiredFields.LABELS.get(name);
+            if (label == null) {
+                throw new IllegalStateException(
+                        "必填矩阵报出的字段在 LABELS 里没有中文标签: " + name + " —— 加字段时忘了登记标签");
+            }
+            labels.add(label);
+        }
+        return String.join("、", labels);
+    }
+
+    /**
+     * 进件配置里的 {@code Integer(11)} 型参数({@code channel_id} / {@code bd_id})→ 出站值。
+     *
+     * <p>★ <b>只接受规范十进制写法</b>:{@code "1001"} 通过;{@code "+1001"} / {@code "001001"} /
+     * {@code "1001.0"} / 超出 int 范围一律拒(首尾空白先 trim 掉 —— 那不改数字)。
+     * 理由不是洁癖:这一步是**唯一**会把配置文本改写成另一个文本的地方 —— {@code "001001"} 若被
+     * 静默接受,发出去的就是 {@code 1001},而通道 id 决定商户开到**哪条通道**上,
+     * 改一个数字的后果是「开到别的通道」且**不报错**。
+     *
+     * <p>★ <b>拒绝时只报键名与长度、不报值</b>(Global Constraint 5「秘密只打印长度,不打印值」;
+     * {@code channel_id} 一族按 Global Constraint 3 就是凭据)。这也是不许
+     * {@link NumberFormatException} 逃出去的原因:它的 message 会把原值整串带出来。
+     * 对外文案与凭据闸同一口径(中文固定文案 + 明细见日志):调用方改不了服务端配置,
+     * 但末句把矛头指向了日志。
+     */
+    private static Integer upstreamInteger(String value, String param) {
+        String canonical = value == null ? "" : value.trim();
+        Integer parsed = null;
+        try {
+            int n = Integer.parseInt(canonical);
+            // 能解析**不等于**没改写:"001001" 解析成 1001 就是改写了一次
+            if (String.valueOf(n).equals(canonical)) {
+                parsed = n;
+            }
+        } catch (NumberFormatException e) {
+            // 不是十进制整数 —— 与「写法不规范」共用下面那个出口(不是吞掉:那里有日志 + 400)
+        }
+        if (parsed != null) {
+            return parsed;
+        }
+        log.error("进件配置项 {} 不是 Integer(11) 的规范十进制写法(长度 {}),拒绝上送",
+                param, canonical.length());
+        throw new BusinessException(400, "服务端进件配置项格式错误,明细见服务端日志");
+    }
+
     /**
      * 必填字符串守卫:空白({@code null} / 空串 / 全空格)⇒ {@link BusinessException}(400)。
      *

+ 32 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/enums/LsyMerchantReviewStatusTest.java

@@ -0,0 +1,32 @@
+package com.payment.platform.module.payment.leshouying.enums;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * {@link LsyMerchantReviewStatus} 的**字面值**钉子。
+ *
+ * <p>【为什么这个枚举值得单独一条用例】它是**跨任务接口**:Task 3 只负责把它建出来,
+ * 真正往 {@code pay_lsy_merchant.review_status} 里写的两处(进件写入口写 {@code PENDING}、
+ * 审核通知回写写 {@code APPROVED} / {@code REJECTED})在后面的任务里 ⇒ 在它们落地之前,
+ * 全仓**没有**任何生产代码会碰这三个常量。没有这条用例的话,「把 {@code REJECTED} 规整成
+ * {@code REFUSED}」在编译期与运行期都不会有任何反应,症状要到管理端显示出读不懂的状态时才会被发现。
+ *
+ * <p>断言写**字面串**而不是 {@code name()}:{@code name()} 与 {@code getValue()} 之间那层间接
+ * 正是这条用例要保护的东西 —— 有人改常量名时({@code name()} 跟着变),落进列里的那个词不许跟着变。
+ */
+class LsyMerchantReviewStatusTest {
+
+    /**
+     * 三个字面值 = 建表语句默认值里的 {@code APPROVED} + 同模块 {@code process_status} 已在用的
+     * {@code REJECTED} + 本批次新引入的 {@code PENDING}(列宽 {@code VARCHAR(32)},
+     * 三个词各自都短于列宽 —— 这条不用断言,它由 DDL 决定)。
+     */
+    @Test
+    void value_isTheWordThatLandsInTheColumn() {
+        assertEquals("PENDING", LsyMerchantReviewStatus.PENDING.getValue());
+        assertEquals("APPROVED", LsyMerchantReviewStatus.APPROVED.getValue());
+        assertEquals("REJECTED", LsyMerchantReviewStatus.REJECTED.getValue());
+    }
+}

+ 671 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingServiceTest.java

@@ -1,22 +1,29 @@
 package com.payment.platform.module.payment.leshouying.service;
 
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
 import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
 import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
 import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
 import com.payment.platform.module.payment.leshouying.dto.LsyAreaItemDTO;
+import com.payment.platform.module.payment.leshouying.dto.LsyMerchantCreateDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyReviewStatusDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyShopCreateDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyShopItemDTO;
+import com.payment.platform.module.payment.leshouying.enums.LsyMerchantCategory;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.slf4j.LoggerFactory;
 
 import java.nio.charset.StandardCharsets;
 import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -1192,4 +1199,668 @@ class LsyOnboardingServiceTest {
         assertEquals(longName, sent.fileName(), "长文件名必须原样送出,不得截断");
         assertEquals(longName, sent.formFields().get("img_name"), "img_name 同样不截断");
     }
+
+    // ==================== Task 3:创建商户(上游 page 196236555,路径 /api/merchant/create) ====================
+    //
+    // 出站参数 = 服务层生成的信封位(trace_no)+ 配置三项(notify_url / channel_id / bd_id / rate_code)
+    //          + 类别派生两项(merchant_type / account_type)+ DTO 的 54 个业务字段
+    // (inst_no / sign 由 LeshouyingClient 在客户端内部补 —— 本类 mock 的正是那个客户端。)
+
+    /** 夹具:通道 id。上游参数表声明它 {@code Integer(11)}。★ 夹具值**不是**线上凭据(GC 4「不留假值」管的是往配置里填)。 */
+    private static final String CFG_CHANNEL_ID = "1001";
+
+    /** 夹具:业务经理 id(上游声明 {@code Integer(11)})。 */
+    private static final String CFG_BD_ID = "2002";
+
+    /** 夹具:通道类型。创建商户这条腿不送它,只为过凭据闸。 */
+    private static final String CFG_CHANNEL_TYPE = "3";
+
+    /** 夹具:费率模板(文档给的格式示例就是 {@code RT0045})。 */
+    private static final String CFG_RATE_CODE = "RT0045";
+
+    /** 夹具:**进件审核**通知地址。 */
+    private static final String CFG_ONBOARDING_NOTIFY_URL = "https://example.com/xjz/notify/lsy/onboarding";
+
+    /**
+     * 夹具:**收款**通知地址 —— 与 {@link #CFG_ONBOARDING_NOTIFY_URL} 逐字不同。
+     *
+     * <p>两个地址必须在**同一次装配里同时设上**:只设进件那个的话,「取的是哪一个」在断言下不可见
+     * (spec §6.5 明确这是两个不同的地址)。
+     */
+    private static final String CFG_PAY_NOTIFY_URL = "https://example.com/xjz/notify/lsy/pay";
+
+    /** 让 {@code createMerchant} 过得了第一步的凭据闸 —— 五个键缺一个都过不去。 */
+    private void withOnboardingCredentials() {
+        props.setChannelId(CFG_CHANNEL_ID);
+        props.setBdId(CFG_BD_ID);
+        props.setChannelType(CFG_CHANNEL_TYPE);
+        props.setRateCode(CFG_RATE_CODE);
+        props.setOnboardingNotifyUrl(CFG_ONBOARDING_NOTIFY_URL);
+    }
+
+    /** 创建商户成功的应答:page 196236555 返回参数表的成功形状,只留本类要读的键。 */
+    private static final String MERCHANT_CREATE_OK =
+            "{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\",\"merchant_no\":\"851901070001\"}";
+
+    /**
+     * 一份**逐字段填满**的创建商户入参:类别取 {@link LsyMerchantCategory#MICRO}({@code (3,4)})。
+     *
+     * <p>★ <b>每个字段的值就是它自己的上游键名</b>({@code merchant_name → "merchant_name"})。
+     * 这一条同时办了两件事:
+     * <ol>
+     *   <li><b>每个字段一个独特值</b> ⇒ 任何一处「A 字段取到了 B 的 getter」都会让**值**对不上
+     *       (Task 2 评审的裁定 d:夹具只有「全填 / 全空」两态时,接错 getter 完全不可见
+     *       —— 那种夹具下 A 与 B 的值同生同灭);</li>
+     *   <li>期望表因此可以写成**字面量**(见 {@link #expectedMerchantParams()})。</li>
+     * </ol>
+     *
+     * <p>★ <b>{@code picture_other} 故意留 {@code null}</b>:钉住「未填的可选字段保持 null」——
+     * 既不得变成字符串 {@code "null"}(那会原样上送),也不得变成空串(本仓签名规则是
+     * <b>null 不参与、空串参与</b>,凭空多一项 {@code picture_other=} 进签名原文)。
+     * 其余 53 个字段各有独特值,所以这一处 null 不削弱上面第 1 条。
+     *
+     * <p>值不写成真手机号 / 真身份证:{@code account_idcard_num} 与 {@code account_card_num}
+     * 在本模块是 PII(GC 5),用例里放一眼可辨的假值比放形似真值的串安全。
+     */
+    private static LsyMerchantCreateDTO merchantDto() {
+        LsyMerchantCreateDTO dto = new LsyMerchantCreateDTO();
+        dto.setCategory(LsyMerchantCategory.MICRO);
+        dto.setMerchantName("merchant_name");
+        dto.setMerchantNameShort("merchant_name_short");
+        dto.setMerchantNameBusiness("merchant_name_business");
+        dto.setMerchantMccCode("merchant_mcc_code");
+        dto.setContactName("contact_name");
+        dto.setContactPhone("contact_phone");
+        dto.setContactEmail("contact_email");
+        dto.setContactService("contact_service");
+        dto.setAddressProvince("address_province");
+        dto.setAddressCity("address_city");
+        dto.setAddressCounty("address_county");
+        dto.setAddressStreet("address_street");
+        dto.setLicenseType("license_type");
+        dto.setLicenseNo("license_no");
+        dto.setLicenseStt("license_stt");
+        dto.setLicenseExpire("license_expire");
+        dto.setLegalName("legal_name");
+        dto.setLegalCertNo("legal_cert_no");
+        dto.setLegalCertStt("legal_cert_stt");
+        dto.setLegalCertExpire("legal_cert_expire");
+        dto.setAccountIdcardNum("account_idcard_num");
+        dto.setAccountIdcardStt("account_idcard_stt");
+        dto.setAccountIdcardExpire("account_idcard_expire");
+        dto.setAccountCardName("account_card_name");
+        dto.setAccountCardNum("account_card_num");
+        dto.setAccountCardPhone("account_card_phone");
+        dto.setAccountBankCode("account_bank_code");
+        dto.setRegCapital("reg_capital");
+        dto.setFSettleAccountType("fSettle_account_type");
+        dto.setActualAddr("actual_addr");
+        dto.setBusinessScope("business_scope");
+        dto.setCheckCode("check_code");
+        dto.setRateCode("rate_code");
+        dto.setPictureLicense("picture_license");
+        dto.setPictureIdcardA("picture_idcard_a");
+        dto.setPictureIdcardB("picture_idcard_b");
+        dto.setPictureBankcardA("picture_bankcard_a");
+        dto.setPictureBankcardB("picture_bankcard_b");
+        dto.setPictureOutside("picture_outside");
+        dto.setPictureInside("picture_inside");
+        dto.setLeaseContract("lease_contract");
+        dto.setPictureTripartite("picture_tripartite");
+        dto.setPictureIdcardHander("picture_idcard_hander");
+        dto.setPictureOpenAccount("picture_open_account");
+        dto.setPictureOrganization("picture_organization");
+        dto.setPictureTaxRegistration("picture_tax_registration");
+        dto.setPictureUnincorporated("picture_unincorporated");
+        dto.setPictureBankIdcardA("picture_bank_idcard_a");
+        dto.setPictureBankIdcardB("picture_bank_idcard_b");
+        dto.setPictureOther(null);
+        dto.setShareScene("share_scene");
+        dto.setShareCertificate("share_certificate");
+        dto.setTripartiteFlowCertificateUrl("tripartite_flow_certificate_url");
+        dto.setPCashier("p_cashier");
+        return dto;
+    }
+
+    /** 在 {@link #merchantDto()}(全填)上改一项。 */
+    private static LsyMerchantCreateDTO merchantDto(Consumer<LsyMerchantCreateDTO> mutation) {
+        LsyMerchantCreateDTO dto = merchantDto();
+        mutation.accept(dto);
+        return dto;
+    }
+
+    /**
+     * 出站参数的**字面期望表**:59 个键({@code trace_no} 另算 —— 它每次新生成,
+     * 见 {@link #createMerchant_traceNo_isFreshPerCall})。
+     *
+     * <p>★ <b>手写字面表,不从 {@code LsyMerchantCreateDTO} 反射生成</b>(Task 2 评审的裁定 h1):
+     * 反射生成的期望集是**自洽式断言** —— 实现改错它跟着一起错,恒绿。手写这 59 行换来的正是
+     * 「实现漏发一个键 ⇒ 红」。
+     *
+     * <p>★ <b>它覆盖 DTO 的全部 54 个字段</b>,其中 18 个是必填矩阵不管的「只带不判」字段
+     * ({@code license_stt} / {@code legal_cert_stt} / {@code account_idcard_stt};
+     * {@code reg_capital} / {@code fSettle_account_type} / {@code actual_addr} /
+     * {@code business_scope} / {@code check_code};{@code lease_contract} /
+     * {@code picture_tripartite} / {@code picture_organization} / {@code picture_tax_registration};
+     * {@code picture_other};{@code share_scene} / {@code share_certificate} /
+     * {@code tripartite_flow_certificate_url};{@code p_cashier};{@code rate_code})——
+     * 这 18 个在**全仓没有别的断言**证明它们真的在 DTO 上(Task 2 的用例钉的是矩阵报得出的 36 个)
+     * ⇒ 本表是它们唯一的钉子。
+     *
+     * <p>★ 值的类型也在表里:{@code channel_id} / {@code bd_id} 是 {@code Integer}
+     * (上游声明 {@code Integer(11)},而请求体是 JSON ⇒ {@code 1001} 与 {@code "1001"} 在**报文**上不同),
+     * 其余是字符串。
+     */
+    private static Map<String, Object> expectedMerchantParams() {
+        Map<String, Object> p = new LinkedHashMap<>();
+        // ---- 非 DTO 的 5 个:配置 4(rate_code 见下)+ 类别派生 2 ----
+        p.put("notify_url", CFG_ONBOARDING_NOTIFY_URL);
+        p.put("channel_id", 1001);          // ★ Integer:配置里是字符串 "1001",上送时必须转成 Integer(11)
+        p.put("bd_id", 2002);
+        p.put("merchant_type", "3");        // LsyMerchantCategory.MICRO 的两个码值
+        p.put("account_type", "4");
+        // ---- DTO 的 54 个:值就是它自己的上游键名(rate_code 这里取 DTO 的值 ⇒ 覆盖配置)----
+        p.put("merchant_name", "merchant_name");
+        p.put("merchant_name_short", "merchant_name_short");
+        p.put("merchant_name_business", "merchant_name_business");
+        p.put("merchant_mcc_code", "merchant_mcc_code");
+        p.put("contact_name", "contact_name");
+        p.put("contact_phone", "contact_phone");
+        p.put("contact_email", "contact_email");
+        p.put("contact_service", "contact_service");
+        p.put("address_province", "address_province");
+        p.put("address_city", "address_city");
+        p.put("address_county", "address_county");
+        p.put("address_street", "address_street");
+        p.put("license_type", "license_type");
+        p.put("license_no", "license_no");
+        p.put("license_stt", "license_stt");
+        p.put("license_expire", "license_expire");
+        p.put("legal_name", "legal_name");
+        p.put("legal_cert_no", "legal_cert_no");
+        p.put("legal_cert_stt", "legal_cert_stt");
+        p.put("legal_cert_expire", "legal_cert_expire");
+        p.put("account_idcard_num", "account_idcard_num");
+        p.put("account_idcard_stt", "account_idcard_stt");
+        p.put("account_idcard_expire", "account_idcard_expire");
+        p.put("account_card_name", "account_card_name");
+        p.put("account_card_num", "account_card_num");
+        p.put("account_card_phone", "account_card_phone");
+        p.put("account_bank_code", "account_bank_code");
+        p.put("reg_capital", "reg_capital");
+        p.put("fSettle_account_type", "fSettle_account_type");
+        p.put("actual_addr", "actual_addr");
+        p.put("business_scope", "business_scope");
+        p.put("check_code", "check_code");
+        p.put("rate_code", "rate_code");
+        p.put("picture_license", "picture_license");
+        p.put("picture_idcard_a", "picture_idcard_a");
+        p.put("picture_idcard_b", "picture_idcard_b");
+        p.put("picture_bankcard_a", "picture_bankcard_a");
+        p.put("picture_bankcard_b", "picture_bankcard_b");
+        p.put("picture_outside", "picture_outside");
+        p.put("picture_inside", "picture_inside");
+        p.put("lease_contract", "lease_contract");
+        p.put("picture_tripartite", "picture_tripartite");
+        p.put("picture_idcard_hander", "picture_idcard_hander");
+        p.put("picture_open_account", "picture_open_account");
+        p.put("picture_organization", "picture_organization");
+        p.put("picture_tax_registration", "picture_tax_registration");
+        p.put("picture_unincorporated", "picture_unincorporated");
+        p.put("picture_bank_idcard_a", "picture_bank_idcard_a");
+        p.put("picture_bank_idcard_b", "picture_bank_idcard_b");
+        p.put("picture_other", null);       // 夹具里这一项没填 ⇒ 原样 null(客户端按 null 剔除)
+        p.put("share_scene", "share_scene");
+        p.put("share_certificate", "share_certificate");
+        p.put("tripartite_flow_certificate_url", "tripartite_flow_certificate_url");
+        p.put("p_cashier", "p_cashier");
+        return p;
+    }
+
+    /**
+     * ★ 出站参数**恰好**是那 60 个键(59 个字面期望键 + 服务自己生成的 {@code trace_no}),
+     * 且**除 trace_no 外每个键的值(含类型)都对得上**。
+     *
+     * <p>两件事分开断言,因为它们的失效方式不同:
+     * <ul>
+     *   <li><b>键集相等</b>(不是 {@code containsKey})挡漏发 / 多发 —— 多发的字段会进签名原文
+     *       (本仓签名规则:null 不参与、**空串参与**),上游可能因此判「参数不合法」,
+     *       而失败形态只是一句笼统的话,根因不可见;</li>
+     *   <li><b>值相等</b>挡「键发对了、值取错了」—— 夹具里每个字段的值就是它自己的上游键名,
+     *       所以「A 字段取了 B 的 getter」在这里必然显示为两个值对不上(裁定 d)。</li>
+     * </ul>
+     *
+     * <p>★ {@code trace_no} 在比较前被摘掉:它每次新生成,值本身不可预期;但它的**形状**照
+     * {@code createShop_sendsExactlyTheSevenServiceOwnedParams} 的做法单独钉(32 位字符串)。
+     * 另:本用例的夹具里 {@code rate_code} 有值 ⇒ 同时钉住「DTO 有值时以 DTO 为准」(计划 §0.8);
+     * 「DTO 没值 ⇒ 用配置」由 {@link #createMerchant_sendsConfiguredCredentials} 钉。
+     */
+    @Test
+    void createMerchant_sendsExactlyTheExpectedParams() {
+        withOnboardingCredentials();
+        stubUpstream(MERCHANT_CREATE_OK);
+
+        service.createMerchant(merchantDto());
+
+        Map<String, Object> sent = sentParams("/api/merchant/create");
+        Set<String> expectedKeys = new LinkedHashSet<>(expectedMerchantParams().keySet());
+        expectedKeys.add("trace_no");
+        assertEquals(expectedKeys, sent.keySet(),
+                "出站参数键集必须恰好是 60 个(59 个字面期望键 + trace_no):" + sent.keySet());
+        Object traceNo = sent.get("trace_no");
+        assertInstanceOf(String.class, traceNo, "trace_no 必须是字符串,实际: " + traceNo);
+        assertEquals(32, ((String) traceNo).length(),
+                "trace_no 必须是 IdUtil.fastSimpleUUID() 的形状(32 位),实际: " + traceNo);
+        Map<String, Object> actual = new LinkedHashMap<>(sent);
+        actual.remove("trace_no");
+        assertEquals(expectedMerchantParams(), actual,
+                "每个键的值(含类型)都要对得上 —— 夹具里每个字段的值就是它自己的上游键名,"
+                        + "所以「A 字段取了 B 的 getter」也会在这里红");
+    }
+
+    /**
+     * ★ 凭据闸:五个键缺任意一个就 400,且**一次上游调用都不发**。
+     *
+     * <p>两个子场景:
+     * <ol>
+     *   <li>一个都没配 ⇒ 报「共 5 项」;</li>
+     *   <li>只把 {@code bd-id} 置成空白(其余四个齐备)⇒ 报「共 1 项」——
+     *       钉住数的是**真的缺失数**,不是恒等于 5 的常数。</li>
+     * </ol>
+     *
+     * <p>★ 对外文案只给**中文固定文案 + 缺失项数**,明细走日志(R23 裁定):那串明细逐条含环境变量名
+     * {@code LESHOUYING_*},是**上游名号的罗马字**,拼进 {@link BusinessException} 的 message 就是越线
+     * (GC 1「文案零上游痕迹」/ spec §8.5 第 5 条)。这条 message 经 {@code GlobalExceptionHandler}
+     * 是 {@code Result.of(code, message)},**原样**回给调用方 ⇒ 它是会到租户眼前的那一类串。
+     * 明细的出口见 {@link #createMerchant_missingCredentials_detailGoesToTheLog}。
+     */
+    @Test
+    void createMerchant_missingCredentials_throws400ListingAll() {
+        // 整个方法**一次都不打桩**:凭据闸必须在开桩之前就把请求拦下
+        BusinessException none = assertThrows(BusinessException.class,
+                () -> service.createMerchant(merchantDto()), "凭据全缺必须被拒");
+        assertEquals(400, none.getCode());
+        assertCredentialsMessage(none.getMessage(), 5);
+
+        withOnboardingCredentials();
+        props.setBdId("   ");
+        BusinessException one = assertThrows(BusinessException.class,
+                () -> service.createMerchant(merchantDto()), "只缺 bd-id 也必须被拒");
+        assertEquals(400, one.getCode());
+        assertCredentialsMessage(one.getMessage(), 1);
+
+        verifyNoInteractions(client);
+    }
+
+    /**
+     * 凭据缺失的对外文案:中文固定文案 + **缺失项数**,且**不含任何明细**。
+     *
+     * <p>「不含明细」逐串断言(而不是只断言「不含 LESHOUYING」一条):明细里还会出现
+     * {@code leshouying.channel-id} 这种**配置键名**与 {@code onboarding-notify-url} 这种
+     * 内部地址名 —— 它们同样在教调用方猜服务端配置。
+     */
+    private void assertCredentialsMessage(String message, int missingCount) {
+        assertTrue(message.contains("共 " + missingCount + " 项"),
+                "对外文案要给出缺失**项数** " + missingCount + ",实际: " + message);
+        for (String leaked : new String[]{"LESHOUYING", "leshouying", "乐收赢",
+                "channel-id", "bd-id", "rate-code", "onboarding-notify-url"}) {
+            assertFalse(message.contains(leaked),
+                    "凭据缺失的对外文案不得含「" + leaked + "」(GC 1;明细只进日志),实际: " + message);
+        }
+    }
+
+    /** 「明细走日志」的那一半:日志里**必须**有配置键名与环境变量名,运维才照得着去配。 */
+    @Test
+    void createMerchant_missingCredentials_detailGoesToTheLog() {
+        withLogCapture(LsyOnboardingService.class, events -> {
+            BusinessException ex = assertThrows(BusinessException.class,
+                    () -> service.createMerchant(merchantDto()));
+            assertEquals(400, ex.getCode());
+            List<String> logged = messages(events);
+            // ★ 先断言「确实捕获到了事件」(正对照):挂错 logger 时,下面那条「日志里有明细」的
+            //   断言会在**空列表**上失败 —— 但若是把它写成「不含某串」的否定式断言,就会静默绿。
+            assertFalse(logged.isEmpty(), "凭据缺失必须留日志 —— 明细只有这一条出口");
+            String all = String.join("\n", logged);
+            for (String key : new String[]{"leshouying.channel-id", "LESHOUYING_CHANNEL_ID",
+                    "leshouying.bd-id", "leshouying.rate-code", "leshouying.onboarding-notify-url"}) {
+                assertTrue(all.contains(key), "日志缺配置键名 / 环境变量名「" + key + "」,实际: " + all);
+            }
+        });
+    }
+
+    /**
+     * ★ 必填缺失 ⇒ 400,文案含**全部**缺失字段的**中文标签**(不是上游参数名),且一次上游调用都不发。
+     *
+     * <p>① 全空 + 小微 ⇒ 18 个无条件 + 小微那 8 个 = **26** 个,逐个点名;同时反向断言文案里
+     * 不得出现上游参数名(GC 1「文案零上游痕迹」:{@code fSettle_account_type}、
+     * {@code picture_idcard_hander} 这种键名出现在对外 400 文案里就是暴露上游词汇)。
+     * 依据还有本模块既有的 {@code requireText} javadoc:报键名的话,操作者得在文档里做一次心算
+     * 才能对上是界面上的哪个输入框 ⇒ <b>首要理由是人话</b>,安全性只是第二理由。
+     *
+     * <p>② 全填 + 类别为空 ⇒ 只缺两个派生字段。这条**顺带**钉住「18 个无条件字段真的按 DTO 取值」:
+     * 全填就该一个都不缺,若实现漏判某个字段的 getter(恒读 null),这里会多报一项。
+     *
+     * <p>★ 期望的标签是**字面中文**,不是 {@code LABELS.get("merchant_name")}(裁定 h2)——
+     * 后者是恒真式断言(表改了它跟着改,什么也钉不住)。Task 2 的用例只**抽查**了 6/62 个标签值,
+     * 本用例点到的这 26 个就是它们的第一批真钉子。
+     */
+    @Test
+    void createMerchant_missingRequiredFields_throws400ListingAll() {
+        withOnboardingCredentials();
+
+        LsyMerchantCreateDTO blank = new LsyMerchantCreateDTO();
+        blank.setCategory(LsyMerchantCategory.MICRO);
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.createMerchant(blank), "全空必须被拒");
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMessage().contains("共 26 项"),
+                "小微商户的必填集是 18 + 8 = 26,实际: " + ex.getMessage());
+        for (String label : MERCHANT_MICRO_MISSING_LABELS) {
+            assertTrue(ex.getMessage().contains(label),
+                    "文案缺中文标签「" + label + "」,实际: " + ex.getMessage());
+        }
+        for (String key : new String[]{"merchant_name", "merchant_mcc_code", "account_idcard_num",
+                "fSettle_account_type", "picture_idcard_hander", "mcht_Landuse"}) {
+            assertFalse(ex.getMessage().contains(key),
+                    "文案出现了上游参数名 " + key + "(GC 1):" + ex.getMessage());
+        }
+
+        BusinessException noCategory = assertThrows(BusinessException.class,
+                () -> service.createMerchant(merchantDto(d -> d.setCategory(null))),
+                "类别为空必须被拒");
+        assertEquals(400, noCategory.getCode());
+        assertTrue(noCategory.getMessage().contains("共 2 项"),
+                "类别为空时只有两个派生字段填不出来,实际: " + noCategory.getMessage());
+        assertTrue(noCategory.getMessage().contains("商户类型"), noCategory.getMessage());
+        assertTrue(noCategory.getMessage().contains("账户类型"), noCategory.getMessage());
+        assertFalse(noCategory.getMessage().contains("商户名称"),
+                "18 个无条件字段全填了,不该报它们中的任何一个:" + noCategory.getMessage());
+
+        verifyNoInteractions(client);
+    }
+
+    /**
+     * 小微商户那一类的 26 个必填字段的**中文标签**,顺序照 {@code LsyMerchantRequiredFields}
+     * 的声明顺序(18 个无条件在前、小微独有的 8 个在后)。
+     *
+     * <p>断言用的是 {@code contains},所以顺序不影响结果 —— 排成这个顺序只为读起来
+     * 能跟矩阵那张表逐行对照。
+     */
+    private static final String[] MERCHANT_MICRO_MISSING_LABELS = {
+            "商户名称",
+            "商户简称",
+            "商户注册名称 小微填姓名,个体企业填营业执照",
+            "商户行业类目code",
+            "商户签约负责人",
+            "商户签约负责人电话",
+            "商户签约负责人邮箱",
+            "商户客服电话",
+            "商户地址省code",
+            "商户地址市code",
+            "商户地址区县code",
+            "商户地址具体街道门牌号",
+            "持卡人身份证号",
+            "持卡人身份证结束时间(格式YYYYMMDD)长期传20991231",
+            "结算卡账户名",
+            "结算卡卡号",
+            "结算卡预留手机号",
+            "结算银行支行联行号",
+            "负责人身份证正面",
+            "银行卡正面",
+            "银行卡反面",
+            "门头照片",
+            "内部前台照片",
+            "手持身份证",
+            "对私账户身份证正面照片",
+            "对私账户身份证反面照片"};
+
+    /**
+     * 配置里的 {@code channel_id} / {@code bd_id} / {@code rate_code} 都要上送,且
+     * {@code channel_id} / {@code bd_id} 是 **{@code Integer}**。
+     *
+     * <p>★ 类型本身就是断言的一部分:上游参数表把这两个声明成 {@code Integer(11)},
+     * 而请求体是 **JSON**({@code LeshouyingClient} 用 Jackson 序列化这个 map)⇒
+     * {@code Integer} 发出去是 {@code 1001}、{@code String} 发出去是 {@code "1001"},
+     * 两者在**报文**上不同。
+     *
+     * <p>本用例让 DTO 的 {@code rate_code} 留空 ⇒ 钉住「DTO 没值就用配置」那一支;
+     * 反支(DTO 有值 ⇒ 以 DTO 为准)在 {@link #createMerchant_sendsExactlyTheExpectedParams} 里。
+     */
+    @Test
+    void createMerchant_sendsConfiguredCredentials() {
+        withOnboardingCredentials();
+        stubUpstream(MERCHANT_CREATE_OK);
+
+        service.createMerchant(merchantDto(d -> d.setRateCode(null)));
+
+        Map<String, Object> sent = sentParams("/api/merchant/create");
+        Object channelId = sent.get("channel_id");
+        Object bdId = sent.get("bd_id");
+        assertInstanceOf(Integer.class, channelId, "channel_id 必须是 Integer(11) 形态,实际: " + channelId);
+        assertInstanceOf(Integer.class, bdId, "bd_id 必须是 Integer(11) 形态,实际: " + bdId);
+        assertEquals(1001, ((Integer) channelId).intValue());
+        assertEquals(2002, ((Integer) bdId).intValue());
+        assertEquals(CFG_RATE_CODE, sent.get("rate_code"),
+                "DTO 没给费率模板 ⇒ 用配置里的(计划 §0.8)");
+    }
+
+    /**
+     * {@code notify_url} 取**进件审核**通知地址({@link LeshouyingProperties#getOnboardingNotifyUrl()}),
+     * **不是**收款那个 {@code notify-url}(spec §6.5 的两个地址之分)。
+     *
+     * <p>两个地址都在位、且逐字不同:只设一个的话,「取错了那个」在断言下不可见。
+     * 正反两条断言都给(相等 + 不相等),让失败信息自己说清取的是哪一个。
+     */
+    @Test
+    void createMerchant_sendsNotifyUrlFromConfig() {
+        withOnboardingCredentials();
+        props.setNotifyUrl(CFG_PAY_NOTIFY_URL);
+        stubUpstream(MERCHANT_CREATE_OK);
+
+        service.createMerchant(merchantDto());
+
+        Object notifyUrl = sentParams("/api/merchant/create").get("notify_url");
+        assertEquals(CFG_ONBOARDING_NOTIFY_URL, notifyUrl,
+                "进件上送的必须是**进件审核**通知地址");
+        assertNotEquals(CFG_PAY_NOTIFY_URL, notifyUrl,
+                "不得取收款通知地址 leshouying.notify-url(spec §6.5)");
+    }
+
+    /**
+     * ★ 取商户号的键**两个都读**:返回参数表写 {@code shoubei_merchant_no}、返回示例写
+     * {@code merchant_no}(spec §6.2(c):只有真跑一次才知道哪个是准的)⇒ 两个都读,
+     * 谁给值就用谁。
+     *
+     * <p>三个子场景:
+     * <ol>
+     *   <li>只有示例那个键({@code merchant_no});</li>
+     *   <li>只有参数表那个键({@code shoubei_merchant_no});</li>
+     *   <li>示例那个键给了**空串**(与没给一样不可用 —— 判据是空白,不是「键在不在」)⇒ 仍要取到参数表那个键。</li>
+     * </ol>
+     *
+     * <p>★ 这里读的两个键名是**上游的**,属于内部代码,不触 GC 1(GC 1 只管网页面 / 错误消息 /
+     * 应答字段名)—— 不要为了「避讳」把它们从解析代码里去掉,那会直接把取号打断。
+     */
+    @Test
+    void createMerchant_readsMerchantNoFromEitherKey() {
+        withOnboardingCredentials();
+        String[][] cases = {
+                {ok(",\"merchant_no\":\"851901070001\""), "851901070001", "返回示例的键"},
+                {ok(",\"shoubei_merchant_no\":\"851901070002\""), "851901070002", "返回参数表的键"},
+                {ok(",\"merchant_no\":\"\",\"shoubei_merchant_no\":\"851901070003\""), "851901070003",
+                        "示例那个键给了空串 ⇒ 与没给一样,仍要取到参数表那个键"}};
+        for (String[] c : cases) {
+            stubUpstream(c[0]);
+            assertEquals(c[1], service.createMerchant(merchantDto()), c[2]);
+        }
+    }
+
+    /**
+     * ★ 上游说成功却**没给商户号** ⇒ 502,**绝不让 null / 空白一路往外走**:调用方拿它去存库
+     * 就是一条查不回来的记录(商户在上游**确实建了**,本地却没有号)。
+     *
+     * <p>三种输入:整段没有那两个键、示例那个键给空串、参数表那个键给全空格。
+     * 分开不是因为形状不同 —— 是因为「只判键在不在」与「判空白」两种实现只在前两种下分得开。
+     *
+     * <p>★ 文案**既不含上游名号、也不含上游专有字段名**(GC 1 / spec §8.5 第 5 条,两个都禁)。
+     * 「上游专有」的判据是两查(R32):① 建表语句 {@code V1.8__create_pay_lsy_tables.sql} 里
+     * 没有这一列;② 没有对外 DTO / 实体把这一串回给租户。<b>两条都不成立才叫专有</b> ——
+     * {@code merchant_no} / {@code shoubei_merchant_no} 两条都不成立(我们自己的字段是
+     * {@code mchNo} ⇒ {@code mch_no}),所以这里**可以**断言它们不出现;
+     * 而 {@code mch_no} / {@code shop_no} 是我们自己的,断言里不列。
+     *
+     * <p>★ 还要**含行为指令**「不得当作失败直接重试」:商户可能已在上游建了,直接重试就是重复进件。
+     * 只照结构的形状照 {@code createShop} 那条 502(「成功但缺号 ⇒ 502 + 行为指令」),
+     * 不照它的字段名(那一条里 {@code shop_no} 是我们自己的列名,本身没问题)。
+     */
+    @Test
+    void createMerchant_noMerchantNoInResponse_throws502() {
+        withOnboardingCredentials();
+        for (String json : new String[]{
+                "{\"result_code\":\"SUCCESS\"}",
+                ok(",\"merchant_no\":\"\""),
+                ok(",\"shoubei_merchant_no\":\"   \"")}) {
+            stubUpstream(json);
+
+            BusinessException ex = assertThrows(BusinessException.class,
+                    () -> service.createMerchant(merchantDto()),
+                    json + " ⇒ 必须抛,不得把 null / 空白当商户号返回");
+            assertEquals(502, ex.getCode(), json);
+            assertTrue(ex.getMessage().contains("不得当作失败直接重试"),
+                    json + " 的文案必须给出行为指令(商户可能已在上游建了),实际: " + ex.getMessage());
+            for (String forbidden : new String[]{"乐收赢", "merchant_no", "shoubei"}) {
+                assertFalse(ex.getMessage().contains(forbidden),
+                        json + " 的文案含「" + forbidden + "」(GC 1:不含上游名号、也不含上游专有字段名),"
+                                + "实际: " + ex.getMessage());
+            }
+        }
+    }
+
+    /**
+     * 本任务新写的**全部三条**对外文案一次过一遍,都不含上游名号(用户硬约束 / GC 1)。
+     *
+     * <p>三条 = 凭据缺失(①)、必填缺失(②)、成功但没商户号(③)。前两条的其余断言在各自的用例里
+     * ({@link #createMerchant_missingCredentials_throws400ListingAll} /
+     * {@link #createMerchant_missingRequiredFields_throws400ListingAll}),第三条还有一条
+     * 「不含上游专有字段名」的断言在 {@link #createMerchant_noMerchantNoInResponse_throws502}。
+     * 这里集中一次,是为了**保证「新写的文案」这个集合被整批覆盖** —— 将来再加一条文案时,
+     * 漏掉它的方式是「没往这里加」,而那是看得见的。
+     *
+     * <p>为什么三条都要:它们都是 {@link BusinessException},走到
+     * {@code GlobalExceptionHandler} 时 {@code Result.of(code, message)} 把 message **原样**回给调用方
+     * ⇒ 三条都会到租户眼前。解法照 {@code createShop_missingShopNo_throws} 那条断言。
+     */
+    @Test
+    void createMerchant_message_neverNamesUpstream() {
+        BusinessException credentials = assertThrows(BusinessException.class,
+                () -> service.createMerchant(merchantDto()));
+
+        withOnboardingCredentials();
+        LsyMerchantCreateDTO blank = new LsyMerchantCreateDTO();
+        BusinessException required = assertThrows(BusinessException.class,
+                () -> service.createMerchant(blank));
+
+        stubUpstream("{\"result_code\":\"SUCCESS\"}");
+        BusinessException noMerchantNo = assertThrows(BusinessException.class,
+                () -> service.createMerchant(merchantDto()));
+
+        Map<String, String> messages = new LinkedHashMap<>();
+        messages.put("凭据缺失", credentials.getMessage());
+        messages.put("必填缺失", required.getMessage());
+        messages.put("成功但没商户号", noMerchantNo.getMessage());
+        for (Map.Entry<String, String> e : messages.entrySet()) {
+            assertFalse(e.getValue().contains("乐收赢"),
+                    e.getKey() + " 的文案出现了上游名号(用户硬约束 / GC 1):" + e.getValue());
+        }
+    }
+
+    /**
+     * ★ 配置里的 {@code channel_id} / {@code bd_id} 必须是**规范十进制**写法。这不是洁癖:
+     * 这一步是**唯一**会把配置文本改写成另一个文本的地方 —— {@code "001001"} 若被静默接受,
+     * 线上发出去的就是 {@code 1001},而通道 id 决定商户开到**哪条通道**上,
+     * 改一个数字的后果是「开到别的通道」且**不报错**。
+     *
+     * <p>三条输入各钉一件事:{@code "abc"}(根本不是数)、{@code "1001.0"}(不是整数写法)、
+     * {@code "001001"}(**能解析成数,但改写了数字** —— 拒绝式过滤器的正主)。
+     * 正对照(放行)在 {@link #createMerchant_sendsConfiguredCredentials}:{@code "1001"}。
+     *
+     * <p>★ 文案与日志都**不得出现那个值**(GC 5「秘密只打印长度,不打印值」;{@code channel_id} 一族
+     * 按 GC 3 就是凭据)。这也是不许 {@code NumberFormatException} 逃出去的原因:它的 message 会把
+     * 原值整串带出来。两处都断言:对外文案(这里)+ 日志(捕获着看),
+     * 并**正对照**「日志里必须有键名」—— 只断言「不含值」的话,空日志也满足。
+     */
+    @Test
+    void createMerchant_nonCanonicalIntegerConfig_rejected400() {
+        withOnboardingCredentials();
+        for (String bad : new String[]{"abc", "1001.0", "001001"}) {
+            props.setChannelId(bad);
+            withLogCapture(LsyOnboardingService.class, events -> {
+                BusinessException ex = assertThrows(BusinessException.class,
+                        () -> service.createMerchant(merchantDto()), "channel_id=" + bad + " 必须被拒");
+                assertEquals(400, ex.getCode(), bad);
+                assertFalse(ex.getMessage().contains(bad),
+                        "对外文案出现了配置值(GC 5),实际: " + ex.getMessage());
+
+                List<String> logged = messages(events);
+                assertFalse(logged.isEmpty(), "这条拒绝必须留日志(否则运维无从定位)");
+                assertTrue(logged.stream().anyMatch(m -> m.contains("channel_id")),
+                        "日志要点出是哪个配置项(只报长度、不报值),实际: " + logged);
+                for (String line : logged) {
+                    assertFalse(line.contains(bad), "日志出现了配置值(GC 5:只打印长度),实际: " + line);
+                }
+            });
+        }
+        verifyNoInteractions(client);
+    }
+
+    /**
+     * ★ 每次请求一个新流水号。{@code trace_no} 的定义就是「请求流水号」——**唯一标识一次请求**。
+     * 两笔进件共用一个流水号,日志与上游对账都对不上;**若**上游按流水号去重,第二笔进件会被
+     * 静默丢弃(商户没建成而我们以为建成了)。「若」是条件不是断言上游行为 —— 文档没写去重,
+     * 但「流水号唯一」是这个词自己的定义。
+     *
+     * <p>反面对照:本用例**不**断言「同一个流水号在两次调用间复用」—— 那正是
+     * {@code IdUtil.fastSimpleUUID()} 每次新生成的性质;若哪天有人把它提到字段上缓存,
+     * 两条读数会相同而本用例红。
+     */
+    @Test
+    void createMerchant_traceNo_isFreshPerCall() {
+        withOnboardingCredentials();
+        stubUpstream(MERCHANT_CREATE_OK);
+
+        service.createMerchant(merchantDto());
+        service.createMerchant(merchantDto());
+
+        List<Map<String, Object>> sent = allSentParams("/api/merchant/create", 2);
+        Object first = sent.get(0).get("trace_no");
+        Object second = sent.get(1).get("trace_no");
+        for (Object traceNo : new Object[]{first, second}) {
+            assertInstanceOf(String.class, traceNo, "trace_no 必须是字符串,实际: " + traceNo);
+            assertEquals(32, ((String) traceNo).length(),
+                    "trace_no 是 32 位 UUID 形状,实际: " + traceNo);
+        }
+        assertNotEquals(first, second, "两笔请求必须是两个流水号(trace_no 定义就是唯一标识一次请求)");
+    }
+
+    /**
+     * 把 {@link ListAppender} 挂到指定 logger 上、跑完 body、**无论成败都摘掉**
+     * —— 与 {@code LsyNotifyServiceTest} / {@code LsyQrPayTest} 同款(本仓库按测试文件各自持有一份,
+     * 不抽公共类)。摘不掉的后果是后续测试的日志被这个 appender 吃进来,故障表现会跑到别的用例上。
+     */
+    private static void withLogCapture(Class<?> type, Consumer<List<ILoggingEvent>> body) {
+        Logger logger = (Logger) LoggerFactory.getLogger(type);
+        ListAppender<ILoggingEvent> appender = new ListAppender<>();
+        appender.start();
+        logger.addAppender(appender);
+        try {
+            body.accept(appender.list);
+        } finally {
+            logger.detachAppender(appender);
+        }
+    }
+
+    /** 断言失败时把实际捕获到的日志正文全列出来 —— 否则只看到「没匹配上」,看不到实际打了什么。 */
+    private static List<String> messages(List<ILoggingEvent> events) {
+        return events.stream().map(ILoggingEvent::getFormattedMessage).toList();
+    }
 }