|
@@ -1,5 +1,6 @@
|
|
|
package com.payment.platform.module.payment.har.service;
|
|
package com.payment.platform.module.payment.har.service;
|
|
|
|
|
|
|
|
|
|
+import cn.hutool.core.util.IdUtil;
|
|
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
|
|
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
|
|
|
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
|
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
|
@@ -13,8 +14,10 @@ import org.junit.jupiter.api.BeforeEach;
|
|
|
import org.junit.jupiter.api.Test;
|
|
import org.junit.jupiter.api.Test;
|
|
|
import org.junit.jupiter.api.extension.ExtendWith;
|
|
import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
import org.mockito.ArgumentCaptor;
|
|
import org.mockito.ArgumentCaptor;
|
|
|
|
|
+import org.mockito.MockedStatic;
|
|
|
import org.mockito.Mock;
|
|
import org.mockito.Mock;
|
|
|
import org.mockito.junit.jupiter.MockitoExtension;
|
|
import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
|
|
+import org.springframework.dao.DuplicateKeyException;
|
|
|
import org.springframework.mock.web.MockHttpServletRequest;
|
|
import org.springframework.mock.web.MockHttpServletRequest;
|
|
|
|
|
|
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.nio.charset.StandardCharsets;
|
|
@@ -93,11 +96,35 @@ class HarCheckoutLinkServiceTest {
|
|
|
assertEquals("测试商户", inserted.getMerchantName());
|
|
assertEquals("测试商户", inserted.getMerchantName());
|
|
|
assertEquals("ENABLED", inserted.getStatus(), "新链接必须是可用状态(显式写,不靠建表默认值)");
|
|
assertEquals("ENABLED", inserted.getStatus(), "新链接必须是可用状态(显式写,不靠建表默认值)");
|
|
|
assertNotNull(inserted.getToken());
|
|
assertNotNull(inserted.getToken());
|
|
|
- assertEquals(32, inserted.getToken().length(), "fastSimpleUUID 形态:32 位十六进制");
|
|
|
|
|
|
|
+ assertEquals(32, inserted.getToken().length(), "simpleUUID 形态:32 位十六进制");
|
|
|
assertTrue(inserted.getToken().matches("[0-9a-f]{32}"), "实际: " + inserted.getToken());
|
|
assertTrue(inserted.getToken().matches("[0-9a-f]{32}"), "实际: " + inserted.getToken());
|
|
|
assertSame(inserted, created, "返回值必须是落库的那一行(调用方要拿它的 token 拼 URL)");
|
|
assertSame(inserted, created, "返回值必须是落库的那一行(调用方要拿它的 token 拼 URL)");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * token 的随机源必须是 CSPRNG(2026-10-06 评审 F4)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>这是**唯一**能钉住这件事的形态:{@code fastSimpleUUID()}(ThreadLocalRandom,48 位 LCG)
|
|
|
|
|
+ * 与 {@code simpleUUID()}(SecureRandom)产出的字符串**一模一样**(都是 32 位 hex)——
|
|
|
|
|
+ * 上面那个形状断言对「换错生成器」永远是绿的,而免登录路径的安全性全押在「这串字符猜不到」
|
|
|
|
|
+ * (类 javadoc「(甲)·预防」第 2 条)。所以这里不测形状,直接钉住**调的是哪一个方法**。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void issue_tokenComesFromSimpleUuid_notFastSimpleUuid() {
|
|
|
|
|
+ when(linkMapper.selectList(any())).thenReturn(List.of());
|
|
|
|
|
+
|
|
|
|
|
+ try (MockedStatic<IdUtil> idUtil = mockStatic(IdUtil.class)) {
|
|
|
|
|
+ idUtil.when(IdUtil::simpleUUID).thenReturn("0123456789abcdef0123456789abcdef");
|
|
|
|
|
+
|
|
|
|
|
+ HarCheckoutLinkEntity created = service.issue(TENANT, MERCHANT_CODE, "测试商户");
|
|
|
|
|
+
|
|
|
|
|
+ idUtil.verify(IdUtil::simpleUUID);
|
|
|
|
|
+ idUtil.verify(IdUtil::fastSimpleUUID, never());
|
|
|
|
|
+ assertEquals("0123456789abcdef0123456789abcdef", created.getToken(),
|
|
|
|
|
+ "生成器的返回值必须原样落到那一行上");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
@Test
|
|
@Test
|
|
|
void issue_whenRowExists_returnsSameToken_andDoesNotInsert() {
|
|
void issue_whenRowExists_returnsSameToken_andDoesNotInsert() {
|
|
|
HarCheckoutLinkEntity existing = row(1L, "abcd1234abcd1234abcd1234abcd1234", "ENABLED");
|
|
HarCheckoutLinkEntity existing = row(1L, "abcd1234abcd1234abcd1234abcd1234", "ENABLED");
|
|
@@ -173,6 +200,39 @@ class HarCheckoutLinkServiceTest {
|
|
|
verify(linkMapper, never()).insert(any());
|
|
verify(linkMapper, never()).insert(any());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 并发双击:两个请求都读到「没有这一行」⇒ 都去 insert ⇒ 后到的撞
|
|
|
|
|
+ * {@code uk_har_checkout_tenant_mch}(2026-10-06 评审 F2)。
|
|
|
|
|
+ * 幂等承诺必须在**并发**下也成立:撞了就按同一 (租户, 商户编号) 重读,
|
|
|
|
|
+ * 把先插的那一行交出去 —— 而不是把异常抛成 500,让运营看到一次
|
|
|
|
|
+ * 「按钮报错了」而链接其实已经建好。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void issue_whenConcurrentInsertLosesRace_reReadsAndReturnsTheWinnersRow() {
|
|
|
|
|
+ HarCheckoutLinkEntity winner = row(1L, "winner", "ENABLED");
|
|
|
|
|
+ when(linkMapper.selectList(any())).thenReturn(List.of(), List.of(winner));
|
|
|
|
|
+ doThrow(new DuplicateKeyException("uk_har_checkout_tenant_mch"))
|
|
|
|
|
+ .when(linkMapper).insert(any());
|
|
|
|
|
+
|
|
|
|
|
+ HarCheckoutLinkEntity got = service.issue(TENANT, MERCHANT_CODE, "测试商户");
|
|
|
|
|
+
|
|
|
|
|
+ assertSame(winner, got, "并发输掉 insert 的一方要把赢家那一行交出去(同一个 token,同一条链接)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * insert 撞的**不是**那条索引(重读仍为空 —— 例如 token 撞车)⇒ 原样抛出,不吞。
|
|
|
|
|
+ * 吞掉的后果是「链接没建出来却报了成功」,比 500 坏得多。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void issue_whenInsertFails_andReReadStillEmpty_rethrows() {
|
|
|
|
|
+ when(linkMapper.selectList(any())).thenReturn(List.of(), List.of());
|
|
|
|
|
+ doThrow(new DuplicateKeyException("uk_har_checkout_token"))
|
|
|
|
|
+ .when(linkMapper).insert(any());
|
|
|
|
|
+
|
|
|
|
|
+ assertThrows(DuplicateKeyException.class,
|
|
|
|
|
+ () -> service.issue(TENANT, MERCHANT_CODE, "测试商户"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// ─────────────────────────── 按 token 解析(免登录) ───────────────────────────
|
|
// ─────────────────────────── 按 token 解析(免登录) ───────────────────────────
|
|
|
|
|
|
|
|
@Test
|
|
@Test
|