Ver código fonte

feat(汇安融): 收款链接(二)—— 管理端「取收款链接」端点

对标乐收赢 LeshouyingMerchantController#checkoutLink:商户页操作列取一条永久收款
链接(顾客免登录打开、自输金额;顾客页见上一刀 2109cb6c)。本刀是管理端入口的
**后端半边**;前端按钮/二维码对话框与装配接线在后续提交。

新增端点
- POST /payment/har/merchant/{id}/checkout-link,权限串复用
  module_payment:har:merchant:list(与 list 同一条:能看见商户页的人本来就读得到
  该行的 merchantCode,本端点没有多给出任何一项**读**的能力)⇒ 不需要新菜单 SQL。
- 幂等且会写库(首次调用建链接行)⇒ POST 不是 GET。
- 应答三格:merchantCode / merchantName / url。merchantName 取**链接行的快照**
  (操作员看到的名字必须与顾客打开链接时看到的一样),url 只从
  HarCheckoutLinkService#buildUrl 出(控制器不自己拼 —— 那是第二个副本,
  两处漂移的后果是「复制的链接」与「二维码里的链接」不是同一条)。

两种调用方,两条腿(逐条对标 LSY)
- 租户用户:requireByIdForTenant(id, 自己的 tenantId);超管:requireById(id),
  租户号取自**那一行** —— 超管的 LoginUser.tenantId 可以是 null(它跨租户看),
  若照「取不到租户就 403」写,超管将完全无法使用这个按钮,而商户编号的录入
  恰恰只有超管能做。
- 分岔判据 LoginUser#getIsSuperuser,与 TenantInnerInterceptor 用的是同一个开关
  (用别的判据会出现「一边以为放行、一边仍在过滤」或更坏的反向组合)。
- 取不到主体 ⇒ 403(默认拒绝,先于任何库操作);非超管且 tenantId==null 仍走
  带谓词的那条腿(这种主体在条件表上会被跳过过滤,落进超管腿就能按 id 读任意
  租户的行)。404 不区分「不存在」与「不属于你」(不构成越权探测面),且
  verifyNoInteractions(linkService) 钉住「拒绝发生在建链接之前」。

测试与验证
- 新增 HarMerchantControllerTest 6 条(先看到编译红:找不到符号 HarCheckoutLinkVO)。
- 定向变异两向验证:
  · 腿分岔取反(Boolean.TRUE.equals → !Boolean.TRUE.equals):4/6 红
    (三条腿用例 + 404 那条);
  · 快照来源改成商户行 m.getMerchantName():精确 1/6 红(tenantUser 那条);
  · 两处还原后 6/6 绿(还原动作本身也验证过)。
- 含一处接口形状反射:权限串与 list 逐字相等(两处一起改也抓得住)+ POST +
  /payment/har/merchant/{id}/checkout-link 拼接路径(前端契约)。
alphaH 1 semana atrás
pai
commit
b00a83485c

+ 117 - 0
java/src/main/java/com/payment/platform/module/payment/har/controller/HarMerchantController.java

@@ -1,18 +1,46 @@
 package com.payment.platform.module.payment.har.controller;
 
+import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.response.PageResult;
 import com.payment.platform.common.response.Result;
+import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.payment.har.dto.HarCheckoutLinkVO;
 import com.payment.platform.module.payment.har.dto.HarMerchantQueryDTO;
 import com.payment.platform.module.payment.har.dto.HarMerchantSaveDTO;
 import com.payment.platform.module.payment.har.dto.HarMerchantVO;
+import com.payment.platform.module.payment.har.entity.HarCheckoutLinkEntity;
+import com.payment.platform.module.payment.har.entity.HarMerchantEntity;
+import com.payment.platform.module.payment.har.service.HarCheckoutLinkService;
 import com.payment.platform.module.payment.har.service.HarMerchantService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.web.bind.annotation.*;
 
+/**
+ * 汇安融商户配置管理 —— 走 JWT 认证(不在 ApiKey 白名单)。
+ *
+ * <p>★ 本类**三种门禁并存**,别照着其中一个去改另一个(对标
+ * {@code LeshouyingMerchantController} 的同名段落):
+ * <ul>
+ *   <li>{@link #list} 是**权限串** {@code module_payment:har:merchant:list} ——
+ *       租户本来就该看得见自己那几行(还要拿它排查「未配置商户编号」),
+ *       而查询本身被 {@code TenantInnerInterceptor} 钉在自己的租户内;</li>
+ *   <li>{@link #save} / {@link #delete} 是**超管身份** {@code @perm.hasRole('SUPERUSER')}
+ *       —— 见那两个方法的 javadoc;</li>
+ *   <li>{@link #checkoutLink} 是**权限串 + 数据闸**:串与 {@code list} 同一条(不新增
+ *       权限点 ⇒ 不需要新菜单 SQL),另加一道「这一行归不归你」。它之所以不能像
+ *       {@code save} 那样挂身份门禁,是因为它**两种调用方都要服务**(租户给自己取、
+ *       超管代任意租户取)—— 见该方法的 javadoc。</li>
+ * </ul>
+ * 权限串与 {@code sys_menu.permission} 是跨任务契约,须逐字一致
+ * ({@code PermissionCheck.hasAny} 是全等匹配,差一个字符即 403)。
+ */
 @Tag(name = "汇安融-商户配置")
 @RestController
 @RequestMapping("/payment/har/merchant")
@@ -21,6 +49,19 @@ public class HarMerchantController {
 
     private final HarMerchantService service;
 
+    /** 收款链接服务 —— 本类只有 {@link #checkoutLink} 一个端点用它。 */
+    private final HarCheckoutLinkService linkService;
+
+    /**
+     * 当前请求 —— 只用来让 {@code HarCheckoutLinkService#buildUrl} 推导链接基址
+     * (配置留空时按 {@code Host} 头推)。
+     *
+     * <p>★ 注入方式照 {@code HarOpenController} / {@code LeshouyingMerchantController}
+     * (本仓既有形状:构造器注入 + Spring 的请求作用域代理)。
+     * 单测里传一个 {@code MockHttpServletRequest} 即可,不需要起容器。
+     */
+    private final HttpServletRequest request;
+
     /** 租户也能看自己的配置(只读),所以这一条是**权限串**,不是超管专用 */
     @GetMapping
     @Operation(summary = "商户配置分页列表")
@@ -65,4 +106,80 @@ public class HarMerchantController {
         service.delete(id);
         return Result.ok();
     }
+
+    /**
+     * 取该商户的收款链接(首次调用建链接行;之后每次返回同一条)。
+     * 属主用户需求:「和 lsy 一样的 checkout」—— 商户页操作列取链接、封装二维码。
+     * (对标 {@code LeshouyingMerchantController#checkoutLink}。)
+     *
+     * <p>幂等:同一个商户多次调用返回**同一条**链接({@code HarCheckoutLinkService#issue})。
+     * 这是刻意的 —— 链接是要印出来/贴出去的东西,每取一次换一个 token 等于让操作员手上
+     * 那张作废。⇒ 这是个会**写**库的端点(首次调用建那一行),所以是 POST 不是 GET。
+     *
+     * <h2>为什么租户号取自「那一行」而不是认证上下文</h2>
+     * 因为本端点要同时服务两种调用方,而**超管没有自己的租户**(它跨租户看,
+     * {@code LoginUser.tenantId} 可以是 null;商户编号的录入恰恰只有超管能做,
+     * 让超管录完号却取不到链接是没道理的)。租户号取自商户行,两种调用方就都通了。
+     *
+     * <h2>那凭什么保证租户读不到别人的行</h2>
+     * 靠两道**独立**的判据,见 {@link #requireMerchantForCurrentUser}:
+     * 非超管走带租户谓词的那条查法,超管走不带谓词的那条。两者的分岔判据是
+     * {@code LoginUser#getIsSuperuser} —— **与 {@code TenantInnerInterceptor} 用的是同一个开关**。
+     * ★ 这条约束不是措辞讲究:若这里换用别的判据(例如「tenantId 是不是 null」),
+     * 就会出现「一边以为放行、另一边仍在过滤」(渲染出恒假谓词、返回空结果且不报错)
+     * 或更坏的「一边以为在过滤、另一边已经放行」。
+     *
+     * <h2>为什么权限串复用 list(不新增权限点)</h2>
+     * 能看见商户页的人本来就读得到这一行的每一列({@link #list} 返回整个
+     * {@code HarMerchantVO},含 {@code merchantCode}),本端点做的是拿那一行的商户编号
+     * 去换一条链接 —— 没有多给出任何一项**读**的能力;而「给哪个租户」由上面那两道闸决定,
+     * 不由权限串决定。换个更窄的串不会让闸更严,只会多一个授不出来的串
+     * (那要用户在库里执行一份新菜单 SQL,属于额外的部署负担)。
+     *
+     * <p>★ 应答里的 {@code merchantName} 是**链接行的快照**,不是商户行那一列:
+     * 操作员看到的名字必须与顾客打开链接时看到的一样。见 {@link HarCheckoutLinkVO}。
+     *
+     * @param id 商户行主键({@code HarMerchantEntity#getId})
+     * @return {@code 0} = 成功({@code Result.ok} 的口径,不是 HTTP 200)
+     * @throws BusinessException 403 取不到登录主体 / 非超管且无归属租户;404 查不到(含别租户)
+     */
+    @PreAuthorize("@perm.hasAny('module_payment:har:merchant:list')")
+    @PostMapping("/{id}/checkout-link")
+    @Operation(summary = "取该商户的收款链接(幂等;租户取自己的,超管可代取)")
+    public Result<HarCheckoutLinkVO> checkoutLink(@PathVariable(name = "id") Long id) {
+        HarMerchantEntity m = requireMerchantForCurrentUser(id);
+        HarCheckoutLinkEntity link = linkService.issue(m.getTenantId(), m.getMerchantCode(), m.getMerchantName());
+
+        HarCheckoutLinkVO vo = new HarCheckoutLinkVO();
+        vo.setMerchantCode(m.getMerchantCode());
+        vo.setMerchantName(link.getMerchantName());
+        vo.setUrl(linkService.buildUrl(link.getToken(), request));
+        return Result.ok(vo);
+    }
+
+    /**
+     * 取「当前调用方有权看见的那一行商户」,取不到就拒绝 —— <b>先于任何写操作</b>。
+     * 逐条对标 {@code LeshouyingMerchantController#requireMerchantForCurrentUser}。
+     *
+     * <p>★ {@code me == null} ⇒ 403(**默认拒绝**)。写成
+     * {@code Boolean.FALSE.equals(me.getIsSuperuser())} 之类的形状时,一旦主体取不到,
+     * 有的写法会把 null 归进「非超管」(对),有的会把整句反过来(错)—— 这里显式判空。
+     *
+     * <p>★ 「非超管且 {@code tenantId == null}」**不是假想组合**:
+     * 写主体的那条路径把 {@code sys_user.tenant_id}(可空)原样搬进 {@code LoginUser},
+     * 而 {@code is_superuser} 是另一个开关。这种主体在条件表上会被**跳过过滤**
+     * (没有租户可过滤)⇒ 若放行,它就能按 id 读到任意租户的商户行、再拿那一行的
+     * 商户编号发出收款链接。{@code HarMerchantService#requireByIdForTenant} 的 403
+     * 就是拦这个的,而它必须发生在查库之前。
+     */
+    private HarMerchantEntity requireMerchantForCurrentUser(Long id) {
+        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+        LoginUser me = (auth != null && auth.getPrincipal() instanceof LoginUser u) ? u : null;
+        if (me == null) {
+            throw new BusinessException(403, "取不到当前登录身份,无法生成收款链接");
+        }
+        return Boolean.TRUE.equals(me.getIsSuperuser())
+                ? service.requireById(id)
+                : service.requireByIdForTenant(id, me.getTenantId());
+    }
 }

+ 42 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarCheckoutLinkVO.java

@@ -0,0 +1,42 @@
+package com.payment.platform.module.payment.har.dto;
+
+import lombok.Data;
+
+/**
+ * 管理端「取收款链接」的应答:页面拿它显示、复制、生成二维码。
+ *
+ * <h2>为什么只有三格</h2>
+ * 这个 VO 的读者是**操作员**(他要把链接印出来/贴出去),不是程序对接方 ——
+ * 多给的每一格都是一项没人用、却要在每次改表时跟着维护的东西。
+ * (逐条对标乐收赢的 {@code LsyCheckoutLinkVO}。)
+ *
+ * <ul>
+ *   <li>{@code token} **不单独返回**:它是 {@code url} 的一部分,单独给一份只会多一个
+ *       与前缀拼法耦合的字段(页面真要它,自己从 url 里取即可)。</li>
+ *   <li>租户号 / 主键 **都不返回**:页面不需要,而它们在管理端属于内部标识。</li>
+ * </ul>
+ *
+ * <p>★ {@code merchantName} 是**链接行上的快照**({@code pay_har_checkout_link.merchant_name}),
+ * 不是 {@code pay_har_merchant} 那一列 —— 两者在「商户改名之后还没重新取过链接」时会不同。
+ * 取快照的理由:操作员看到的名字必须**与顾客打开链接时看到的一样**,
+ * 否则他会拿着一张写着「甲」的桌牌去问为什么顾客手机上显示「乙」。
+ * (快照的刷新时机见 {@code HarCheckoutLinkService#issue}。)
+ */
+@Data
+public class HarCheckoutLinkVO {
+
+    /** 汇安融商户编号 —— 操作员据此确认取的是哪一家。 */
+    private String merchantCode;
+
+    /** 商户显示名(**快照**,见类 javadoc)。可能为 null:商户行没填名字时不编一个出来。 */
+    private String merchantName;
+
+    /**
+     * 完整收款链接(形如 {@code https://<域名>/#/checkout/har/<token>})。
+     *
+     * <p>★ 基址由 {@code HarCheckoutLinkService#buildUrl} 推导(配置优先,否则按当前请求的
+     * Host 推导)—— 控制器**不自己拼**:那是第二个副本,而两处漂移的后果是
+     * 「页面上复制的链接」与「二维码里的链接」不是同一条。
+     */
+    private String url;
+}

+ 285 - 0
java/src/test/java/com/payment/platform/module/payment/har/controller/HarMerchantControllerTest.java

@@ -0,0 +1,285 @@
+package com.payment.platform.module.payment.har.controller;
+
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.payment.har.dto.HarCheckoutLinkVO;
+import com.payment.platform.module.payment.har.entity.HarCheckoutLinkEntity;
+import com.payment.platform.module.payment.har.entity.HarMerchantEntity;
+import com.payment.platform.module.payment.har.service.HarCheckoutLinkService;
+import com.payment.platform.module.payment.har.service.HarMerchantService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.*;
+
+/**
+ * 管理端「取收款链接」(商户页操作列那个按钮背后的端点)。
+ * 逐条对标乐收赢的 {@code LeshouyingMerchantControllerTest}。
+ *
+ * <p>【本类只覆盖新增的这一个端点,且只覆盖它真正负责的事】其余方法级授权({@code save} /
+ * {@code delete} 的超管门禁、{@code list} 的权限串)与本功能的装配事实
+ * ({@code SecurityConfig.WHITE_LIST} / {@code TenantInnerInterceptor.CONDITIONAL_TABLES})
+ * 都不在本类 —— 后者由 {@code HarCheckoutWiringTest} 守。
+ *
+ * <p>【本类真正钉的是什么】这个端点的**归属判据**。它要同时服务两种调用方:
+ * <ul>
+ *   <li>租户用户:只能给自己那一行取(拿到的链接决定钱进哪个汇安融商户编号);</li>
+ *   <li>超管:可以给任意一行取(商户编号的录入恰恰只有超管能做 —— 见
+ *       {@code HarMerchantController#save} 的门禁 —— 让超管录完号却取不到链接没道理)。</li>
+ * </ul>
+ * 判据必须与 {@code TenantInnerInterceptor} **用同一个开关**({@code LoginUser#getIsSuperuser})——
+ * 用别的开关(例如「tenantId 是不是 null」)会出现「一边以为放行、一边仍在过滤」
+ * 或更坏的「一边以为在过滤、一边已经放行」,两种组合界面上都看不出来。
+ *
+ * <p>【量具】本仓所有 {@code *ControllerTest} 都是**普通方法直调**控制器
+ * (不起 Spring 上下文、{@code @PreAuthorize} 一步都不参与)—— 所以「哪个服务方法被调用、
+ * 传了什么租户号」只能靠 {@code verify} 读,而「注解写的是什么」只能靠反射读(见最后一条用例)。
+ */
+@ExtendWith(MockitoExtension.class)
+class HarMerchantControllerTest {
+
+    @Mock private HarMerchantService merchantService;
+    @Mock private HarCheckoutLinkService linkService;
+
+    private MockHttpServletRequest request;
+    private HarMerchantController controller;
+
+    @BeforeEach
+    void setUp() {
+        request = new MockHttpServletRequest("POST", "/api/v1/payment/har/merchant/1/checkout-link");
+        controller = new HarMerchantController(merchantService, linkService, request);
+    }
+
+    /**
+     * ★ 必须清:{@code SecurityContextHolder} 是**线程本地且跨用例存活**的,
+     * 而 Surefire 默认在同一线程里跑完整个类。不清的话某条用例的 {@code login(null, true)}
+     * 会泄漏给后面每一条「不该放行」的用例,让它们集体变绿 ——
+     * 那种「全绿但什么也没验」的形态在本仓出现过。
+     */
+    @AfterEach
+    void clearSecurityContext() {
+        SecurityContextHolder.clearContext();
+    }
+
+    // ────────────────────────────── 工具 ──────────────────────────────
+
+    /** 把当前主体放进 SecurityContext。 */
+    private static void login(Long tenantId, boolean isSuperuser) {
+        LoginUser u = new LoginUser();
+        u.setUserId(1L);
+        u.setUsername("tester");
+        u.setStatus("0");
+        u.setTenantId(tenantId);
+        u.setIsSuperuser(isSuperuser);
+        u.setAuthorities(List.of());
+        SecurityContextHolder.getContext().setAuthentication(
+                new UsernamePasswordAuthenticationToken(u, null, List.of()));
+    }
+
+    private static HarMerchantEntity merchant(long id, Long tenantId, String code, String name) {
+        HarMerchantEntity m = new HarMerchantEntity();
+        m.setId(id);
+        m.setTenantId(tenantId);
+        m.setMerchantCode(code);
+        m.setMerchantName(name);
+        return m;
+    }
+
+    private static HarCheckoutLinkEntity link(String code, String snapshotName, String token) {
+        HarCheckoutLinkEntity l = new HarCheckoutLinkEntity();
+        l.setId(9L);
+        l.setMerchantCode(code);
+        l.setMerchantName(snapshotName);
+        l.setToken(token);
+        l.setStatus("ENABLED");
+        return l;
+    }
+
+    // ───────────────────────── 一、两种调用方,两条查法 ─────────────────────────
+
+    /**
+     * 租户用户:谓词里**显式**写上自己的租户号。
+     *
+     * <p>★ 这与拦截器追加的那一条**重复**,是**故意**的(纵深防御):两道判据由两条完全
+     * 独立的代码路径写出,手写的那一条写错时拦截器那一条还在。
+     *
+     * <p>★ 快照那一格:应答里的 {@code merchantName} 必须取自**链接行**
+     * ({@code link.getMerchantName()}),不是商户行那一列 —— 这是本用例把两个名字
+     * 写成不同值的理由(真实流程里 {@code issue} 会把快照刷成商户行的名字,两者随即相同;
+     * 但「从哪个对象抄」是控制器自己的选择,抄错了没有任何一处会报错)。
+     */
+    @Test
+    void checkoutLink_tenantUser_readsViaTenantScopedLeg_tenantIdFromPrincipal() {
+        login(3L, false);
+        HarMerchantEntity row = merchant(1L, 3L, "M003", "商户表里的名字");
+        when(merchantService.requireByIdForTenant(1L, 3L)).thenReturn(row);
+        when(linkService.issue(3L, "M003", "商户表里的名字"))
+                .thenReturn(link("M003", "链接行里的名字", "tok3"));
+        when(linkService.buildUrl(same("tok3"), same(request)))
+                .thenReturn("https://pay.example.com/#/checkout/har/tok3");
+
+        HarCheckoutLinkVO vo = controller.checkoutLink(1L).getData();
+
+        verify(merchantService).requireByIdForTenant(1L, 3L);
+        verify(merchantService, never()).requireById(anyLong());
+        verify(linkService).issue(3L, "M003", "商户表里的名字");
+        assertEquals("M003", vo.getMerchantCode(), "商户编号要回给页面显示(操作员得知道自己在给谁取链接)");
+        assertEquals("链接行里的名字", vo.getMerchantName(),
+                "显示名取自**链接行**(顾客打开链接时看到的就是它),不是商户行那一列");
+        assertEquals("https://pay.example.com/#/checkout/har/tok3", vo.getUrl(),
+                "URL 必须来自服务层的 buildUrl(基址推导那套逻辑只有一份,"
+                        + "控制器自己拼一个就是第二个副本)");
+    }
+
+    /**
+     * 超管:不写租户谓词,**租户号取自那一行**。
+     *
+     * <p>★ 这条是本端点存在两种调用方的**全部理由**:超管的 {@code LoginUser.tenantId}
+     * 可以是 null(它跨租户看,所以根本没有「自己的租户」可写),
+     * 于是若照 {@code HarCashierService#requireTenantId} 那样「取不到租户就 403」,
+     * 超管将**完全无法使用这个按钮**。
+     * ★ 这里显式断言 {@code requireByIdForTenant} **一次都没被调**:那是两条查法
+     * 不互相串味的正对照(少了它,一个「两条都调」的实现照样绿)。
+     */
+    @Test
+    void checkoutLink_superuser_readsViaPkLeg_soItWorksWithoutOwnTenant() {
+        login(null, true);
+        HarMerchantEntity row = merchant(1L, 5L, "M005", "五号商户");
+        when(merchantService.requireById(1L)).thenReturn(row);
+        when(linkService.issue(5L, "M005", "五号商户")).thenReturn(link("M005", "五号商户", "tok5"));
+        when(linkService.buildUrl(same("tok5"), same(request)))
+                .thenReturn("https://pay.example.com/#/checkout/har/tok5");
+
+        HarCheckoutLinkVO vo = controller.checkoutLink(1L).getData();
+
+        verify(merchantService).requireById(1L);
+        verify(merchantService, never()).requireByIdForTenant(anyLong(), any());
+        verify(linkService).issue(5L, "M005", "五号商户");
+        assertEquals("https://pay.example.com/#/checkout/har/tok5", vo.getUrl(),
+                "超管没有自己的租户号 —— 租户号必须取自那一行,两种调用方才都通");
+    }
+
+    // ───────────────────────── 二、闸:取不到归属就拒绝 ─────────────────────────
+
+    /**
+     * 非超管**且**没有归属租户 ⇒ 仍然走**带租户谓词**的那条查法,绝不落到超管那条。
+     *
+     * <p>★ 这不是假想的组合:写主体的那条路径把 {@code sys_user.tenant_id}(可空)
+     * 原样搬进 {@code LoginUser},而 {@code is_superuser} 是另一个开关。
+     * 这种主体在条件表上会被**跳过过滤**(没有租户可过滤)⇒ 若它落进超管那条腿,
+     * 就能按 id 读到**任意租户**的商户行、再拿那一行的商户编号发出一条收款链接。
+     *
+     * <p>【为什么这条只断言「走了哪条腿」,不断言 403】那句 403 是**服务层的契约**
+     * ({@code HarMerchantService#requireByIdForTenant} 在 {@code tenantId == null} 时抛,
+     * 且发生在查库之前),证据在 {@code HarMerchantServiceTest}(那里跑的是真实现)。
+     * 在这里把 mock 设成「抛 403」再断言收到 403,验的是**测试自己设的桩**,不是被测代码
+     * —— 所以本类只测控制器真正负责的那一件事:**分岔**。
+     * 两条腿都用 {@code never()} 互为正对照。
+     */
+    @Test
+    void checkoutLink_nonSuperuserWithoutTenant_stillTakesTheTenantScopedLeg() {
+        login(null, false);
+        when(merchantService.requireByIdForTenant(1L, null))
+                .thenReturn(merchant(1L, null, "M001", null));
+        when(linkService.issue(null, "M001", null)).thenReturn(link("M001", null, "tok1"));
+        when(linkService.buildUrl(same("tok1"), same(request)))
+                .thenReturn("https://pay.example.com/#/checkout/har/tok1");
+
+        controller.checkoutLink(1L);
+
+        verify(merchantService).requireByIdForTenant(1L, null);
+        verify(merchantService, never()).requireById(anyLong());
+    }
+
+    /**
+     * 取不到登录主体 ⇒ 403(**默认拒绝**,不是默认放行),且一条库都不查、一个字都不写。
+     */
+    @Test
+    void checkoutLink_noPrincipal_403_andTouchesNothing() {
+        SecurityContextHolder.clearContext();
+
+        BusinessException e = assertThrows(BusinessException.class, () -> controller.checkoutLink(1L));
+
+        assertEquals(403, e.getCode(), "取不到主体时必须拒绝,不能默认放行");
+        assertEquals("取不到当前登录身份,无法生成收款链接", e.getMessage());
+        verifyNoInteractions(merchantService, linkService);
+    }
+
+    // ───────────────────────── 三、查不到就不发链接 ─────────────────────────
+
+    /**
+     * 别租户 / 不存在的商户行,**同一个 404**(不区分这两种 —— 区分开等于给出一个
+     * 越权探测面),且**不生成链接**。
+     *
+     * <p>★ 断言 {@code verifyNoInteractions(linkService)} 而不是只看异常类型:
+     * 若实现在闸之前先 {@code issue(...)},那么「拒绝」只是**发晚了** ——
+     * 链接行已经落库、token 已经发出去。
+     */
+    @Test
+    void checkoutLink_missingOrForeignMerchant_404_andNeverIssuesALink() {
+        login(3L, false);
+        when(merchantService.requireByIdForTenant(1L, 3L))
+                .thenThrow(new BusinessException(404, "商户配置不存在"));
+
+        BusinessException e = assertThrows(BusinessException.class, () -> controller.checkoutLink(1L));
+
+        assertEquals(404, e.getCode(), "不区分「不存在」与「不属于你」—— 区分开等于给出一个越权探测面");
+        verifyNoInteractions(linkService);
+    }
+
+    // ───────────────────────── 四、接口形状(契约) ─────────────────────────
+
+    /**
+     * 前端要调的就是这个形状,所以它得有个钉住的地方:**路径 + 方法 + 权限串**。
+     *
+     * <p>★ 权限串**复用** {@code merchant:list}(本控制器 {@code list} 用的那一条),
+     * 不新增权限点 —— 因此**不需要新的菜单 SQL**:能看见商户页的人本来就读得到这一行的
+     * 每一列({@code list} 返回整个 {@code HarMerchantVO},含 {@code merchantCode});
+     * 本端点做的是拿那一行的编号换一条链接,没有多给出任何一项**读**的能力。
+     * 「给哪个租户」由上面那两道闸决定,不由权限串决定。
+     */
+    @Test
+    void checkoutLink_contract_permissionStringIsTheSameOneAsList_andPathIsTheFrontendContract() throws Exception {
+        var method = HarMerchantController.class.getMethod("checkoutLink", Long.class);
+
+        PreAuthorize pa = method.getAnnotation(PreAuthorize.class);
+        assertNotNull(pa, "端点必须有 @PreAuthorize(它会按 id 读任意商户行并写一行链接)");
+        assertEquals("@perm.hasAny('module_payment:har:merchant:list')", pa.value(),
+                "权限串必须与 list 逐字相同(复用同一个权限点,不新增菜单 SQL)");
+
+        PreAuthorize listPa = HarMerchantController.class
+                .getMethod("list", int.class, int.class, Long.class, String.class, String.class, String.class)
+                .getAnnotation(PreAuthorize.class);
+        assertNotNull(listPa);
+        assertEquals(listPa.value(), pa.value(),
+                "两处必须真的相等 —— 只写死同一个字面量的话,有人把两边一起改掉时照样绿");
+
+        PostMapping pm = method.getAnnotation(PostMapping.class);
+        assertNotNull(pm, "取链接会**写**一行(首次调用要建链接行)⇒ 必须是 POST,不是 GET");
+        RequestMapping rm = HarMerchantController.class.getAnnotation(RequestMapping.class);
+        assertNotNull(rm, "类级 @RequestMapping 不得为空 —— 真实路径拼不出来");
+
+        // ★ 两段各自与**同一个字面量**比:分开断言会在「有人把两段一起改掉」时照样绿
+        //   —— 而那个改动正是前端 404 的成因。
+        assertEquals(List.of("/{id}/checkout-link"), List.of(pm.value()));
+        assertEquals("/payment/har/merchant/{id}/checkout-link",
+                rm.value()[0] + pm.value()[0],
+                "前后端契约路径。前端商户页操作列按这一条调;改这段而不同步前端 ⇒ 按钮点了 404");
+    }
+}