|
|
@@ -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")
|