Эх сурвалжийг харах

refactor(乐收赢)!: 去企业化收尾 —— 按商户号寻址,一个租户可以有多个商户号

用户 2026-09-30 的质疑:文档里写着 enterprise_id,而「lsy 产品与系统原有企业毫无关系,
接口中应传商户号才对」。核对下来用户是对的,且比文档更彻底 —— 那层企业维度在本通道
**从头到尾没有上游依据**:

  · 文档 §5.1/5.2/5.3 把 enterprise_id 标成必填,而 DTO 从 bcdb2b9e 起就是可选(文档早已过期)。
    照文档字面写死的调用方**必然失败**,线上 0 条企业级行;
  · 文档自己把「企业」限定在第 10 章(汇安融),第 34/46/250/297/298 行都指向第 10 章;
  · pay_lsy_merchant.enterprise_id 上从来没有外键,也不对应上游任何字段。

于是本批把企业维度整个拿掉,寻址改成与汇安融逐字同构的「租户 + 商户号」:

  实体      LsyMerchantEntity.enterpriseId 删除
  服务      requireByTenant 变三态:0 条 ⇒ 404「该租户未配置商户号,请联系平台运营登记」;
            1 条 ⇒ 用它;多条 ⇒ 400「请在请求中指定 merchant_code」。
            **不做 LIMIT 1 随便挑** —— 挑错了钱会进另一个商户号,且全程没有报错,
            要等对账才发现。
  服务      save() 的可编辑列从 6 个降到 5 个(tenant_id 仍不可改);
            rejectUniqueConflicts 只探一件事:**全局**重号(uk_lsy_merchant_mch_no)。
            原先那两个租户内槽位探测一并作废 —— 一个租户多个商户号现在是合法的,
            再拦就是一个凭空的禁令(用户撞上的 500 正是其中一条衍生的)。
  进件      CreateMerchantRequest.enterpriseId 删除(上游 54 个字段里没有它)
  收银台    差异表里那一行改成「两侧都不设」,留痕而不是抹掉
  开放 API  三个下单 DTO 各加 merchant_code;enterprise_id **保留但响亮拒绝** ——
            本仓 FAIL_ON_UNKNOWN_PROPERTIES 是 true,删掉它会让老调用方报文里那个键
            变成 500「服务器内部错误」,留着才能走到那句能照做的 400。
            由 qrcodeCreate_withEnterpriseId_isRejectedLoudly 钉住(400 + 提示点名
            merchant_code + 不查任何商户号 + 不 insert)。

★ 数据库那一列 pay_lsy_merchant.enterprise_id **故意不删**:删列不可逆,且老 jar 仍在写它。
  新代码不再映射它 ⇒ 新行恒为 NULL,目的已经达到。LsyEntityMappingTest.UNMAPPED_COLUMNS
  就是这个「故意不映射」的登记处 —— 那条测试要求它必须是一次**被写下来**的决定。

★ 成对的 DDL:db/lsy-merchant-multi-per-tenant.sql
  uk_lsy_merchant_tenant_level(UNIQUE(tenant_id) WHERE enterprise_id IS NULL)必须删掉 ——
  enterprise_id 恒 NULL 会让它退化成「一个租户只许有一个商户号」,与三态里的「多条」分支
  直接矛盾。**必须先执行它、再部署新 jar**(反过来的失败形态与执行窗口的风险都写在脚本里)。
  同批只留两道真正在用的唯一约束:uk_lsy_merchant_mch_no(全局,免登录的审核通知腿靠它
  定位到唯一一行)与 uk_lsy_merchant_tenant_ent(enterprise_id 恒 NULL ⇒ 永不触发,无害)。

读数:LSY 全类 615 绿。★ 浏览器渲染与真上游联调未验。
alphaH 2 долоо хоног өмнө
parent
commit
f529b44d37
14 өөрчлөгдсөн 512 нэмэгдсэн , 525 устгасан
  1. 6 2
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCashierController.java
  2. 15 18
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingController.java
  3. 29 25
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenController.java
  4. 14 2
      java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyBarcodePayDTO.java
  5. 14 2
      java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyH5CreateDTO.java
  6. 13 4
      java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyQrCreateDTO.java
  7. 11 3
      java/src/main/java/com/payment/platform/module/payment/leshouying/entity/LsyMerchantEntity.java
  8. 89 149
      java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantService.java
  9. 81 0
      java/src/main/resources/db/lsy-merchant-multi-per-tenant.sql
  10. 32 30
      java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingControllerTest.java
  11. 48 34
      java/src/test/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenControllerTest.java
  12. 15 5
      java/src/test/java/com/payment/platform/module/payment/leshouying/dto/LsyOpenApiJsonContractTest.java
  13. 25 4
      java/src/test/java/com/payment/platform/module/payment/leshouying/entity/LsyEntityMappingTest.java
  14. 120 247
      java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantServiceTest.java

+ 6 - 2
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCashierController.java

@@ -287,8 +287,12 @@ public class LeshouyingCashierController {
      *       <td>服务端生成 {@code "SK"+雪花}</td><td>收银员不输入单号</td></tr>
      *   <tr><td>{@code apiKeyId}</td><td>认证上下文里的 ApiKey</td><td>null</td>
      *       <td>收银台走 JWT,不走 ApiKey 认证面</td></tr>
-     *   <tr><td>{@code enterpriseId}</td><td>可选(企业级映射分流)</td><td>不设(null)</td>
-     *       <td>{@code requireByTenant} 找的就是 {@code enterprise_id IS NULL} 那一行</td></tr>
+     *   <tr><td>{@code enterpriseId}</td><td><b>两侧都不设(null)</b></td><td>不设(null)</td>
+     *       <td>本行**不再是差异**,留着是为了留痕:2026-09-30 去企业化前,
+     *           开放 API 侧会把它落成「企业级映射」的标记({@code requireByEnterprise} 按它分流)。
+     *           那条寻址维度连同字段一起删了(见 {@code LsyMerchantEntity} 的类 javadoc),
+     *           {@code pay_lsy_order.enterprise_id} 这一列则**保留** —— 它上面有历史订单的值,
+     *           删列不可逆,且新单恒为 NULL 已经达到目的。</td></tr>
      *   <tr><td>{@code notifyUrl}</td><td>调用方传入</td><td><b>有意不设</b></td>
      *       <td>见类 javadoc「为什么收银单不设 notify_url」</td></tr>
      *   <tr><td>{@code orderBody}</td><td>{@code dto.getOrderBody()}</td><td>{@code dto.getRemark()}</td>

+ 15 - 18
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingController.java

@@ -194,7 +194,7 @@ public class LeshouyingOnboardingController {
         String mchNo = onboardingService.createMerchant(body);
         try {
             return Result.ok(merchantService.createOnboardingRow(
-                    tenantId, body.getEnterpriseId(), mchNo, body.getMerchantName()));
+                    tenantId, mchNo, body.getMerchantName()));
         } catch (RuntimeException e) {
             // 原始异常进日志(带栈),不进应答文案。
             log.error("进件的本地落库失败(上游商户已创建,本地无行):tenantId={}, mchNo={}, 异常={}",
@@ -301,10 +301,10 @@ public class LeshouyingOnboardingController {
      * 更新请求体 = 上游「更新商户信息」那 50 个字段({@link LsyMerchantUpdateDTO})**加**一个
      * 只为响亮拒绝而存在的 {@code tenantId}。
      *
-     * <p>★ 与创建腿的 {@link CreateMerchantRequest} **不同**:那里还多一个本地的
-     * {@code enterprise_id}(那一行属于哪个企业)。更新腿**没有**这个字段 ——
-     * 企业归属是**建行时**定下的,{@code pay_lsy_merchant} 里那一列不在上游报文里,
-     * 也不该被一次「改商户资料」顺手改掉(那会是一次静默的归属迁移)。
+     * <p>★ 创建腿与更新腿的请求体现在**只剩 {@code tenantId} 一处差别**(同款「只为响亮拒绝」
+     * 字段,见 {@link CreateMerchantRequest})。这里原先还拿 {@code enterprise_id} 举过例
+     * ——「企业归属是建行时定下的,不该被一次改商户资料顺手改掉」——那个对比随
+     * 2026-09-30 去企业化一起没了(字段已删,无从改起)。
      *
      * <p>★ 也**没有** {@code category}:更新页没有 {@code merchant_type},
      * 创建腿那套「一个 category 派生两个输出」在这里不成立 —— 见 {@link LsyMerchantUpdateDTO}
@@ -767,25 +767,22 @@ public class LeshouyingOnboardingController {
     }
 
     /**
-     * 建号请求体 = 上游「创建商户」那 54 个字段({@link LsyMerchantCreateDTO})**加**本地的
-     * {@code enterprise_id}。
-     *
-     * <p>为什么要多这一个字段:{@code enterprise_id} 是 {@code pay_lsy_merchant} 的列
-     * (本地业务字段:这一行属于哪个企业),而 {@code LsyMerchantCreateDTO} 是**上游报文**的投影,
-     * 上游没有这个概念。新建的那一行必须带上它({@code uk_lsy_merchant_tenant_ent} 就建在
-     * {@code (tenant_id, enterprise_id)} 上);留空 ⇒ 写成 NULL,这一行即**租户级**映射
-     * (语义见 {@code LsyMerchantService.requireByTenant})。
+     * 建号请求体 = 上游「创建商户」那 54 个字段({@link LsyMerchantCreateDTO})**加**一个
+     * 只为响亮拒绝而存在的 {@code tenantId}。
      *
-     * <p>★ 字段名随应用 ObjectMapper 的 {@code SNAKE_CASE} ⇒ 报文里的键是 {@code enterprise_id},
-     * 与上游那 54 个键同一套写法。
+     * <p>★ <b>2026-09-30 去企业化:这里原先还多一个本地的 {@code enterprise_id}</b>
+     * (那一行属于哪个企业)。乐收赢与企业概念无关 —— 那个字段既不来自上游、也不来自
+     * {@code pay_enterprise},只是最初设计里的一层本地分组;随着「按商户号寻址」落地,
+     * 它连同 {@code pay_lsy_merchant.enterprise_id} 的 Java 映射一起删了
+     * (见 {@code LsyMerchantEntity} 的类 javadoc)。
+     * <b>不是</b>改成「只为响亮拒绝而声明」:这个端点的调用方只有本平台管理端,
+     * 它从不发这个键(见 {@code frontend/.../collection/onboarding/index.vue} 的启动注释),
+     * 没有需要迁移的外部调用方。
      */
     @Data
     @EqualsAndHashCode(callSuper = true)
     public static class CreateMerchantRequest extends LsyMerchantCreateDTO {
 
-        /** 本地业务字段(对应 {@code pay_lsy_merchant.enterprise_id})。空白 ⇒ NULL。 */
-        private String enterpriseId;
-
         /**
          * ★ <b>只为「响亮拒绝」而声明,从不被读取。</b>它在这里的意义是:请求体里塞了
          * {@code tenant_id} 时,Jackson 不会把它当未知字段丢掉(本仓

+ 29 - 25
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenController.java

@@ -74,15 +74,12 @@ public class LeshouyingOpenController {
 
         LsyOrderEntity order = newOrder(dto.getOutTradeNo(), tenantId,
                 apiKeyId(), "BARCODE", "000");
-        // 归一化:空白 ⇒ NULL。存成 "" 或 "   " 的话,这一行永远查不回来
-        // (requireByTenant 找的是 IS NULL,见 LsyStrings)。
-        order.setEnterpriseId(LsyStrings.blankToNull(dto.getEnterpriseId()));
         order.setTotalAmount(dto.getTotalAmount());
         order.setOrderBody(dto.getOrderBody());
         order.setAttach(dto.getAttach());
         order.setNotifyUrl(dto.getNotifyUrl());
 
-        LsyMerchantEntity merchant = requireMerchant(tenantId, dto.getEnterpriseId());
+        LsyMerchantEntity merchant = requireMerchant(tenantId, dto.getMerchantCode(), dto.getEnterpriseId());
         order.setMchNo(merchant.getMchNo());
         order.setShopNo(merchant.getShopNo());
         // ★ 落库必须在 mch_no/shopNo 装配之后:V1.8 的 pay_lsy_order.mch_no 是 NOT NULL 且无默认值
@@ -104,14 +101,12 @@ public class LeshouyingOpenController {
 
         LsyOrderEntity order = newOrder(dto.getOutTradeNo(), tenantId,
                 apiKeyId(), "QRCODE", null);
-        // 归一化:空白 ⇒ NULL(同 barcodePay 的说明)
-        order.setEnterpriseId(LsyStrings.blankToNull(dto.getEnterpriseId()));
         order.setTotalAmount(dto.getTotalAmount());
         order.setOrderBody(dto.getOrderBody());
         order.setAttach(dto.getAttach());
         order.setNotifyUrl(dto.getNotifyUrl());
 
-        LsyMerchantEntity merchant = requireMerchant(tenantId, dto.getEnterpriseId());
+        LsyMerchantEntity merchant = requireMerchant(tenantId, dto.getMerchantCode(), dto.getEnterpriseId());
         order.setMchNo(merchant.getMchNo());
         order.setShopNo(merchant.getShopNo());
         // ★ 落库必须在 mch_no/shopNo 装配之后:V1.8 的 pay_lsy_order.mch_no 是 NOT NULL 且无默认值
@@ -131,14 +126,12 @@ public class LeshouyingOpenController {
 
         LsyOrderEntity order = newOrder(dto.getOutTradeNo(), tenantId,
                 apiKeyId(), "H5", dto.getPayType());
-        // 归一化:空白 ⇒ NULL(同 barcodePay 的说明)
-        order.setEnterpriseId(LsyStrings.blankToNull(dto.getEnterpriseId()));
         order.setTotalAmount(dto.getTotalAmount());
         order.setOrderBody(dto.getOrderBody());
         order.setAttach(dto.getAttach());
         order.setNotifyUrl(dto.getNotifyUrl());
 
-        LsyMerchantEntity merchant = requireMerchant(tenantId, dto.getEnterpriseId());
+        LsyMerchantEntity merchant = requireMerchant(tenantId, dto.getMerchantCode(), dto.getEnterpriseId());
         order.setMchNo(merchant.getMchNo());
         order.setShopNo(merchant.getShopNo());
         // ★ 落库必须在 mch_no/shopNo 装配之后:V1.8 的 pay_lsy_order.mch_no 是 NOT NULL 且无默认值
@@ -349,26 +342,37 @@ public class LeshouyingOpenController {
     }
 
     /**
-     * 三个下单方法共用的商户映射分流(2026-09-22 去企业化)。
+     * 三个下单方法共用的商户映射分流(2026-09-30 改为按**商户号**寻址)。
      *
-     * <p>{@code enterpriseId} 空白 ⇒ 租户级({@link LsyMerchantService#requireByTenant});
-     * 非空 ⇒ 企业级({@link LsyMerchantService#requireByEnterprise})。两条路径**并存**,
-     * 不是替代 —— 传了 {@code enterpriseId} 的老调用方一字不用改,走的还是原来那条。
+     * <p>{@code merchantCode} 空白 ⇒ {@link LsyMerchantService#requireByTenant}:
+     * 该租户只登记了一个商户号时直接用它(绝大多数调用方什么都不用传),
+     * 登记了多个则报 400 要求指定 —— 不由系统替你挑。
+     * 非空 ⇒ {@link LsyMerchantService#requireByMchNoForTenant}:按 (租户, 商户号) 精确匹配。
      *
-     * <p><b>判空用 {@code StrUtil.isBlank} 而不是 {@code == null}:</b>报文里给一个
-     * {@code "enterprise_id": ""} 或 {@code "   "} 与不传这个键,在业务上是同一个意思
-     * (「不绑企业」)。若只判 null,空白串会落进企业级分支,
-     * 而 requireByEnterprise 转头就抛 400「enterprise_id 不能为空」——
-     * 同样是「不绑企业」的两种写法,一种能用一种报错。
+     * <p>两条腿都带租户谓词。缺了它,A 租户传 B 租户的商户号会被受理、钱进 B 的账户,
+     * 而全程没有任何报错。
      *
-     * <p>★ 明确**不**做「先查租户级、查不到再退回企业级」的兜底链:
-     * 那会让一次漏配商户映射(本该 404 的配置错误)静默降级成「用了另一个企业的商户号收款」。
-     * 选哪条路由请求体里的一个字段唯一决定,不清楚就报错。
+     * <p><b>为什么不再有 {@code enterprise_id}:</b>乐收赢与企业概念无关,那个字段既不来自
+     * 上游、也不是 {@code pay_enterprise} 的外键,只是本地一个分区键。老调用方若仍在发它,
+     * 这里**响亮拒绝**而不是静默忽略 —— 见下。
+     *
+     * <p>★ 明确**不**做「先查指定的、查不到再退回租户级」的兜底链:
+     * 那会让一次配置错误(本该 404)静默降级成「用了另一个商户号收款」。
+     * 用哪个号由请求体里的一个字段唯一决定,不清楚就报错。
      */
-    private LsyMerchantEntity requireMerchant(Long tenantId, String enterpriseId) {
-        return StrUtil.isBlank(enterpriseId)
+    private LsyMerchantEntity requireMerchant(Long tenantId, String merchantCode, String legacyEnterpriseId) {
+        if (StrUtil.isNotBlank(legacyEnterpriseId)) {
+            // ★ 字段留着、但一个字节都不读它 —— 只为能给出这一句。
+            //   本仓 FAIL_ON_UNKNOWN_PROPERTIES 是 true:把 enterpriseId 从 DTO 上删掉,
+            //   老调用方报文里那个键就会变成 500「服务器内部错误」,而它需要看到的是一句
+            //   能照做的迁移提示(同 LeshouyingOnboardingController 里 tenant_id / rateCode
+            //   两个「只为响亮拒绝而声明」的字段)。
+            throw new BusinessException(400,
+                    "本接口已不再使用 enterprise_id,请改用 merchant_code 指定商户号");
+        }
+        return StrUtil.isBlank(merchantCode)
                 ? merchantService.requireByTenant(tenantId)
-                : merchantService.requireByEnterprise(tenantId, enterpriseId);
+                : merchantService.requireByMchNoForTenant(merchantCode, tenantId);
     }
 
     /**

+ 14 - 2
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyBarcodePayDTO.java

@@ -15,8 +15,20 @@ public class LsyBarcodePayDTO {
     @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_trade_no " + LsyBizNoPattern.MESSAGE)
     private String outTradeNo;
     /**
-     * 选填(2026-09-22 去企业化)。留空 ⇒ 走**租户级**商户映射;传值 ⇒ 仍走企业级。
-     * 字段保留、只去掉 `@NotBlank`;分流见 `LeshouyingOpenController.requireMerchant`。
+     * 选填:本笔收款用哪个商户号(运营给你登记的那个号)。
+     *
+     * <p>留空 ⇒ 该租户只登记了一个商户号时直接用它(绝大多数调用方什么都不用传);
+     * 登记了多个 ⇒ **报 400 要求指定**,不由系统替你挑。分流见
+     * `LeshouyingOpenController.requireMerchant`。
+     */
+    private String merchantCode;
+    /**
+     * ★ **只为「响亮拒绝」而声明,从不被读取。**
+     *
+     * <p>2026-09-30 起本接口按**商户号**寻址,不再有企业概念。字段留着是因为本仓
+     * {@code FAIL_ON_UNKNOWN_PROPERTIES} 是 true —— 删掉它,老调用方报文里那个键绑不上,
+     * 会一路冒到兜底变成 500「服务器内部错误」;而它需要看到的是一句能照做的迁移提示
+     * (400,见 `LeshouyingOpenController.requireMerchant`)。
      */
     private String enterpriseId;
     /** 用户付款码,18 位纯数字,以 10-15 开头 */

+ 14 - 2
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyH5CreateDTO.java

@@ -15,8 +15,20 @@ public class LsyH5CreateDTO {
     @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_trade_no " + LsyBizNoPattern.MESSAGE)
     private String outTradeNo;
     /**
-     * 选填(2026-09-22 去企业化)。留空 ⇒ 走**租户级**商户映射;传值 ⇒ 仍走企业级。
-     * 字段保留、只去掉 `@NotBlank`;分流见 `LeshouyingOpenController.requireMerchant`。
+     * 选填:本笔收款用哪个商户号(运营给你登记的那个号)。
+     *
+     * <p>留空 ⇒ 该租户只登记了一个商户号时直接用它(绝大多数调用方什么都不用传);
+     * 登记了多个 ⇒ **报 400 要求指定**,不由系统替你挑。分流见
+     * `LeshouyingOpenController.requireMerchant`。
+     */
+    private String merchantCode;
+    /**
+     * ★ **只为「响亮拒绝」而声明,从不被读取。**
+     *
+     * <p>2026-09-30 起本接口按**商户号**寻址,不再有企业概念。字段留着是因为本仓
+     * {@code FAIL_ON_UNKNOWN_PROPERTIES} 是 true —— 删掉它,老调用方报文里那个键绑不上,
+     * 会一路冒到兜底变成 500「服务器内部错误」;而它需要看到的是一句能照做的迁移提示
+     * (400,见 `LeshouyingOpenController.requireMerchant`)。
      */
     private String enterpriseId;
     /**

+ 13 - 4
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyQrCreateDTO.java

@@ -15,11 +15,20 @@ public class LsyQrCreateDTO {
     @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_trade_no " + LsyBizNoPattern.MESSAGE)
     private String outTradeNo;
     /**
-     * 选填(2026-09-22 去企业化)。留空 ⇒ 走**租户级**商户映射;传值 ⇒ 仍走企业级,
-     * 老调用方一字不用改。
+     * 选填:本笔收款用哪个商户号(运营给你登记的那个号)。
      *
-     * ★ 去掉的是 `@NotBlank`,不是字段本身 —— 字段必须保留,向后兼容靠它。
-     *   留空不是「没填」,是一个明确语义,分流见 `LeshouyingOpenController.requireMerchant`。
+     * <p>留空 ⇒ 该租户只登记了一个商户号时直接用它(绝大多数调用方什么都不用传);
+     * 登记了多个 ⇒ **报 400 要求指定**,不由系统替你挑。分流见
+     * `LeshouyingOpenController.requireMerchant`。
+     */
+    private String merchantCode;
+    /**
+     * ★ **只为「响亮拒绝」而声明,从不被读取。**
+     *
+     * <p>2026-09-30 起本接口按**商户号**寻址,不再有企业概念。字段留着是因为本仓
+     * {@code FAIL_ON_UNKNOWN_PROPERTIES} 是 true —— 删掉它,老调用方报文里那个键绑不上,
+     * 会一路冒到兜底变成 500「服务器内部错误」;而它需要看到的是一句能照做的迁移提示
+     * (400,见 `LeshouyingOpenController.requireMerchant`)。
      */
     private String enterpriseId;
     /**

+ 11 - 3
java/src/main/java/com/payment/platform/module/payment/leshouying/entity/LsyMerchantEntity.java

@@ -8,10 +8,19 @@ import lombok.Data;
 import lombok.EqualsAndHashCode;
 
 /**
- * 企业 ↔ 乐收赢商户映射。A 阶段管理端手工维护。
+ * 租户 ↔ 乐收赢商户映射。A 阶段管理端手工维护。
+ *
+ * <p><b>2026-09-30 去企业化:</b>本类原先还有一个 {@code enterpriseId}(映射
+ * {@code pay_lsy_merchant.enterprise_id})。乐收赢与企业概念无关 —— 那个字段既不来自上游、
+ * 也不来自 {@code pay_enterprise}(列上从来没有外键),它只是最初设计里的一层本地分组。
+ * 随着「按商户号寻址」落地(见 {@code LsyMerchantService.requireByTenant} 与
+ * {@code LeshouyingOpenController.requireMerchant}),那层分组的最后两个消费者
+ * (下单入口、管理端表单)都没了,字段一并删除。
+ * ★ 数据库那一列**还在**(删列是不可逆的线上操作,且旧 jar 仍在运行会撞 NOT NULL),
+ * 只是不再有 Java 映射 —— 见 {@code db/lsy-merchant-multi-per-tenant.sql}。
  *
  * <p><b>{@code status} / {@code review_status} <b>在编辑路径上</b>不经 {@code LsyMerchantService.save}
- * 写入:</b>该方法的编辑分支按 6 列字面枚举做定向更新(5 个业务列加 {@code updated_time}),这两列不在其中;
+ * 写入:</b>该方法的编辑分支按 5 列字面枚举做定向更新(4 个业务列加 {@code updated_time}),这两列不在其中;
  * <b>唯一的例外</b>是新增分支({@code entity.getId() == null})里的默认值补全
  * {@code setReviewStatus("APPROVED")} / {@code setStatus("ENABLED")}。
  * B 阶段进件成功后写这一行、以及乐收赢审核通知回写 {@code review_status},
@@ -34,7 +43,6 @@ public class LsyMerchantEntity extends PaymentTenantBaseEntity {
         return super.getId();
     }
 
-    private String enterpriseId;
     private String mchNo;
     private String shopNo;
     private String merchantName;

+ 89 - 149
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantService.java

@@ -22,17 +22,17 @@ import java.util.List;
 import java.util.stream.Collectors;
 
 /**
- * 企业 ↔ 乐收赢商户映射服务。A 阶段由管理端手工维护(测试商户 Y52211090004)。
+ * 租户 ↔ 乐收赢商户映射服务。A 阶段由管理端手工维护(测试商户 Y52211090004)。
  *
- * <p><b>{@link #save} 的写入职责边界:</b>管理端可编辑的 5 个业务列
- * ({@code enterprise_id} / {@code mch_no} / {@code shop_no} / {@code merchant_name} / {@code description})
+ * <p><b>{@link #save} 的写入职责边界:</b>管理端可编辑的 4 个业务列
+ * ({@code mch_no} / {@code shop_no} / {@code merchant_name} / {@code description})
  * 加 {@code updated_time}。{@code status} / {@code review_status} <b>在编辑路径上</b>不经
  * {@code save} 写入 —— <b>唯一的例外</b>是新增分支({@code entity.getId() == null})里的默认值补全
  * {@code setReviewStatus("APPROVED")} 与 {@code setStatus("ENABLED")}。
  *
  * <p><b>B 阶段(进件)写这一行要另起写入口:</b>进件成功后写 {@code pay_lsy_merchant},
  * 以及乐收赢审核通知回写 {@code review_status},走 B 阶段自己的方法或 mapper 调用,
- * 不要复用 {@link #save} —— 它的编辑分支按 6 列字面枚举做定向更新,这两列不在其中,
+ * 不要复用 {@link #save} —— 它的编辑分支按 5 列字面枚举做定向更新,这两列不在其中,
  * 而调用方拿到的是回读行(见 {@code save_updateBranch_success_returnsReloadedRow}):
  * 请求体里这两列的值不会出现在返回值里。
  */
@@ -43,56 +43,42 @@ public class LsyMerchantService {
 
     private final LsyMerchantMapper merchantMapper;
 
-    public LsyMerchantEntity requireByEnterprise(Long tenantId, String enterpriseId) {
-        if (StrUtil.isBlank(enterpriseId)) {
-            throw new BusinessException(400, "enterprise_id 不能为空");
-        }
-        LsyMerchantEntity m = merchantMapper.selectOne(new LambdaQueryWrapper<LsyMerchantEntity>()
-                .eq(LsyMerchantEntity::getTenantId, tenantId)
-                .eq(LsyMerchantEntity::getEnterpriseId, enterpriseId)
-                .last("LIMIT 1"));
-        if (m == null) {
-            // ★ 出口文案(2026-09-30):**不再**指向「手工登记商户号」。
-            //   录入已从「租户自助」改成**超管代录**(LeshouyingMerchantController 的
-            //   save/delete 挂 @perm.hasRole('SUPERUSER')),租户看不到按钮、端点也 403 ——
-            //   旧文案会把调用方指去一个它自己进不去的地方,是一条照做不了的死路。
-            //   这里服务的是本方法的两类读者:租户(照做的动作只有「联系平台运营」)
-            //   与超管(读到自己该去录)。回归:LsyMerchantServiceTest 两条
-            //   `…_message_routesToPlatformOps_…`(正负两侧都断言)。
-            throw new BusinessException(404,
-                    "该企业未配置商户号,请联系平台运营登记: " + enterpriseId);
-        }
-        return m;
-    }
-
     /**
-     * 取**租户级**商户映射(2026-09-22 去企业化)。
+     * 取该租户的商户映射 —— 调用方**未指定**商户号时用。
+     *
+     * <p><b>三态</b>,与汇安融 {@code HarMerchantService#requireByTenant} 逐字同构:
+     * 0 条 ⇒ 404;1 条 ⇒ 用它;多条 ⇒ 400 要求指定 {@code merchant_code}。
      *
-     * <p>租户级商户 = {@code pay_lsy_merchant} 里 {@code enterprise_id IS NULL} 的那一行。
-     * 不新增列、不新增表;唯一性由局部唯一索引 {@code uk_lsy_merchant_tenant_level}
-     * (建在 {@code (tenant_id) WHERE enterprise_id IS NULL} 上)保证 ——
-     * 原有的 {@code uk_lsy_merchant_tenant_ent} 建在 {@code (tenant_id, enterprise_id)} 上,
-     * 而 PostgreSQL 唯一索引里 NULL 互不相等,光靠它拦不住同租户多行租户级商户。
+     * <p><b>多条为什么报 400 而不是随便挑一条:</b>{@code LIMIT 1} 又不带 {@code ORDER BY} 时
+     * PostgreSQL 返回哪一行是任意的 —— 挑错了钱会进另一个商户号,全程没有任何报错,
+     * 要等到对账才发现。钱进谁的账户必须由调用方说清楚。
      *
-     * <p><b>为什么必须带 {@code isNull}:</b>少了它,查询退化成「本租户的任意一行商户映射」,
-     * 捞到**企业级**那一行也照常返回 —— 而企业级绑企业、租户级不绑,两者语义不同,
-     * 静默串用是这次改造最想防的事。回归在
-     * {@code LsyMerchantServiceTest.requireByTenant_queriesNullEnterpriseId}。
+     * <p><b>为什么不再是「{@code enterprise_id IS NULL} 那一行」(2026-09-30):</b>
+     * 乐收赢与企业概念无关。{@code enterprise_id} 既不来自上游、也不是 {@code pay_enterprise}
+     * 的外键,只是本地一个分区键。分区键去掉后,一个租户可以登记多个商户号、用商户号本身
+     * 寻址 —— 与汇安融同款。原 {@code uk_lsy_merchant_tenant_level} 单条唯一索引是本方法
+     * 「多条」分支存在的前提,随 {@code db/lsy-merchant-multi-per-tenant.sql} 删除。
      *
-     * <p>与 {@link #requireByEnterprise} 是**并存**关系,不是替代:传了 {@code enterpriseId}
-     * 的调用方仍走企业级路径(分流见 {@code LeshouyingOpenController} 的三个下单方法)。
+     * <p>0 行与多行是**两个不同的出口**,不合并:前者调用方该去联系运营,后者该去改请求体。
+     * 回归:{@code LsyMerchantServiceTest.requireByTenant_multipleRows_throws400_neverPicksOne}。
      */
     public LsyMerchantEntity requireByTenant(Long tenantId) {
-        LsyMerchantEntity m = merchantMapper.selectOne(new LambdaQueryWrapper<LsyMerchantEntity>()
-                .eq(LsyMerchantEntity::getTenantId, tenantId)
-                .isNull(LsyMerchantEntity::getEnterpriseId)
-                .last("LIMIT 1"));
-        if (m == null) {
-            // 同 requireByEnterprise:出口文案不再指向对租户只读的那一页(2026-09-30)。
-            throw new BusinessException(404,
-                    "该租户未配置商户号,请联系平台运营登记");
+        List<LsyMerchantEntity> rows = merchantMapper.selectList(new LambdaQueryWrapper<LsyMerchantEntity>()
+                .eq(LsyMerchantEntity::getTenantId, tenantId));
+
+        if (rows.isEmpty()) {
+            // 出口文案不指向「手工登记商户号」那一页(2026-09-30):录入已从「租户自助」
+            // 改成**超管代录**(LeshouyingMerchantController 的 save/delete 挂
+            // @perm.hasRole('SUPERUSER')),租户看不到按钮、端点也 403 ——
+            // 叫它去那里登记是一条照做不了的死路。回归:
+            // LsyMerchantServiceTest.requireByTenant_missing_message_routesToPlatformOps_…。
+            throw new BusinessException(404, "该租户未配置商户号,请联系平台运营登记");
         }
-        return m;
+        if (rows.size() > 1) {
+            throw new BusinessException(400,
+                    "当前租户配置了多个商户编号,请在请求中指定 merchant_code");
+        }
+        return rows.get(0);
     }
 
     /**
@@ -154,7 +140,7 @@ public class LsyMerchantService {
      * 「拿一个结果去决定怎么应答上游」。两条都反 ⇒ 本方法自己 {@code selectList}。
      * 也不要去「顺手修」它 —— 它的既有调用方在<b>已认证</b>路径上,改它就是动无关行为。
      *
-     * <p><b>为什么不复用 {@link #save}</b>:它的编辑分支按 6 列字面枚举做定向更新,
+     * <p><b>为什么不复用 {@link #save}</b>:它的编辑分支按 5 列字面枚举做定向更新,
      * {@code review_status} 不在其中,而返回值(回读行)又与正常成功无法区分 —— 调用方
      * 拿到一行的「成功」而库里那一列其实没动。类 javadoc 与 {@code save} 的 javadoc 都已点名本条。
      *
@@ -370,19 +356,17 @@ public class LsyMerchantService {
      * 再把值写上,让自动填充无从介入。
      *
      * <p>★ <b>本方法刻意不加 {@code @Transactional}</b>:写库的只有一条 INSERT(外加唯一约束预检
-     * 的两次只读探测,见 {@link #rejectUniqueConflicts}),事务不提供任何东西 ——
-     * 探测与 insert 之间的 TOCTOU 窗口不是事务能关的,关它的是数据库那三条唯一索引。
+     * 的一次只读探测,见 {@link #rejectUniqueConflicts}),事务不提供任何东西 ——
+     * 探测与 insert 之间的 TOCTOU 窗口不是事务能关的,关它的是数据库那条唯一索引。
      * 真正需要补偿的窗口在**上游建号成功、本地落行失败**那半边,
      * 而那次上游调用不在任何本地事务的覆盖范围内。(该窗口未做补偿,已在报告中列出。)
      *
      * @param tenantId     **必须来自认证上下文**(为 {@code null} ⇒ 403)
-     * @param enterpriseId 本地业务字段(来自请求体)。空白 ⇒ NULL ⇒ 这一行是**租户级**映射
      * @param mchNo        上游建号返回的商户号(空白 ⇒ 400;这是本地行与上游对号入座的唯一键)
      * @param merchantName 商户名称(来自请求体)。空白 ⇒ NULL
      * @return 落进去的那一行({@code id} 由 MyBatis-Plus 的 {@code ASSIGN_ID} 分配后写回实体)
      */
-    public LsyMerchantEntity createOnboardingRow(Long tenantId, String enterpriseId, String mchNo,
-                                                 String merchantName) {
+    public LsyMerchantEntity createOnboardingRow(Long tenantId, String mchNo, String merchantName) {
         if (tenantId == null) {
             throw new BusinessException(403, "当前登录身份没有归属租户,无法创建商户,请联系管理员");
         }
@@ -390,18 +374,11 @@ public class LsyMerchantService {
             throw new BusinessException(400, "商户号不能为空");
         }
 
-        // 先归一化、再探测、再落行 —— 三步必须用**同一个**值。探测按「空白 ⇒ null」分流到租户级那条,
-        // 而 insert 落的也是 NULL;若探测时还用着未归一化的 "   ",探的是企业级、写的是租户级,
-        // 于是挡路的行探测不到、数据库照样拒(就退回修复前那个 500)。
-        String normalizedEnterpriseId = LsyStrings.blankToNull(enterpriseId);
         // 唯一约束预检 —— 与 save 的新增分支共用同一道,理由见 rejectUniqueConflicts。
-        rejectUniqueConflicts(tenantId, normalizedEnterpriseId, mchNo);
+        rejectUniqueConflicts(mchNo);
 
         LsyMerchantEntity row = new LsyMerchantEntity();
         row.setTenantId(tenantId);
-        // 可空列:空 ⇒ NULL(口径见 LsyStrings)—— 存成空串的话任何 `IS NULL` 都查不到这一行,
-        // 而「租户级映射」找的正是 enterprise_id IS NULL。
-        row.setEnterpriseId(normalizedEnterpriseId);
         row.setMchNo(mchNo);
         row.setMerchantName(LsyStrings.blankToNull(merchantName));
         // ★ 新行是「待审核」,不是建表语句默认的 APPROVED,也不是上游的码值。
@@ -470,9 +447,11 @@ public class LsyMerchantService {
         Page<LsyMerchantEntity> page = new Page<>(pageNo, pageSize);
         LambdaQueryWrapper<LsyMerchantEntity> w = new LambdaQueryWrapper<>();
         if (StrUtil.isNotBlank(keyword)) {
+            // ★ 2026-09-30 去企业化:原先这里还有第三处 `.or().like(enterprise_id, keyword)`。
+            //   字段随去企业化删了,那一格自然去掉;页面搜索框的 placeholder 同步改掉
+            //   (否则它会承诺一个搜不到的维度)。
             w.and(q -> q.like(LsyMerchantEntity::getMchNo, keyword)
-                    .or().like(LsyMerchantEntity::getMerchantName, keyword)
-                    .or().like(LsyMerchantEntity::getEnterpriseId, keyword));
+                    .or().like(LsyMerchantEntity::getMerchantName, keyword));
         }
         w.orderByDesc(LsyMerchantEntity::getCreatedTime);
         Page<LsyMerchantEntity> r = merchantMapper.selectPage(page, w);
@@ -482,23 +461,23 @@ public class LsyMerchantService {
     /**
      * 新增 / 编辑商户映射。两条路径**各自独立**,编辑**不得**走「读改写回整行」。
      *
-     * <p><b>契约:PUT 语义(对 5 个业务列的完整替换),不是 PATCH</b>
+     * <p><b>契约:PUT 语义(对 4 个业务列的完整替换),不是 PATCH</b>
      * (HTTP 端点在 {@code LeshouyingMerchantController} 的 {@code save} 方法上,动词是
-     * {@code @PostMapping})。请求体须携带这 5 个可编辑键
-     * ({@code enterprise_id} / {@code mch_no} / {@code shop_no} / {@code merchant_name} /
-     * {@code description})。「这一列没出现在请求里」与「操作者把这一列清空了」在服务端**不可区分** ——
+     * {@code @PostMapping})。请求体须携带这 4 个可编辑键
+     * ({@code mch_no} / {@code shop_no} / {@code merchant_name} / {@code description})。
+     * 「这一列没出现在请求里」与「操作者把这一列清空了」在服务端**不可区分** ——
      * 两者反序列化后是同一个实体(没传的键落在实体上就是 null)—— 载体本身表达不了「没传」。
-     * 缺键按清空处理是**故意的**(P3 要求「清空 ⇒ NULL」),对**4 个可空列**成立
-     * ({@code enterprise_id} / {@code shop_no} / {@code merchant_name} / {@code description}
-     * ⇒ 写 NULL);只有 {@code mch_no} 没有「被清空」这个结果 —— 缺键或空白一律 **400**
+     * 缺键按清空处理是**故意的**(P3 要求「清空 ⇒ NULL」),对**3 个可空列**成立
+     * ({@code shop_no} / {@code merchant_name} / {@code description} ⇒ 写 NULL);
+     * 只有 {@code mch_no} 没有「被清空」这个结果 —— 缺键或空白一律 **400**
      * (方法开头那个校验)。需要部分更新的调用方要换一个能表达**存在性**的载体
      * (例如 {@code Map} 或专用 DTO),不要复用本方法。
      *
-     * <p>★ 2026-09-22 去企业化:{@code enterprise_id} 从「必填、空白即 400」变成了第 4 个
-     * 可空列 —— 清空它 ⇒ 这一行变成**租户级**映射({@link #requireByTenant} 找的就是
-     * {@code enterprise_id IS NULL})。这条语义变化由
-     * {@code save_updateBranch_blankEnterpriseId_writesNull} 与
-     * {@code save_insertBranch_blankEnterpriseId_inserts} 覆盖。
+     * <p>★ 2026-09-30 去企业化收尾:{@code enterprise_id} 这一列整个退出了本方法 ——
+     * 2026-09-22 时它从「必填」放宽成「可空 = 租户级」,今天连字段一起删了
+     * (理由见 {@link LsyMerchantEntity} 的类 javadoc)。原先由
+     * {@code save_updateBranch_blankEnterpriseId_writesNull} /
+     * {@code save_insertBranch_blankEnterpriseId_inserts} 覆盖的那条语义随之作废。
      *
      * <p><b>新增</b>:默认值({@code review_status=APPROVED}、{@code status=ENABLED})**只在这里**补。
      * 编辑分支若也补,就会出现这条静默数据缺陷:请求体不带这两列(管理端表单不投影它们)
@@ -506,9 +485,9 @@ public class LsyMerchantService {
      * (以及 B 阶段进件后由 B 阶段自己的写入口写入的审核态)被一次「只改备注」的编辑**抹平**,
      * 而界面照常弹「成功」。
      *
-     * <p><b>编辑</b>:定向更新,SET 列表是下面 6 列的**字面枚举**。两个理由都不能省:
+     * <p><b>编辑</b>:定向更新,SET 列表是下面 5 列的**字面枚举**。两个理由都不能省:
      * <ol>
-     *   <li>不经实体 ⇒ 除这 6 列外一个列都进不了 SET —— 包括 {@code status} / {@code review_status} /
+     *   <li>不经实体 ⇒ 除这 5 列外一个列都进不了 SET —— 包括 {@code status} / {@code review_status} /
      *       {@code created_time} / {@code tenant_id},也包括**本方法将来不知道的新列**
      *       (新列在编辑路径上写不进去,而本方法的返回值不体现这件事:调用方拿到的是回读行,
      *       回读行里那个字段仍是库里的旧值);</li>
@@ -526,9 +505,9 @@ public class LsyMerchantService {
      *
      * <p><b>归属({@code tenant_id},2026-09-30)</b>:新增时**必填**,缺即 400
      * (理由见下面新增分支里的注释);编辑时**一律忽略**请求体里的这个值 ——
-     * 编辑分支的 SET 列表是 5 个业务列的字面枚举,{@code tenant_id} 不在其中,
+     * 编辑分支的 SET 列表是 4 个业务列的字面枚举,{@code tenant_id} 不在其中,
      * 所以改归属在**结构上**不成立(由
-     * {@code LsyMerchantServiceTest#save_updateBranch_writesExactlyTheSixOwnedColumns}
+     * {@code LsyMerchantServiceTest#save_updateBranch_writesExactlyTheFiveOwnedColumns}
      * 的 {@code assertFalse(cols.contains("tenant_id"))} 与
      * {@code save_updateBranch_tenantFromBody_neverReachesTheSetClause} 两处钉住)。
      * <b>不</b>额外读一次旧行、比对后报 400:那会往编辑分支插一次 {@code selectById},
@@ -544,20 +523,12 @@ public class LsyMerchantService {
         if (StrUtil.isBlank(entity.getMchNo())) {
             throw new BusinessException(400, "商户号不能为空");
         }
-        // ⚠ 2026-09-22 去企业化:这里原本还有一条「企业 ID 不能为空」的 400。
-        //   那条校验的前提是 pay_lsy_merchant.enterprise_id 为 NOT NULL(留空没有合法落点);
-        //   列放开后前提消失,留空有了明确语义 —— **租户级商户**(见 requireByTenant)。
-        //   想回到「必须填企业」的话,加回的是校验,而不是把列改回 NOT NULL。
-        //
-        // 可空列(shop_no / merchant_name / description / enterprise_id):空 ⇒ NULL。
+        // 可空列(shop_no / merchant_name / description):空 ⇒ NULL。
         // 收敛点放在本层而不是前端:管理端页面经这里写库,而前端只能「不产生空串」,
         // 拦不住别人 POST 一个空串进来。
-        // ★ 可空列由下面四行**正面枚举**:将来新增可空列,必须同时加一行 blankToNull,
-        //   否则新列会开始接受空串 —— 而 save_updateBranch_writesExactlyTheSixOwnedColumns
+        // ★ 可空列由下面三行**正面枚举**:将来新增可空列,必须同时加一行 blankToNull,
+        //   否则新列会开始接受空串 —— 而 save_updateBranch_writesExactlyTheFiveOwnedColumns
         //   的集合相等断言不会提醒你(它盯的是编辑分支的 SET 列表,管不到这里)。
-        // ★ enterprise_id 尤其不能漏:存成 "   " 而不是 NULL,requireByTenant 找的是 IS NULL,
-        //   那一行就永远查不到了(写入方以为建好了租户级映射,下单方报「未配置商户号」)。
-        entity.setEnterpriseId(LsyStrings.blankToNull(entity.getEnterpriseId()));
         entity.setShopNo(LsyStrings.blankToNull(entity.getShopNo()));
         entity.setMerchantName(LsyStrings.blankToNull(entity.getMerchantName()));
         entity.setDescription(LsyStrings.blankToNull(entity.getDescription()));
@@ -579,7 +550,7 @@ public class LsyMerchantService {
             //     (租户谓词只能从旧行取 —— 请求体里的 tenant_id 在编辑路径上是**被忽略**的),
             //     而 save_updateBranch_zeroAffectedRows_throws404 明确钉着「0 行路径不得回读」。
             //     取舍与剩余敞口见 rejectUniqueConflicts 的 javadoc。
-            rejectUniqueConflicts(entity.getTenantId(), entity.getEnterpriseId(), entity.getMchNo());
+            rejectUniqueConflicts(entity.getMchNo());
             // ★ 默认值只在新增分支补 —— 见方法注释
             if (StrUtil.isBlank(entity.getReviewStatus())) {
                 entity.setReviewStatus("APPROVED");   // A 阶段手工维护,默认视为已审核
@@ -591,7 +562,6 @@ public class LsyMerchantService {
         } else {
             LambdaUpdateWrapper<LsyMerchantEntity> w = new LambdaUpdateWrapper<LsyMerchantEntity>()
                     .eq(LsyMerchantEntity::getId, entity.getId())
-                    .set(LsyMerchantEntity::getEnterpriseId, entity.getEnterpriseId())
                     .set(LsyMerchantEntity::getMchNo, entity.getMchNo())
                     .set(LsyMerchantEntity::getShopNo, entity.getShopNo())
                     .set(LsyMerchantEntity::getMerchantName, entity.getMerchantName())
@@ -622,10 +592,10 @@ public class LsyMerchantService {
     }
 
     /**
-     * 写入前的唯一性预检 —— 把三条唯一约束的违约,从 PostgreSQL 的 500 换成一句能照做的人话。
+     * 写入前的唯一性预检 —— 把唯一约束违约从 PostgreSQL 的 500 换成一句能照做的人话。
      *
-     * <p><b>为什么需要它(2026-09-30 用户报的线上缺陷):</b>{@code pay_lsy_merchant} 上有三条唯一约束,
-     * 而两条写入口({@link #save} 的新增分支、{@link #createOnboardingRow})原先都是**直接 insert、
+     * <p><b>为什么需要它(2026-09-30 用户报的线上缺陷):</b>两条写入口
+     * ({@link #save} 的新增分支、{@link #createOnboardingRow})原先都是**直接 insert、
      * 一条都不先探**。撞上时的出口是 {@code GlobalExceptionHandler} 的兜底分支
      * (实测该 handler 只有 BusinessException / AccessDeniedException / 参数校验 / NoResourceFound /
      * 兜底五个,**没有** DuplicateKeyException / DataIntegrityViolationException)
@@ -633,73 +603,43 @@ public class LsyMerchantService {
      * 实测复现:超管在商户页给租户 2 新增一条「企业 ID 留空」的行 ⇒
      * {@code duplicate key value violates unique constraint "uk_lsy_merchant_tenant_level"}。
      *
-     * <p><b>三条约束分别是什么:</b>
-     * <ol>
-     *   <li>{@code uk_lsy_merchant_tenant_level} —— {@code UNIQUE(tenant_id) WHERE enterprise_id IS NULL}
-     *       ({@code db/lsy-tenant-level.sql}):**一个租户至多一行租户级映射**。
-     *       为什么不能靠 {@code uk_lsy_merchant_tenant_ent}:PostgreSQL 唯一索引里 NULL 互不相等,
-     *       {@code (tenant_id, enterprise_id)} 拦不住第二行 {@code enterprise_id IS NULL};</li>
-     *   <li>{@code uk_lsy_merchant_tenant_ent} —— {@code UNIQUE(tenant_id, enterprise_id)}
-     *       (建表语句 V1.8):一个租户下同一企业至多一行;</li>
-     *   <li>{@code uk_lsy_merchant_mch_no} —— {@code UNIQUE(mch_no)}({@code db/lsy-merchant-mch-no-uk.sql}):
-     *       商户号**全局**唯一。不带 tenant_id 是故意的 —— 审核通知那条腿**免登录、无认证上下文**,
-     *       只能按 mch_no 定位本地行(见 {@link #requireByMchNo} 与 {@link #updateReviewStatusByMchNo}),
-     *       跨租户撞号会让那条腿返回 AMBIGUOUS 并**一行都不写**。</li>
-     * </ol>
+     * <p><b>2026-09-30 起只探一件事:全局重号。</b>唯一还立着的约束是
+     * {@code uk_lsy_merchant_mch_no}({@code UNIQUE(mch_no)},{@code db/lsy-merchant-mch-no-uk.sql})。
+     * 商户号**全局**唯一是故意的 —— 审核通知那条腿**免登录、无认证上下文**,只能按 mch_no
+     * 定位本地行(见 {@link #requireByMchNo} 与 {@link #updateReviewStatusByMchNo}),
+     * 跨租户撞号会让那条腿返回 AMBIGUOUS 并**一行都不写**。
      *
-     * <p>★ <b>本方法不是保证,只是文案。</b>唯一性的保证在数据库那三条索引上(它们对并发、对任何
+     * <p>原先还探两个**租户内**的槽位冲突,随去企业化收尾一并去掉 —— 一个租户登记多个商户号
+     * 现在是**合法**的(见 {@link #requireByTenant} 的三态),再拦就是一个凭空的禁令:
+     * <ul>
+     *   <li>{@code uk_lsy_merchant_tenant_level} —— {@code UNIQUE(tenant_id) WHERE enterprise_id IS NULL}
+     *       ({@code db/lsy-tenant-level.sql}),**正是用户 2026-09-30 撞上的那一条**。
+     *       {@code enterprise_id} 字段删掉后每一行新映射的 {@code enterprise_id} 都是 NULL
+     *       ⇒ 这个索引退化成「一个租户只许有一个商户号」,与 {@link #requireByTenant} 的多行分支
+     *       直接矛盾 ⇒ 随去企业化一并删除({@code db/lsy-merchant-multi-per-tenant.sql},
+     *       **必须先执行再部署**,理由见该脚本)。它当年为什么存在:PostgreSQL 唯一索引里
+     *       NULL 互不相等,{@code (tenant_id, enterprise_id)} 拦不住第二行
+     *       {@code enterprise_id IS NULL};</li>
+     *   <li>{@code uk_lsy_merchant_tenant_ent} —— {@code UNIQUE(tenant_id, enterprise_id)}(建表语句 V1.8):
+     *       **留着,无害**。{@code enterprise_id} 恒为 NULL 时它永不触发。</li>
+     * </ul>
+     *
+     * <p>★ <b>本方法不是保证,只是文案。</b>唯一性的保证在数据库那条索引上(它对并发、对任何
      * 绕过本服务的写入都成立);本方法只做「先看一眼,好把话说清楚」。二者的分工是有意的:
      * 探测与 insert 之间存在 TOCTOU 窗口,两个并发新增仍可能有一个落到 500 兜底 ——
      * 那与修复前的行为完全一样,不新增失败模式。
      * 把窗口也堵上要另说(给 GlobalExceptionHandler 加 DuplicateKeyException 分支改的是**全平台**
      * 所有模块的出口行为,超出本次范围)。
      *
-     * <p><b>探测顺序 = 提示的优先级</b>:租户内的冲突排在全局的商户号冲突前面 ——
-     * 前者能直接去那张表里改/删挡路的那一行,后者得先怀疑是不是自己把号填错了。
-     *
-     * <p>★ <b>剩余敞口(未修,别当成已修):编辑分支没有这道理。</b>把 {@code enterprise_id}
-     * 改成一个本租户已占用的值,会把 {@code uk_lsy_merchant_tenant_ent} 撞成同样的 500。
-     * 没顺手补的原因不是漏了:编辑分支要判「撞的是不是**别人**」就得读一次旧行,而
-     * 租户谓词只能从旧行取(请求体里的 {@code tenant_id} 在编辑路径上是被**忽略**的,
-     * 拿它做谓词等于把一条明确作废的输入重新赋予语义)—— 那会拆掉一条既有契约
-     * ({@code save_updateBranch_zeroAffectedRows_throws404} 钉着「0 行路径不得回读」)。
-     * 要不要为此改那条契约,是另一个决定。
-     *
-     * @param enterpriseId 必须**已归一化**(空白 ⇒ {@code null})—— 传进来还是 {@code "   "} 的话,
-     *                     这里会走企业级探测,而 insert 落的是 NULL,两边对不上
+     * @param mchNo 上游建号返回或操作者填写的商户号(非空白由调用方保证)
      */
-    private void rejectUniqueConflicts(Long tenantId, String enterpriseId, String mchNo) {
-        LsyMerchantEntity taken;
-        if (enterpriseId == null) {
-            taken = merchantMapper.selectOne(new LambdaQueryWrapper<LsyMerchantEntity>()
-                    .eq(LsyMerchantEntity::getTenantId, tenantId)
-                    .isNull(LsyMerchantEntity::getEnterpriseId)
-                    .last("LIMIT 1"));
-            if (taken != null) {
-                throw new BusinessException(409,
-                        "该租户已有租户级商户号(" + taken.getMchNo()
-                                + ")。一个租户只能有一个「企业 ID 留空」的商户号 —— "
-                                + "请改为编辑那一行,或先删除它,或给这一行填写企业 ID");
-            }
-        } else {
-            taken = merchantMapper.selectOne(new LambdaQueryWrapper<LsyMerchantEntity>()
-                    .eq(LsyMerchantEntity::getTenantId, tenantId)
-                    .eq(LsyMerchantEntity::getEnterpriseId, enterpriseId)
-                    .last("LIMIT 1"));
-            if (taken != null) {
-                throw new BusinessException(409,
-                        "该企业在本租户下已有商户号(" + taken.getMchNo() + ")—— "
-                                + "请改为编辑那一行,或先删除它");
-            }
-        }
-
+    private void rejectUniqueConflicts(String mchNo) {
         LsyMerchantEntity sameNo = merchantMapper.selectOne(new LambdaQueryWrapper<LsyMerchantEntity>()
                 .eq(LsyMerchantEntity::getMchNo, mchNo)
                 .last("LIMIT 1"));
         if (sameNo != null) {
             // 带上租户 ID:超管那张表是跨租户的,不给租户的话「去表里找那一行」找不到。
-            // 也回显一次商户号本身:另外两条消息都以挡路那一行的商户号开头,这条若只有租户 ID,
-            // 三种 409 里就它答不出「挡着的是哪个号」。
+            // 也回显一次商户号本身:这条 409 要能答出「挡着的是哪个号」。
             throw new BusinessException(409,
                     "该商户号已被登记(" + mchNo + ",租户 ID " + sameNo.getTenantId() + ")—— "
                             + "同一商户号不能登记两次,请核对是否填错了商户号");

+ 81 - 0
java/src/main/resources/db/lsy-merchant-multi-per-tenant.sql

@@ -0,0 +1,81 @@
+-- 一个租户允许多个乐收赢商户号(2026-09-30 去企业化收尾)
+-- 手工执行: psycopg2 / psql 连接 payment_platform_java 库
+--
+-- 【本脚本尚未执行】执行由用户做。
+--
+-- ── 背景 ─────────────────────────────────────────────────────────────
+-- db/lsy-tenant-level.sql 在 2026-09-22 建了这条索引:
+--     uk_lsy_merchant_tenant_level  ON pay_lsy_merchant (tenant_id)
+--                                   WHERE enterprise_id IS NULL
+-- 它的语义是「一个租户只能有一条 enterprise_id 为空的商户映射」。
+--
+-- 2026-09-30 去企业化把 `enterprise_id` 整个从寻址维度里拿掉(`LsyMerchantEntity`
+-- 上的字段已删,见 LsyEntityMappingTest.UNMAPPED_COLUMNS):
+--   · 每一行**新**映射的 enterprise_id 都是 NULL(没有字段就进不了 MP 的 INSERT 列清单);
+--   · 于是这条偏索引退化成「一个租户只许有一个商户号」。
+-- 这正是用户 2026-09-30 在商户页撞上的那个 500
+-- (`duplicate key value violates unique constraint "uk_lsy_merchant_tenant_level"`)。
+--
+-- 而「一个租户多个商户号」现在是**合法且必需**的:寻址改成按商户号
+-- (`LsyMerchantService#requireByTenant` 的三态:0 条 404 / 1 条用它 / 多条 400 要求指定
+-- merchant_code),与汇安融逐字同构(见 db/har-merchant-multi-per-tenant.sql,同款处置)。
+-- 索引不去掉,第二条就永远插不进去 —— 而代码侧已经不再探这个槽位了
+-- (`rejectUniqueConflicts` 现在只探全局重号),撞上就是一句 500,不是能照做的提示。
+--
+-- 去掉了会不会失去保护?不会:
+--   · `uk_lsy_merchant_mch_no` UNIQUE(mch_no)(db/lsy-merchant-mch-no-uk.sql)
+--     ⇒ 同一个商户号不可能被登记两次,跨租户也不行。这条**必须留着** ——
+--     审核通知那条腿免登录、无认证上下文,只能按 mch_no 定位本地行,跨租户撞号会让它
+--     返回 AMBIGUOUS 并一行都不写(见 `LsyMerchantService#updateReviewStatusByMchNo`)。
+--   · `uk_lsy_merchant_tenant_ent` UNIQUE(tenant_id, enterprise_id)(建表语句 V1.8)
+--     ⇒ **留着,无害**:enterprise_id 恒为 NULL 时它永不触发(PG 里 NULL 互不相等)。
+-- 也就是说本条索引本来只管「租户内条数」,而条数现在由业务自己决定。
+--
+-- ⚠ 代价(如实写):多条之后,「用哪一条」不再有唯一答案。
+--   代码侧已按「谁下单谁指定」处理 —— 该租户有多条时抛 400「请在请求中指定 merchant_code」,
+--   **不做 LIMIT 1 随便挑**(挑错了钱会进错商户账号,且全程无报错,要等对账才发现)。
+--   三个下单入口的请求体都有 merchant_code(DTO 上是可选,不指定且本租户只有一条时自动选中)。
+--
+-- ── ⚠ 顺序:**先执行本脚本,再部署新 jar**(与 har 那份不同,这份有先后约束)─────
+--
+--   · 新代码 + 索引还在 ⇒ 超管给同一租户加第二个商户号必撞 23505,而新代码的
+--     `rejectUniqueConflicts` **只探全局重号、不会探到它** ⇒ 操作员看到的是一句 500。
+--     这就是「先部署后执行」的失败形态。
+--   · 执行了、还没部署 ⇒ 老 jar 仍在跑。老 jar 的 `requireByTenant` 是
+--     `selectOne(... LIMIT 1)`(无 ORDER BY),老管理端表单里那一格「企业 ID」还在、
+--     老代码仍会把它写进库。所以这个窗口里真被插进第二行的概率很小,但它**确实不是零**,
+--     而后果是「下单可能落到另一个商户号且不报错」。
+--     ⇒ 请把执行安排成**紧接着部署**,别让中间空很久。
+--
+-- ── 只读核对(执行后手工跑一遍)──────────────────────────────────────
+--
+-- 1) 那条索引确实没了(**期望 0 行**)
+--   SELECT indexname FROM pg_indexes
+--    WHERE indexname = 'uk_lsy_merchant_tenant_level';
+--
+-- 2) 该留的两道仍然在(**期望 2 行**)
+--   SELECT indexname FROM pg_indexes
+--    WHERE indexname IN ('uk_lsy_merchant_mch_no', 'uk_lsy_merchant_tenant_ent');
+--   ★ 若 uk_lsy_merchant_mch_no 不见了,先别建第二个商户号 ——
+--     免登录的审核通知腿靠它保证「按 mch_no 定位到唯一一行」。
+--
+-- 3) enterprise_id 这一列**还在**(**期望 1 行 / is_nullable = YES**)——
+--   这是**故意的**,不是漏删:
+--   SELECT is_nullable FROM information_schema.columns
+--    WHERE table_name = 'pay_lsy_merchant' AND column_name = 'enterprise_id';
+--   ★ 列留着的原因:删列是不可逆的线上操作,而且老 jar 仍按 NOT NULL/DEFAULT 语义写它;
+--     新代码不再映射它 ⇒ 新行恒为 NULL,已经达到目的。历史行的值原样保留。
+--
+-- 4) 端到端(唯一能证明「多个商户号」真的成立的一条):
+--   用超管在「商户映射」页给**同一个租户**登记第二条商户号(另一个真实号),
+--   应保存成功;再用该租户的账号调开放 API 下单:
+--     · 不带 merchant_code ⇒ 400「当前租户配置了多个商户编号,请在请求中指定 merchant_code」;
+--     · 带其中一条 ⇒ 成功,且订单那一行用的是它。
+--
+-- ── 收回(本脚本不可逆)────────────────────────────────────────────────
+--   索引可以重建(把下面那句 DROP 换成 lsy-tenant-level.sql 里那段 CREATE),
+--   但**重建之前必须先清掉同租户的重复行**,否则建索引当场失败。清理语句留在
+--   lsy-tenant-level.sql 的注释里,本脚本不替你删任何数据行。
+-- ============================================================================
+
+DROP INDEX IF EXISTS uk_lsy_merchant_tenant_level;

+ 32 - 30
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingControllerTest.java

@@ -176,11 +176,11 @@ class LeshouyingOnboardingControllerTest {
     void createEndpoint_usesTenantFromSecurityContext() {
         login(TENANT, false);
         when(onboardingService.createMerchant(any())).thenReturn(MCH_NO);
-        when(merchantService.createOnboardingRow(any(), any(), any(), any())).thenReturn(row(MCH_NO));
+        when(merchantService.createOnboardingRow(any(), any(), any())).thenReturn(row(MCH_NO));
 
-        controller.create(createBody("E1", "示例商户"));
+        controller.create(createBody("示例商户"));
 
-        verify(merchantService).createOnboardingRow(eq(TENANT), any(), eq(MCH_NO), any());
+        verify(merchantService).createOnboardingRow(eq(TENANT), eq(MCH_NO), any());
     }
 
     /**
@@ -200,7 +200,7 @@ class LeshouyingOnboardingControllerTest {
         LeshouyingOnboardingController c = controllerWithRealMerchantService(mapper);
         when(onboardingService.createMerchant(any())).thenReturn(MCH_NO);
 
-        c.create(createBody("E1", "示例商户"));
+        c.create(createBody("示例商户"));
 
         ArgumentCaptor<LsyMerchantEntity> captor = ArgumentCaptor.forClass(LsyMerchantEntity.class);
         verify(mapper).insert(captor.capture());
@@ -209,7 +209,6 @@ class LeshouyingOnboardingControllerTest {
         assertEquals("PENDING", inserted.getReviewStatus(),
                 "新行必须是待审核,不得落到建表语句的默认值 APPROVED。实测: " + inserted.getReviewStatus());
         assertEquals(TENANT, inserted.getTenantId(), "tenant_id 只能来自认证上下文");
-        assertEquals("E1", inserted.getEnterpriseId(), "enterprise_id 来自请求体(业务字段)");
         assertEquals(MCH_NO, inserted.getMchNo(), "mch_no 来自上游建号的返回值");
         assertEquals("示例商户", inserted.getMerchantName());
         assertNull(inserted.getId(), "主键由 MP 分配(ASSIGN_ID),调用方不得自带 id");
@@ -229,7 +228,7 @@ class LeshouyingOnboardingControllerTest {
         when(onboardingService.createMerchant(any()))
                 .thenThrow(new BusinessException(502, "上游返回成功但没有可用的商户号"));
 
-        assertThrows(BusinessException.class, () -> c.create(createBody("E1", "示例商户")));
+        assertThrows(BusinessException.class, () -> c.create(createBody("示例商户")));
 
         verify(mapper, never()).insert(any());
     }
@@ -252,8 +251,9 @@ class LeshouyingOnboardingControllerTest {
      * 「既不属 MyBatis 也不属 Spring」两种形态。四者都不是 {@code BusinessException}
      * ⇒ **没有护栏时四个都会红**;而 {@code catch (DuplicateKeyException)} 这类窄护栏会漏掉后三个。
      *
-     * <p>夹具用 {@code enterprise_id = null}:那是「租户级映射」的主路径,也正是撞
-     * {@code uk_lsy_merchant_tenant_level} 的那一类输入。
+     * <p>★ 夹具原先注的是「{@code enterprise_id = null}:那是「租户级映射」的主路径,也正是撞
+     * {@code uk_lsy_merchant_tenant_level} 的那一类输入」。去企业化之后 {@code enterprise_id}
+     * 字段已删 ⇒ **每一行**都是那一类输入,这个括注不再有区分度,删掉。
      */
     @Test
     void createEndpoint_localPersistFailure_saysUpstreamMerchantCreated_andForbidsRetry() throws Exception {
@@ -326,7 +326,7 @@ class LeshouyingOnboardingControllerTest {
 
         mockMvc.perform(post(PREFIX + "/create")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content("{\"enterprise_id\":\"E1\",\"merchant_name\":\"示例商户\"}"))
+                        .content("{\"merchant_name\":\"示例商户\"}"))
                 // 出口形态:本仓业务失败的既有出口是 HTTP 200 + 业务码(同上面那条 409 的用例)。
                 // 掉回 catch-all 的话这两个断言会一起红(HTTP 500 / 业务码 500)。
                 .andExpect(status().isOk())
@@ -337,7 +337,7 @@ class LeshouyingOnboardingControllerTest {
                 .andExpect(jsonPath("$.msg", not(containsString("HTTP_504"))));
 
         // 上游失败 ⇒ 本地一行都不写(与上面那条「不得留半个行」同一条理由)。
-        verify(merchantService, never()).createOnboardingRow(any(), any(), any(), any());
+        verify(merchantService, never()).createOnboardingRow(any(), any(), any());
     }
 
     /**
@@ -386,23 +386,23 @@ class LeshouyingOnboardingControllerTest {
     void createEndpoint_rejectsBodySuppliedTenantId() throws Exception {
         login(TENANT, false);
         when(onboardingService.createMerchant(any())).thenReturn(MCH_NO);
-        when(merchantService.createOnboardingRow(any(), any(), any(), any())).thenReturn(row(MCH_NO));
+        when(merchantService.createOnboardingRow(any(), any(), any())).thenReturn(row(MCH_NO));
 
         // 正对照:同样的报文、去掉 tenant_id ⇒ 受理
         mockMvc.perform(post(PREFIX + "/create")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content("{\"enterprise_id\":\"E1\",\"merchant_name\":\"示例商户\"}"))
+                        .content("{\"merchant_name\":\"示例商户\"}"))
                 .andExpect(jsonPath("$.code").value(0));
 
         // 反例:多一个 tenant_id ⇒ 拒绝(业务码 400,HTTP 200 —— 本仓 BusinessException 的既有出口)
         mockMvc.perform(post(PREFIX + "/create")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content("{\"enterprise_id\":\"E1\",\"tenant_id\":" + BODY_TENANT + "}"))
+                        .content("{\"tenant_id\":" + BODY_TENANT + "}"))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(400));
 
-        verify(merchantService, never()).createOnboardingRow(eq(BODY_TENANT), any(), any(), any());
-        verify(merchantService, times(1)).createOnboardingRow(any(), any(), any(), any());
+        verify(merchantService, never()).createOnboardingRow(eq(BODY_TENANT), any(), any());
+        verify(merchantService, times(1)).createOnboardingRow(any(), any(), any());
     }
 
     /**
@@ -424,13 +424,13 @@ class LeshouyingOnboardingControllerTest {
     void createEndpoint_rejectsBodySuppliedRateCode_beforeTouchingUpstream() throws Exception {
         login(TENANT, false);
         when(onboardingService.createMerchant(any())).thenReturn(MCH_NO);
-        when(merchantService.createOnboardingRow(any(), any(), any(), any())).thenReturn(row(MCH_NO));
+        when(merchantService.createOnboardingRow(any(), any(), any())).thenReturn(row(MCH_NO));
 
         // 反例:费率模板由请求体给 ⇒ 400(业务码,HTTP 200 —— 本仓 BusinessException 的既有出口),
         //       且此刻上游一次都没被调到。
         mockMvc.perform(post(PREFIX + "/create")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content("{\"enterprise_id\":\"E1\",\"merchant_name\":\"示例商户\","
+                        .content("{\"merchant_name\":\"示例商户\","
                                 + "\"rate_code\":\"RT0001\"}"))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(400))
@@ -442,7 +442,7 @@ class LeshouyingOnboardingControllerTest {
         // 正对照:同形报文、去掉 rate_code ⇒ 受理
         mockMvc.perform(post(PREFIX + "/create")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content("{\"enterprise_id\":\"E1\",\"merchant_name\":\"示例商户\"}"))
+                        .content("{\"merchant_name\":\"示例商户\"}"))
                 .andExpect(jsonPath("$.code").value(0));
         verify(onboardingService).createMerchant(any());
     }
@@ -458,12 +458,12 @@ class LeshouyingOnboardingControllerTest {
     void createEndpoint_allowsBlankRateCode() throws Exception {
         login(TENANT, false);
         when(onboardingService.createMerchant(any())).thenReturn(MCH_NO);
-        when(merchantService.createOnboardingRow(any(), any(), any(), any())).thenReturn(row(MCH_NO));
+        when(merchantService.createOnboardingRow(any(), any(), any())).thenReturn(row(MCH_NO));
 
         for (String blank : List.of("", "   ")) {
             mockMvc.perform(post(PREFIX + "/create")
                             .contentType(MediaType.APPLICATION_JSON)
-                            .content("{\"enterprise_id\":\"E1\",\"merchant_name\":\"示例商户\","
+                            .content("{\"merchant_name\":\"示例商户\","
                                     + "\"rate_code\":\"" + blank + "\"}"))
                     .andExpect(jsonPath("$.code").value(0));
         }
@@ -829,7 +829,7 @@ class LeshouyingOnboardingControllerTest {
     void category_fromJson_byConstantName_isAccepted() throws Exception {
         login(TENANT, false);
         when(onboardingService.createMerchant(any())).thenReturn(MCH_NO);
-        when(merchantService.createOnboardingRow(any(), any(), any(), any())).thenReturn(row(MCH_NO));
+        when(merchantService.createOnboardingRow(any(), any(), any())).thenReturn(row(MCH_NO));
 
         mockMvc.perform(post(PREFIX + "/create")
                         .contentType(MediaType.APPLICATION_JSON)
@@ -864,7 +864,7 @@ class LeshouyingOnboardingControllerTest {
     void category_fromJson_byChineseLabel_reachesServiceAsThatEnum() throws Exception {
         login(TENANT, false);
         when(onboardingService.createMerchant(any())).thenReturn(MCH_NO);
-        when(merchantService.createOnboardingRow(any(), any(), any(), any())).thenReturn(row(MCH_NO));
+        when(merchantService.createOnboardingRow(any(), any(), any())).thenReturn(row(MCH_NO));
 
         // 反例先行:连谁都不是的值
         mockMvc.perform(post(PREFIX + "/create")
@@ -1875,7 +1875,10 @@ class LeshouyingOnboardingControllerTest {
                 // PageResult 分页壳
                 "page_no", "page_size", "total", "has_next", "list",
                 // pay_lsy_merchant 的列(V1.8 建表语句)
-                "id", "tenant_id", "enterprise_id", "mch_no", "shop_no", "merchant_name",
+                // ★ 2026-09-30 去企业化:原先这里还有 "enterprise_id"。那一列的 Java 映射
+                //   已删(见 LsyMerchantEntity 的类 javadoc),本白名单只列「我们**可能**发布
+                //   出去的名字」⇒ 摘掉它,等于给「哪天有人把字段加回来」留一道红。
+                "id", "tenant_id", "mch_no", "shop_no", "merchant_name",
                 "review_status", "status", "description", "created_time", "updated_time",
                 // LsyAreaItemDTO 的五个属性(按 SNAKE_CASE 展开)。★ 按上面那两查它们**不是**
                 // 「上游专有」:① 建表语句里没有这五列(在 src/main/resources/db 的 23 个 .sql 上
@@ -2083,7 +2086,7 @@ class LeshouyingOnboardingControllerTest {
     private List<Runnable> allEndpoints() {
         return List.of(
                 () -> controller.page(1, 20, null),
-                () -> controller.create(createBody("E1", "示例商户")),
+                () -> controller.create(createBody("示例商户")),
                 () -> controller.commit(commitBody(MCH_NO)),
                 () -> controller.update(updateBody(MCH_NO)),
                 () -> controller.areas("1", null),
@@ -2137,24 +2140,23 @@ class LeshouyingOnboardingControllerTest {
         return (AbstractWrapper<?, ?, ?>) captor.getValue();
     }
 
-    private static LeshouyingOnboardingController.CreateMerchantRequest createBody(
-            String enterpriseId, String merchantName) {
+    /** ★ 2026-09-30 去企业化:原先还带一个 {@code enterpriseId} 参数(本地业务字段),字段已删。 */
+    private static LeshouyingOnboardingController.CreateMerchantRequest createBody(String merchantName) {
         LeshouyingOnboardingController.CreateMerchantRequest b =
                 new LeshouyingOnboardingController.CreateMerchantRequest();
-        b.setEnterpriseId(enterpriseId);
         b.setMerchantName(merchantName);
         return b;
     }
 
     /**
-     * 一份**真的走 JSON 绑定**的创建报文,只给三个键(企业号 / 商户名 / 类别)。
+     * 一份**真的走 JSON 绑定**的创建报文,只给两个键(商户名 / 类别)。
      *
-     * <p>★ 为什么不用 {@link #createBody(String, String)} 那个 DTO 夹具:那条路是**直调**
+     * <p>★ 为什么不用 {@link #createBody(String)} 那个 DTO 夹具:那条路是**直调**
      * {@code controller.create(dto)},压根不经过 Jackson —— 而本节要钉的正是「Jackson 怎么解
      * 这个字段」。所以这里只能拼字符串、走 MockMvc。
      */
     private static String createBodyWithCategory(String categoryValue) {
-        return "{\"enterprise_id\":\"E1\",\"merchant_name\":\"示例商户\",\"category\":\""
+        return "{\"merchant_name\":\"示例商户\",\"category\":\""
                 + categoryValue + "\"}";
     }
 

+ 48 - 34
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenControllerTest.java

@@ -81,14 +81,14 @@ class LeshouyingOpenControllerTest {
 
     @Test
     void barcodePay_mapsDtoToEntityAndReturnsResult() {
-        when(merchantService.requireByEnterprise(any(), eq("ENT1"))).thenReturn(merchant());
+        when(merchantService.requireByMchNoForTenant(eq("Y52211090004"), eq(TENANT))).thenReturn(merchant());
         // 与 brief:363 原文 `eq("000")` 不同:控制器原样透传(本用例未 set payType ⇒ null),兜底在下游 LsyOrderService.java:149 `blankToDefault`。生产代码未动。
         when(orderService.createBarcodeOrder(any(), eq("134567890123456789"), isNull()))
                 .thenReturn(result("SUCCESS"));
 
         LsyBarcodePayDTO dto = new LsyBarcodePayDTO();
         dto.setOutTradeNo("OUT001");
-        dto.setEnterpriseId("ENT1");
+        dto.setMerchantCode("Y52211090004");
         dto.setAuthCode("134567890123456789");
         dto.setTotalAmount(100L);
 
@@ -114,20 +114,18 @@ class LeshouyingOpenControllerTest {
         assertEquals(1, atInsert.size(), "落库应恰好一次(不是「一次都没落」也不是「落两次」)");
         assertEquals("Y52211090004", atInsert.get(0).getMchNo(),
                 "★ 落库必须发生在 mch_no 装配之后 —— V1.8 的 pay_lsy_order.mch_no 是 NOT NULL 且无默认值");
-        assertEquals("ENT1", atInsert.get(0).getEnterpriseId(),
-                "★ 同理:enterprise_id 也必须在 insert 前装配");
     }
 
-    // ───────────── 去企业化(2026-09-22):不传 enterprise_id ⇒ 租户级;传了 ⇒ 企业级 ─────────────
+    // ────── 按商户号寻址(2026-09-30):不传 merchant_code ⇒ 租户唯一那个;传了 ⇒ 按号精确匹配 ──────
 
     @Test
-    void barcodePay_blankEnterpriseId_usesTenantLevelMerchant() {
+    void barcodePay_blankMerchantCode_usesTheOnlyMerchantOfTheTenant() {
         when(merchantService.requireByTenant(TENANT)).thenReturn(merchant());
         when(orderService.createBarcodeOrder(any(), any(), any())).thenReturn(result("SUCCESS"));
 
         LsyBarcodePayDTO dto = new LsyBarcodePayDTO();
         dto.setOutTradeNo("OUT001");
-        dto.setEnterpriseId("");
+        dto.setMerchantCode("");
         dto.setAuthCode("134567890123456789");
         dto.setTotalAmount(100L);
         List<LsyOrderEntity> atInsert = snapshotOrderAtInsert();
@@ -135,68 +133,86 @@ class LeshouyingOpenControllerTest {
         controller.barcodePay(dto);
 
         verify(merchantService).requireByTenant(TENANT);
-        verify(merchantService, never()).requireByEnterprise(any(), any());
-        assertTenantLevelOrderRow(atInsert);
+        verify(merchantService, never()).requireByMchNoForTenant(any(), any());
+        assertOrderRowCarriesNoEnterprise(atInsert);
     }
 
+    /** 指定了商户号 ⇒ 按号精确匹配,且**不去碰**租户唯一那条腿。 */
     @Test
-    void qrcodeCreate_blankEnterpriseId_usesTenantLevelMerchant() {
+    void h5Create_withMerchantCode_matchesByCodeAndNeverFallsBackToTenant() {
+        when(merchantService.requireByMchNoForTenant(eq("Y52211090004"), eq(TENANT))).thenReturn(merchant());
+        when(orderService.createH5Order(any(), any())).thenReturn(result("PAY_SUCCESS"));
+
+        LsyH5CreateDTO dto = new LsyH5CreateDTO();
+        dto.setOutTradeNo("OUT001");
+        dto.setMerchantCode("Y52211090004");
+        dto.setTotalAmount(100L);
+        List<LsyOrderEntity> atInsert = snapshotOrderAtInsert();
+
+        controller.h5Create(dto);
+
+        verify(merchantService, never()).requireByTenant(any());
+        assertOrderRowCarriesNoEnterprise(atInsert);
+    }
+
+    @Test
+    void qrcodeCreate_nullMerchantCode_usesTheOnlyMerchantOfTheTenant() {
         when(merchantService.requireByTenant(TENANT)).thenReturn(merchant());
         when(orderService.createQrOrder(any(), any())).thenReturn(result("PAY_SUCCESS"));
 
         LsyQrCreateDTO dto = new LsyQrCreateDTO();
         dto.setOutTradeNo("OUT001");
-        dto.setEnterpriseId(null);          // 缺键与空白等价
+        dto.setMerchantCode(null);          // 缺键与空白等价
         dto.setTotalAmount(100L);
         List<LsyOrderEntity> atInsert = snapshotOrderAtInsert();
 
         controller.qrcodeCreate(dto);
 
         verify(merchantService).requireByTenant(TENANT);
-        verify(merchantService, never()).requireByEnterprise(any(), any());
-        assertTenantLevelOrderRow(atInsert);
+        verify(merchantService, never()).requireByMchNoForTenant(any(), any());
+        assertOrderRowCarriesNoEnterprise(atInsert);
     }
 
     @Test
-    void h5Create_blankEnterpriseId_usesTenantLevelMerchant() {
+    void h5Create_blankSpacesMerchantCode_usesTheOnlyMerchantOfTheTenant() {
         when(merchantService.requireByTenant(TENANT)).thenReturn(merchant());
         when(orderService.createH5Order(any(), any())).thenReturn(result("PAY_SUCCESS"));
 
         LsyH5CreateDTO dto = new LsyH5CreateDTO();
         dto.setOutTradeNo("OUT001");
-        dto.setEnterpriseId("   ");
+        dto.setMerchantCode("   ");
         dto.setTotalAmount(100L);
         List<LsyOrderEntity> atInsert = snapshotOrderAtInsert();
 
         controller.h5Create(dto);
 
         verify(merchantService).requireByTenant(TENANT);
-        verify(merchantService, never()).requireByEnterprise(any(), any());
-        assertTenantLevelOrderRow(atInsert);
+        verify(merchantService, never()).requireByMchNoForTenant(any(), any());
+        assertOrderRowCarriesNoEnterprise(atInsert);
     }
 
     /**
-     * 反向:带了 {@code enterprise_id} 就**不许**去碰租户级。
+     * ★ 老调用方若还在发 {@code enterprise_id} ⇒ **响亮拒绝**:既不静默忽略,也不走任何查号路径。
      *
-     * <p>分流是「按入参选一条路」,不是「先租户级、查不到再退回企业级」那种兜底 ——
-     * 后者会把一个显式指定了企业的请求静默落到另一个商户号上,出金口径随之改变。
+     * <p>静默忽略是最坏的一种 —— 调用方以为「指定了企业」,实际落到租户唯一那个商户号上,
+     * 出金口径变了而它不知道。字段之所以还留在 DTO 上而不是直接删掉,是因为本仓
+     * {@code FAIL_ON_UNKNOWN_PROPERTIES} 是 true:删了它,这个键会变成 500 而不是这句提示。
      */
     @Test
-    void qrcodeCreate_withEnterpriseId_neverConsultsTenantLevel() {
-        when(merchantService.requireByEnterprise(any(), eq("ENT1"))).thenReturn(merchant());
-        when(orderService.createQrOrder(any(), any())).thenReturn(result("PAY_SUCCESS"));
-
+    void qrcodeCreate_withEnterpriseId_isRejectedLoudly() {
         LsyQrCreateDTO dto = new LsyQrCreateDTO();
         dto.setOutTradeNo("OUT001");
         dto.setEnterpriseId("ENT1");
         dto.setTotalAmount(100L);
-        List<LsyOrderEntity> atInsert = snapshotOrderAtInsert();
 
-        controller.qrcodeCreate(dto);
+        BusinessException ex = assertThrows(BusinessException.class, () -> controller.qrcodeCreate(dto));
 
+        assertEquals(400, ex.getCode(), "实测: " + ex.getCode());
+        assertTrue(ex.getMessage().contains("merchant_code"),
+                "提示必须点名改成传哪个字段。实测: " + ex.getMessage());
         verify(merchantService, never()).requireByTenant(any());
-        assertEquals(1, atInsert.size());
-        assertEquals("ENT1", atInsert.get(0).getEnterpriseId(), "企业级下单照旧带上企业");
+        verify(merchantService, never()).requireByMchNoForTenant(any(), any());
+        verify(orderMapper, never()).insert(any());
     }
 
     /** 落库那一刻的快照(用法与理由见 {@code barcodePay_mapsDtoToEntityAndReturnsResult}) */
@@ -213,11 +229,11 @@ class LeshouyingOpenControllerTest {
         return atInsert;
     }
 
-    private static void assertTenantLevelOrderRow(List<LsyOrderEntity> atInsert) {
+    private static void assertOrderRowCarriesNoEnterprise(List<LsyOrderEntity> atInsert) {
         assertEquals(1, atInsert.size(), "落库应恰好一次");
-        assertEquals("Y52211090004", atInsert.get(0).getMchNo(), "商户号取自租户级映射");
+        assertEquals("Y52211090004", atInsert.get(0).getMchNo(), "商户号取自商户映射");
         assertNull(atInsert.get(0).getEnterpriseId(),
-                "租户级下单的 enterprise_id 必须是 NULL —— 落成空串后,任何 `IS NULL` 的查询都找不到这一行");
+                "订单不再挂企业 —— 企业概念已于 2026-09-30 从乐收赢整个去掉");
     }
 
     @Test
@@ -231,7 +247,6 @@ class LeshouyingOpenControllerTest {
 
         LsyBarcodePayDTO dto = new LsyBarcodePayDTO();
         dto.setOutTradeNo("OUT001");
-        dto.setEnterpriseId("ENT1");
         dto.setAuthCode("134567890123456789");
         dto.setTotalAmount(100L);
 
@@ -243,12 +258,11 @@ class LeshouyingOpenControllerTest {
 
     @Test
     void h5Create_passesPayTypeThrough() {
-        when(merchantService.requireByEnterprise(any(), any())).thenReturn(merchant());
+        when(merchantService.requireByTenant(TENANT)).thenReturn(merchant());
         when(orderService.createH5Order(any(), eq("400"))).thenReturn(result("SUCCESS"));
 
         LsyH5CreateDTO dto = new LsyH5CreateDTO();
         dto.setOutTradeNo("OUT002");
-        dto.setEnterpriseId("ENT1");
         dto.setTotalAmount(200L);
         dto.setPayType("400");
 

+ 15 - 5
java/src/test/java/com/payment/platform/module/payment/leshouying/dto/LsyOpenApiJsonContractTest.java

@@ -37,10 +37,18 @@ class LsyOpenApiJsonContractTest {
         }
     }
 
+    // ★ 三个请求 DTO 的键集在 2026-09-30 去企业化时各多了一个 `merchant_code`,
+    //   而 `enterprise_id` **仍然在**(不是漏删)。理由见 DTO 上那个字段的注释:
+    //   本仓 FAIL_ON_UNKNOWN_PROPERTIES 是 true,删掉它会让老调用方报文里那个键绑不上、
+    //   一路冒到兜底变成 500;留着它才能走到 LeshouyingOpenController.requireMerchant
+    //   那句「本接口已不再使用 enterprise_id,请改用 merchant_code」的 400。
+    //   所以本类断言的是**对外键集**,不随内部字段删除而缩小。
+
     @Test
     void barcodeRequestFields() {
         LsyBarcodePayDTO d = new LsyBarcodePayDTO();
         d.setOutTradeNo("T1");
+        d.setMerchantCode("Y52211090004");
         d.setEnterpriseId("E1");
         d.setAuthCode("2808");
         d.setTotalAmount(100L);
@@ -48,8 +56,8 @@ class LsyOpenApiJsonContractTest {
         d.setOrderBody("b");
         d.setAttach("a");
         d.setNotifyUrl("https://x/cb");
-        assertEquals(Set.of("out_trade_no", "enterprise_id", "auth_code", "total_amount",
-                        "pay_type", "order_body", "attach", "notify_url"),
+        assertEquals(Set.of("out_trade_no", "merchant_code", "enterprise_id", "auth_code",
+                        "total_amount", "pay_type", "order_body", "attach", "notify_url"),
                 keysOf(d));
     }
 
@@ -57,13 +65,14 @@ class LsyOpenApiJsonContractTest {
     void qrCreateRequestFields() {
         LsyQrCreateDTO d = new LsyQrCreateDTO();
         d.setOutTradeNo("T1");
+        d.setMerchantCode("Y52211090004");
         d.setEnterpriseId("E1");
         d.setTotalAmount(100L);
         d.setOrderBody("b");
         d.setAttach("a");
         d.setNotifyUrl("https://x/cb");
         d.setTimeExpire(15);
-        assertEquals(Set.of("out_trade_no", "enterprise_id", "total_amount",
+        assertEquals(Set.of("out_trade_no", "merchant_code", "enterprise_id", "total_amount",
                         "order_body", "attach", "notify_url", "time_expire"),
                 keysOf(d));
     }
@@ -72,14 +81,15 @@ class LsyOpenApiJsonContractTest {
     void h5CreateRequestFields() {
         LsyH5CreateDTO d = new LsyH5CreateDTO();
         d.setOutTradeNo("T1");
+        d.setMerchantCode("Y52211090004");
         d.setEnterpriseId("E1");
         d.setTotalAmount(100L);
         d.setPayType("300");
         d.setOrderBody("b");
         d.setAttach("a");
         d.setNotifyUrl("https://x/cb");
-        assertEquals(Set.of("out_trade_no", "enterprise_id", "total_amount", "pay_type",
-                        "order_body", "attach", "notify_url"),
+        assertEquals(Set.of("out_trade_no", "merchant_code", "enterprise_id", "total_amount",
+                        "pay_type", "order_body", "attach", "notify_url"),
                 keysOf(d));
     }
 

+ 25 - 4
java/src/test/java/com/payment/platform/module/payment/leshouying/entity/LsyEntityMappingTest.java

@@ -18,11 +18,32 @@ class LsyEntityMappingTest {
     private static final String MIGRATION = "db/migration/V1.8__create_pay_lsy_tables.sql";
 
     /**
-     * **故意**不被实体映射的列(`表名.列名`)。**目前为空,且希望一直为空。**
-     * 留这个集合是为了让「不映射某一列」必须是一次**被写下来**的决定,
-     * 而不是一个没人注意到的默认状态。
+     * **故意**不被实体映射的列(`表名.列名`)。留这个集合是为了让「不映射某一列」
+     * 必须是一次**被写下来**的决定,而不是一个没人注意到的默认状态。
+     *
+     * <p>★ <b>2026-09-30 去企业化:{@code pay_lsy_merchant.enterprise_id} 是这里的第一条。</b>
+     * 乐收赢与企业概念无关 —— 那一列既不来自上游、也不是 {@code pay_enterprise} 的外键
+     * (建表语句里从来没有 FK),只是最初设计里的一层本地分组。随着「按商户号寻址」落地
+     * ({@code LsyMerchantService.requireByTenant} + {@code LeshouyingOpenController.requireMerchant}),
+     * 它的 Java 映射已删。
+     *
+     * <p><b>为什么是「故意不映射」而不是「从建表语句里删掉」:</b>{@code V1.8} 是**已执行过**的
+     * 迁移(线上库里有这张表),改历史迁移会让「新装环境」与「线上库」这两份 schema 分叉,
+     * 而分叉正是本条测试要防的东西。列本身留着无害:{@code enterprise_id} 可空、
+     * 新写入恒为 NULL(没有字段就进不了 MP 的 INSERT 列清单),且
+     * {@code uk_lsy_merchant_tenant_ent} 建在 {@code (tenant_id, enterprise_id)} 上,
+     * 恒 NULL 时永不触发。
+     *
+     * <p><b>为什么不能靠「反正没人写」就放着不提:</b>这一列**曾经**是个真缺陷的载体 ——
+     * 它让「一个租户只能有一个商户号」这条不存在的规则看起来成立,用户 2026-09-30 撞的
+     * {@code uk_lsy_merchant_tenant_level} 就是它的衍生物。写在这里,等于给下一个读代码的人
+     * 留一句「这一列是故意的、别再映射回来」。
+     *
+     * <p>★ 与之成对的一半在 {@code db/lsy-merchant-multi-per-tenant.sql}:
+     * {@code uk_lsy_merchant_tenant_level} 那条部分唯一索引**必须**删掉,
+     * 否则 {@code enterprise_id} 恒 NULL 会让它退化成「一个租户只许一个商户号」。
      */
-    private static final Set<String> UNMAPPED_COLUMNS = Set.of();
+    private static final Set<String> UNMAPPED_COLUMNS = Set.of("pay_lsy_merchant.enterprise_id");
 
     @Test
     void allEntitiesDeclareExpectedTableName() {

+ 120 - 247
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantServiceTest.java

@@ -50,10 +50,10 @@ import static org.mockito.Mockito.*;
  *       原值 {@code NULL} 被写成空串 ⇒ 此后 {@code WHERE shop_no IS NULL} 永远查不到这一行。</li>
  * </ul>
  * 所以本类的主断言不是「调用成功」,而是**SET 子句里到底有哪几列**:
- * 列集必须**恰好**是服务自持的那 6 列(5 个业务列 + {@code updated_time})。
+ * 列集必须**恰好**是服务自持的那 5 列(4 个业务列 + {@code updated_time})。
  *
  * <p>★ 断言写法(在 MP 3.5.5 上实测过,不是推断):{@code getSqlSet()} 返回形如
- * {@code enterprise_id=#{ew.paramNameValuePairs.MPGENVAL1},...,updated_time=#{...}} 的文本,
+ * {@code mch_no=#{ew.paramNameValuePairs.MPGENVAL1},...,updated_time=#{...}} 的文本,
  * 列名在 SQL 文本里、**值在 {@code getParamNameValuePairs()} 里**(值为 null 的键也在,
  * 见 {@code save_updateBranch_blankTextBecomesNullInSql})。
  * 列集合一律按 {@code ,} 切、取 {@code =} 之前那一段后做**集合相等** —— 不用
@@ -82,55 +82,6 @@ class LsyMerchantServiceTest {
         TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), LsyMerchantEntity.class);
     }
 
-    @Test
-    void requireByEnterprise_found_returnsEntity() {
-        LsyMerchantEntity m = new LsyMerchantEntity();
-        m.setMchNo("Y52211090004");
-        when(merchantMapper.selectOne(any())).thenReturn(m);
-
-        assertEquals("Y52211090004", service.requireByEnterprise(1L, "ENT1").getMchNo());
-    }
-
-    @Test
-    void requireByEnterprise_missing_throws404() {
-        when(merchantMapper.selectOne(any())).thenReturn(null);
-
-        BusinessException ex = assertThrows(BusinessException.class,
-                () -> service.requireByEnterprise(1L, "ENT1"));
-        assertEquals(404, ex.getCode());
-    }
-
-    /**
-     * ★ 文案不能把调用方送去一个**它自己进不去**的地方(2026-09-30)。
-     *
-     * <p>本次改动之前,「请先在「手工登记商户号」中登记」是**可照做**的指引 ——
-     * 那时租户能自助录入商户号。改动后录入改成**超管代录**
-     * ({@code LeshouyingMerchantController} 的 save/delete 挂 {@code @perm.hasRole('SUPERUSER')}):
-     * 租户既看不到新增按钮、端点也 403,同一句话就成了一条**死路** ——
-     * 操作员会照着它去那一页找一个不存在的按钮。
-     *
-     * <p>两侧都断言,缺一不可:只断言「不含旧说法」的话,把 message 换成空串也能绿。
-     * 前端同款文案见 {@code collection/merchant/index.vue} 的只读提示。
-     */
-    @Test
-    void requireByEnterprise_missing_message_routesToPlatformOps_notToTheNowReadOnlyPage() {
-        when(merchantMapper.selectOne(any())).thenReturn(null);
-
-        BusinessException ex = assertThrows(BusinessException.class,
-                () -> service.requireByEnterprise(1L, "ENT1"));
-
-        assertEquals(404, ex.getCode());
-        String msg = ex.getMessage();
-        assertTrue(msg.contains("联系平台运营"), "租户能照做的动作只有「联系平台运营」。实测文案: " + msg);
-        assertFalse(msg.contains("手工登记商户号"),
-                "「手工登记商户号」对租户已只读 ⇒ 不能再叫它去那里登记。实测文案: " + msg);
-    }
-
-    @Test
-    void requireByEnterprise_blankEnterpriseId_throws() {
-        assertThrows(BusinessException.class, () -> service.requireByEnterprise(1L, "  "));
-    }
-
     @Test
     void requireByMchNo_missing_throws404() {
         when(merchantMapper.selectOne(any())).thenReturn(null);
@@ -146,14 +97,14 @@ class LsyMerchantServiceTest {
     void requireByTenant_found_returnsEntity() {
         LsyMerchantEntity m = new LsyMerchantEntity();
         m.setMchNo("Y52211090004");
-        when(merchantMapper.selectOne(any())).thenReturn(m);
+        when(merchantMapper.selectList(any())).thenReturn(List.of(m));
 
         assertEquals("Y52211090004", service.requireByTenant(1L).getMchNo());
     }
 
     @Test
     void requireByTenant_missing_throws404() {
-        when(merchantMapper.selectOne(any())).thenReturn(null);
+        when(merchantMapper.selectList(any())).thenReturn(List.of());
 
         BusinessException ex = assertThrows(BusinessException.class, () -> service.requireByTenant(1L));
 
@@ -169,7 +120,7 @@ class LsyMerchantServiceTest {
      */
     @Test
     void requireByTenant_missing_message_routesToPlatformOps_notToTheNowReadOnlyPage() {
-        when(merchantMapper.selectOne(any())).thenReturn(null);
+        when(merchantMapper.selectList(any())).thenReturn(List.of());
 
         BusinessException ex = assertThrows(BusinessException.class, () -> service.requireByTenant(1L));
 
@@ -181,47 +132,57 @@ class LsyMerchantServiceTest {
     }
 
     /**
-     * ★ 本组最要紧的一条:钉住**查询条件里带 {@code enterprise_id IS NULL}**。
+     * ★ 钉住查询条件:**只有租户**,没有任何 enterprise 谓词。
      *
-     * <p>上面两条用例只喂 mock、不看 SQL,漏掉 {@code isNull} 时它们**照样全绿** ——
-     * 而查询已经退化成「该租户的任意一行商户映射」,捞到**企业级**那一行也照常返回。
-     * 企业级与租户级的语义完全不同(前者绑企业、后者不绑),静默串用是本轮改造最想防的事。
+     * <p>上面两条用例只喂 mock、不看 SQL,条件写错时它们照样全绿。这里按 {@code AND} 切做
+     * **集合断言**,不用 contains —— 同文件里 {@code review_status} 含 {@code status} 子串的坑是同一类。
      *
-     * <p>条件串按 {@code AND} 切开做**集合断言**,不用 contains —— 同文件里
-     * {@code review_status} 含 {@code status} 子串的坑是同一类。
+     * <p>★ 2026-09-30 去企业化收尾后,条件集应当**恰好一条**。多出任何一条都说明
+     * enterprise 槽位逻辑还有残留 —— 而它已经不参与寻址了,残留只会让人误以为它还在起作用。
      */
     @Test
-    void requireByTenant_queriesNullEnterpriseId() {
-        when(merchantMapper.selectOne(any())).thenReturn(row(EDIT_ID));
+    void requireByTenant_queriesByTenantOnly_noEnterprisePredicate() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of(row(EDIT_ID)));
 
         service.requireByTenant(7L);
 
-        ArgumentCaptor<LambdaQueryWrapper> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
-        verify(merchantMapper).selectOne(captor.capture());
-        LambdaQueryWrapper<?> w = captor.getValue();
+        AbstractWrapper<?, ?, ?> w = capturedSelectList();
         String seg = w.getSqlSegment();
+        Set<String> conds = Arrays.stream(seg.replaceAll("[()]", "").split(" AND "))
+                .map(String::trim).collect(Collectors.toSet());
 
-        // ★ 实测(不要照模型写):getSqlSegment() **包含** .last() 追加的那一段,
-        //   形状是 `(tenant_id = #{…MPGENVAL1} AND enterprise_id IS NULL) LIMIT 1`。
-        //   条件里没有嵌套括号,所以第一个 ')' 就是条件组的收尾。
-        assertTrue(seg.startsWith("("), "WHERE 应以条件组开头。实测: [" + seg + "]");
-        int close = seg.indexOf(')');
-        assertTrue(close > 0, "WHERE 条件组应有收尾括号。实测: [" + seg + "]");
-        assertEquals("LIMIT 1", seg.substring(close + 1).trim(),
-                "selectOne 必须带 LIMIT 1(租户级映射理论上唯一,但别依赖它)。实测: [" + seg + "]");
+        assertFalse(seg.contains("enterprise_id"),
+                "enterprise_id 不应再出现在寻址条件里。实测: [" + seg + "]");
+        assertTrue(conds.stream().anyMatch(c -> c.startsWith("tenant_id = ")),
+                "必须带租户条件(少了会跨租户串号)。实测: " + conds);
+        assertEquals(1, conds.size(), "条件应恰好一条。实测: [" + seg + "] ⇒ " + conds);
+        assertEquals(7L, boundPredicateValue(w, "tenant_id"),
+                "租户条件的占位符应绑定入参 tenantId。实测: " + w.getParamNameValuePairs());
+    }
 
-        Set<String> conds = Arrays.stream(seg.substring(1, close).split(" AND "))
-                .map(String::trim).collect(Collectors.toSet());
+    /**
+     * ★★ 多条 ⇒ **报 400 要求指定商户号**,绝不 {@code LIMIT 1} 随便挑一行。
+     *
+     * <p>挑错了不会报错:钱进另一个商户号,要等到对账才发现。这与汇安融
+     * {@code HarMerchantService#requireByTenant} 是同一取舍 —— 2026-09-24 用户裁定
+     * 「一个租户允许配置多个商户」,乐收赢现在同理(原 {@code uk_lsy_merchant_tenant_level}
+     * 单条唯一索引随 {@code db/lsy-merchant-multi-per-tenant.sql} 删除)。
+     *
+     * <p>0 行与多行必须是**两个不同的出口**:前者是「还没报备」(照做的动作是联系运营),
+     * 后者是「报备了多个、你得说清用哪个」(照做的动作是改请求体)。合并成一句会把
+     * 两种完全不同的处置搅在一起。
+     */
+    @Test
+    void requireByTenant_multipleRows_throws400_neverPicksOne() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of(
+                rowWithReviewStatus(1L, "APPROVED"),
+                rowWithReviewStatus(2102313081860202497L, "APPROVED")));
 
-        assertTrue(conds.contains("enterprise_id IS NULL"),
-                "必须带 enterprise_id IS NULL —— 漏了它就会捞到企业级商户。实测条件: " + conds);
-        assertEquals(1, conds.stream().filter(c -> c.startsWith("tenant_id = ")).count(),
-                "必须恰有一条租户条件(少了会跨租户串号)。实测条件: " + conds);
-        assertEquals(2, conds.size(), "条件应恰好两条。实测条件: " + conds);
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.requireByTenant(7L));
 
-        assertEquals(7L, w.getParamNameValuePairs().values().stream()
-                        .filter(Long.class::isInstance).findFirst().orElse(null),
-                "租户条件的占位符应解析成入参 tenantId。实测: " + w.getParamNameValuePairs());
+        assertEquals(400, ex.getCode(), "多条是「调用方没说清」,不是「不存在」。实测: " + ex.getCode());
+        assertTrue(ex.getMessage().contains("merchant_code"),
+                "提示必须点名要传哪个字段,否则调用方不知道改什么。实测: " + ex.getMessage());
     }
 
     // ─────────────────── requireByMchNoForTenant(进件端点的租户归属闸,Task 7) ───────────────────
@@ -229,7 +190,7 @@ class LsyMerchantServiceTest {
     /**
      * ★ 查询条件里**有** {@code tenant_id},且占位符绑定的是入参那个租户。
      *
-     * <p>形状照 {@link #requireByTenant_queriesNullEnterpriseId}:条件集相等 + 逐条点名。
+     * <p>形状照 {@link #requireByTenant_queriesByTenantOnly_noEnterprisePredicate}:条件集相等 + 逐条点名。
      * 不用 {@code contains("tenant_id")} —— {@code tenant_id} 是 {@code pay_lsy_merchant} 的列名,
      * 但它同时是 {@code WHERE} 里唯一一个「本方法自己加上去」的条件,
      * 必须有**恰一条**而不是「出现过」。
@@ -344,7 +305,7 @@ class LsyMerchantServiceTest {
     @Test
     void createOnboardingRow_nullTenant_throws403() {
         BusinessException ex = assertThrows(BusinessException.class,
-                () -> service.createOnboardingRow(null, "E1", "Y52211090004", "示例商户"));
+                () -> service.createOnboardingRow(null, "Y52211090004", "示例商户"));
 
         assertEquals(403, ex.getCode(), "实测: " + ex.getCode());
         verify(merchantMapper, never()).insert(any());
@@ -362,15 +323,13 @@ class LsyMerchantServiceTest {
     void createOnboardingRow_slotsClear_insertsWithPendingReviewStatus() {
         when(merchantMapper.selectOne(any())).thenReturn(null);
 
-        service.createOnboardingRow(7L, "   ", "Y00000000009", "示例商户");
+        service.createOnboardingRow(7L, "Y00000000009", "示例商户");
 
         ArgumentCaptor<LsyMerchantEntity> inserted = ArgumentCaptor.forClass(LsyMerchantEntity.class);
         verify(merchantMapper).insert(inserted.capture());
         assertEquals("PENDING", inserted.getValue().getReviewStatus(),
                 "进件新行是「待审核」,不是建表默认的 APPROVED。实测: "
                         + inserted.getValue().getReviewStatus());
-        assertNull(inserted.getValue().getEnterpriseId(),
-                "空白企业 ID ⇒ NULL(租户级),存成 \"   \" 会让 requireByTenant 永远查不到它");
         assertEquals(7L, inserted.getValue().getTenantId());
     }
 
@@ -380,17 +339,25 @@ class LsyMerchantServiceTest {
      * <p>2026-09-23 的 B 阶段报告已经记过这条缺口(「{@code create} 的 insert 会撞既有唯一索引,
      * 且出口是 HTTP 500」,当时「未获裁定」)。这里与 {@code save} 一并收口:
      * 预检**只在数据库本来就会拒的时候才拦**,所以它不可能弄坏任何现在能走的流程。
+     *
+     * <p>★ 2026-09-30 改:原用例钉的是「**租户级槽位**被占 ⇒ 409」—— 那条槽位探测已随
+     * 去企业化删除。现在建号唯一会撞的是**全局重号**(挡路那行可以在任意租户),
+     * 本用例改钉那个,并顺带证明「同租户已有别的号**不拦**」。
      */
     @Test
-    void createOnboardingRow_tenantLevelSlotTaken_throws409() {
+    void createOnboardingRow_duplicateMchNo_throws409() {
+        // 挡路那一行与本次要建的号**同号**,但属于另一个租户(9)——
+        // 同租户里另有一行不同号的行是合法的,不该出现在这个 stub 里。
         when(merchantMapper.selectOne(any()))
-                .thenReturn(existingRow(9L, 7L, null, "Y52211090004"));
+                .thenReturn(existingRow(9L, 9L, "Y00000000009"));
 
         BusinessException ex = assertThrows(BusinessException.class,
-                () -> service.createOnboardingRow(7L, "   ", "Y00000000009", "示例商户"));
+                () -> service.createOnboardingRow(7L, "Y00000000009", "示例商户"));
 
         assertEquals(409, ex.getCode(), "实测: " + ex.getCode());
-        assertTrue(ex.getMessage().contains("Y52211090004"), "实测: " + ex.getMessage());
+        assertTrue(ex.getMessage().contains("Y00000000009"), "实测: " + ex.getMessage());
+        assertTrue(ex.getMessage().contains("9"),
+                "提示要说出**哪个租户**占了这个号。实测: " + ex.getMessage());
         verify(merchantMapper, never()).insert(any());
     }
 
@@ -591,7 +558,7 @@ class LsyMerchantServiceTest {
     // ─────────────────────── save():编辑分支(D-1 / D-2 / 0 行判据) ───────────────────────
 
     @Test
-    void save_updateBranch_writesExactlyTheSixOwnedColumns() {
+    void save_updateBranch_writesExactlyTheFiveOwnedColumns() {
         when(merchantMapper.update(any(), any())).thenReturn(1);
         when(merchantMapper.selectById(any())).thenReturn(row(EDIT_ID));
 
@@ -601,8 +568,8 @@ class LsyMerchantServiceTest {
         Set<String> cols = setColumns(w);
 
         // 少一列 ⇒ 页面改不动那个字段(静默丢改动);多一列 ⇒ 覆盖了本服务不拥有的状态列(D-1)
-        assertEquals(Set.of("enterprise_id", "mch_no", "shop_no", "merchant_name", "description", "updated_time"),
-                cols, "编辑分支的 SET 必须恰好是这 6 列。实测: " + w.getSqlSet());
+        assertEquals(Set.of("mch_no", "shop_no", "merchant_name", "description", "updated_time"),
+                cols, "编辑分支的 SET 必须恰好是这 5 列。实测: " + w.getSqlSet());
         assertFalse(cols.contains("status"), "P1:status 不得进 SET(旧写法的 updateById 会把默认值写回去)");
         assertFalse(cols.contains("review_status"),
                 "P1:review_status 不得进 SET(B 阶段由**我们自己的写入口**回写 —— "
@@ -725,33 +692,9 @@ class LsyMerchantServiceTest {
                 "实测参数表: " + w.getParamNameValuePairs());
     }
 
-    /**
-     * 去企业化:企业字段留空 ⇒ 写 NULL(变成租户级映射)。
-     *
-     * <p>改前这里是 **400**(那时 {@code pay_lsy_merchant.enterprise_id} 是 {@code NOT NULL},
-     * 留空没有合法落点)。列放开后 400 的前提消失,留空有了明确语义。
-     */
-    @Test
-    void save_updateBranch_blankEnterpriseId_writesNull() {
-        when(merchantMapper.update(any(), any())).thenReturn(1);
-        when(merchantMapper.selectById(any())).thenReturn(row(EDIT_ID));
-
-        LsyMerchantEntity body = new LsyMerchantEntity();
-        body.setId(EDIT_ID);
-        body.setEnterpriseId("");          // 操作者把一条企业级映射改成租户级
-        body.setMchNo("Y52211090004");
-
-        service.save(body);                // 不得抛 400
-
-        LambdaUpdateWrapper<?> w = capturedUpdate();
-        assertTrue(setColumns(w).contains("enterprise_id"),
-                "enterprise_id 仍必须在 SET 里(当成 skip 就会保留旧企业)。实测: " + w.getSqlSet());
-        // 与三个可空列一起,共 4 个 NULL —— 空串会以 "" 入参数表,
-        // 而 requireByTenant 找的是 IS NULL,那一行将永远查不到
-        assertEquals(4, w.getParamNameValuePairs().values().stream().filter(v -> v == null).count(),
-                "enterprise_id 应与三个可空列一样以 NULL 入 SET。实测参数表: " + w.getParamNameValuePairs());
-        assertNull(body.getEnterpriseId(), "空 ⇒ NULL 的收敛点在 service 层");
-    }
+    // ★ 2026-09-30 去企业化:原有一条 `save_updateBranch_blankEnterpriseId_writesNull`
+    //   (证明「企业字段留空 ⇒ 写 NULL,且仍进 SET」),随 enterpriseId 字段删除一并作废。
+    //   它当年钉的是「改成租户级映射」这个动作 —— 现在没有企业级可言,那个动作不存在了。
 
     @Test
     void save_updateBranch_zeroAffectedRows_throws404() {
@@ -782,7 +725,6 @@ class LsyMerchantServiceTest {
     @Test
     void save_insertBranch_appliesDefaults_andNeverCallsUpdate() {
         LsyMerchantEntity body = new LsyMerchantEntity();
-        body.setEnterpriseId("ENT1");
         body.setMchNo("Y52211090004");
         body.setTenantId(77L);   // 归属必填(见下面「归属」一节)—— 本用例管的是默认值,随便给一个
         // 模拟 MP 的 IdType.ASSIGN_ID:insert 时把雪花 id 写回实体(mock 不做这件事,不模拟回读的就是 null)
@@ -802,27 +744,10 @@ class LsyMerchantServiceTest {
         verify(merchantMapper, never()).updateById(any());
     }
 
-    /** 去企业化:不填企业也能新增一条映射(租户级)。改前这里是 400。 */
-    @Test
-    void save_insertBranch_blankEnterpriseId_inserts() {
-        LsyMerchantEntity body = new LsyMerchantEntity();
-        body.setEnterpriseId("   ");       // 只有空白 —— 必须与 null 同等对待
-        body.setMchNo("Y52211090004");
-        body.setTenantId(77L);             // 同上:归属必填
-        when(merchantMapper.insert(any())).thenAnswer(inv -> {
-            ((LsyMerchantEntity) inv.getArgument(0)).setId(9L);
-            return 1;
-        });
-        when(merchantMapper.selectById(9L)).thenReturn(row(9L));
-
-        service.save(body);                // 不得抛 400
-
-        ArgumentCaptor<LsyMerchantEntity> inserted = ArgumentCaptor.forClass(LsyMerchantEntity.class);
-        verify(merchantMapper).insert(inserted.capture());
-        assertNull(inserted.getValue().getEnterpriseId(),
-                "租户级商户的 enterprise_id 必须是 NULL —— 存成 \"   \" 会让 requireByTenant 永远查不到它");
-        verify(merchantMapper, never()).update(any(), any());
-    }
+    // ★ 2026-09-30 去企业化:原有一条 `save_insertBranch_blankEnterpriseId_inserts`
+    //   (证明「空白企业 ID ⇒ 落 NULL、不抛 400」),随字段删除一并作废。
+    //   它当年的正对照(`save_insertBranch_appliesDefaults_andNeverCallsUpdate`
+    //   当时带 `setEnterpriseId("ENT1")`)也一并去掉了那一行。
 
     // ─────────────────────────── save():归属(2026-09-30) ───────────────────────────
     //
@@ -880,8 +805,8 @@ class LsyMerchantServiceTest {
 
     // ─────────── 两条写入口的唯一约束预检(2026-09-30,线上 500 回归) ───────────
     //
-    // 【为什么要有预检】`pay_lsy_merchant` 上有三条唯一约束,而两条写入口
-    // (`save` 的新增分支、`createOnboardingRow`)都是**直接 insert、一条都不先探**。
+    // 【为什么要有预检】两条写入口(`save` 的新增分支、`createOnboardingRow`)原先都是
+    // **直接 insert、一条都不先探**。
     // 撞上时 `GlobalExceptionHandler` 没有 `DuplicateKeyException` 分支(实测只有
     // BusinessException / AccessDeniedException / 参数校验 / NoResourceFound / 兜底五个),
     // ⇒ 落到兜底 ⇒ HTTP 500「服务器内部错误」,操作员看不到「哪一行挡着、该怎么办」。
@@ -897,40 +822,35 @@ class LsyMerchantServiceTest {
     // **不**选「给 GlobalExceptionHandler 加 DuplicateKeyException 分支」:那是改全平台
     // 所有模块的出口行为,超出本次范围(线上正常运行中,别的模块不动)。
     //
-    // 【探测顺序 = 提示的优先级】**租户内**的冲突排在**全局**的商户号冲突前面:
-    // 前者能直接去表里改/删那一行,后者要先怀疑是不是自己把号填错了。
-
-    @Test
-    void save_insertBranch_tenantLevelSlotTaken_throws409NamingTheBlocker() {
-        LsyMerchantEntity body = new LsyMerchantEntity();
-        body.setMchNo("Y00000000009");   // 要新增的号 —— 与挡路那一行不同,冲突来自**槽位**不是号
-        body.setTenantId(77L);           // enterprise_id 留空 ⇒ 这一行是租户级
-        when(merchantMapper.selectOne(any()))
-                .thenReturn(existingRow(9L, 77L, null, "Y52211090004"));
-
-        BusinessException ex = assertThrows(BusinessException.class, () -> service.save(body));
-
-        assertEquals(409, ex.getCode(),
-                "撞唯一键是**冲突**,不是「服务器内部错误」。实测: " + ex.getCode());
-        assertTrue(ex.getMessage().contains("Y52211090004"),
-                "提示必须点名挡路那一行的商户号 —— 否则操作员只知道「撞了」,不知道去删哪一行。实测: "
-                        + ex.getMessage());
-        verify(merchantMapper, never()).insert(any());
-    }
+    // 【2026-09-30 起只剩一条探测:全局重号】原先还有两条**租户内**的槽位探测
+    // (先租户级、后企业级),随去企业化一起删了 —— 一个租户登记多个商户号现在是**合法**的
+    // (消费方是 `requireByTenant` 的多行分支),再拦就是个凭空的禁令。
+    // 剩这一条的性质分头钉住:
+    //   · 探测**只按 mch_no**(不看 tenant_id、不看 enterprise_id)
+    //     ⇒ `save_insertBranch_mchNoProbe_isScopedToThatExactMchNo`;
+    //   · 有行同号 ⇒ 409,且点名商户号与挡路那一行的租户
+    //     ⇒ `save_insertBranch_mchNoTakenByAnotherRow_throws409`;
+    //   · 没行同号 ⇒ 照常写入(正对照,防「无条件 409」)
+    //     ⇒ `save_insertBranch_mchNoProbeClear_inserts`。
+    //
+    // ★ **这里看不见另一半保证**:「一个租户多个商户号」还要求数据库那条
+    //   `uk_lsy_merchant_tenant_level` 真的被删掉 —— 本类的 mapper 是 mock 的,
+    //   索引不参与。索引若还在,探测放行之后 insert 照样被拒成 500。
+    //   见 `db/lsy-merchant-multi-per-tenant.sql`(**必须先执行再部署**)。
 
     /**
-     * ★ 探测条件本身是本修复的实质,必须单独钉 —— 上面那条只证明「有行挡路就 409」。
+     * ★ 探测条件本身是本修复的实质,必须单独钉 —— 上面那条只证明「有行同号就 409」。
      *
-     * <p>少了 {@code isNull(enterpriseId)},探测就退化成「本租户的任意一行」⇒
-     * **租户下只要有一行企业级映射,租户级新增就被误拒**(那是一条合法路径:
-     * 先给某企业配一号、再配一条租户级兜底)。少了 {@code eq(tenantId)},
-     * **别的租户**的租户级行会把本租户的新增误拒。
-     * 两个方向都不会报错,只会让人建不出号 —— 所以读的是 WHERE 的形状,不是「成功与否」。
+     * <p>少了 {@code eq(mchNo)},探测退化成「表里**任意**一行」⇒ 表里只要有数据,
+     * 每一个新增都被误拒。反过来,多带了 {@code tenant_id} 或 {@code enterprise_id} 谓词,
+     * 就是**把刚删掉的那两条租户内槽位探测又请回来**:同租户有另一行(哪怕号不同)
+     * 就会被误拒,「一个租户多个商户号」当场失效。两个方向都不会报错,只会让人建不出号
+     * —— 所以读的是 WHERE 的形状,不是「成功与否」。
      *
      * <p>注意断言用的是**捕获到的 wrapper**(`any()` 会把「按哪个键查」整个吃掉)。
      */
     @Test
-    void save_insertBranch_tenantLevelProbe_isScopedToTenantAndNullEnterpriseId() {
+    void save_insertBranch_mchNoProbe_isScopedToThatExactMchNo() {
         LsyMerchantEntity body = new LsyMerchantEntity();
         body.setMchNo("Y00000000009");
         body.setTenantId(77L);
@@ -944,61 +864,13 @@ class LsyMerchantServiceTest {
 
         AbstractWrapper<?, ?, ?> probe = firstSelectOneWrapper();
         String seg = probe.getSqlSegment();
-        assertTrue(Pattern.compile("enterprise_id\\s+IS\\s+NULL", Pattern.CASE_INSENSITIVE)
-                        .matcher(seg).find(),
-                "租户级探测必须带 `enterprise_id IS NULL` —— 少了它,租户下有任何一行企业级映射"
-                        + "就会把租户级新增误拒。实测 WHERE: " + seg);
-        assertEquals(77L, boundPredicateValue(probe, "tenant_id"),
-                "租户谓词必须绑在入参租户上。实测 WHERE: " + seg);
-    }
-
-    @Test
-    void save_insertBranch_enterpriseSlotTaken_throws409NamingTheBlocker() {
-        LsyMerchantEntity body = new LsyMerchantEntity();
-        body.setEnterpriseId("ENT1");
-        body.setMchNo("Y00000000009");
-        body.setTenantId(77L);
-        when(merchantMapper.selectOne(any()))
-                .thenReturn(existingRow(9L, 77L, "ENT1", "Y52211090004"));
-
-        BusinessException ex = assertThrows(BusinessException.class, () -> service.save(body));
-
-        assertEquals(409, ex.getCode(), "实测: " + ex.getCode());
-        assertTrue(ex.getMessage().contains("Y52211090004"),
-                "同款:提示要点名挡路那一行的商户号。实测: " + ex.getMessage());
-        verify(merchantMapper, never()).insert(any());
-    }
-
-    /**
-     * 正对照:**企业级**探测不得带上 {@code enterprise_id IS NULL}。
-     *
-     * <p>若把两条探测合成一条(例如无条件地 {@code isNull}),企业级新增会被
-     * 自己那一条探测整段跳过 —— 上面那条企业级冲突用例就会红;反过来,若企业级分支
-     * 误用了租户级谓词,本用例红。两条各钉一个方向。
-     */
-    @Test
-    void save_insertBranch_enterpriseProbe_isScopedToTheExactEnterprise() {
-        LsyMerchantEntity body = new LsyMerchantEntity();
-        body.setEnterpriseId("ENT1");
-        body.setMchNo("Y00000000009");
-        body.setTenantId(77L);
-        when(merchantMapper.insert(any())).thenAnswer(inv -> {
-            ((LsyMerchantEntity) inv.getArgument(0)).setId(9L);
-            return 1;
-        });
-        when(merchantMapper.selectById(9L)).thenReturn(row(9L));
-
-        service.save(body);
-
-        AbstractWrapper<?, ?, ?> probe = firstSelectOneWrapper();
-        String seg = probe.getSqlSegment();
-        assertFalse(Pattern.compile("enterprise_id\\s+IS\\s+NULL", Pattern.CASE_INSENSITIVE)
-                        .matcher(seg).find(),
-                "企业级探测绑的是具体企业,不是 NULL。实测 WHERE: " + seg);
-        assertEquals(77L, boundPredicateValue(probe, "tenant_id"),
-                "实测 WHERE: " + seg);
-        assertEquals("ENT1", boundPredicateValue(probe, "enterprise_id"),
-                "实测 WHERE: " + seg);
+        assertEquals("Y00000000009", boundPredicateValue(probe, "mch_no"),
+                "探测必须绑在**本次要写的那个号**上。实测 WHERE: " + seg);
+        assertFalse(seg.contains("tenant_id"),
+                "探测不得带租户谓词 —— 带上就成了「本租户内重号」,"
+                        + "而 uk_lsy_merchant_mch_no 是**全局**唯一。实测 WHERE: " + seg);
+        assertFalse(seg.contains("enterprise_id"),
+                "探测不得带企业谓词 —— 那条维度已随去企业化删除。实测 WHERE: " + seg);
     }
 
     @Test
@@ -1006,14 +878,18 @@ class LsyMerchantServiceTest {
         LsyMerchantEntity body = new LsyMerchantEntity();
         body.setMchNo("Y52211090004");   // 与库里已有的那行**同号**
         body.setTenantId(77L);
+        // 单条探测:只 stub 一次。挡路那一行属于**另一个租户**(42)——
+        // 全局唯一本来就跨租户生效,这里顺带钉住「不是只查自己租户」。
         when(merchantMapper.selectOne(any()))
-                .thenReturn(null)                    // 第 1 次探测:本租户的租户级槽位空着
-                .thenReturn(existingRow(9L, 42L, "ENT9", "Y52211090004"));  // 第 2 次:商户号被占了
+                .thenReturn(existingRow(9L, 42L, "Y52211090004"));
 
         BusinessException ex = assertThrows(BusinessException.class, () -> service.save(body));
 
-        assertEquals(409, ex.getCode(), "实测: " + ex.getCode());
-        assertTrue(ex.getMessage().contains("Y52211090004"), "实测: " + ex.getMessage());
+        assertEquals(409, ex.getCode(),
+                "撞唯一键是**冲突**,不是「服务器内部错误」。实测: " + ex.getCode());
+        assertTrue(ex.getMessage().contains("Y52211090004"),
+                "提示必须点名挡路那一行的商户号 —— 否则操作员只知道「撞了」,不知道去删哪一行。实测: "
+                        + ex.getMessage());
         assertTrue(ex.getMessage().contains("42"),
                 "要说清是**哪个租户**占了这个号 —— 否则超管在一张跨租户的表里找不到那一行。实测: "
                         + ex.getMessage());
@@ -1021,14 +897,13 @@ class LsyMerchantServiceTest {
     }
 
     /**
-     * 正对照:两条探测都没有命中时**必须照常写入**(且归属/企业原样落库)。
+     * 正对照:探测没命中时**必须照常写入**(且归属原样落库)。
      *
-     * <p>缺了它,把预检写成「无条件 409」也能让上面四条全绿。
+     * <p>缺了它,把预检写成「无条件 409」也能让上面两条全绿。
      */
     @Test
-    void save_insertBranch_allProbesClear_inserts() {
+    void save_insertBranch_mchNoProbeClear_inserts() {
         LsyMerchantEntity body = new LsyMerchantEntity();
-        body.setEnterpriseId("ENT1");
         body.setMchNo("Y00000000009");
         body.setTenantId(77L);
         when(merchantMapper.selectOne(any())).thenReturn(null);
@@ -1043,15 +918,15 @@ class LsyMerchantServiceTest {
         ArgumentCaptor<LsyMerchantEntity> inserted = ArgumentCaptor.forClass(LsyMerchantEntity.class);
         verify(merchantMapper).insert(inserted.capture());
         assertEquals(77L, inserted.getValue().getTenantId());
-        assertEquals("ENT1", inserted.getValue().getEnterpriseId());
+        assertEquals("Y00000000009", inserted.getValue().getMchNo());
     }
 
     /**
      * 编辑分支**不接受**改归属:请求体里带的 tenant_id 进不了 SET。
      *
-     * <p>★ 这是**结构保证**(SET 列表是 5 个业务列的字面枚举,tenant_id 不在其中),
+     * <p>★ 这是**结构保证**(SET 列表是 4 个业务列的字面枚举,tenant_id 不在其中),
      * 不是一句「我们会小心」——同一条性质由
-     * {@link #save_updateBranch_writesExactlyTheSixOwnedColumns} 的
+     * {@link #save_updateBranch_writesExactlyTheFiveOwnedColumns} 的
      * {@code assertFalse(cols.contains("tenant_id"))} 从**列集合**那一侧再钉一次。
      * 两处都留着:本条读的是「带着一个不同的 tenant_id 进来时 SET 里仍没有它」,
      * 那条读的是「不带时也没有」——只留一条的话,另一种输入形态就没被走过。
@@ -1367,11 +1242,10 @@ class LsyMerchantServiceTest {
 
     // ─────────────────────────────────── 夹具 ───────────────────────────────────
 
-    /** 页面送来的编辑请求体:只有 `id` + 5 个业务键(`merchant/index.vue` 的 FORM_FIELDS),服务端字段一个都不带 */
+    /** 页面送来的编辑请求体:只有 `id` + 4 个业务键(`merchant/index.vue` 的 FORM_FIELDS),服务端字段一个都不带 */
     private LsyMerchantEntity editBody() {
         LsyMerchantEntity e = new LsyMerchantEntity();
         e.setId(EDIT_ID);
-        e.setEnterpriseId("ENT1");
         e.setMchNo("Y52211090004");
         return e;
     }
@@ -1379,26 +1253,25 @@ class LsyMerchantServiceTest {
     private static LsyMerchantEntity row(long id) {
         LsyMerchantEntity r = new LsyMerchantEntity();
         r.setId(id);
-        r.setEnterpriseId("ENT1");
         r.setMchNo("Y52211090004");
         return r;
     }
 
     /** 库里**已经有**的那一行 —— 预检要读出它的 mch_no / tenant_id 写进提示里。 */
-    private static LsyMerchantEntity existingRow(long id, Long tenantId, String enterpriseId, String mchNo) {
+    private static LsyMerchantEntity existingRow(long id, Long tenantId, String mchNo) {
         LsyMerchantEntity r = new LsyMerchantEntity();
         r.setId(id);
         r.setTenantId(tenantId);
-        r.setEnterpriseId(enterpriseId);
         r.setMchNo(mchNo);
         return r;
     }
 
     /**
-     * 唯一约束预检的**第一次** selectOne(`any()` 会把「按哪个键查」整个吃掉,必须单独取出来看)。
+     * 唯一约束预检那次 selectOne(`any()` 会把「按哪个键查」整个吃掉,必须单独取出来看)。
      *
-     * <p>用 {@code atLeastOnce()} 而不是 {@code times(1)}:预检本来就有两次探测
-     * (先租户内槽位、后全局商户号),顺利路径上两次都会跑到。
+     * <p>★ 2026-09-30 去企业化后预检只剩**一次**探测(原先是「先租户级槽位、后全局商户号」两次)。
+     * 仍用 {@code atLeastOnce()} 而不是 {@code times(1)}:本夹具被多个用例调用,
+     * 这里要的是「取出那一次的形状」,把次数断言并进来只会让它在别的用例里变成假红。
      */
     private AbstractWrapper<?, ?, ?> firstSelectOneWrapper() {
         @SuppressWarnings("unchecked")