瀏覽代碼

fix(汇安融): 收款链接评审修复 —— 并发幂等、顾客语 404、token 换 CSPRNG

2026-10-06 独立评审(agent har-checkout-review,台账 logs/_ledger-append-har-checkout-review.md)
提了 F1..F5;用户授权「你决策就行哈」后:修 F2/F3/F4,不修 F1/F5(理由见台账「修复轮」)。
四个文件,全部是本特性自己的新代码。

F2 并发双击 ⇒ 500(HarCheckoutLinkService.issue)
- 先读后插之间的窗口:两个请求都读到「没有这一行」⇒ 都 insert ⇒ 后到的撞
  uk_har_checkout_tenant_mch,异常穿成 500,而链接其实已经建好 ——
  「重复取链接返回同一条」这个承诺在并发下不成立。
- 改为 insert 包 catch DataIntegrityViolationException → 按同一 (租户, 商户编号) 重读:
  命中 1 行 ⇒ 返回赢家那一行(log.info,同一条链接同一个 token);
  仍为空(撞的是别的索引,例如 token 撞车)⇒ 原样抛出,绝不吞 ——
  「链接没建出来却报了成功」比一次 500 坏得多。
- 两处读抽出 selectByTenantAndCode:一份谓词,免得两处 WHERE 慢慢长歪(少一个条件就是跨租户)。
- ★ 能捕到靠 mybatis-spring 的异常翻译(SQLIntegrityConstraintViolation → DuplicateKeyException);
  裸 PersistenceException 会穿过去 —— 那是与修复前完全相同的行为,不是新增盲区。注释里写明了。

F3 管理端文案上屏给顾客(HarCheckoutController.create)
- 本地闸 requireByTenantAndCode 抛的是「商户编号不存在或不属于当前租户: X」。
  这句话写给我们自己的操作员是恰当的,取链接、收银台几条腿共用同一条消息、**不改**;
  但这条腿的出口是免登录顾客页,前端 checkout/har/index.vue:395 把 res.msg 原样显示
  ⇒ 顾客会读到「租户」「商户编号」这些内部词汇加一串内部编号。
- 改为就地 catch BusinessException → log.warn 留痕 + 换顾客语常量再抛。
  状态码照旧 404(它是有意的,不变)。
- 文案「收款方暂时无法收款,请稍后重试或联系收款方」,与 MSG_PAY_FAILED 的差别是刻意的:
  那条重按有用(再按生成新单号,重试安全);这条重按没用(商户配置不会自愈)⇒ 指引带「联系收款方」。

F4 token 随机源不是 CSPRNG + 注释失实(HarCheckoutLinkService / HarCheckoutController)
- javap 复现(量具 hutool-all-5.8.34):IdUtil.fastSimpleUUID() → randomUUID(false)
  → RandomUtil.getRandom() = ThreadLocalRandom(48 位 LCG);只有无参 randomUUID()
  (= UUID.randomUUID().toString(true))走 SecureRandom。
- 两者产出的字符串**形态一模一样**(32 位 hex)—— 这正是当初写错还看不出来的原因,
  而免登录路径的安全性全押在「这串字符猜不到」上(类 javadoc「(甲)·预防」第 2 条)。
- 类注释原文「IdUtil.fastSimpleUUID,122 bit 熵」两头都不对(122 是 UUIDv4 的格式位,
  本身不是不可预测性论证)。改为 IdUtil.simpleUUID() + 注释订正,并留一段「评审订正」
  说明为什么两者形态相同。未部署 ⇒ 无兼容包袱;已发出的 token 不受影响(库里旧值不动、照用)。
- ★ 2109cb6c 的**提交信息**里仍有那句失实断言 —— 不改历史提交,订正在本条正文里。

测试(四条新用例)
- issue_whenConcurrentInsertLosesRace_reReadsAndReturnsTheWinnersRow(F2 正)
- issue_whenInsertFails_andReReadStillEmpty_rethrows(F2 负:钉「不许吞」;前后都绿,是用例守卫不是 RED→GREEN)
- create_missingMerchantRow_messageIsCustomerFacing_notTheAdminWording(F3)
- issue_tokenComesFromSimpleUuid_notFastSimpleUuid(F4)
  · 这是 F4 **唯一可行**的守卫形态:形状断言对「换错生成器」永远是绿的(两者形态相同),
    所以用 mockStatic 直接钉住调的是哪一个方法。本仓首次用 mockStatic(Mockito 5 inline
    mock maker,实测可用)。

验证读数(本机实跑)
- RED(修复前):mvn -Dtest=HarCheckoutLinkServiceTest,HarCheckoutControllerTest test
  → Tests run: 32, Failures: 1, Errors: 1
    · F3 用例:expected <收款方暂时无法收款…> but was <商户编号不存在或不属于当前租户: M001>
    · F2 用例:DuplicateKeyException uk_har_checkout_tenant_mch 穿出(ERROR)
  (logs/_t-harcheckout-fix-1-red.txt)
- GREEN(修复后):同命令 → Tests run: 33, Failures: 0, Errors: 0,BUILD SUCCESS
  (logs/_t-harcheckout-fix-2-green.txt)
- F4 变异两向:实现换回 fastSimpleUUID() → 恰好那条守卫 RED(19 run / 1 failure),
  而**形状用例仍绿**(证明形状断言确实抓不到这一类);换回 simpleUUID() → 33/33 绿。
  (logs/_t-harcheckout-fix-3-mutant.txt、_t-harcheckout-fix-4-restored.txt)
- 全量:mvn test → Tests run: 1151, Failures: 0, Errors: 0,BUILD SUCCESS
  (修复前 1147 ⇒ +4,正好是这四条;logs/_t-harcheckout-fix-5-full.txt)
- 交付件:jar 已重打 —— target/payment-platform-1.0.0.jar,mtime 2026-10-06 13:37:05,
  sha256 942d3be956884131cc8ced282499d71b0624618e76018c08d0cc4b06ff11fbcf,
  143117157 字节,1180 条目;正对照 HarCheckoutLinkService.class / HarCheckoutController.class
  都在 BOOT-INF(读回 true)。旧 jar 8e9ba10a… 作废。
  frontend/dist.zip **不变**(本轮零前端改动),sha256 1e9716e3…

未验证(如实记)
- F2 那条 catch 的**真实**触发路径未在真库复现(并发时序);且 pay_har_checkout_link 的建表 SQL
  至今没在任何真库跑过 ⇒ 该表上的唯一索引冲突无法实测。单元测试抛的是异常翻译后的形态(应然)。
- pay_url 从未被真机扫过;顾客页从未用真实 token 在浏览器打开过(沿用首次交付未验证项)。
- F1(无限流/金额无上界)与 F5(订单号单前缀 HAR)**未改**,报用户拍板。
alphaH 1 周之前
父節點
當前提交
b97a0e9311

+ 34 - 5
java/src/main/java/com/payment/platform/module/payment/har/controller/HarCheckoutController.java

@@ -53,8 +53,13 @@ import java.math.BigDecimal;
  *       {@code uk_har_checkout_token} 单独建在 {@code token} 上(**不是** {@code (tenant_id, token)})
  *       ⇒ 「同一个 token 命中两行」在 schema 层建不出来。这是本仓通则的 **(甲)·预防**,
  *       论证与「别把它收窄」写在 {@link HarCheckoutLinkService} 的类 javadoc 与建表脚本里。</li>
- *   <li><b>token 是我们生成的 32 位随机十六进制</b>({@code IdUtil.fastSimpleUUID},
- *       122 bit 熵),不是可枚举的序号 ⇒ 拿到别人的链接只能靠拿到那一串字符。</li>
+ *   <li><b>token 是 CSPRNG 生成的 32 位随机十六进制</b>({@code IdUtil.simpleUUID}
+ *       = {@code UUID.randomUUID().toString(true)},走 {@code SecureRandom}),
+ *       不是可枚举的序号 ⇒ 拿到别人的链接只能靠拿到那一串字符。
+ *       <br>★ 2026-10-06 评审订正:此前这里写的是 {@code IdUtil.fastSimpleUUID}「122 bit 熵」,
+ *       两头都失实 —— 那个方法走 {@code RandomUtil.getRandom()} = {@code ThreadLocalRandom}
+ *       (48 位 LCG),而 122 是 UUIDv4 的**格式位**,本身不是不可预测性论证。两者产出的
+ *       字符串**形态一模一样**(这正是当初写错还看不出来的原因),生成器已换成 simpleUUID。</li>
  * </ol>
  * <p>★ 一条同层的硬约束:{@code pay_har_checkout_link} <b>必须</b>登记在
  * {@code TenantInnerInterceptor.CONDITIONAL_TABLES}。漏登记 ⇒ 拦截器给这条查询追加
@@ -136,6 +141,19 @@ public class HarCheckoutController {
      */
     private static final String MSG_PAY_FAILED = "支付发起失败,请稍后重试";
 
+    /**
+     * 本地闸拦下时给顾客的一句话(2026-10-06 评审 F3)。
+     *
+     * <p>本地闸抛的是**管理端**的话({@code "商户编号不存在或不属于当前租户: M001"}),
+     * 它写给我们自己的操作员是恰当的,那条消息在取链接、收银台几条腿上共用、**不能改**;
+     * 但这条腿的出口是没有登录态的顾客页,前端把 {@code res.msg} 原样上屏 ⇒ 不拦一层的话,
+     * 顾客会读到「租户」「商户编号」这些内部词汇加一串内部编号。
+     *
+     * <p>措辞里带「联系收款方」:对顾客而言这里没有自助可做(重按也没用,商户配置不会自愈),
+     * 唯一能推动的是收款方 —— 与 {@link #MSG_PAY_FAILED}(重按有用)是两种不同的指引。
+     */
+    private static final String MSG_MERCHANT_UNAVAILABLE = "收款方暂时无法收款,请稍后重试或联系收款方";
+
     private final HarCheckoutLinkService linkService;
     private final HarMerchantService merchantService;
     private final HarOrderService orderService;
@@ -180,8 +198,11 @@ public class HarCheckoutController {
      * 校验一步都不参与 ⇒ 只挂注解的话这条规则在测试里没有任何守卫)。文案两边同源,
      * 都是 {@link HarAmountFloor#MESSAGE}。
      *
-     * <p>★ <b>本地闸那条 404 在 catch 之外</b>:商户行没了时它直接穿出去;进到 catch 里的
-     * 只有下单腿自己的失败(见类 javadoc「上游失败收两类」)。
+     * <p>★ <b>本地闸那道 404 在「上游失败」的 catch 之外</b>(有意):商户行没了时它是 404,
+     * 不会被折叠成 500 —— 两种处境的操作员改法完全不同。但它的**文案换了一层**:
+     * 闸抛的是管理端措辞,这里就地拦下换成 {@link #MSG_MERCHANT_UNAVAILABLE} 再抛 404
+     * (2026-10-06 评审 F3:前端把 {@code res.msg} 原样显示给顾客)。
+     * 进到「上游失败」那个 catch 里的只有下单腿自己的失败(见类 javadoc「上游失败收两类」)。
      */
     @PostMapping("/{token}/orders")
     @Operation(summary = "顾客页下单:按输入金额建主扫单并回支付链接(免登录)")
@@ -201,7 +222,15 @@ public class HarCheckoutController {
 
             // 本地闸(第二道;create 内部还会按同一口径再查一次)。
             // 特意放在下面那个 catch 之外:商户配置没了要报 404,不是 500。
-            merchantService.requireByTenantAndCode(link.getTenantId(), link.getMerchantCode());
+            // 但闸抛的是**管理端**措辞(还有商户编号),这条腿的出口是顾客页 ⇒ 就地换成顾客语,
+            // 状态码照旧 404。
+            try {
+                merchantService.requireByTenantAndCode(link.getTenantId(), link.getMerchantCode());
+            } catch (BusinessException e) {
+                log.warn("收款链接下单被本地闸拦下: linkId={}, merchantCode={}, 原因={}",
+                        link.getId(), link.getMerchantCode(), e.getMessage());
+                throw new BusinessException(404, MSG_MERCHANT_UNAVAILABLE);
+            }
 
             HarOrderCreateDTO create = new HarOrderCreateDTO();
             create.setMerchantCode(link.getMerchantCode());

+ 43 - 6
java/src/main/java/com/payment/platform/module/payment/har/service/HarCheckoutLinkService.java

@@ -11,6 +11,7 @@ import com.payment.platform.module.payment.har.mapper.HarCheckoutLinkMapper;
 import jakarta.servlet.http.HttpServletRequest;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DataIntegrityViolationException;
 import org.springframework.stereotype.Service;
 
 import java.time.OffsetDateTime;
@@ -67,6 +68,9 @@ public class HarCheckoutLinkService {
      *
      * <p>顺带刷新 {@code merchant_name} 快照 —— 顾客页顶部显示的就是它。
      *
+     * <p>★ 并发双击(两个请求同时读到「没有这一行」)**不会**变成 500:输掉 insert 的一方
+     * 重读并返回赢家那一行 —— 幂等承诺在并发下同样成立(见下面 insert 处的注释)。
+     *
      * @throws BusinessException 403 租户为空;400 商户编号空白;409 本租户内该商户编号命中多行
      *                           (不静默挑一行;唯一索引本应让这一态不可达,但本仓吃过
      *                           「索引建不出来」的亏,所以照样响亮失败)
@@ -79,23 +83,45 @@ public class HarCheckoutLinkService {
             throw new BusinessException(400, "商户编号不能为空");
         }
 
-        List<HarCheckoutLinkEntity> rows = linkMapper.selectList(
-                new LambdaQueryWrapper<HarCheckoutLinkEntity>()
-                        .eq(HarCheckoutLinkEntity::getTenantId, tenantId)
-                        .eq(HarCheckoutLinkEntity::getMerchantCode, merchantCode));
+        List<HarCheckoutLinkEntity> rows = selectByTenantAndCode(tenantId, merchantCode);
 
         if (rows.isEmpty()) {
             HarCheckoutLinkEntity row = new HarCheckoutLinkEntity();
             row.setTenantId(tenantId);
             row.setMerchantCode(merchantCode);
-            row.setToken(IdUtil.fastSimpleUUID());
+            // ★ 必须是 CSPRNG 那一支(2026-10-06 评审 F4 订正):
+            //   simpleUUID() = UUID.randomUUID().toString(true) —— 走 SecureRandom,32 位 hex;
+            //   而 fastSimpleUUID() 走 RandomUtil.getRandom() = **ThreadLocalRandom**(48 位 LCG),
+            //   两者**字符串形态完全一样**(这正是当初写错的原因),但免登录路径全靠这串字符
+            //   不可猜(类 javadoc「(甲)·预防」第 2 条)。别改回 fastSimpleUUID。
+            row.setToken(IdUtil.simpleUUID());
             // 空 ⇒ null,不 trim:口径同 LsyStrings.blankToNull(那个类在乐收赢包下,
             // 不为这一行跨模块引;表达式逐字一致,同 HarMerchantService.save 的先例)。
             row.setMerchantName(StrUtil.isBlank(merchantName) ? null : merchantName);
             // ★ 显式写状态,不靠建表默认值:不写的话实体里 status=null,而调用方紧接着
             //   要拿这一行去拼 URL / 序列化给我方页面,null 会被读成「不可用」。
             row.setStatus(STATUS_ENABLED);
-            linkMapper.insert(row);
+            try {
+                linkMapper.insert(row);
+            } catch (DataIntegrityViolationException e) {
+                // 先读后插之间的并发窗口:两个请求都读到空 ⇒ 都 insert ⇒ 后到的撞
+                // uk_har_checkout_tenant_mch(2026-10-06 评审 F2)。「重复取链接返回同一条」
+                // 这个承诺必须在并发下也成立 —— 撞了就按同一 (租户, 商户编号) 重读,
+                // 把先插的那一行交出去(同一个 token,同一条链接),而不是把异常抛成 500,
+                // 让运营看到「按钮报错」而链接其实已经建好。
+                List<HarCheckoutLinkEntity> winner = selectByTenantAndCode(tenantId, merchantCode);
+                if (winner.size() == 1) {
+                    log.info("收款链接并发创建:本请求输掉 insert,返回已存在的那一行: tenantId={}, merchantCode={}, linkId={}",
+                            tenantId, merchantCode, winner.get(0).getId());
+                    return winner.get(0);
+                }
+                // 撞的不是那条索引(例如 token 撞车)或重读仍为空 ⇒ 原样抛出,绝不吞:
+                // 「链接没建出来却报了成功」比一次 500 坏得多。
+                // ★ 这里能捕到,靠的是 mybatis-spring 的异常翻译(SQLIntegrityConstraintViolation
+                //   → DuplicateKeyException → 本类的父类);裸 PersistenceException 会穿过去 ——
+                //   那是与修复前完全一样的行为,不是新增的盲区。
+                throw e;
+            }
             return row;
         }
         if (rows.size() > 1) {
@@ -121,6 +147,17 @@ public class HarCheckoutLinkService {
         return row;
     }
 
+    /**
+     * 按 (租户, 商户编号) 取行。发放这条腿的**两处**读用同一份谓词(首次读 / 并发输掉后重读),
+     * 免得两处 WHERE 慢慢长歪 —— 这里少一个条件就是跨租户读。
+     */
+    private List<HarCheckoutLinkEntity> selectByTenantAndCode(Long tenantId, String merchantCode) {
+        return linkMapper.selectList(
+                new LambdaQueryWrapper<HarCheckoutLinkEntity>()
+                        .eq(HarCheckoutLinkEntity::getTenantId, tenantId)
+                        .eq(HarCheckoutLinkEntity::getMerchantCode, merchantCode));
+    }
+
     /**
      * 按 token 定位(**免登录路径**,顾客页的两个端点都从这里开始)。
      *

+ 28 - 0
java/src/test/java/com/payment/platform/module/payment/har/controller/HarCheckoutControllerTest.java

@@ -252,6 +252,34 @@ class HarCheckoutControllerTest {
                 "抛出的那条路径也要还原上下文(finally)");
     }
 
+    /**
+     * 同一个 404,**文案必须换成顾客语**(2026-10-06 评审 F3)。
+     *
+     * <p>本地闸抛的是管理端的话({@code "商户编号不存在或不属于当前租户: M001"}),
+     * 它写给我们自己的操作员看是恰当的(取链接、收银台那些腿共用同一条消息);但这条腿的
+     * 出口是**没有登录态的顾客页**,前端把 {@code res.msg} 原样上屏 ⇒ 顾客会读到
+     * 「租户」「商户编号」这些内部词汇加一串内部编号。
+     *
+     * <p>所以这里钉住:404 状态码照旧(它是有意的,见上一个用例),但消息必须是我们写给顾客的
+     * 那一句,且不得包含商户编号与管理端措辞。
+     */
+    @Test
+    void create_missingMerchantRow_messageIsCustomerFacing_notTheAdminWording() {
+        when(linkService.requireByToken("tok")).thenReturn(link());
+        when(merchantService.requireByTenantAndCode(TENANT, MCH))
+                .thenThrow(new BusinessException(404, "商户编号不存在或不属于当前租户: " + MCH));
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> controller.create("tok", pay("5")));
+
+        assertEquals(404, ex.getCode());
+        assertEquals("收款方暂时无法收款,请稍后重试或联系收款方", ex.getMessage(),
+                "顾客页拿到什么就显示什么 ⇒ 出口这条 404 必须是顾客语");
+        assertFalse(ex.getMessage().contains(MCH), "不许把商户编号带给顾客");
+        assertFalse(ex.getMessage().contains("租户"), "不许把内部词汇带给顾客");
+        verifyNoInteractions(orderService);
+    }
+
     @Test
     void create_upstreamRejects_returnsBusinessFailure_withoutUpstreamWording() {
         when(linkService.requireByToken("tok")).thenReturn(link());

+ 61 - 1
java/src/test/java/com/payment/platform/module/payment/har/service/HarCheckoutLinkServiceTest.java

@@ -1,5 +1,6 @@
 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.conditions.AbstractWrapper;
 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.extension.ExtendWith;
 import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DuplicateKeyException;
 import org.springframework.mock.web.MockHttpServletRequest;
 
 import java.nio.charset.StandardCharsets;
@@ -93,11 +96,35 @@ class HarCheckoutLinkServiceTest {
         assertEquals("测试商户", inserted.getMerchantName());
         assertEquals("ENABLED", inserted.getStatus(), "新链接必须是可用状态(显式写,不靠建表默认值)");
         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());
         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
     void issue_whenRowExists_returnsSameToken_andDoesNotInsert() {
         HarCheckoutLinkEntity existing = row(1L, "abcd1234abcd1234abcd1234abcd1234", "ENABLED");
@@ -173,6 +200,39 @@ class HarCheckoutLinkServiceTest {
         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 解析(免登录) ───────────────────────────
 
     @Test