Преглед на файлове

fix(乐收赢): 商户号登记撞唯一约束不再 500,改为点名挡路那一行的 409

线上报障:超管删除一个商户后重新新增,得到
「duplicate key value violates unique constraint "uk_lsy_merchant_tenant_level"
详细:Key (tenant_id)=(2) already exists」,外面裹着 HTTP 500「服务器内部错误」
和一坨 PSQL 原文。操作员据此无法知道挡路的是哪一行、该怎么办。

根因不是软删。`pay_lsy_merchant` 上三条唯一约束都由数据库把关,而两条写入路径
(`save` 的新增分支、`createOnboardingRow`)**一条都没预检**,撞了就直接抛;
`GlobalExceptionHandler` 只有 5 个 handler,没有 `DuplicateKeyException` 分支,
于是落到兜底 `Exception` → 500 + 原始 SQL。障碍行本身也没被删掉:
`delete` 走的是真删(`selectById` 后 `deleteById`,超管在
`TenantInnerInterceptor.ignoreTable` 里被放行 ⇒ 无租户谓词),
而 tenant 2 的租户级槽位(`enterprise_id IS NULL`)自三-B 进件联调起就占着
(读数见 `db/lsy-merchant-mch-no-uk.sql` 头部:候选 B id=2102313081860202497),
用户删掉的是**另一行**。

三条约束:
  · `uk_lsy_merchant_tenant_level` = `UNIQUE(tenant_id) WHERE enterprise_id IS NULL`
    —— 偏索引。`uk_lsy_merchant_tenant_ent` 办不到这件事,因为 PG 认为 NULL 互不相同。
  · `uk_lsy_merchant_tenant_ent`  = `UNIQUE(tenant_id, enterprise_id)`(V1.8 基线)
  · `uk_lsy_merchant_mch_no`      = `UNIQUE(mch_no)`,**全局**、不带 tenant_id

改法:新增私有 `rejectUniqueConflicts`,两条写入路径共用。三条各探测一次,
命中即抛 `BusinessException(409, …)`,消息里点名挡路那一行的商户号(第三条附带租户 ID
—— 超管那张表跨租户,只给租户号找不到行)。

**这个方法不是保证,只是措辞。** 保证仍是数据库那三条索引:探测与 insert 之间的
TOCTOU 窗口依然存在(超管两个会话同时新增,后来者照样吃 500),与修复前同形。
事务关不掉这个窗口,所以没往这上面加 `@Transactional`。

探测顺序 = 消息优先级:租户内那两条在前,全局 `mch_no` 在后 —— 同一个 `mch_no`
既撞租户槽位又撞全局时,先说「这个租户已经有租户级号了」,因为那是能自己动手解决的那条。

**一处故意不修**:编辑分支没有这道预检。要判「撞的是不是别人」必须读出旧行
(租户谓词只能从旧行取 —— 请求体里的 `tenant_id` 在编辑路径上被忽略),
而 `save_updateBranch_zeroAffectedRows_throws404` 明确钉着「0 行路径不得回读」。
取舍与剩余敞口写在 `rejectUniqueConflicts` 的 javadoc 里。

测试:新增 8 条(新增分支 6 条 + 进件 2 条,其中 `createOnboardingRow_*_inserts…`
是该方法的**首条** happy path —— 此前唯一那条在第 3 行就 403 返回,整个方法体不可达)。
红:实现前 8 条全红(6 条 "Expected BusinessException to be thrown, but nothing was thrown",
2 条 `UnnecessaryStubbingException`)。绿:本类 51/51,全量 950/950 BUILD SUCCESS。
两向变异:
  · 摘掉租户级探测的 `.isNull(enterpriseId)` ⇒ 恰好 1 条红(探针范围那条);
  · 把 `mch_no` 探测挪到最前 ⇒ 6 条红(含钉顺序的
    `save_insertBranch_mchNoTakenByAnotherRow_throws409` —— 它拿到了租户级那条消息)。
还原后 51/51,生产文件 sha256 与变异前逐字节相同。
alphaH преди 2 седмици
родител
ревизия
607f3394b8

+ 103 - 2
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantService.java

@@ -369,7 +369,9 @@ public class LsyMerchantService {
      * 调用方会静默拿到「当前登录者的租户」而不是一个错误。所以这里先显式拦 {@code null},
      * 再把值写上,让自动填充无从介入。
      *
-     * <p>★ <b>本方法刻意不加 {@code @Transactional}</b>:只有一条 INSERT,事务不提供任何东西;
+     * <p>★ <b>本方法刻意不加 {@code @Transactional}</b>:写库的只有一条 INSERT(外加唯一约束预检
+     * 的两次只读探测,见 {@link #rejectUniqueConflicts}),事务不提供任何东西 ——
+     * 探测与 insert 之间的 TOCTOU 窗口不是事务能关的,关它的是数据库那三条唯一索引。
      * 真正需要补偿的窗口在**上游建号成功、本地落行失败**那半边,
      * 而那次上游调用不在任何本地事务的覆盖范围内。(该窗口未做补偿,已在报告中列出。)
      *
@@ -388,11 +390,18 @@ public class LsyMerchantService {
             throw new BusinessException(400, "商户号不能为空");
         }
 
+        // 先归一化、再探测、再落行 —— 三步必须用**同一个**值。探测按「空白 ⇒ null」分流到租户级那条,
+        // 而 insert 落的也是 NULL;若探测时还用着未归一化的 "   ",探的是企业级、写的是租户级,
+        // 于是挡路的行探测不到、数据库照样拒(就退回修复前那个 500)。
+        String normalizedEnterpriseId = LsyStrings.blankToNull(enterpriseId);
+        // 唯一约束预检 —— 与 save 的新增分支共用同一道,理由见 rejectUniqueConflicts。
+        rejectUniqueConflicts(tenantId, normalizedEnterpriseId, mchNo);
+
         LsyMerchantEntity row = new LsyMerchantEntity();
         row.setTenantId(tenantId);
         // 可空列:空 ⇒ NULL(口径见 LsyStrings)—— 存成空串的话任何 `IS NULL` 都查不到这一行,
         // 而「租户级映射」找的正是 enterprise_id IS NULL。
-        row.setEnterpriseId(LsyStrings.blankToNull(enterpriseId));
+        row.setEnterpriseId(normalizedEnterpriseId);
         row.setMchNo(mchNo);
         row.setMerchantName(LsyStrings.blankToNull(merchantName));
         // ★ 新行是「待审核」,不是建表语句默认的 APPROVED,也不是上游的码值。
@@ -564,6 +573,13 @@ public class LsyMerchantService {
             if (entity.getTenantId() == null) {
                 throw new BusinessException(400, "请选择租户");
             }
+            // ★ 唯一约束预检(2026-09-30 线上 500 回归)—— 见 rejectUniqueConflicts。
+            //   位置在「归属说清楚」之后、「补默认值」之前:400 是调用方契约违例,必须比 409 先生效。
+            //   ★ 只在新增加这道理,**编辑分支没有**:那条路要判「是不是撞了别人」就得读一次旧行
+            //     (租户谓词只能从旧行取 —— 请求体里的 tenant_id 在编辑路径上是**被忽略**的),
+            //     而 save_updateBranch_zeroAffectedRows_throws404 明确钉着「0 行路径不得回读」。
+            //     取舍与剩余敞口见 rejectUniqueConflicts 的 javadoc。
+            rejectUniqueConflicts(entity.getTenantId(), entity.getEnterpriseId(), entity.getMchNo());
             // ★ 默认值只在新增分支补 —— 见方法注释
             if (StrUtil.isBlank(entity.getReviewStatus())) {
                 entity.setReviewStatus("APPROVED");   // A 阶段手工维护,默认视为已审核
@@ -604,4 +620,89 @@ public class LsyMerchantService {
         }
         merchantMapper.deleteById(id);
     }
+
+    /**
+     * 写入前的唯一性预检 —— 把三条唯一约束的违约,从 PostgreSQL 的 500 换成一句能照做的人话。
+     *
+     * <p><b>为什么需要它(2026-09-30 用户报的线上缺陷):</b>{@code pay_lsy_merchant} 上有三条唯一约束,
+     * 而两条写入口({@link #save} 的新增分支、{@link #createOnboardingRow})原先都是**直接 insert、
+     * 一条都不先探**。撞上时的出口是 {@code GlobalExceptionHandler} 的兜底分支
+     * (实测该 handler 只有 BusinessException / AccessDeniedException / 参数校验 / NoResourceFound /
+     * 兜底五个,**没有** DuplicateKeyException / DataIntegrityViolationException)
+     * ⇒ HTTP 500「服务器内部错误」+ 日志里一段 PSQL 原文,操作员看不到「哪一行挡着、该怎么办」。
+     * 实测复现:超管在商户页给租户 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>本方法不是保证,只是文案。</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,两边对不上
+     */
+    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() + ")—— "
+                                + "请改为编辑那一行,或先删除它");
+            }
+        }
+
+        LsyMerchantEntity sameNo = merchantMapper.selectOne(new LambdaQueryWrapper<LsyMerchantEntity>()
+                .eq(LsyMerchantEntity::getMchNo, mchNo)
+                .last("LIMIT 1"));
+        if (sameNo != null) {
+            // 带上租户 ID:超管那张表是跨租户的,不给租户的话「去表里找那一行」找不到。
+            // 也回显一次商户号本身:另外两条消息都以挡路那一行的商户号开头,这条若只有租户 ID,
+            // 三种 409 里就它答不出「挡着的是哪个号」。
+            throw new BusinessException(409,
+                    "该商户号已被登记(" + mchNo + ",租户 ID " + sameNo.getTenantId() + ")—— "
+                            + "同一商户号不能登记两次,请核对是否填错了商户号");
+        }
+    }
 }

+ 235 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantServiceTest.java

@@ -350,6 +350,50 @@ class LsyMerchantServiceTest {
         verify(merchantMapper, never()).insert(any());
     }
 
+    /**
+     * 正对照:槽位空着时必须**照常落行** —— 这条在本类里是新的。
+     *
+     * <p>此前 {@code createOnboardingRow} 只有「null 租户 ⇒ 403」一条用例,成功路径**一次都没走过**:
+     * 那条用例里的 403 在方法第 3 行就抛了,下面写 {@code PENDING} / {@code blankToNull} /
+     * {@code insert} 的整段是**不可达**的。只靠它,「本方法被整段改成空实现」也全绿。
+     * 本次要给这个方法加唯一约束预检,预检的正对照就是这条。
+     */
+    @Test
+    void createOnboardingRow_slotsClear_insertsWithPendingReviewStatus() {
+        when(merchantMapper.selectOne(any())).thenReturn(null);
+
+        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());
+    }
+
+    /**
+     * 进件那条写入口走的是**同一个**预检(线上 500 的同一个根因)。
+     *
+     * <p>2026-09-23 的 B 阶段报告已经记过这条缺口(「{@code create} 的 insert 会撞既有唯一索引,
+     * 且出口是 HTTP 500」,当时「未获裁定」)。这里与 {@code save} 一并收口:
+     * 预检**只在数据库本来就会拒的时候才拦**,所以它不可能弄坏任何现在能走的流程。
+     */
+    @Test
+    void createOnboardingRow_tenantLevelSlotTaken_throws409() {
+        when(merchantMapper.selectOne(any()))
+                .thenReturn(existingRow(9L, 7L, null, "Y52211090004"));
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.createOnboardingRow(7L, "   ", "Y00000000009", "示例商户"));
+
+        assertEquals(409, ex.getCode(), "实测: " + ex.getCode());
+        assertTrue(ex.getMessage().contains("Y52211090004"), "实测: " + ex.getMessage());
+        verify(merchantMapper, never()).insert(any());
+    }
+
     // ────────────────── markSubmitted():进件「已提交」记号(2026-09-23 傻瓜化) ──────────────────
 
     /**
@@ -834,6 +878,174 @@ class LsyMerchantServiceTest {
                 "归属必须原样落库。实测: " + inserted.getValue().getTenantId());
     }
 
+    // ─────────── 两条写入口的唯一约束预检(2026-09-30,线上 500 回归) ───────────
+    //
+    // 【为什么要有预检】`pay_lsy_merchant` 上有三条唯一约束,而两条写入口
+    // (`save` 的新增分支、`createOnboardingRow`)都是**直接 insert、一条都不先探**。
+    // 撞上时 `GlobalExceptionHandler` 没有 `DuplicateKeyException` 分支(实测只有
+    // BusinessException / AccessDeniedException / 参数校验 / NoResourceFound / 兜底五个),
+    // ⇒ 落到兜底 ⇒ HTTP 500「服务器内部错误」,操作员看不到「哪一行挡着、该怎么办」。
+    //
+    // 【线上实测(2026-09-30,用户报)】超管在商户页给租户 2 新增一条「企业 ID 留空」的行:
+    //   duplicate key value violates unique constraint "uk_lsy_merchant_tenant_level"
+    //   Key (tenant_id)=(2) already exists.
+    //   抛在 LsyMerchantService.save:574 的 insert 上。
+    //
+    // 【为什么是预检,不是 catch 异常】预检能**点名挡路那一行的商户号**,catch 只能说「撞了唯一键」。
+    // 代价是一个 TOCTOU 窗口(两个并发新增)—— 本页是超管单人操作的后台,不成立;
+    // 真撞上也只是退回今天的 500,不新增失败模式。
+    // **不**选「给 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());
+    }
+
+    /**
+     * ★ 探测条件本身是本修复的实质,必须单独钉 —— 上面那条只证明「有行挡路就 409」。
+     *
+     * <p>少了 {@code isNull(enterpriseId)},探测就退化成「本租户的任意一行」⇒
+     * **租户下只要有一行企业级映射,租户级新增就被误拒**(那是一条合法路径:
+     * 先给某企业配一号、再配一条租户级兜底)。少了 {@code eq(tenantId)},
+     * **别的租户**的租户级行会把本租户的新增误拒。
+     * 两个方向都不会报错,只会让人建不出号 —— 所以读的是 WHERE 的形状,不是「成功与否」。
+     *
+     * <p>注意断言用的是**捕获到的 wrapper**(`any()` 会把「按哪个键查」整个吃掉)。
+     */
+    @Test
+    void save_insertBranch_tenantLevelProbe_isScopedToTenantAndNullEnterpriseId() {
+        LsyMerchantEntity body = new LsyMerchantEntity();
+        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();
+        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);
+    }
+
+    @Test
+    void save_insertBranch_mchNoTakenByAnotherRow_throws409() {
+        LsyMerchantEntity body = new LsyMerchantEntity();
+        body.setMchNo("Y52211090004");   // 与库里已有的那行**同号**
+        body.setTenantId(77L);
+        when(merchantMapper.selectOne(any()))
+                .thenReturn(null)                    // 第 1 次探测:本租户的租户级槽位空着
+                .thenReturn(existingRow(9L, 42L, "ENT9", "Y52211090004"));  // 第 2 次:商户号被占了
+
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.save(body));
+
+        assertEquals(409, ex.getCode(), "实测: " + ex.getCode());
+        assertTrue(ex.getMessage().contains("Y52211090004"), "实测: " + ex.getMessage());
+        assertTrue(ex.getMessage().contains("42"),
+                "要说清是**哪个租户**占了这个号 —— 否则超管在一张跨租户的表里找不到那一行。实测: "
+                        + ex.getMessage());
+        verify(merchantMapper, never()).insert(any());
+    }
+
+    /**
+     * 正对照:两条探测都没有命中时**必须照常写入**(且归属/企业原样落库)。
+     *
+     * <p>缺了它,把预检写成「无条件 409」也能让上面四条全绿。
+     */
+    @Test
+    void save_insertBranch_allProbesClear_inserts() {
+        LsyMerchantEntity body = new LsyMerchantEntity();
+        body.setEnterpriseId("ENT1");
+        body.setMchNo("Y00000000009");
+        body.setTenantId(77L);
+        when(merchantMapper.selectOne(any())).thenReturn(null);
+        when(merchantMapper.insert(any())).thenAnswer(inv -> {
+            ((LsyMerchantEntity) inv.getArgument(0)).setId(9L);
+            return 1;
+        });
+        when(merchantMapper.selectById(9L)).thenReturn(row(9L));
+
+        service.save(body);
+
+        ArgumentCaptor<LsyMerchantEntity> inserted = ArgumentCaptor.forClass(LsyMerchantEntity.class);
+        verify(merchantMapper).insert(inserted.capture());
+        assertEquals(77L, inserted.getValue().getTenantId());
+        assertEquals("ENT1", inserted.getValue().getEnterpriseId());
+    }
+
     /**
      * 编辑分支**不接受**改归属:请求体里带的 tenant_id 进不了 SET。
      *
@@ -1172,6 +1384,29 @@ class LsyMerchantServiceTest {
         return r;
     }
 
+    /** 库里**已经有**的那一行 —— 预检要读出它的 mch_no / tenant_id 写进提示里。 */
+    private static LsyMerchantEntity existingRow(long id, Long tenantId, String enterpriseId, String mchNo) {
+        LsyMerchantEntity r = new LsyMerchantEntity();
+        r.setId(id);
+        r.setTenantId(tenantId);
+        r.setEnterpriseId(enterpriseId);
+        r.setMchNo(mchNo);
+        return r;
+    }
+
+    /**
+     * 唯一约束预检的**第一次** selectOne(`any()` 会把「按哪个键查」整个吃掉,必须单独取出来看)。
+     *
+     * <p>用 {@code atLeastOnce()} 而不是 {@code times(1)}:预检本来就有两次探测
+     * (先租户内槽位、后全局商户号),顺利路径上两次都会跑到。
+     */
+    private AbstractWrapper<?, ?, ?> firstSelectOneWrapper() {
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Wrapper<LsyMerchantEntity>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(merchantMapper, atLeastOnce()).selectOne(captor.capture());
+        return (AbstractWrapper<?, ?, ?>) captor.getAllValues().get(0);
+    }
+
     private LambdaUpdateWrapper<?> capturedUpdate() {
         ArgumentCaptor<LambdaUpdateWrapper> captor = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
         verify(merchantMapper).update(any(), captor.capture());