Ver Fonte

feat(进件): 门店创建与列表 - manager_name 以参数表为准

两个上游调用:page 196236559(/api/shop/create)与 page 196236560
(/api/shop/list),复用 Task 4 的 mchNoParams(merchant_no + 每次新生成的
trace_no)。本 task 只做上游 leg:不加端点、不加菜单、不写 SQL。

三处要点:

  1. manager_name / manager_phone 的键名以**上游参数表**为准,示例里的
     maneger_name 是文档错字:page 196236559 的请求参数表写 manager_name /
     manager_phone,而同一页的请求示例写成 maneger_*(表与示例冲突,取表)。
     键名收在两个常量里,真机若翻转只改常量。
  2. createShop 的五个业务字段全部按必填处理(空白 ⇒ 400,一个字节不出海):
     依据是 page 196236559 请求参数表的「必填」列七行**全是「是」**。
     merchant_no 的空白校验复用 mchNoParams,不另写一份(抄两份必然分叉)。
  3. 出站参数恰好 7 个键:{merchant_no, trace_no, shop_name, shop_phone,
     shop_address, manager_name, manager_phone}。inst_no / sign 由
     LeshouyingClient 内部补,服务层不放(放了会被覆盖或被重签)。
     断言「键集合相等」而非 contains:多发字段会进签名。

★ 成功但缺 shop_no ⇒ BusinessException(502),文案点明「门店可能已在上游创建,
不得当作失败直接重试」—— 这类「成功但没回执」的重试会建出第二家门店。不用
LeshouyingApiException:它的 message 模板会输出「乐收赢接口失败[SUCCESS]:
操作成功」这种自相矛盾的话,且它的字段契约要求逐字来自上游,此处没有。

★ 嵌套 JSON null 在**读取点**处理(asText,与 areaList 同一个 helper),没有
改 parser 递归:那会动到 Task 1-4 与批次一全部消费方的返回形状。代价与再评估
条件写在 toShopItems 的 javadoc(每个读嵌套字段的消费方都得自己过 asText;
消费方到 3 处就反过来把解析器改递归)。上游返回示例三个门店里第二项就没有
shop_tel 键,这条路径必须有覆盖。

listShops 的边界:shop_list 缺失 / null / 不是数组 ⇒ 空列表(该页 shop_list
的「必含」列是空的,即上游可以不给);非 Map 元素跳过;单个门店四个字段都可能
为 null。createShop(dto.mchNo) 与 listShops(mchNo) 的 R-4 租户缺口同前,三-B
的端点必须先证明该 mchNo 属于当前租户。

TDD:先写 10 条用例 → 红(COMPILATION ERROR 11 处找不到 createShop/listShops)
→ 实现 → 绿。本类原有 21 条未改。

两向变异(逐条读数与三条编译证据见 task-5-report.md):
  M-1 KEY_MANAGER_NAME 改成示例拼写 maneger_name → 2 红
  M-2 删掉五条 requireText                        → 1 红(400 变 502)
  M-3 多发一个 shop_no=""                         → 1 红(键集合相等)
  M-4 asText 去掉 JSONNull 分支                   → 2 红
  M-5 createShop 的 map 换 HashMap                → 仍绿(无害;class 层已证明变异生效)
  M-6 shop_no 判空 isBlank 改 == null             → 1 红
  M-7 给「空白必填」用例打桩但不真调用            → 1 红(UnnecessaryStubbing)

本提交字节 = 跑过的字节:全量 482 / 0 / 0 / 0(基线 472 + 本类新增 10)。
alphaH há 3 semanas atrás
pai
commit
23ae062407

+ 55 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyShopCreateDTO.java

@@ -0,0 +1,55 @@
+package com.payment.platform.module.payment.leshouying.dto;
+
+import lombok.Data;
+
+/**
+ * 创建门店入参 —— 上游「创建门店」接口(page 196236559,路径 {@code /api/shop/create})。
+ *
+ * <p><b>六个字段与上游键名的对应</b>(键名不是字段名的机械转换,逐条列出来免得下次去猜):
+ * <pre>
+ *   mchNo        → merchant_no
+ *   shopName     → shop_name
+ *   shopPhone    → shop_phone
+ *   shopAddress  → shop_address
+ *   managerName  → manager_name      ★ 见 {@code LsyOnboardingService} 里那两个键常量(R-2)
+ *   managerPhone → manager_phone     ★ 同上
+ * </pre>
+ * 上游入参里另有 {@code inst_no} / {@code sign},**不在本类里**:它们由
+ * {@code LeshouyingClient} 在出站链路上补,服务层与调用方都不该碰。
+ *
+ * <p>★ <b>为什么商户号在这个 DTO 里</b>:上游入参含 {@code merchant_no},而服务层**没有别的来源**
+ * (签名的 {@code String mchNo} 形态是给「只收商户号」的查询类接口用的)。三-B 的端点收下本 DTO
+ * 时**必须先证明该 {@code mchNo} 属于当前租户** —— 见
+ * {@code LsyOnboardingService} 类 javadoc 里那条租户缺口(R-4),那里是它的前置条件。
+ *
+ * <p>★ <b>本类不做校验</b>:空白一律由 {@code LsyOnboardingService.createShop} 在**组报文之前**
+ * 拒掉(400),校验点只有一处 —— 放在 DTO 的注解上会变成两份口径,而两份口径分叉的症状是
+ * 「某个字段有时拦、有时不拦」。
+ */
+@Data
+public class LsyShopCreateDTO {
+
+    /** 乐收赢商户号(上游 {@code merchant_no},必填)。 */
+    private String mchNo;
+
+    /** 门店名称(上游 {@code shop_name},必填)。 */
+    private String shopName;
+
+    /** 门店电话(上游 {@code shop_phone},必填)。 */
+    private String shopPhone;
+
+    /** 门店地址(上游 {@code shop_address},必填)。 */
+    private String shopAddress;
+
+    /**
+     * 门店管理员姓名(上游 {@code manager_name},必填)。
+     *
+     * <p>★ 上游 page 196236559 的**参数表**写 {@code manager_name},同一页的**请求示例**写
+     * {@code maneger_name}(错拼)。本字段按**参数表**口径发(计划 R-2),真机取证见
+     * {@code LsyOnboardingService} 里 {@code KEY_MANAGER_NAME} 的 javadoc。
+     */
+    private String managerName;
+
+    /** 门店管理员手机号(上游 {@code manager_phone},必填)。拼写问题同上。 */
+    private String managerPhone;
+}

+ 34 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyShopItemDTO.java

@@ -0,0 +1,34 @@
+package com.payment.platform.module.payment.leshouying.dto;
+
+import lombok.Data;
+
+/**
+ * 门店列表的一项 —— 上游「获取门店列表」接口(page 196236560)返回的 {@code shop_list} 里的一个元素。
+ *
+ * <p>字段与上游键名一一对应:{@code shopNo → shop_no}、{@code shopName → shop_name}、
+ * {@code shopAddress → shop_address}、{@code shopTel → shop_tel}。
+ *
+ * <p>★ <b>{@link #shopTel} 是这一个接口里唯一「上游常常不给」的字段</b>:page 196236560 的返回示例
+ * 三项里第二项就**没有 {@code shop_tel} 键**(2026-09-22 取原文复核)。所以四个字段都可能为
+ * {@code null},**缺字段与空值都是 Java {@code null},不会变成字符串 {@code "null"}** ——
+ * 后者会让页面显示一个不存在的电话,并把 {@code "null"} 原样回传给上游。
+ * 处理方式与理由见 {@code LsyOnboardingService} 读取嵌套字段的那段 javadoc({@code JSONNull})。
+ *
+ * <p>本类不筛选、不补默认值:上游没给就是没给,「这个门店没有电话」与「上游没返回电话」
+ * 在这一层不可区分 —— 要区分得靠上游,不是靠这里猜。
+ */
+@Data
+public class LsyShopItemDTO {
+
+    /** 门店编号(上游 {@code shop_no})。 */
+    private String shopNo;
+
+    /** 门店名称(上游 {@code shop_name})。 */
+    private String shopName;
+
+    /** 门店地址(上游 {@code shop_address})。 */
+    private String shopAddress;
+
+    /** 门店电话(上游 {@code shop_tel});上游示例里就有门店不带这个键 ⇒ 可能为 {@code null}。 */
+    private String shopTel;
+}

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

@@ -7,6 +7,8 @@ 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.dto.LsyShopCreateDTO;
+import com.payment.platform.module.payment.leshouying.dto.LsyShopItemDTO;
 import com.payment.platform.module.payment.leshouying.enums.LsyReviewStatus;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -58,6 +60,23 @@ public class LsyOnboardingService {
      */
     private static final String LEVEL_PROVINCE = "1";
 
+    /**
+     * 门店管理员**姓名**的键名 —— 上游 page 196236559 的**参数表**口径。
+     *
+     * <p>★ <b>R-2:同一页的请求示例写的是 {@code maneger_name}</b>("maneger" 是错拼)。
+     * 参数表是规范、示例是手打的 ⇒ 发这个拼写。键名本身**进签名原文**,所以发错不是
+     * 「上游忽略它」而是**签名直接对不上**(或上游判「参数不合法」),失败形态笼统。
+     *
+     * <p>★ <b>这是可实测项,真机翻转时只改这一个常量</b>:Task 7 会先按这个拼写真发一次,
+     * 若上游回「参数不合法 / 缺少 manager_name」,说明它认的是错拼版 —— 那时改这里的字面值,
+     * 并把 {@code createShop_sendsManagerNameSpelledAsTable} 的断言一起反转。
+     * **不要**在方法体里另写一遍字面量:那会变成两处要同时改的地方。
+     */
+    private static final String KEY_MANAGER_NAME = "manager_name";
+
+    /** 门店管理员**手机号**的键名。拼写冲突与 {@link #KEY_MANAGER_NAME} 完全同源(示例写 {@code maneger_phone})。 */
+    private static final String KEY_MANAGER_PHONE = "manager_phone";
+
     private final LeshouyingClient client;
 
     /**
@@ -170,6 +189,99 @@ public class LsyOnboardingService {
         return dto;
     }
 
+    /**
+     * 创建门店(上游 page 196236559,路径 {@code /api/shop/create})。
+     *
+     * <p>上游入参:{@code inst_no} / {@code trace_no} / {@code merchant_no} / {@code shop_name} /
+     * {@code shop_phone} / {@code shop_address} / {@code manager_name} / {@code manager_phone} /
+     * {@code sign}(前两个中的 {@code inst_no} 与末一个由 {@link LeshouyingClient} 补)。
+     * 出参:{@code result_code} / {@code result_msg} / {@code inst_no} / {@code trace_no} /
+     * {@code shop_no}(**操作成功时返回**)/ {@code sign}。
+     *
+     * <p>【必填校验:六个字段,全部在**组报文之前**】{@code merchant_no} 走
+     * {@link #mchNoParams}(不另写一份空白检查 —— 抄两份就会出现「某天只给其中一处加校验」
+     * 的分叉),DTO 的五个在方法开头逐个过 {@link #requireText}。文案用**中文字段名**
+     * (「门店名称」而不是 {@code shop_name}):报键名的话,操作者得在文档里做一次心算才能
+     * 对上界面上的哪个输入框。空白的三种形态({@code null} / 空串 / 全空格)一律拒。
+     *
+     * <p>★ 【这五个必填**是上游的**,不是本模块加的严格度 —— 但「空白算不算填了」是我们的判据】
+     * page 196236559 的请求参数表有一列「必填」,{@code shop_name} / {@code shop_phone} /
+     * {@code shop_address} / {@code manager_name} / {@code manager_phone} **五个都是「是」**
+     * (2026-09-22 取原文复核,U+662F)。所以本地这道校验把上游的必填要求提前到了出站之前,
+     * **只改失败形态**:本侧是一句点名中文字段的 400,上游那边只会回一句笼统的
+     * 「参数不合法」,从它看不出是哪个参数。文档**没说**空串/全空格会被上游当成「已填」,
+     * 本侧一律当没填 —— 这个判据(以及 400 的形状)照的是 {@code LsyMerchantService.save}
+     * 的口径,不是上游的规定。
+     *
+     * <p>★ <b>{@code shop_no} 缺失或空白 ⇒ {@link BusinessException}(502),不返回 null、不静默。</b>
+     * 理由分两层:
+     * <ol>
+     *   <li><b>为什么不返回 null</b>:调用方拿 null 存库就是一条**查不回来**的记录 ——
+     *       门店在上游**确实建了**,本地却没有号。空串同理(与「没给」一样不可用),
+     *       所以判据是 {@code isBlank} 而不是「只判 null」。</li>
+     *   <li><b>为什么不是 {@link com.payment.platform.module.payment.leshouying.client.LeshouyingApiException}</b>:
+     *       那个异常的三个字段是**上游报文的逐字回显**,message 是
+     *       {@code 乐收赢接口失败[<code>]: <msg>}。这里手上只有一份 **SUCCESS** 报文,
+     *       逐字回显就成了「乐收赢接口失败[SUCCESS]: 操作成功」——一句**自相矛盾**的话,
+     *       比不报还坏;而另外编一套 code/message 塞进去,则违反它「三个 Raw 字段 = 上游原文」
+     *       的契约(那条契约有测试钉着)。502 的语义正是「上游给了个不合规的响应」,
+     *       与「上游说业务失败」区分得开,调用方不会把它误当成一次正常的业务拒绝。</li>
+     * </ol>
+     * ★ <b>操作上的要点:走到这条分支时门店**可能已经在上游创建了**,调用方不得把它
+     * 当作「创建失败」直接重试</b> —— 那会建出第二家门店。要确认得回去查
+     * {@link #listShops}。
+     *
+     * <p>★ 租户缺口(R-4)对本法同样成立:{@code dto.mchNo} 不受租户约束,
+     * 三-B 的端点必须先证明它属于当前租户,见类 javadoc。
+     *
+     * @param dto 六个字段全必填;空白 ⇒ {@link BusinessException}(400),**不发上游**
+     * @return 上游给的 {@code shop_no},**保证非空白**
+     * @throws BusinessException 400 必填项空白;502 上游返回成功但没给可用的 {@code shop_no}
+     */
+    public String createShop(LsyShopCreateDTO dto) {
+        requireText(dto.getShopName(), "门店名称");
+        requireText(dto.getShopPhone(), "门店电话");
+        requireText(dto.getShopAddress(), "门店地址");
+        requireText(dto.getManagerName(), "门店管理员姓名");
+        requireText(dto.getManagerPhone(), "门店管理员手机号");
+
+        Map<String, Object> p = mchNoParams(dto.getMchNo());
+        p.put("shop_name", dto.getShopName());
+        p.put("shop_phone", dto.getShopPhone());
+        p.put("shop_address", dto.getShopAddress());
+        p.put(KEY_MANAGER_NAME, dto.getManagerName());
+        p.put(KEY_MANAGER_PHONE, dto.getManagerPhone());
+
+        String shopNo = asText(client.postApi("/api/shop/create", p).get("shop_no"));
+        if (StrUtil.isBlank(shopNo)) {
+            throw new BusinessException(502,
+                    "乐收赢返回成功但缺少门店号 shop_no(门店可能已在上游创建,不得当作失败直接重试)");
+        }
+        return shopNo;
+    }
+
+    /**
+     * 获取门店列表(上游 page 196236560,路径 {@code /api/shop/list})。
+     *
+     * <p>上游入参只有四个:{@code inst_no} / {@code trace_no} / {@code merchant_no} / {@code sign}
+     * (与另外两个收 {@code mchNo} 的接口同一组 ⇒ 复用 {@link #mchNoParams})。
+     * 出参:{@code merchant_no} / {@code merchant_name} / {@code shop_list[]}。
+     *
+     * <p>★ <b>本方法只返回 {@code shop_list},不带 {@code merchant_name}</b>:三-A 没有消费方,
+     * 将来要用就**加字段**,不要在别处另开一条读同一次响应的路径(与 {@link #queryReview}
+     * 不带 {@code merchant_name} 是同一条边界)。
+     *
+     * <p>★ {@code shop_list} 缺失 / 为 null / 不是数组 ⇒ **空列表**(不是 null,也不抛):
+     * 该页返回参数表里 {@code shop_list} 的「必含」列是**空的**(2026-09-22 取原文复核),
+     * 即上游可以不给 ⇒「这个商户没有门店」是一个合法的成功结果。口径与 {@link #areaList} 一致。
+     *
+     * @param mchNo 乐收赢商户号。空白 ⇒ {@link BusinessException}(400),**不发上游**
+     * @return 门店列表,**永不为 null**;单个门店的四个字段都可能为 {@code null}
+     */
+    public List<LsyShopItemDTO> listShops(String mchNo) {
+        return toShopItems(client.postApi("/api/shop/list", mchNoParams(mchNo)));
+    }
+
     /**
      * 两个收 {@code mchNo} 的接口共用的出站参数:{@code merchant_no} + 一个**新生成**的
      * {@code trace_no}。
@@ -202,6 +314,68 @@ public class LsyOnboardingService {
         return p;
     }
 
+    /**
+     * 必填字符串守卫:空白({@code null} / 空串 / 全空格)⇒ {@link BusinessException}(400)。
+     *
+     * <p>{@code label} 用**上游参数表里的中文字段名**(「门店名称」而不是 {@code shop_name}):
+     * 报键名的话,操作者得在文档里做一次心算才能对上是界面上的哪个输入框。
+     *
+     * <p>形状照 {@code LsyMerchantService.save} 的 {@code "商户号不能为空"}(空白检查 + 400),
+     * 只是那句是单字段的字面量、这里带一个 {@code label} 参数(六个字段同一句话,
+     * 抄六遍就会出现「某天只给其中一句改字」的分叉)。
+     */
+    private static void requireText(String value, String label) {
+        if (StrUtil.isBlank(value)) {
+            throw new BusinessException(400, label + "不能为空");
+        }
+    }
+
+    /**
+     * {@code shop_list} → DTO 列表的**防御性读取**:缺键、null、不是数组,三种都归成空列表;
+     * 数组元素不是对象时跳过。与 {@link #toAreaItems} 同一口径、同一理由(那里写得细,
+     * 不复述第二份)。
+     *
+     * <p>★ <b>字段值一律过 {@link #asText},这是本方法的重点,不是顺手</b>:
+     * {@code LeshouyingResponseParser} 的归一化**只走顶层、不递归**,而这里是**嵌套**数组
+     * ⇒ 里面取出的 JSON {@code null} 是 Hutool 的 {@code JSONNull} **单例**,
+     * 裸 {@code String.valueOf} 会得到**字符串** {@code "null"}(长度 4)。那会被页面当成
+     * 一个真实存在的门店电话显示出来,并原样回传给上游。
+     * 上游示例里第二项**就是没有 {@code shop_tel} 键**(另一条路径:{@code Map.get} 给 Java
+     * {@code null},不过 {@code asText} 的话同样会变成字符串 {@code "null"})。
+     *
+     * <p>★ <b>为什么不去把解析器改成递归</b>(另一条路):那会动到 Task 1-4 与批次一
+     * **全部**路径的返回形状,超出本 task 边界。
+     * <br>★ <b>这是对解析器那条注释的一次<u>有意偏离</u></b>,逐字记清楚免得后来人以为它同意了:
+     * {@code LeshouyingResponseParser.toMap} 的注释里写的是
+     * 「<b>谁要开始读嵌套字段,先回来把这里改成递归</b>」—— 它给的路是**改它**;
+     * 本 task 按派发件的裁定走了另一条(在读取点归一),理由是改动面:改解析器要同时动
+     * 批次一(支付/回调/退款)全部消费方,而本批次一处都不碰。
+     * <br>代价与再评估条件一起记着:**每个**读嵌套字段的消费方都得自己记得过 {@code asText},
+     * 漏一处就是一条静默的字符串 {@code "null"}。**消费方到 3 处就该反过来做**——
+     * 那时把解析器改递归,并删掉这里的 {@code JSONNull} 分支(两处都留会变成两份口径)。
+     */
+    private static List<LsyShopItemDTO> toShopItems(Map<String, Object> resp) {
+        Object raw = resp.get("shop_list");
+        if (!(raw instanceof List<?> list)) {
+            return new ArrayList<>();
+        }
+        List<LsyShopItemDTO> items = new ArrayList<>(list.size());
+        for (Object o : list) {
+            // 非对象元素跳过:映射成一行「四个字段都空」的门店,会在门店列表里显示一行
+            // 没有编号也没有名字的空行(与 toAreaItems 同一个失败形态)。
+            if (!(o instanceof Map<?, ?> m)) {
+                continue;
+            }
+            LsyShopItemDTO item = new LsyShopItemDTO();
+            item.setShopNo(asText(m.get("shop_no")));
+            item.setShopName(asText(m.get("shop_name")));
+            item.setShopAddress(asText(m.get("shop_address")));
+            item.setShopTel(asText(m.get("shop_tel")));
+            items.add(item);
+        }
+        return items;
+    }
+
     /**
      * {@code area_list} → DTO 列表的**防御性读取**:缺键、null、不是数组,三种都归成空列表
      * (契约里写明的三种输入),数组元素不是对象时跳过。

+ 305 - 2
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingServiceTest.java

@@ -5,6 +5,8 @@ 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 com.payment.platform.module.payment.leshouying.dto.LsyShopCreateDTO;
+import com.payment.platform.module.payment.leshouying.dto.LsyShopItemDTO;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -16,6 +18,7 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.function.Consumer;
 
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.*;
@@ -225,8 +228,10 @@ class LsyOnboardingServiceTest {
      *
      * <p>{@link LeshouyingResponseParser} 的归一化**只走顶层**(不递归),这一点由它自己的
      * javadoc 与 {@code parseV3_normalization_isTopLevelOnly_nestedJsonNullSurvives} 钉住,
-     * 那里还写着「谁要开始读嵌套字段,必须先把这里改成递归**或在这里补归一化**」。
-     * 本类是全模块第一个读嵌套字段的消费方,所以归一化补在读取处:
+     * 那里还写着「谁要开始读嵌套字段,先回来把这里改成递归」—— 它只给了**改解析器**这一条路,
+     * 而本 task 的边界不允许改(会动到 Task 1-4 与批次一全部路径)。本类是全模块第一个读嵌套
+     * 字段的消费方,所以归一化补在读取处,重新评估的条件见
+     * {@code LsyOnboardingService#toShopItems} 的 javadoc:
      * {@code area_list[i].area_code} 的 JSON null 若按 {@code String.valueOf} 走,
      * 得到的是**字符串** {@code "null"}(实测,见解析器 javadoc),
      * 那会被当成一个真实的区划编码继续传给上游(三级联动的下一跳 parent_code)。
@@ -507,4 +512,302 @@ class LsyOnboardingServiceTest {
         }
         verifyNoInteractions(client);
     }
+
+    // ================= 门店创建 + 门店列表(上游 page 196236559 / 196236560) =================
+
+    /**
+     * 一份**能通过全部必填校验**的创建门店入参 —— 各条用例只改它关心的那一项。
+     *
+     * <p>值取自 page 196236559 请求参数表的「参数示例」列(门店名称「小店」、门店电话
+     * {@code 13566668888}、门店地址「湖北武汉」、门店管理员姓名「店小二」);商户号用本类的
+     * {@link #MCH_NO},与其它用例同一个口径。
+     */
+    private static LsyShopCreateDTO validShopDto() {
+        LsyShopCreateDTO dto = new LsyShopCreateDTO();
+        dto.setMchNo(MCH_NO);
+        dto.setShopName("小店");
+        dto.setShopPhone("13566668888");
+        dto.setShopAddress("湖北武汉");
+        dto.setManagerName("店小二");
+        dto.setManagerPhone("13566668888");
+        return dto;
+    }
+
+    /** 在 {@link #validShopDto()} 上改一项。 */
+    private static LsyShopCreateDTO shopDto(Consumer<LsyShopCreateDTO> mutation) {
+        LsyShopCreateDTO dto = validShopDto();
+        mutation.accept(dto);
+        return dto;
+    }
+
+    /** 创建门店成功的应答:page 196236559 的返回示例里操作成功时的形状,只留本类要读的键。 */
+    private static final String SHOP_CREATE_OK =
+            "{\"result_code\":\"SUCCESS\",\"shop_no\":\"861907240003\"}";
+
+    /**
+     * page 196236560 的返回示例原文(2026-09-22 从上游文档站重取复核过),
+     * **两处非法 JSON 已修好**,其余逐字照抄:
+     * <ol>
+     *   <li>{@code "trace_no": "123456"} 那一行末尾**缺一个逗号**(spec §6.2(e) 已记录);</li>
+     *   <li>末尾 {@code "merchant_name": "糖糖西饼屋",} 后面那个**多余的尾逗号**。</li>
+     * </ol>
+     * 修它们是因为**夹具本身必须是合法 JSON** —— 照抄的话「上游报文坏了」与「我们读错了」
+     * 两种失败在用例里长得一样,而前者会把这个用例变成一条永远查不出根因的假红。
+     * 两处都在本类要断言的四个字段之外,改动不触及任何断言。
+     *
+     * <p>★ 第二项({@code 糖糖西饼屋默认门店})**没有 {@code shop_tel} 键** —— 那是上游原文的
+     * 真实形状,不是这里漏抄。
+     */
+    private static final String SHOP_LIST_EXAMPLE =
+            "{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\",\"inst_no\":\"900024\","
+                    + "\"trace_no\":\"123456\",\"merchant_no\":\"851811130002\",\"shop_list\":["
+                    + "{\"shop_tel\":\"13277581225\",\"shop_name\":\"一分店\","
+                    + "\"shop_address\":\"司门口大智路111号\",\"shop_no\":\"861811140412\"},"
+                    + "{\"shop_name\":\"糖糖西饼屋默认门店\",\"shop_address\":\"地址\","
+                    + "\"shop_no\":\"86201812290001\"},"
+                    + "{\"shop_tel\":\"13470123132131\",\"shop_name\":\"西饼一店\","
+                    + "\"shop_address\":\"湖北武汉\",\"shop_no\":\"86201812290002\"}],"
+                    + "\"merchant_name\":\"糖糖西饼屋\"}";
+
+    /**
+     * ★ 钉的是**R-2 的裁定**:发的是参数表的拼写 {@code manager_name} / {@code manager_phone},
+     * **不是**同页请求示例里的 {@code maneger_name} / {@code maneger_phone}("maneger" 是错拼)。
+     *
+     * <p>依据:page 196236559 的**参数表**写 {@code manager_name}/{@code manager_phone},
+     * 同一页的**请求示例**写 {@code maneger_name}/{@code maneger_phone} —— 参数表是规范,
+     * 示例是手打的。★ 这是**可实测项**:Task 7 真机一轮拿这条去取证,若上游回
+     * 「参数不合法 / 缺少 manager_name」,说明它认的是错拼版,那时**只改
+     * {@code LsyOnboardingService} 里的常量 + 反转本条断言**。
+     */
+    @Test
+    void createShop_sendsManagerNameSpelledAsTable() {
+        stubUpstream(SHOP_CREATE_OK);
+
+        service.createShop(validShopDto());
+
+        Map<String, Object> sent = sentParams("/api/shop/create");
+        assertEquals("店小二", sent.get("manager_name"));
+        assertEquals("13566668888", sent.get("manager_phone"));
+        assertFalse(sent.containsKey("maneger_name"),
+                "不得发请求示例里的错拼版 maneger_name(R-2:参数表为准):" + sent);
+        assertFalse(sent.containsKey("maneger_phone"),
+                "不得发请求示例里的错拼版 maneger_phone(R-2:参数表为准):" + sent);
+    }
+
+    /**
+     * 出站参数**恰好**是服务自己拥有的 7 个键 —— {@code merchant_no} + {@code trace_no}
+     * + DTO 的五个。**集合相等**,不是 {@code contains}:多发的字段会进签名,上游可能因此判
+     * 「参数不合法」,而失败形态是一句笼统的话,根因不可见。
+     *
+     * <p>★ 【为什么不是上游入参表的 9 个键】{@code inst_no} 与 {@code sign} 由
+     * {@link LeshouyingClient} 在**客户端内部**补({@code signedPayload}),本类 mock 的正是
+     * 那个客户端 ⇒ 服务层交出去的 map 里**永远不会**有这两个键。同文件里
+     * {@code merchantInfo_sendsMerchantNoAndTraceNo} 断言的也正是
+     * {@code Set.of("merchant_no","trace_no")},同一个口径。
+     * 服务层若自己补这两个键,{@code inst_no} 会被客户端无条件覆盖、{@code sign} 会被重签
+     * —— 那是把 Task 1 已落地的契约又实现一遍(变体 M-3 的反面)。
+     */
+    @Test
+    void createShop_sendsExactlyTheSevenServiceOwnedParams() {
+        stubUpstream(SHOP_CREATE_OK);
+
+        service.createShop(validShopDto());
+
+        Map<String, Object> sent = sentParams("/api/shop/create");
+        assertEquals(Set.of("merchant_no", "trace_no", "shop_name", "shop_phone", "shop_address",
+                        "manager_name", "manager_phone"), sent.keySet(),
+                "出站参数必须恰好是服务拥有的 7 个键(inst_no / sign 归客户端补):" + sent);
+        assertEquals(MCH_NO, sent.get("merchant_no"));
+        assertEquals("小店", sent.get("shop_name"));
+        assertEquals("13566668888", sent.get("shop_phone"));
+        assertEquals("湖北武汉", sent.get("shop_address"));
+        assertEquals("店小二", sent.get("manager_name"));
+        assertEquals("13566668888", sent.get("manager_phone"));
+        Object traceNo = sent.get("trace_no");
+        assertInstanceOf(String.class, traceNo, "trace_no 必须是字符串,实际: " + traceNo);
+        assertEquals(32, ((String) traceNo).length(),
+                "trace_no 必须是 IdUtil.fastSimpleUUID() 的形状(32 位),实际: " + traceNo);
+    }
+
+    /**
+     * 六个必填项**逐个**置空白 ⇒ 全部 400,且**一次上游调用都没发出** ——
+     * 这条钉的是「本地校验先于出站」。
+     *
+     * <p>六个 = {@code merchant_no}(走 Task 4 的 {@code mchNoParams},不另写一份空白检查)
+     * + DTO 的五个。空白的三种形态({@code null} / 空串 / 全空格)逐个试,因为
+     * 「漏了其中一个」正是这条要防的形态。
+     *
+     * <p>★ 本用例**一次都不打桩**(整个方法里不出现 {@code stubUpstream}):
+     * {@code verifyNoInteractions} 的前提就是「这条用例没打过桩」。
+     * (Mockito 的严格桩检查会把「打了桩却没人用」也判成失败 —— 但那**不是**本断言,
+     * 两者不要混为一谈。)
+     */
+    @Test
+    void createShop_blankRequiredField_throws400() {
+        for (String blank : new String[]{null, "", "   "}) {
+            assertRejected400("merchant_no", "商户号不能为空", shopDto(d -> d.setMchNo(blank)));
+            assertRejected400("shop_name", "门店名称不能为空", shopDto(d -> d.setShopName(blank)));
+            assertRejected400("shop_phone", "门店电话不能为空", shopDto(d -> d.setShopPhone(blank)));
+            assertRejected400("shop_address", "门店地址不能为空", shopDto(d -> d.setShopAddress(blank)));
+            assertRejected400("manager_name", "门店管理员姓名不能为空",
+                    shopDto(d -> d.setManagerName(blank)));
+            assertRejected400("manager_phone", "门店管理员手机号不能为空",
+                    shopDto(d -> d.setManagerPhone(blank)));
+        }
+        verifyNoInteractions(client);
+    }
+
+    /** 断言某一次 {@code createShop} 被 400 拒掉,且文案点名的是**中文字段名**。 */
+    private void assertRejected400(String field, String expectedMessage, LsyShopCreateDTO dto) {
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.createShop(dto), field + " 置空白必须被拒");
+        assertEquals(400, ex.getCode(), field);
+        assertEquals(expectedMessage, ex.getMessage(), field);
+    }
+
+    /** 操作成功时把上游给的 {@code shop_no} 原样带回。 */
+    @Test
+    void createShop_returnsShopNo() {
+        stubUpstream(SHOP_CREATE_OK);
+
+        assertEquals("861907240003", service.createShop(validShopDto()));
+    }
+
+    /**
+     * ★ 上游说成功却**没给门店号** ⇒ 抛异常,**绝不让 null 一路往外走**:调用方拿 null 存库
+     * 就是一条查不回来的记录(门店在上游**确实建了**,本地却没有号)。
+     *
+     * <p>两个输入:brief 的字面夹具(整段没有 {@code shop_no} 键)与**空串**。
+     * 分开不是因为形状不同,而是因为它们**钉的不是同一个判据**:只测「缺键」的话,
+     * 「只判 null」与「判空白」两种实现分不开,而后者才是对的 —— 上游回
+     * {@code "shop_no":""} 与没回一样不可用,空串照样是一条查不回来的记录。
+     *
+     * <p>异常类型是**实现者裁定的**(brief 允许二选一),这里是
+     * {@link BusinessException}(502):见 {@code LsyOnboardingService#createShop} 的 javadoc
+     * 里那段「为什么不是 {@code LeshouyingApiException}」。
+     */
+    @Test
+    void createShop_missingShopNo_throws() {
+        for (String json : new String[]{
+                "{\"result_code\":\"SUCCESS\"}",
+                "{\"result_code\":\"SUCCESS\",\"shop_no\":\"\"}"}) {
+            stubUpstream(json);
+
+            BusinessException ex = assertThrows(BusinessException.class,
+                    () -> service.createShop(validShopDto()), json + " ⇒ 必须抛,不得返回 null/空串");
+            assertEquals(502, ex.getCode(), json);
+        }
+    }
+
+    /**
+     * 门店列表的出站参数与另外两个 {@code /api/*} 接口同一组:{@code merchant_no} + {@code trace_no}
+     * (键集合相等;{@code inst_no} / {@code sign} 归客户端补,理由见
+     * {@link #createShop_sendsExactlyTheSevenServiceOwnedParams})。路径按文档字面写死。
+     */
+    @Test
+    void listShops_sendsMerchantNoAndTraceNo() {
+        stubUpstream(SHOP_LIST_EXAMPLE);
+
+        service.listShops(MCH_NO);
+
+        Map<String, Object> sent = sentParams("/api/shop/list");
+        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 196236560 返回示例里三项、四个字段全部映射上(四项 =
+     * {@code shop_no} / {@code shop_name} / {@code shop_address} / {@code shop_tel})。
+     *
+     * <p>三项都断言,是因为示例里三项的**键集并不相同**(第二项没有 {@code shop_tel})——
+     * 只断言第一项的话,「按第一项的形状硬编码」这种实现照样绿。
+     */
+    @Test
+    void listShops_mapsFourFields() {
+        stubUpstream(SHOP_LIST_EXAMPLE);
+
+        List<LsyShopItemDTO> shops = service.listShops(MCH_NO);
+
+        assertEquals(3, shops.size(), "上游示例里就是三项:" + shops);
+        assertEquals("861811140412", shops.get(0).getShopNo());
+        assertEquals("一分店", shops.get(0).getShopName());
+        assertEquals("司门口大智路111号", shops.get(0).getShopAddress());
+        assertEquals("13277581225", shops.get(0).getShopTel());
+        assertEquals("86201812290001", shops.get(1).getShopNo());
+        assertEquals("糖糖西饼屋默认门店", shops.get(1).getShopName());
+        assertEquals("地址", shops.get(1).getShopAddress());
+        assertEquals("86201812290002", shops.get(2).getShopNo());
+        assertEquals("西饼一店", shops.get(2).getShopName());
+        assertEquals("湖北武汉", shops.get(2).getShopAddress());
+        assertEquals("13470123132131", shops.get(2).getShopTel());
+    }
+
+    /**
+     * ★ 这条钉的是 **Hutool 的 {@code JSONNull}**:没有电话的门店,{@code shopTel} 必须是
+     * Java {@code null},**不是** 4 个字符的字符串 {@code "null"}。
+     *
+     * <p>{@link LeshouyingResponseParser} 对 JSON null 的归一化**只走顶层、不递归**
+     * (由 {@code parseV3_normalization_isTopLevelOnly_nestedJsonNullSurvives} 钉住),
+     * 而 {@code shop_list} 是**嵌套**数组 ⇒ 里面取出来的 null 仍是 {@code JSONNull} 单例,
+     * 裸 {@code String.valueOf} 会得到字符串 {@code "null"}。那会被页面当成一个真实的
+     * 门店电话显示出来,并原样回传给上游。
+     *
+     * <p>两种输入**都要测,因为它们走的是两条不同的路径**,谁也不覆盖谁:
+     * <ol>
+     *   <li><b>键不存在</b>(上游示例第二项就是)⇒ {@code Map.get} 给 Java {@code null},
+     *       一路到 {@code String.valueOf(Object)} 同样会变成字符串 {@code "null"};</li>
+     *   <li><b>键在、值是 JSON null</b> ⇒ 取到的是 {@code JSONNull} <b>单例</b>,
+     *       与上一条**完全不是同一个对象**。只测第 1 种的话,「只判 Java null」的实现照样绿,
+     *       而它在真机上遇到显式 null 就漏 —— 变体 M-4 打的就是这一条。</li>
+     * </ol>
+     */
+    @Test
+    void listShops_blankShopTel_doesNotBecomeStringNull() {
+        stubUpstream(SHOP_LIST_EXAMPLE);
+        assertNull(service.listShops(MCH_NO).get(1).getShopTel(),
+                "第二项没有 shop_tel 键 ⇒ 必须是 Java null,不是字符串 \"null\"");
+
+        stubUpstream(ok(",\"shop_list\":[{\"shop_no\":\"861811140412\",\"shop_tel\":null}]"));
+        assertNull(service.listShops(MCH_NO).get(0).getShopTel(),
+                "JSON null ⇒ 必须是 Java null,不是字符串 \"null\"");
+    }
+
+    /**
+     * {@code shop_list} 缺失 / 为 null / 不是数组 ⇒ **空列表**(不是 null,也不抛)。
+     *
+     * <p>口径与同类的 {@code areaList_blankList_returnsEmpty} 一致:上游返回参数表里
+     * {@code shop_list} 的「必含」列是**空的**(page 196236560,2026-09-22 取原文复核),
+     * 即上游可以不给 ⇒ 「这个商户没有门店」是一个合法的成功结果,
+     * 不该让调用方拿到 null 再去判空。
+     */
+    @Test
+    void listShops_blankList_returnsEmpty() {
+        for (String json : new String[]{
+                ok(""),
+                ok(",\"shop_list\":null"),
+                ok(",\"shop_list\":\"不是数组\"")}) {
+            stubUpstream(json);
+
+            List<LsyShopItemDTO> shops = service.listShops(MCH_NO);
+
+            assertNotNull(shops, json);
+            assertTrue(shops.isEmpty(), json + " ⇒ 必须返回空列表,实际: " + shops);
+        }
+    }
+
+    /** 数组元素不是对象时跳过 —— 与 {@code areaList_nonMapItem_isSkipped_notBlankRow} 同一口径。 */
+    @Test
+    void listShops_nonMapItem_isSkipped() {
+        stubUpstream(ok(",\"shop_list\":[\"oops\",{\"shop_no\":\"861811140412\",\"shop_name\":\"一分店\"}]"));
+
+        List<LsyShopItemDTO> shops = service.listShops(MCH_NO);
+
+        assertEquals(1, shops.size(), "非对象元素不得被映射成一行空门店,实际: " + shops);
+        assertEquals("861811140412", shops.get(0).getShopNo());
+    }
 }