Parcourir la source

feat(进件): 商户信息与审核状态查询 - 值域不跨接口归一化

两个接口,上游 page 196236558(/api/merchant/info)与 page 196236567
(/api/merchant/query),共用同一个私有参数组装 mchNoParams(merchant_no +
每次新生成的 trace_no,IdUtil.fastSimpleUUID)。

三处边界是本提交的要点:

  1. merchantInfo **不做字段白名单**:上游给什么键就带回什么键。spec §6.2 里
     还没定下来的字段名/类型不一致有四处(本页自己占两条:wx_channel_rate /
     ali_channel_rate 在返回参数表里各出现两次;返回示例里 merchant_type /
     account_type / license_type / wx_channel_id 是数字而表里写 String),
     此刻立白名单=把未定的契约固化进代码,而被剔掉的键在下游看起来与「上游
     没返回」完全一样。取值口径归调用方(spec §6.2(e):读时按 String.valueOf 归一)。
  2. queryReview 只按 page 196236567 **这一套**值域翻译,未知码不抛:原值照带、
     statusText 为 null。LsyReviewStatus 的 javadoc 写明它不通用 —— 同名
     reviewed_status 另有 page 196236556(示例是中文字符串)与 196236566
     (同页两个互相冲突的 0-6 映射)两套值域,冲突清单见 spec §6.2(b)。
     码用精确字符串匹配而非 Integer.parseInt:"10" 与 "1" 是两个审核态,
     而文档里已经出现过非数字码。
  3. reviewed_reason 只在 reviewedStatus 为 "9"(审核驳回)时才是驳回原因:
     page 196236567 的返回示例里 reviewed_status:0(通过)配的 reason 就是
     "审核通过"——驳回原因字段里填的不是驳回原因。实现不做归一化,原样返回。
     字段缺失 / JSON null ⇒ 三个字段都是 Java null,不得变成字符串 "null"
     (读取一律过 asText,不是裸 String.valueOf)。

★ 类 javadoc 补 R-4 的租户缺口警告(完成标准第 550 条,裁定归本任务):
本层收下的 mchNo 不受租户约束;不查 LsyMerchantService 是有意的(进件阶段商户
还没进 pay_lsy_merchant,查必然落空 = 「校验前提不成立」);三-B 加 HTTP 端点时
必须在端点侧先证明该 mchNo 属于当前租户,否则就是一个 IDOR(读到 B 商户的进件
资料与通道费率)。三-A 没有端点 ⇒ 当前无可达路径,但这段 javadoc 是三-B 的前置条件。

★ 空白 mchNo ⇒ BusinessException(400, "商户号不能为空"),一个字节不出海。
形状照 LsyMerchantService.requireByMchNo(空白检查 + 400);文案按 brief 字面值
(那个先例的实际文案是 "mch_no 不能为空",与 brief 不一致,已按裁定取 brief 的)。

TDD:先写 11 条用例 → 跑出红(COMPILATION ERROR:找不到 LsyReviewStatusDTO,
是「类不存在」不是打错字)→ 实现 → 绿。本类原有 9 条全部保留未改(sentParams
抽出一个带路径的重载,原无参签名行为不变)。

两向变异(含无效变异,逐条读数见 task-4-report.md):
  M-1 trace_no 固定串          → 3 红(twoCalls + 两条长度断言)
  M-2 of() 未知码抛异常        → 2 红
  M-3 剔除 merchant_type       → 1 红(returnsRawUpstreamFields_untouched)
  M-4 parseInt 解析            → ★ **无效变异**:brief 点名的 "7" 用例实测仍绿
                                 ("7" 能被 parseInt),改测非数字码 "0A" 才红
  M-5 加一个未使用的枚举常量    → 仍绿(无害,已在 class 层证明变异体进了编译产物)
  H-2 交换两个 put 的顺序       → 仍绿(无害)
  M-6 去掉空白守卫             → 2 红
  M-7 statusText 回填原值      → 2 红
  M-8 reviewed_reason 改裸 String.valueOf → 1 红(missingOrNullFields_areJavaNull_notStringNull)

本提交字节 = 跑过的字节:全量 472 / 0 / 0 / 0(基线 460 + 本类新增 12),
按本次运行(marker + find -newer)统计,未对累积目录求和。
alphaH il y a 3 semaines
Parent
commit
7afea7d030

+ 42 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyReviewStatusDTO.java

@@ -0,0 +1,42 @@
+package com.payment.platform.module.payment.leshouying.dto;
+
+import lombok.Data;
+
+/**
+ * 商户审核状态 —— 上游「商户审核主动查询」接口(page 196236567)的返回。
+ *
+ * <p>三个字段**全部原样透传**,本 DTO 不做任何跨接口归一化;值域冲突见
+ * {@link com.payment.platform.module.payment.leshouying.enums.LsyReviewStatus} 的 javadoc。
+ */
+@Data
+public class LsyReviewStatusDTO {
+
+    /**
+     * 上游的 {@code reviewed_status},**原值**(取成文本形态:上游既给过字符串也给过数字,
+     * 见 {@code LsyOnboardingService#queryReview})。
+     *
+     * <p>枚举匹配不到时这里仍是原始码 —— 这是**故意的**:页面据此显示原始码,
+     * 而不是整条链路失败。
+     */
+    private String reviewedStatus;
+
+    /**
+     * 上游的 {@code reviewed_reason},**原样**(不清空、不替换)。
+     *
+     * <p>★ <b>只在 {@link #reviewedStatus} 为 {@code "9"}(审核驳回)时才当作驳回原因。</b>
+     * 依据:page 196236567 的返回示例里 {@code reviewed_status: 0}(审核通过)配的
+     * {@code reviewed_reason} 就是 {@code "审核通过"} —— <b>驳回原因字段里填的不是驳回原因</b>。
+     * 其余状态下这个字段的语义**未知**,不要拿它当结论,也不要因此把它清空
+     * (清空是归一化,会让「上游真给了值」这件事消失)。
+     */
+    private String reviewedReason;
+
+    /**
+     * 「商户审核主动查询」**这一套**值域的文案({@link
+     * com.payment.platform.module.payment.leshouying.enums.LsyReviewStatus})。
+     *
+     * <p><b>码未知时为 {@code null}</b> —— 不回填原始码:{@code statusText} 是文案,
+     * 回填会让调用方分不清「码已知」与「码未知」,而原始码本来就在 {@link #reviewedStatus} 里。
+     */
+    private String statusText;
+}

+ 75 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/enums/LsyReviewStatus.java

@@ -0,0 +1,75 @@
+package com.payment.platform.module.payment.leshouying.enums;
+
+import cn.hutool.core.util.StrUtil;
+
+/**
+ * 「**商户审核主动查询**」这一套 {@code reviewed_status} 值域 → 文案(上游 page 196236567)。
+ *
+ * <p>★ <b>本枚举不通用 —— 不得拿它去解别的接口的 {@code reviewed_status}。</b>
+ * 同名字段还有两套值域,冲突清单见 spec §6.2(b):
+ * <ul>
+ *   <li><b>page 196236556(商户审核异步通知)</b>:参数表写
+ *       {@code 1.审核通过,2.审核驳回}(类型标称 {@code String(4)}),而**同页的请求示例**写的是
+ *       中文字符串 {@code "reviewed_status":"审核通过"}(正好 4 字);该示例里还多出参数表
+ *       没有的 {@code settlement_status}。</li>
+ *   <li><b>page 196236566(查询商户入网状态)</b>:返回参数表写
+ *       {@code 0.审核中,1.审核通过,2.审核驳回,3.预审核通过,4.待提交,5.已提交,6.重新进件},
+ *       而**同页的接口描述块**给的是另一个 0–6 映射
+ *       ({@code 0已发起进件 / 1返回渠道子商户进件流程成功的终态 / 2重新修改信息进件 /
+ *       3银行审核中 / 4需要调用入网接口 / 5银行审核中 / 6需要重新调用创建商户接口})。
+ *       <b>同一页对一个字段两个映射</b>,无法靠「各按各的解析」绕开。</li>
+ * </ul>
+ * 前两处是**跨接口**的,各按各的解析就不冲突;第三处连「各按各的」都绕不开,只能等实测。
+ * 三套值域**各自实现、不做归一化**(spec §6.4):本枚举只服务 page 196236567。
+ * 入网状态那一套({@code reportQuery})属三-B,用到时另建,不要扩本枚举。
+ */
+public enum LsyReviewStatus {
+    /** 0 */
+    APPROVED("0", "审核通过"),
+    /** 1 */
+    REVIEWING("1", "审核中"),
+    /** 9。★ 只有这个码上的 {@code reviewed_reason} 才是驳回原因(见 {@code LsyReviewStatusDTO})。 */
+    REJECTED("9", "审核驳回"),
+    /** 10 */
+    CHANNEL_REPORTING("10", "通道报备中"),
+    /** 98 */
+    DISABLED("98", "停用"),
+    /** 99 */
+    CLOSED("99", "关闭");
+
+    private final String code;
+    private final String text;
+
+    LsyReviewStatus(String code, String text) {
+        this.code = code;
+        this.text = text;
+    }
+
+    public String getCode() { return code; }
+
+    public String getText() { return text; }
+
+    /**
+     * 码 → 枚举,**精确字符串匹配**。匹配不到返回 {@code null},**不抛**。
+     *
+     * <p>【为什么不是 {@code Integer.parseInt}】上游的码里已经有 {@code "10"} 这种两位数
+     * ——数字比较要多绕一圈,而「{@code "10"}" 与「{@code 10}」在类型上本来是同一批值的两种写法,
+     * 精确字符串匹配对两种形态是同一条判据。更关键的是**非数字码**:文档已经出现过
+     * {@code "审核通过"}(page 196236556 的示例)这类值,{@code parseInt} 会直接抛,
+     * 而这里的正确行为是「原样透传、页面显示原始码」。
+     *
+     * <p>【为什么匹配不到不抛】同一个值域在文档里就已经三套了,上游加码是**预期内**的事。
+     * 抛异常的后果是「整条链路 500」,而调用方需要的只是把原始码显示出来。
+     */
+    public static LsyReviewStatus of(String code) {
+        if (StrUtil.isBlank(code)) {
+            return null;
+        }
+        for (LsyReviewStatus s : values()) {
+            if (s.code.equals(code)) {
+                return s;
+            }
+        }
+        return null;
+    }
+}

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

@@ -1,8 +1,13 @@
 package com.payment.platform.module.payment.leshouying.service;
 
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.StrUtil;
 import cn.hutool.json.JSONNull;
+import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
 import com.payment.platform.module.payment.leshouying.dto.LsyAreaItemDTO;
+import com.payment.platform.module.payment.leshouying.dto.LsyReviewStatusDTO;
+import com.payment.platform.module.payment.leshouying.enums.LsyReviewStatus;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
@@ -17,6 +22,26 @@ import java.util.Map;
  * (见 {@link LeshouyingClient#postApi}):非 {@code SUCCESS} 一律抛
  * {@link com.payment.platform.module.payment.leshouying.client.LeshouyingApiException},
  * 不存在「抛异常 = 通信失败 / 正常返回 = 业务失败」那种二分。
+ *
+ * <p><b>★★ 租户缺口(R-4):本层收下的 {@code mchNo} 不受租户约束 —— 三-B 加 HTTP 端点前必读。</b>
+ * <ol>
+ *   <li><b>本层不校验 {@code mchNo} 属于谁。</b>它的方法签名只收 {@code String mchNo},
+ *       既不接受 {@code tenantId},也<b>不查 {@code LsyMerchantService}</b>。</li>
+ *   <li><b>不查本地映射是有意的,不是漏了。</b>进件阶段这个商户<b>还没进
+ *       {@code pay_lsy_merchant}</b>(那一行是进件成功之后才写的)—— 所以在这里调
+ *       {@code LsyMerchantService.requireByTenant} / {@code requireByMchNo} 必然落空,
+ *       报的是它们各自那句 404(「该租户未配置商户号,请先在『商户映射』中配置」/
+ *       「商户号不存在」)。那正是本轮要防的「校验前提不成立」:**在进件阶段,
+ *       『配置里没有这个商户』不是异常,而是这个阶段的常态** —— 一句 404 会把操作者
+ *       送去配一个此刻不可能存在的映射。</li>
+ *   <li><b>三-B 加 HTTP 端点时,必须在端点侧先证明该 {@code mchNo} 属于当前租户</b>
+ *       (依据是进件成功后落库的本地记录),否则就是一个 <b>IDOR</b>:
+ *       A 租户拿 B 商户的 {@code mchNo} 调一次,就能读到 B 的进件资料与<b>通道费率</b>
+ *       ({@link #merchantInfo} 返回的 {@code wx_channel_rate} / {@code ali_channel_rate} 等)。</li>
+ *   <li><b>三-A 没有端点 ⇒ 当前没有可达路径</b>(本批次一行 {@code @RestController} 都不加)。
+ *       但<b>这行 javadoc 是三-B 的前置条件,不许删</b> —— 三-B 的端点是这个缺口的第一个可达入口,
+ *       而到那时「本层看着像做过校验」(方法名收 mchNo、还抛 400)正是最容易被误判的地方。</li>
+ * </ol>
  */
 @Slf4j
 @Service
@@ -53,6 +78,121 @@ public class LsyOnboardingService {
         return toAreaItems(client.postApi("/api/info/area", p));
     }
 
+    /**
+     * 获取商户信息(上游 page 196236558,路径 {@code /api/merchant/info})。
+     *
+     * <p>出参:{@code merchant_no}、{@code merchant_type}、{@code account_type}、
+     * {@code merchant_name} / {@code merchant_name_short} / {@code merchant_name_business}、
+     * {@code merchant_mcc_code}、联系人四处、{@code address_province/city/county/street}、
+     * {@code license_*} / {@code legal_*} / {@code account_*} 各四处、{@code picture_*} 十五处、
+     * {@code apply_h5_url},以及三组通道字段 {@code wx_channel_*} / {@code ali_channel_*} /
+     * {@code uni_channel_*}({@code id} / {@code name} / {@code mch_id} / {@code sub_mch_id} /
+     * {@code status} / <b>{@code rate} 费率</b>)。
+     *
+     * <p>★ <b>这里刻意不抄各字段的值域</b>:本方法不解释任何字段,抄一份码表进 javadoc
+     * 只会多一份会过期的副本。某些字段的值域在文档里**本来就自相矛盾** ——
+     * 例如 {@code merchant_type} 四处出处里有一处把 1 和 2 对调(spec §6.2(a),
+     * 裁定以三处一致者为准,且该裁定**留在 spec 里**,本批次不建对应枚举)。
+     *
+     * <p>★ <b>本方法不做字段白名单:上游给了什么键,就原样带回什么键。</b>
+     * 理由是 spec §6.2 里**还没定下来**的字段名/类型不一致就有四处 —— 本页自己占两条
+     * (§6.2(e):{@code wx_channel_rate} / {@code ali_channel_rate} 在返回参数表里各出现两次;
+     * 返回示例里 {@code merchant_type} / {@code account_type} / {@code license_type} /
+     * {@code wx_channel_id} 是**数字**而表里写 {@code String}),§6.2(c)(商户号字段名
+     * {@code shoubei_merchant_no} vs {@code merchant_no})与 §6.2(d)({@code merchant_mcc_code}
+     * 类型)各一条。此刻立白名单=把未定的契约固化进代码,而被剔掉的键在下游看起来
+     * 与「上游没返回」**完全一样**(都是缺键),没人会去查。
+     *
+     * <p>所以取值口径是**调用方**的事,按 spec §6.2(e) 的裁定:读时按 {@code String.valueOf} 归一。
+     * 本方法连读都不读,也就无从归一。
+     *
+     * <p>★ {@code /api/*} 报文非 {@code SUCCESS} 一律抛异常(见类 javadoc),
+     * 所以**能返回就说明上游认了这笔查询** —— 不存在「返回值里 {@code result_code} 是 FAIL」这种情况。
+     * {@code result_code} / {@code result_msg} 两个信封字段仍原样留在返回值里。
+     *
+     * @param mchNo 乐收赢商户号。空白 ⇒ {@link BusinessException}(400),**不发上游**
+     * @return 解析器给的整个响应 map(含两个信封字段),**未做任何筛选**
+     */
+    public Map<String, Object> merchantInfo(String mchNo) {
+        return client.postApi("/api/merchant/info", mchNoParams(mchNo));
+    }
+
+    /**
+     * 商户审核主动查询(上游 page 196236567,路径 {@code /api/merchant/query})。
+     *
+     * <p>出参只有四个:{@code merchant_no}、{@code merchant_name}、
+     * {@code reviewed_status}、{@code reviewed_reason}。
+     *
+     * <p>【映射口径】{@code reviewedStatus} 与 {@code reviewedReason} 一律是**上游原值**,
+     * {@code statusText} 只按**本页这一套**值域翻译(见 {@link LsyReviewStatus}):
+     * <ul>
+     *   <li><b>码未知</b>(上游加码)⇒ 不抛,{@code reviewedStatus} 保留原始码,
+     *       {@code statusText} 为 {@code null}。值是文本,页面据此显示原始码即可 ——
+     *       抛异常的代价是整条链路 500,换不来任何信息。</li>
+     *   <li><b>上游给数字也给字符串</b>:本页的返回示例是 {@code "reviewed_status": 0}(数字),
+     *       而值域表把它当码写;两种形态都要能读。所以这里复用 {@link #asText}
+     *       ({@code String.valueOf} 语义;它自己那条「必须容忍数字」的 javadoc 讲的是
+     *       {@code area_code},这里是同一件事的第二个实例)—— 直接 {@code (String)} 强转
+     *       会在活服务上 {@code ClassCastException}。
+     *       走 {@code String.valueOf} 也与 spec §6.2(e) 对同批字段的裁定一致。</li>
+     *   <li><b>字段缺失 / {@code null}</b> ⇒ 两个字段都是 Java {@code null},
+     *       {@code statusText} 也是 {@code null}({@link LsyReviewStatus#of} 对空白返回 null)。</li>
+     * </ul>
+     *
+     * <p>★ <b>{@code reviewed_reason} 的语义边界:只有在 {@code reviewedStatus} 为
+     * {@code "9"}(审核驳回)时,它才是驳回原因。</b>依据是本页的返回示例 ——
+     * {@code reviewed_status: 0}(审核通过)配的 {@code reviewed_reason} 就是 {@code "审核通过"},
+     * 即**驳回原因字段里填的不是驳回原因**。其余状态下该字段语义未知。
+     * 实现**不做归一化**:不清空、不替换,原样返回(清空会让「上游真给了值」这件事消失)。
+     *
+     * <p>【不做跨接口归一化】没有第二套值域的映射、没有 {@code reportQuery}(入网状态)
+     * 的码表 —— 那是三-B 的接口,且它自己一页就有两个互相冲突的映射(spec §6.2(b))。
+     *
+     * <p>★ <b>DTO 只带三个字段:本页返回的 {@code merchant_name} 不在其中</b>
+     * ({@code merchant_no} 也不在 —— 它本来就是本方法的入参)。这不是「上游没返回」,
+     * 而是三-A 里没有消费方;将来要用就**加字段**,不要在别处另开一条读同一次响应的路径。
+     *
+     * @param mchNo 乐收赢商户号。空白 ⇒ {@link BusinessException}(400),**不发上游**
+     * @return 三个字段均可能为 {@code null}(上游没给就是没给)
+     */
+    public LsyReviewStatusDTO queryReview(String mchNo) {
+        Map<String, Object> resp = client.postApi("/api/merchant/query", mchNoParams(mchNo));
+
+        LsyReviewStatusDTO dto = new LsyReviewStatusDTO();
+        dto.setReviewedStatus(asText(resp.get("reviewed_status")));
+        dto.setReviewedReason(asText(resp.get("reviewed_reason")));
+        LsyReviewStatus status = LsyReviewStatus.of(dto.getReviewedStatus());
+        dto.setStatusText(status == null ? null : status.getText());
+        return dto;
+    }
+
+    /**
+     * 两个收 {@code mchNo} 的接口共用的出站参数:{@code merchant_no} + 一个**新生成**的
+     * {@code trace_no}。
+     *
+     * <p>【为什么共用一个私有方法】上游契约里这两个接口的入参是同一组({@code inst_no} /
+     * {@code trace_no} / {@code merchant_no} / {@code sign},{@code inst_no} 与 {@code sign}
+     * 由 {@link LeshouyingClient} 补),所以「空白校验 + 流水号生成」必须**只有一份** ——
+     * 抄两份就会出现「某天只给其中一个加了校验」的分叉,而症状是其中一个接口静默把空商户号发出去。
+     *
+     * <p>★ {@code trace_no} 每次调用**重新生成**({@code IdUtil.fastSimpleUUID()},32 位):
+     * 写成常量、或缓存在字段里只生成一次,会在上游那边撞流水,而失败形态是笼统的「重复请求」。
+     *
+     * <p>★ 空白 {@code mchNo} 在**这里**就拒({@link BusinessException} 400),一个字节不出海 ——
+     * 空串发上游的后果是上游回一句笼统的「参数不合法」,本侧看不出是哪个参数。
+     * 形状照 {@code LsyMerchantService.requireByMchNo}(空白检查 + 400);
+     * 文案取「商户号不能为空」。
+     */
+    private static Map<String, Object> mchNoParams(String mchNo) {
+        if (StrUtil.isBlank(mchNo)) {
+            throw new BusinessException(400, "商户号不能为空");
+        }
+        Map<String, Object> p = new LinkedHashMap<>();
+        p.put("merchant_no", mchNo);
+        p.put("trace_no", IdUtil.fastSimpleUUID());
+        return p;
+    }
+
     /**
      * {@code area_list} → DTO 列表的**防御性读取**:缺键、null、不是数组,三种都归成空列表
      * (契约里写明的三种输入),数组元素不是对象时跳过。

+ 263 - 4
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingServiceTest.java

@@ -1,8 +1,10 @@
 package com.payment.platform.module.payment.leshouying.service;
 
+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.dto.LsyAreaItemDTO;
+import com.payment.platform.module.payment.leshouying.dto.LsyReviewStatusDTO;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -10,6 +12,7 @@ import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -19,7 +22,8 @@ import static org.mockito.ArgumentMatchers.*;
 import static org.mockito.Mockito.*;
 
 /**
- * 进件服务「获取省市区」的出站契约与响应映射(上游 page 196236568)。
+ * 进件服务的出站契约与响应映射:省市区(page 196236568)、获取商户信息(page 196236558)、
+ * 商户审核主动查询(page 196236567)。
  *
  * <p>【mock 的是谁】这里 mock 的是 {@link LeshouyingClient},不是 {@code RestTemplate}
  * —— 后者在客户端内部,服务层看不见。所以本类里「没有出海」的断言一律写作
@@ -42,6 +46,9 @@ import static org.mockito.Mockito.*;
 @ExtendWith(MockitoExtension.class)
 class LsyOnboardingServiceTest {
 
+    /** 测试商户号。A 阶段手工维护的就是这一个(见 {@code LsyMerchantService} 的类 javadoc)。 */
+    private static final String MCH_NO = "Y52211090004";
+
     @Mock private LeshouyingClient client;
 
     private LsyOnboardingService service;
@@ -61,15 +68,29 @@ class LsyOnboardingServiceTest {
         return "{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\"" + extraFields + "}";
     }
 
-    /** 捕获唯一一次出站调用的业务参数,并钉住路径 */
-    @SuppressWarnings("unchecked")
+    /** 捕获唯一一次出站调用的业务参数,并钉住路径(默认是省市区那条腿) */
     private Map<String, Object> sentParams() {
+        return sentParams("/api/info/area");
+    }
+
+    /** 同上,路径由调用方指定 —— 本类从 Task 4 起覆盖不止一个接口 */
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> sentParams(String path) {
         ArgumentCaptor<Map<String, Object>> captor =
                 ArgumentCaptor.forClass((Class<Map<String, Object>>) (Class<?>) Map.class);
-        verify(client, times(1)).postApi(eq("/api/info/area"), captor.capture());
+        verify(client, times(1)).postApi(eq(path), captor.capture());
         return captor.getValue();
     }
 
+    /** 捕获**全部**出站调用的业务参数(同一路径上调了多次时用) */
+    @SuppressWarnings("unchecked")
+    private List<Map<String, Object>> allSentParams(String path, int times) {
+        ArgumentCaptor<Map<String, Object>> captor =
+                ArgumentCaptor.forClass((Class<Map<String, Object>>) (Class<?>) Map.class);
+        verify(client, times(times)).postApi(eq(path), captor.capture());
+        return captor.getAllValues();
+    }
+
     /**
      * ★ 「1 级不填 parent_code」钉的是 level,**不是** parentCode 是否为 null。
      *
@@ -235,4 +256,242 @@ class LsyOnboardingServiceTest {
         assertEquals("某区", areas.get(0).getAreaName());
         assertEquals("100G", areas.get(0).getAreaCode());
     }
+
+    // ================= 获取商户信息(上游 page 196236558) =================
+
+    /**
+     * 出站参数**恰好**是 {@code merchant_no} + {@code trace_no}(键集合相等,多一个少一个都红),
+     * 路径按文档字面写死。
+     *
+     * <p>trace_no 断言长度 32:{@code IdUtil.fastSimpleUUID()} 的形状就是这个 ——
+     * 光断言「非空白」的话,一个写死的短串也能过。
+     */
+    @Test
+    void merchantInfo_sendsMerchantNoAndTraceNo() {
+        stubUpstream(ok(""));
+
+        service.merchantInfo(MCH_NO);
+
+        Map<String, Object> sent = sentParams("/api/merchant/info");
+        assertEquals(MCH_NO, sent.get("merchant_no"));
+        Object traceNo = sent.get("trace_no");
+        assertInstanceOf(String.class, traceNo, "trace_no 必须是字符串,实际: " + traceNo);
+        assertFalse(((String) traceNo).isBlank(), "trace_no 不得为空白");
+        assertEquals(32, ((String) traceNo).length(),
+                "trace_no 必须是 IdUtil.fastSimpleUUID() 的形状(32 位),实际: " + traceNo);
+        assertEquals(Set.of("merchant_no", "trace_no"), sent.keySet(),
+                "出站参数必须恰好是 merchant_no + trace_no:" + sent);
+    }
+
+    /**
+     * ★ 钉的是**流水号唯一**:连调两次,两次的 {@code trace_no} 必须不相等。
+     *
+     * <p>写死常量、或把 trace_no 缓存在字段里只生成一次,都会在**上游那边撞流水** ——
+     * 而上游的失败形态是笼统的「重复请求」,本侧看不出根因。
+     */
+    @Test
+    void merchantInfo_twoCalls_produceDifferentTraceNo() {
+        stubUpstream(ok(""));
+
+        service.merchantInfo(MCH_NO);
+        service.merchantInfo(MCH_NO);
+
+        List<Map<String, Object>> sent = allSentParams("/api/merchant/info", 2);
+        assertNotEquals(sent.get(0).get("trace_no"), sent.get(1).get("trace_no"),
+                "两次调用必须各生成一个 trace_no,实际两次都是: " + sent.get(0).get("trace_no"));
+    }
+
+    /**
+     * ★ 本方法**不做字段白名单**:上游给了什么键就带回什么键。
+     *
+     * <p>依据:spec §6.2 记着四处字段名/类型不一致,其中「获取商户信息」这一页自己就占两条
+     * (§6.2(e):{@code wx_channel_rate} / {@code ali_channel_rate} 在返回参数表里各出现两次;
+     * 返回示例里 {@code merchant_type} / {@code account_type} / {@code license_type} /
+     * {@code wx_channel_id} 是**数字**而表里写 {@code String}),§6.2(c)(d) 又各有一条跨页的。
+     * 现在立白名单 = 把还没定下来的字段集固化成代码,被剔掉的键看起来会像「上游没有」。
+     * 读具体字段的调用方按 spec §6.2(e) 的口径自行 {@code String.valueOf} 归一。
+     */
+    @Test
+    void merchantInfo_returnsRawUpstreamFields_untouched() {
+        stubUpstream(ok(",\"merchant_type\":3,\"wx_channel_rate\":\"0.006\""));
+
+        Map<String, Object> info = service.merchantInfo(MCH_NO);
+
+        assertEquals("0.006", info.get("wx_channel_rate"), "字符串型费率必须原样带回");
+        assertEquals("3", String.valueOf(info.get("merchant_type")),
+                "数字型字段必须原样带回,被剔掉的话这里会是字符串 \"null\"");
+        assertEquals(Set.of("result_code", "result_msg", "merchant_type", "wx_channel_rate"),
+                info.keySet(), "不得立白名单:上游给了什么键就带什么键,实际: " + info.keySet());
+    }
+
+    /**
+     * 空白 mchNo ⇒ 400,且**一个字节都不出海**。
+     *
+     * <p>文案逐字取自 brief。brief 同一句里又说「照 {@code LsyMerchantService.requireByMchNo}
+     * 的口径」,而那个先例的文案其实是 {@code "mch_no 不能为空"} —— 两者不一致;
+     * 「照口径」指的是**空白检查 + 400 这个形状**,文案按 brief 的字面值走。
+     */
+    @Test
+    void merchantInfo_blankMchNo_throws400WithoutCallingUpstream() {
+        for (String blank : new String[]{null, "", "   "}) {
+            BusinessException ex = assertThrows(BusinessException.class,
+                    () -> service.merchantInfo(blank), "mchNo=[" + blank + "] 必须被拒");
+            assertEquals(400, ex.getCode(), "mchNo=[" + blank + "]");
+            assertEquals("商户号不能为空", ex.getMessage(), "mchNo=[" + blank + "]");
+        }
+        verifyNoInteractions(client);
+    }
+
+    // ================= 商户审核主动查询(上游 page 196236567) =================
+
+    @Test
+    void queryReview_sendsMerchantNoAndTraceNo() {
+        stubUpstream(ok(",\"reviewed_status\":\"0\""));
+
+        service.queryReview(MCH_NO);
+
+        Map<String, Object> sent = sentParams("/api/merchant/query");
+        assertEquals(MCH_NO, sent.get("merchant_no"));
+        Object traceNo = sent.get("trace_no");
+        assertInstanceOf(String.class, traceNo, "trace_no 必须是字符串,实际: " + traceNo);
+        assertEquals(32, ((String) traceNo).length(),
+                "trace_no 必须是 IdUtil.fastSimpleUUID() 的形状(32 位),实际: " + traceNo);
+        assertEquals(Set.of("merchant_no", "trace_no"), sent.keySet(),
+                "出站参数必须恰好是 merchant_no + trace_no:" + sent);
+    }
+
+    /**
+     * page 196236567 的六码映射逐条对上。
+     *
+     * <p>{@code "10"} 这一条同时钉住**精确字符串匹配**:任何「前缀/包含」式的匹配
+     * 都会把 {@code "10"} 读成 {@code "1"}(审核中),而这是两个不同的审核态。
+     */
+    @Test
+    void queryReview_mapsKnownStatusCodes() {
+        Map<String, String> expected = new LinkedHashMap<>();
+        expected.put("0", "审核通过");
+        expected.put("1", "审核中");
+        expected.put("9", "审核驳回");
+        expected.put("10", "通道报备中");
+        expected.put("98", "停用");
+        expected.put("99", "关闭");
+
+        for (Map.Entry<String, String> e : expected.entrySet()) {
+            stubUpstream(ok(",\"reviewed_status\":\"" + e.getKey() + "\""));
+
+            LsyReviewStatusDTO dto = service.queryReview(MCH_NO);
+
+            assertEquals(e.getKey(), dto.getReviewedStatus(), "原值必须原样带回");
+            assertEquals(e.getValue(), dto.getStatusText(), "码 " + e.getKey() + " 的文案");
+        }
+    }
+
+    /**
+     * ★ 钉的是**不猜值域**:将来上游加一个码,正确行为是「页面显示原始码」,
+     * 而不是「整条链路 500」。
+     *
+     * <p>实现选的是「{@code statusText} 置 **null**」而不是「回填原值」——两个字段的职责必须可区分:
+     * {@code statusText} 是**文案**,回填原值会让调用方分不清「码已知」与「码未知」,
+     * 而「码未知」正是页面要显示原始码的那个状态(原始码本来就在 {@code reviewedStatus} 里,不会丢)。
+     */
+    @Test
+    void queryReview_unknownStatus_keepsRawAndDoesNotThrow() {
+        stubUpstream(ok(",\"reviewed_status\":\"7\""));
+
+        LsyReviewStatusDTO dto = assertDoesNotThrow(() -> service.queryReview(MCH_NO));
+
+        assertEquals("7", dto.getReviewedStatus());
+        assertNull(dto.getStatusText(),
+                "未知码的 statusText 必须是 null(原始码在 reviewedStatus 里,不在这里回填)");
+    }
+
+    /**
+     * **非数字**的未知码同样「原样透传、不抛」。
+     *
+     * <p>与上一条是同一族(未知码)的两个形态,必须**分开**钉:上一条的 {@code "7"} 是
+     * 可被 {@code Integer.parseInt} 解析的,把一个 `parseInt` 式实现放进去**它也照样绿**
+     * ——那种实现只是「这次碰巧没炸」(spec §6.2(b) 里 page 196236556 的示例就是
+     * 中文字符串 {@code "审核通过"} 落在同名 {@code reviewed_status} 上:**数字不是唯一形态**)。
+     * 本条的 {@code "0A"} 会让任何 `parseInt` / `(String)` 式读法直接抛。
+     */
+    @Test
+    void queryReview_nonNumericStatus_keepsRawAndDoesNotThrow() {
+        stubUpstream(ok(",\"reviewed_status\":\"0A\""));
+
+        LsyReviewStatusDTO dto = assertDoesNotThrow(() -> service.queryReview(MCH_NO));
+
+        assertEquals("0A", dto.getReviewedStatus());
+        assertNull(dto.getStatusText(), "未知码的 statusText 必须是 null");
+    }
+
+    /**
+     * ★ 上游两处示例一个给字符串一个给数字,本条钉住**数字形态**:
+     * page 196236567 的返回示例就是 {@code "reviewed_status": 0}(数字)。
+     *
+     * <p>读法必须过 {@code String.valueOf}(服务层复用既有的 {@code asText})——
+     * 直接 {@code (String)} 强转会在活服务上 ClassCastException。
+     */
+    @Test
+    void queryReview_numericStatusFromUpstream_isReadable() {
+        stubUpstream(ok(",\"reviewed_status\":0"));
+
+        LsyReviewStatusDTO dto = service.queryReview(MCH_NO);
+
+        assertEquals("0", dto.getReviewedStatus(), "数字形态必须被取成文本 \"0\"");
+        assertEquals("审核通过", dto.getStatusText());
+    }
+
+    /**
+     * ★ {@code reviewed_reason} 的语义边界:page 196236567 的返回示例里
+     * {@code reviewed_status: 0}(审核通过)配的 reason 就是 {@code "审核通过"} ——
+     * **驳回原因字段里填的不是驳回原因**。所以它只在 {@code reviewedStatus} 为 {@code "9"}
+     * 时才当作驳回原因,其余情况语义未知。
+     *
+     * <p>实现**不做归一化**:不清空、不替换,原样返回。
+     */
+    @Test
+    void queryReview_reviewedReason_isNotTrustedAsReason() {
+        stubUpstream(ok(",\"reviewed_status\":0,\"reviewed_reason\":\"审核通过\""));
+
+        LsyReviewStatusDTO dto = service.queryReview(MCH_NO);
+
+        assertEquals("审核通过", dto.getReviewedReason(),
+                "不得因为 status=0(通过)就把 reason 清空或改写 —— 那是归一化");
+    }
+
+    /**
+     * 字段缺失 / 为 JSON {@code null} ⇒ 三个字段都是 Java {@code null},
+     * **不得变成 4 个字符的字符串 {@code "null"}**。
+     *
+     * <p>{@code reviewed_reason} 这一支与 {@code areaList_itemJsonNull_isJavaNull_notStringNull}
+     * 是同一条判据:裸 {@code String.valueOf(null)} 得到的是字符串 {@code "null"},
+     * 那会被页面当成一句真实的驳回原因显示出来。两种输入都测:键**缺失**、
+     * 键在但值为 {@code null}(解析器对顶层 JSON null 已归一成 Java null,两条路径都要成立)。
+     */
+    @Test
+    void queryReview_missingOrNullFields_areJavaNull_notStringNull() {
+        for (String json : new String[]{
+                ok(""),
+                ok(",\"reviewed_status\":null,\"reviewed_reason\":null")}) {
+            stubUpstream(json);
+
+            LsyReviewStatusDTO dto = service.queryReview(MCH_NO);
+
+            assertNull(dto.getReviewedStatus(), json);
+            assertNull(dto.getReviewedReason(), json);
+            assertNull(dto.getStatusText(), json);
+        }
+    }
+
+    /** 空白 mchNo 与 {@code merchantInfo} 同一条守卫(同一个 400、同一句文案),同样不出海。 */
+    @Test
+    void queryReview_blankMchNo_throws400WithoutCallingUpstream() {
+        for (String blank : new String[]{null, "", "   "}) {
+            BusinessException ex = assertThrows(BusinessException.class,
+                    () -> service.queryReview(blank), "mchNo=[" + blank + "] 必须被拒");
+            assertEquals(400, ex.getCode(), "mchNo=[" + blank + "]");
+            assertEquals("商户号不能为空", ex.getMessage(), "mchNo=[" + blank + "]");
+        }
+        verifyNoInteractions(client);
+    }
 }