|
|
@@ -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());
|