Przeglądaj źródła

feat(乐收赢): 收款链接后端 —— 一条永久复用的链接,顾客自填金额后走真实主扫

上游的主扫是**一码一收**(`/v3/qrpay` 每单一个 `code_url`,默认 15 分钟),
所以「贴在收银台上长期不变的那张码」在上游根本不存在,只能由我们自己补一层:
一张永久链接 → 打开的是我们的页面 → 顾客当场输金额 → 那一刻才向后端要一个真码。
用户 2026-09-30 的两句话定的就是这个:链接无限次使用、打开即 checkout、金额由顾客输。

新增:
- `pay_lsy_checkout_link` 建表脚本(**只写不执行**,交用户跑)。一商户一条。
  ★ token 的唯一索引**不含 tenant_id**:打开链接的人没有登录态,我们只能按 token 定位
    那一行;按本仓通则「无认证定位只走有全局唯一约束的列」,收窄成 (tenant_id, token)
    等于这条未认证读**不带租户条件**,索引对它提供零保护(同 uk_lsy_order_out_trade_no
    那条注释里记着的陷阱)。另一条 `uk_lsy_checkout_tenant_mch` 只服务已认证的发放路径。
- `LsyCheckoutLinkService`:`issue`(按 (tenant_id, mch_no) 幂等 —— 重入返回**同一条**,
  链接要印出来贴出去,每取一次换一个 token 等于让客户手上那张作废)、
  `requireByToken`(未知/空白/停用一律同一条 404,不告诉打开的人「存在但停用」)、
  `buildUrl`(基址推导**只有这一份**,管理端与开放 API 共用)。
- `LeshouyingCheckoutController`(`/payment/checkout`,**免登录**两个端点):
  `GET /{token}` 只回商户显示名(`LsyCheckoutInfoVO` 有意只有一格);
  `POST /{token}/orders` 建真主扫单,只收 `amount` 一个键。
- 两条**发链接**的腿共用一个服务:管理端 `POST /payment/collection/merchant/{id}/checkout-link`
  (权限串复用 `:list`,不新增权限点 ⇒ 不需要新菜单 SQL)与开放 API
  `POST /payment/openapi/pay/checkout/link`(走既有 `requireMerchant` 分流)。

几处顺序是同一条规矩,不是风格:
- 顾客下单那条腿:解析 token → 把**链接行上的**租户写进 SecurityContext → 本地闸 →
  落库 → 上游调用。本地闸排在**任何上游调用之前** —— 反了不是「同样安全」,
  那等于拿一个还没证明属于本租户的商户号去发起一次上游调用(同收银台与开放 API 的规矩)。
- 落库早于 `createQrOrder`:上游那一跳用 `order.getId()` 做条件更新,而 id 由 insert 生成;
  且 `pay_lsy_order.mch_no` 是 NOT NULL 且无默认值。
- 金额下限判**两道**(DTO 的 `@Min` 与控制器里的显式判断):本仓控制器用例都是普通方法
  直调,Spring 的参数校验一步都不参与 ⇒ 只挂注解的话这条规则在测试里没有任何守卫。

有意留空的 `notify_url`(同收银台那条腿):上游回调走平台级 `leshouying.notify-url`,
下游通知按 `LsyCallbackService` 的 resolveUrl 回退到 `open_conf`。★ 这一格**不能**照抄
开放 API 那三个下单端点 —— 它们填的是「我们自己的回调入口」,照抄进来就是自环。

全量 1007 绿(Tests run: 1007, Failures: 0, Errors: 0, Skipped: 0)。
alphaH 2 tygodni temu
rodzic
commit
87536f0c75
27 zmienionych plików z 2176 dodań i 37 usunięć
  1. 8 0
      java/src/main/java/com/payment/platform/core/security/SecurityConfig.java
  2. 5 1
      java/src/main/java/com/payment/platform/core/security/TenantApiKeyAuthFilter.java
  3. 12 0
      java/src/main/java/com/payment/platform/core/tenant/TenantInnerInterceptor.java
  4. 14 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/config/LeshouyingProperties.java
  5. 301 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCheckoutController.java
  6. 106 2
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingMerchantController.java
  7. 45 2
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenController.java
  8. 24 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyCheckoutInfoVO.java
  9. 34 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyCheckoutLinkCreateDTO.java
  10. 41 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyCheckoutLinkVO.java
  11. 36 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyCheckoutOrderVO.java
  12. 40 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyCheckoutPayDTO.java
  13. 51 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/entity/LsyCheckoutLinkEntity.java
  14. 9 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/mapper/LsyCheckoutLinkMapper.java
  15. 174 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyCheckoutLinkService.java
  16. 74 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantService.java
  17. 5 5
      java/src/main/java/com/payment/platform/module/payment/leshouying/util/LsyAmountFloor.java
  18. 48 0
      java/src/main/resources/db/lsy-checkout-link.sql
  19. 37 3
      java/src/test/java/com/payment/platform/core/security/LeshouyingWiringTest.java
  20. 289 0
      java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCheckoutControllerTest.java
  21. 287 0
      java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingMerchantControllerTest.java
  22. 4 2
      java/src/test/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenApiErrorContractTest.java
  23. 91 3
      java/src/test/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenControllerTest.java
  24. 34 19
      java/src/test/java/com/payment/platform/module/payment/leshouying/dto/LsyAmountFloorTest.java
  25. 25 0
      java/src/test/java/com/payment/platform/module/payment/leshouying/dto/LsyOpenApiJsonContractTest.java
  26. 276 0
      java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyCheckoutLinkServiceTest.java
  27. 106 0
      java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantServiceTest.java

+ 8 - 0
java/src/main/java/com/payment/platform/core/security/SecurityConfig.java

@@ -71,6 +71,14 @@ public class SecurityConfig {
             "/payment/aplipay/auth",
             // 乐收赢回调入口(免登录;身份由 MD5 验签保证,不通过则不写任何业务状态)
             "/payment/collection/notify/**",
+            // 乐收赢收款链接(顾客页,免登录)—— 打开链接的人没有登录态,这是设计。
+            // 身份不是认证来的,而是那串 token:它有**全局唯一**索引
+            // (uk_lsy_checkout_token),且是 32 位随机十六进制(见 LsyCheckoutLinkService)。
+            // ★ 与上一行只是**看起来**像:/payment/collection/** 是管理端 + 审核通知,
+            //   本行 /payment/checkout/** 是顾客页,两条路径的认证面完全不同。
+            // ★ 放行的是**授权层**;租户隔离仍由 TenantInnerInterceptor 的条件表
+            //   (pay_lsy_checkout_link 已登记)+ 控制器里显式绑定的租户上下文负责。
+            "/payment/checkout/**",
             "/v3/api-docs/**",
             "/swagger-ui/**",
             "/swagger-ui.html",

+ 5 - 1
java/src/main/java/com/payment/platform/core/security/TenantApiKeyAuthFilter.java

@@ -97,11 +97,15 @@ public class TenantApiKeyAuthFilter extends OncePerRequestFilter {
             "/payment/openapi/account/transfer",
             "/payment/openapi/account/transfer/query",
             "/payment/openapi/account/balance/query",
-            // 乐收赢支付通道的 7 条端点。逐条列出便于核对;
+            // 乐收赢支付通道的 8 条端点。逐条列出便于核对;
             // 认证判定按前缀(见 OPENAPI_PREFIXES),漏列**不会**导致失去认证。
             "/payment/openapi/pay/barcode/pay",
             "/payment/openapi/pay/qrcode/create",
             "/payment/openapi/pay/h5/create",
+            // 收款链接(2026-09-30):给下游系统取「顾客扫码用」的固定链接。
+            // ★ 它是**已认证**的发放口,不是给顾客打开的页面 —— 顾客页在 /payment/checkout/**,
+            //   那条**必须**保持免登录,由 LeshouyingWiringTest 反向守着别被挪进本前缀。
+            "/payment/openapi/pay/checkout/link",
             "/payment/openapi/pay/order/query",
             "/payment/openapi/pay/order/close",
             "/payment/openapi/pay/refund",

+ 12 - 0
java/src/main/java/com/payment/platform/core/tenant/TenantInnerInterceptor.java

@@ -210,6 +210,18 @@ public class TenantInnerInterceptor extends TenantLineInnerInterceptor {
             "pay_lsy_refund",
             "pay_lsy_merchant",
             "pay_lsy_notify_log",
+            "pay_lsy_checkout_link", // 收款链接(顾客页按 token 定位,**免登录**)。
+                                     //   ★ 漏登记 ⇒ 未认证查询被追加 AND tenant_id = 0
+                                     //     ⇒ **每条链接都查不到**,表现是顾客页一律「链接无效」,
+                                     //     而且日志里一个字都没有。
+                                     //   ★ 走通则的 (甲)·预防:定位键是 token,而 uk_lsy_checkout_token
+                                     //     **单独**建在 token 上(不是 (tenant_id, token))⇒
+                                     //     同一个 token 命中两行在 schema 层建不出来。
+                                     //     别把它收窄 —— 收窄后该索引对无认证查询提供零保护
+                                     //     (同 uk_lsy_order_out_trade_no 那个陷阱)。
+                                     //   ★ 定位用 selectOne 是**允许**的:因为走了 (甲)。
+                                     //     若哪天这个索引不在了,本行必须改成 (乙)(selectList 后自判多行),
+                                     //     否则「未认证 + LIMIT 1」就是本清单禁止的静默挑行。
             "pay_har_merchant",      // 汇安融商户配置(下单时按 enterprise_id 反查,开放API无登录态)
             "pay_har_shop",          // 汇安融门店(开放API创建/查询无登录态)
             "pay_har_order"          // 汇安融订单(上游通知回调 + 定时轮询均无认证上下文;

+ 14 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/config/LeshouyingProperties.java

@@ -127,6 +127,20 @@ public class LeshouyingProperties {
     /** 下游回调最大重试次数 */
     private int downstreamNotifyMaxRetries = 8;
 
+    /**
+     * 收款链接(checkout)的**对外基址**,形如 {@code https://qcsj88888.com}
+     * —— 不带结尾斜杠、不带 {@code /api/v1}(顾客页是前端静态资源,不在后端 context-path 下)。
+     *
+     * <p>★ 默认留空 = 按调用方的请求头推导({@code X-Forwarded-Proto/Host} → {@code Host})。
+     * 之所以敢推导:前端与后端**同源**({@code VITE_API_BASE_URL} 与 {@code VITE_APP_BASE_API}
+     * 指向同一个域名),所以「调用方访问我们用的那个 Host」就是「顾客要打开的 Host」,零配置即正确。
+     * 留这个开关是给推导不出来的部署用(反代把 Host 换成内网名、或租户经内网地址调开放 API),
+     * 那时在 yml 里写死对外域名即可,**不需要改代码**。
+     *
+     * <p>它只影响**发出去的链接长什么样**,不影响支付本身(付款交给上游返回的 {@code code_url})。
+     */
+    private String checkoutBaseUrl;
+
     /**
      * 上传图片的**本地**大小上限(字节),默认 2 MiB。
      *

+ 301 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCheckoutController.java

@@ -0,0 +1,301 @@
+package com.payment.platform.module.payment.leshouying.controller;
+
+import cn.hutool.core.util.IdUtil;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.Result;
+import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.payment.leshouying.client.LeshouyingApiException;
+import com.payment.platform.module.payment.leshouying.dto.LsyCheckoutInfoVO;
+import com.payment.platform.module.payment.leshouying.dto.LsyCheckoutOrderVO;
+import com.payment.platform.module.payment.leshouying.dto.LsyCheckoutPayDTO;
+import com.payment.platform.module.payment.leshouying.entity.LsyCheckoutLinkEntity;
+import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
+import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
+import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
+import com.payment.platform.module.payment.leshouying.service.LsyCheckoutLinkService;
+import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
+import com.payment.platform.module.payment.leshouying.service.LsyOrderService;
+import com.payment.platform.module.payment.leshouying.util.LsyAmountFloor;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 顾客页(收款链接)—— <b>免登录</b>的两个端点:读商户名 / 按顾客输入的金额发起一次主扫支付。
+ *
+ * <h2>它解决什么</h2>
+ * 乐收赢的主扫是**一码一收**(每笔都要先下单、由上游发一个只对这笔有效的 {@code code_url}),
+ * 所以「贴一张静态码在墙上」在这条通道里做不到。折中办法是贴一个**我们自己的**链接:
+ * 链接与商户绑定、可无限次使用,顾客打开后自己输金额,我们当场建一笔单、把上游返回的
+ * {@code code_url} 交还给浏览器去跳转。这个控制器就是那个链接的落点。
+ *
+ * <h2>★★ 免登录:凭什么安全</h2>
+ * 打开链接的人没有登录态,本控制器**两个端点都不带任何认证**({@code SecurityConfig.WHITE_LIST}
+ * 里放行 {@code /payment/checkout/**})。安全依据是两件事,缺一不可:
+ * <ol>
+ *   <li><b>定位键有全局唯一约束</b>:顾客页只能用 URL 里那一串 {@code token} 说话,而
+ *       {@code uk_lsy_checkout_token} 单独建在 {@code token} 上(**不是** {@code (tenant_id, token)})
+ *       ⇒ 「同一个 token 命中两行」在 schema 层建不出来。这是本仓通则的 **(甲)·预防**,
+ *       论证与「别把它收窄」写在 {@link LsyCheckoutLinkService} 的类 javadoc 与建表脚本里。</li>
+ *   <li><b>token 是我们生成的 32 位随机十六进制</b>({@code IdUtil.fastSimpleUUID},
+ *       122 bit 熵),不是可枚举的序号 ⇒ 拿到别人的链接只能靠拿到那一串字符。</li>
+ * </ol>
+ * <p>★ 一条同层的硬约束:{@code pay_lsy_checkout_link} <b>必须</b>登记在
+ * {@code TenantInnerInterceptor.CONDITIONAL_TABLES}。漏登记 ⇒ 拦截器给这条查询追加
+ * {@code AND tenant_id = 0}(未认证时 {@code getTenantId()} 返回 0)⇒ <b>每条链接都查不到</b>,
+ * 表现是顾客页一律「链接无效」,而日志里一个字都没有。
+ *
+ * <h2>★★ 租户身份怎么来的:从链接那一行,而且要在库操作之前绑好</h2>
+ * 顾客页的请求里没有任何租户维度,唯一的来源是 {@link LsyCheckoutLinkEntity#getTenantId()}。
+ * 定位到那一行之后、**任何一次属于该租户的库操作之前**,本类把租户写进
+ * {@code SecurityContext}(见 {@link #scopedTo}),请求结束再**还原**。
+ *
+ * <p>为什么值得新开这个模式(本仓此前没有非 Filter 代码动过 SecurityContext):
+ * 这条路径上的三张表({@code pay_lsy_checkout_link} / {@code pay_lsy_merchant} /
+ * {@code pay_lsy_order})<b>全都在 CONDITIONAL_TABLES 里</b>,而拦截器对它们
+ * <b>无认证时是「跳过过滤」</b>({@code ignoreTable} 返回 true)—— 也就是说,不绑租户时
+ * 整条路径跑在**完全没有租户谓词**的状态下,正确性全部押在我们手写的每一个 WHERE 上。
+ * 绑上之后,拦截器会**独立地再写一遍**同一个边界:{@code requireByMchNoForTenant} 自带的
+ * {@code tenant_id = ?} 与拦截器追加的那一条互为对照 —— 手写谓词写错(传错变量、少写一列)
+ * 时,另一条还在。
+ *
+ * <p>★ 这**不是**「不绑就查不到」:不绑时拦截器跳过过滤,查询照样命中。所以这里绑的是
+ * **纵深防御**,不是修一个已经坏掉的东西 —— 别把这段读成「原先有 bug」。
+ *
+ * <p>★ 还原用 {@code setContext(previous)}(整个上下文换回去),不是
+ * {@code setAuthentication(null)}:后者在「本来就没有认证」之外还抹掉了上下文里可能有的别的东西。
+ * 而且它在 {@code finally} 里 —— 这是容器的线程,不是我们自己的线程。
+ *
+ * <h2>与另外两条腿的关系:第三份落库装配,**有意不合并**</h2>
+ * {@link #newOrder} 与收银台({@code LeshouyingCashierController.newOrder})及开放 API
+ * ({@code LeshouyingOpenController.newOrder})是同一件事的三份实现。合并会迫使另外两条
+ * 腿的既有测试全部改构造方式,而它们**线上正在跑**。代价是各自漂移,所以差异逐条写在下表里。
+ *
+ * <table border="1">
+ *   <caption>三个入口的落库字段差异(改任一侧先看这张表)</caption>
+ *   <tr><th>字段</th><th>开放 API</th><th>收银台</th><th>本类(顾客页)</th></tr>
+ *   <tr><td>{@code outTradeNo}</td><td>调用方传入</td><td>{@code "SK"+雪花}</td>
+ *       <td>{@code "CS"+雪花}(三处前缀互不相同 ⇒ 拿到单号就知道它从哪条腿来)</td></tr>
+ *   <tr><td>租户来源</td><td>ApiKey 认证上下文</td><td>JWT 认证上下文</td>
+ *       <td><b>链接那一行</b>(唯一一处不是认证上下文)</td></tr>
+ *   <tr><td>{@code apiKeyId}</td><td>认证上下文</td><td>null</td><td>null(顾客页没有下游身份)</td></tr>
+ *   <tr><td>{@code notifyUrl}</td><td>调用方传入</td><td><b>有意不设</b></td><td><b>有意不设</b>(同收银台)</td></tr>
+ *   <tr><td>{@code orderBody}</td><td>{@code dto.getOrderBody()}</td><td>{@code dto.getRemark()}</td>
+ *       <td>不设(null)—— 顾客页只有金额一个输入框,多一个字段就是多一处要解释的东西</td></tr>
+ * </table>
+ *
+ * <p>★ {@code notify_url} 为什么留空,论证与收银台**逐字同一份**(那里写了完整的三段:
+ * 一列两义会自环、上游回调地址其实来自平台级配置、改了会牵动已评审的线上代码),
+ * 见 {@code LeshouyingCashierController} 类 javadoc 的「为什么收银单不设 notify_url」。
+ * 这里不复制那三段,只留结论与指针 —— 顾客页连「顾客付完页面变绿」这件事都不做
+ * (页面把顾客送走后就不再回来),比收银台更没有理由设它。
+ *
+ * <h2>端点清单</h2>
+ * <table border="1">
+ *   <caption>两个端点都不带权限串(免登录)</caption>
+ *   <tr><th>方法</th><th>完整路径</th><th>用途</th></tr>
+ *   <tr><td>GET</td><td>{@code /payment/checkout/{token}}</td><td>顾客页开屏:显示商户名</td></tr>
+ *   <tr><td>POST</td><td>{@code /payment/checkout/{token}/orders}</td>
+ *       <td>顾客按下「去支付」:建单 → 上游下单 → 回 {@code code_url}</td></tr>
+ * </table>
+ * <p>★ 路径段不含 {@code collect}/{@code collection} 字样,与 {@code /payment/collection/**}
+ * (管理端 + 免登录审核通知)在字形上分得开:两者只是**看起来**像,前缀不同、认证面也不同。
+ */
+@Slf4j
+// 总开关:leshouying.enabled=false 时本类不装配(端点不存在),见 LeshouyingProperties#enabled
+@ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
+@RestController
+@RequestMapping("/payment/checkout")
+@RequiredArgsConstructor
+public class LeshouyingCheckoutController {
+
+    /** 与 {@code LsyOrderService} / 收银台同一份交易时间格式。 */
+    private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+
+    /** 单号前缀:{@code CS} = checkout。三个入口三份前缀,见类 javadoc 的差异表。 */
+    private static final String OTN_PREFIX = "CS";
+
+    /**
+     * 上游那一跳失败时给顾客的一句话。
+     *
+     * <p>不含上游词汇、不含错误码、不教顾客「稍后重试」以外的事 —— 顾客能做且只能做的
+     * 就是再按一次(再按会生成**新的单号**,旧行既不占资金也不再被使用,重试完全安全)。
+     */
+    private static final String MSG_PAY_FAILED = "支付发起失败,请稍后重试";
+
+    private final LsyCheckoutLinkService linkService;
+    private final LsyMerchantService merchantService;
+    private final LsyOrderService orderService;
+    private final LsyOrderMapper orderMapper;
+
+    // ──────────────────────────── 1. 开屏:显示商户名 ────────────────────────────
+
+    /**
+     * 顾客页开屏读一次,拿商户显示名。
+     *
+     * <p>这里只碰 {@code pay_lsy_checkout_link} 一张表,**不绑租户上下文**:按 token 定位是
+     * 未认证读,唯一依据是那个全局唯一索引(类 javadoc 第 1 条)。返回值里故意只有显示名,
+     * 见 {@link LsyCheckoutInfoVO}。
+     *
+     * @throws BusinessException 404 —— 未知 / 空白 / 已停用一律同一条(由服务层统一给出)
+     */
+    @GetMapping("/{token}")
+    public Result<LsyCheckoutInfoVO> info(@PathVariable String token) {
+        LsyCheckoutLinkEntity link = linkService.requireByToken(token);
+        LsyCheckoutInfoVO vo = new LsyCheckoutInfoVO();
+        vo.setMerchantName(link.getMerchantName());
+        return Result.ok(vo);
+    }
+
+    // ──────────────────────────── 2. 下单:建单 → 上游下单 → 交还链接 ────────────────────────────
+
+    /**
+     * 顾客按下「去支付」。
+     *
+     * <p>顺序固定,四步都不可换:
+     * <ol>
+     *   <li><b>解析 token</b> —— 免登录读,未认证状态下按全局唯一的 token 定位(类 javadoc);</li>
+     *   <li><b>金额下限</b> —— 见下面的双保险说明;</li>
+     *   <li><b>绑租户 → 本地闸 → 落库 → 上游下单</b> —— 本地闸({@code requireByMchNoForTenant})
+     *       必须排在**任何上游调用之前**:反了不是「同样安全」,那等于拿一个还没证明属于本租户的
+     *       商户号去发起一次上游调用(同收银台与开放 API 那条腿的规矩);</li>
+     *   <li><b>还原上下文</b>({@code finally})。</li>
+     * </ol>
+     *
+     * <p>★ <b>落库必须在 {@code createQrOrder} 之前</b>:上游下单要用 {@code order.getId()}
+     * 做条件更新(写 {@code code_url} 与状态),而 id 由 insert 生成。另外
+     * {@code pay_lsy_order.mch_no} 是 NOT NULL 且无默认值 ⇒ 装配必须先于 insert。
+     *
+     * <p>★ <b>金额下限判两道</b>:{@link LsyCheckoutPayDTO} 上的 {@code @Min}(真请求走这条)
+     * 与下面的显式判断(本仓所有控制器用例都是普通方法直调,Spring 的参数校验一步都不参与
+     * ⇒ 只挂注解的话这条规则在测试里没有任何守卫)。文案两边同源,都是
+     * {@link LsyAmountFloor#MESSAGE}。
+     */
+    @PostMapping("/{token}/orders")
+    public Result<LsyCheckoutOrderVO> create(@PathVariable String token,
+                                             @Valid @RequestBody LsyCheckoutPayDTO dto) {
+        LsyCheckoutLinkEntity link = linkService.requireByToken(token);
+
+        Long amount = dto == null ? null : dto.getAmount();
+        if (amount == null || amount < LsyAmountFloor.MIN) {
+            // ★ 这里**不做**任何库操作、不打上游:顾客只会看到一句能照做的话。
+            throw new BusinessException(400, "amount " + LsyAmountFloor.MESSAGE);
+        }
+
+        SecurityContext previous = SecurityContextHolder.getContext();
+        try {
+            SecurityContextHolder.setContext(scopedTo(link.getTenantId()));
+
+            LsyMerchantEntity merchant = merchantService.requireByMchNoForTenant(
+                    link.getMchNo(), link.getTenantId());
+
+            LsyOrderEntity order = newOrder(link, merchant, amount);
+            orderMapper.insert(order);
+
+            try {
+                orderService.createQrOrder(order, null);
+            } catch (LeshouyingApiException e) {
+                // 上游这一跳失败:本地这一行会留在 CREATED 且没有 code_url。
+                // 它**不是死行** —— LsyReconcileScheduler.expireStaleOrders 扫的是整个
+                // LsyOrderStatus.PENDING(含 CREATED),超过 absoluteExpireHours 会被强制
+                // 收敛为 EXPIRED。所以不在这里额外清理(三条腿在这件事上行为一致)。
+                log.warn("收款链接下单未成功: outTradeNo={}, linkId={}, 原因={}",
+                        order.getOutTradeNo(), link.getId(), e.getMessage());
+                return Result.of(500, MSG_PAY_FAILED);
+            }
+            return Result.ok(toVo(order));
+        } finally {
+            SecurityContextHolder.setContext(previous);
+        }
+    }
+
+    // ──────────────────────────── 落库装配(第三条腿) ────────────────────────────
+
+    /**
+     * 组装顾客单。字段差异逐条列在类 javadoc 的表里。
+     *
+     * <p>★ 三个「服务端决定」的字段(单号 / 交易时间 / 流水号)都在这里生成:
+     * 顾客页的请求体里只有金额,多一个可传的字段就是多一处能被塞进别的值的输入。
+     * <p>★ 单号用雪花而不是时间戳式自增:唯一性是 {@code (tenant_id, out_trade_no)},
+     * 两个租户同一秒各建一单时,时间戳式自增会撞在一起。
+     */
+    private LsyOrderEntity newOrder(LsyCheckoutLinkEntity link, LsyMerchantEntity merchant, Long amount) {
+        OffsetDateTime now = OffsetDateTime.now();
+        LsyOrderEntity o = new LsyOrderEntity();
+        o.setTenantId(link.getTenantId());
+
+        o.setOutTradeNo(OTN_PREFIX + IdUtil.getSnowflakeNextIdStr());
+        o.setMchNo(merchant.getMchNo());
+        o.setShopNo(merchant.getShopNo());
+
+        o.setPayScene("QRCODE");
+        // 聚合码不传 pay_type —— 上游按用户扫出来的码自动识别支付宝/微信。
+        o.setPayType(null);
+        o.setPayTraceNo(IdUtil.fastSimpleUUID());
+        o.setPayTime(LocalDateTime.now().format(FMT));
+
+        // 落库这一刻还没下单 ⇒ 只能是 CREATED。createQrOrder 之后会按上游应答改成 PAYING/FAIL。
+        o.setStatus("CREATED");
+        o.setTotalAmount(amount);
+        o.setRefundedAmount(0L);
+        o.setQueryCount(0);
+
+        o.setNotifyStatus("PENDING");
+        o.setNotifyCount(0);
+        // ★ notifyUrl 有意留空,apiKeyId 也有意留空 —— 见类 javadoc 的差异表。
+
+        o.setCreatedTime(now);
+        o.setUpdatedTime(now);
+        return o;
+    }
+
+    // ──────────────────────────── 租户上下文 ────────────────────────────
+
+    /**
+     * 造一个**只带租户**的上下文:没有角色、没有权限、{@code isSuperuser=false}。
+     *
+     * <p>★ {@code isSuperuser} 必须**显式写 false**:{@code TenantInnerInterceptor.ignoreTable}
+     * 的第一条就是超管直通(返回 true = 不做任何租户过滤)。留 null 虽然也走不到那一支
+     * (判据是 {@code user.getIsSuperuser() != null && user.getIsSuperuser()}),
+     * 但这条路径上「不过滤」正是我们要避免的那一态 —— 别让它由 null 的语义顺手决定。
+     *
+     * <p>★ 权限列表给空:这条路径不经过任何 {@code @PreAuthorize},
+     * 这个主体的唯一用途就是让拦截器读到 {@code tenantId}。
+     */
+    private static SecurityContext scopedTo(Long tenantId) {
+        LoginUser user = new LoginUser();
+        user.setTenantId(tenantId);
+        user.setIsSuperuser(false);
+        user.setAuthorities(AuthorityUtils.NO_AUTHORITIES);
+
+        SecurityContext ctx = SecurityContextHolder.createEmptyContext();
+        ctx.setAuthentication(new UsernamePasswordAuthenticationToken(
+                user, null, AuthorityUtils.NO_AUTHORITIES));
+        return ctx;
+    }
+
+    // ──────────────────────────── 出口 ────────────────────────────
+
+    private static LsyCheckoutOrderVO toVo(LsyOrderEntity o) {
+        LsyCheckoutOrderVO vo = new LsyCheckoutOrderVO();
+        vo.setOutTradeNo(o.getOutTradeNo());
+        vo.setCodeUrl(o.getCodeUrl());
+        vo.setTotalAmount(o.getTotalAmount());
+        return vo;
+    }
+}

+ 106 - 2
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingMerchantController.java

@@ -1,25 +1,37 @@
 package com.payment.platform.module.payment.leshouying.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.leshouying.dto.LsyCheckoutLinkVO;
+import com.payment.platform.module.payment.leshouying.entity.LsyCheckoutLinkEntity;
 import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
+import com.payment.platform.module.payment.leshouying.service.LsyCheckoutLinkService;
 import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
+import jakarta.servlet.http.HttpServletRequest;
 import lombok.RequiredArgsConstructor;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 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 白名单)。
  * A 阶段联调必需:不配 mch_no 就无法下单。
  *
- * <p>★ 本类**两种门禁并存**,别照着其中一个去改另一个:
+ * <p>★ 本类**三种门禁并存**,别照着其中一个去改另一个:
  * <ul>
  *   <li>{@link #page} 是**权限串** {@code module_payment:leshouying:merchant:list} ——
  *       租户本来就该看得见自己那几行(还要拿它排查「未配置商户号」),
  *       而查询本身被 {@code TenantInnerInterceptor} 钉在自己的租户内;</li>
  *   <li>{@link #save} / {@link #delete} 是**超管身份** {@code @perm.hasRole('SUPERUSER')}
- *       —— 见那两个方法的 javadoc(2026-09-30 用户裁定)。</li>
+ *       —— 见那两个方法的 javadoc(2026-09-30 用户裁定);</li>
+ *   <li>{@link #checkoutLink} 是**权限串 + 数据闸**:串与 {@code page} 同一条(不新增
+ *       权限点 ⇒ 不需要新菜单 SQL),另加一道「这一行归不归你」。它之所以不能像
+ *       {@code save} 那样挂身份门禁,是因为它**两种调用方都要服务**(租户给自己取、
+ *       超管代任意租户取)—— 见该方法的 javadoc。</li>
  * </ul>
  * 权限串与 Task 18 的 sys_menu.permission 是**跨任务契约**,须逐字一致
  * (PermissionCheck.hasAny 是全等匹配,差一个字符即 403)。角色名不在任何菜单 SQL 里 ——
@@ -33,6 +45,17 @@ import org.springframework.web.bind.annotation.*;
 public class LeshouyingMerchantController {
 
     private final LsyMerchantService merchantService;
+    private final LsyCheckoutLinkService linkService;
+
+    /**
+     * 当前请求 —— 只用来让 {@code LsyCheckoutLinkService#buildUrl} 推导链接基址
+     * (配置留空时按 {@code Host} 头推)。
+     *
+     * <p>★ 注入方式照 {@code LeshouyingOpenController} / {@code OpenapiController} /
+     * {@code HarOpenController}(本仓既有形状:构造器注入 + Spring 的请求作用域代理)。
+     * 单测里传一个 {@code MockHttpServletRequest} 即可,不需要起容器。
+     */
+    private final HttpServletRequest request;
 
     @PreAuthorize("@perm.hasAny('module_payment:leshouying:merchant:list')")
     @GetMapping("/page")
@@ -83,4 +106,85 @@ public class LeshouyingMerchantController {
         merchantService.delete(id);
         return Result.ok();
     }
+
+    /**
+     * 取该商户的**收款链接**(商户页操作列那个按钮背后的端点,2026-09-30 用户需求:
+     * 「checkout链接获取的按钮可以做到系统页面上,顺便封装成二维码,链接和二维码都可以拿走直接用」)。
+     *
+     * <p>幂等:同一个商户多次调用返回**同一条**链接({@code LsyCheckoutLinkService#issue})。
+     * 这是刻意的 —— 链接是要印出来/贴出去的东西,每取一次换一个 token 等于让操作员手上
+     * 那张作废。⇒ 这是个会**写**库的端点(首次调用建那一行),所以是 POST 不是 GET。
+     *
+     * <h2>为什么租户号取自「那一行」而不是认证上下文</h2>
+     * 因为本端点要同时服务两种调用方,而**超管没有自己的租户**(它跨租户看,
+     * {@code LoginUser.tenantId} 可以是 null;商户号的录入恰恰只有超管能做,
+     * 让超管录完号却取不到链接是没道理的)。租户号取自商户行,两种调用方就都通了。
+     *
+     * <h2>那凭什么保证租户读不到别人的行</h2>
+     * 靠两道**独立**的判据,见 {@link #requireMerchantForCurrentUser}:
+     * 非超管走带租户谓词的那条查法,超管走不带谓词的那条。两者的分岔判据是
+     * {@code LoginUser#getIsSuperuser} —— **与 {@code TenantInnerInterceptor} 用的是同一个开关**。
+     * ★ 这条约束不是措辞讲究:若这里换用别的判据(例如「tenantId 是不是 null」),
+     * 就会出现「一边以为放行、另一边仍在过滤」(渲染出恒假谓词、返回空结果且不报错)
+     * 或更坏的「一边以为在过滤、另一边已经放行」。同款论证见
+     * {@code LeshouyingOrderController#applyTenantFilter}。
+     *
+     * <h2>为什么权限串复用 list(不新增权限点)</h2>
+     * 能看见商户页的人本来就读得到这一行的每一列({@code /page} 返回整个实体,含
+     * {@code mchNo}),本端点做的是拿那一行的商户号去换一条链接 —— 没有多给出任何一项
+     * **读**的能力;而「给哪个租户」由上面那两道闸决定,不由权限串决定。
+     * 换个更窄的串不会让闸更严,只会多一个授不出来的串
+     * ({@code LeshouyingAdminWiringTest} 腿③后半会因为菜单里找不到它而变红,
+     * 除非同时新增一份菜单 SQL —— 而那要用户在库里执行,属于额外的部署负担)。
+     *
+     * <p>★ 应答里的 {@code merchantName} 是**链接行的快照**,不是商户行那一列:
+     * 操作员看到的名字必须与顾客打开链接时看到的一样。见 {@code LsyCheckoutLinkVO}。
+     *
+     * @param id 商户行主键({@code LsyMerchantEntity#getId})
+     * @return {@code 0} = 成功({@code Result.ok} 的口径,不是 HTTP 200)
+     * @throws BusinessException 403 取不到登录主体 / 非超管且无归属租户;404 查不到(含别租户)
+     */
+    @PreAuthorize("@perm.hasAny('module_payment:leshouying:merchant:list')")
+    @PostMapping("/{id}/checkout-link")
+    public Result<LsyCheckoutLinkVO> checkoutLink(@PathVariable Long id) {
+        LsyMerchantEntity m = requireMerchantForCurrentUser(id);
+        LsyCheckoutLinkEntity link = linkService.issue(m.getTenantId(), m.getMchNo(), m.getMerchantName());
+
+        LsyCheckoutLinkVO vo = new LsyCheckoutLinkVO();
+        vo.setMchNo(m.getMchNo());
+        vo.setMerchantName(link.getMerchantName());
+        vo.setUrl(linkService.buildUrl(link.getToken(), request));
+        return Result.ok(vo);
+    }
+
+    /**
+     * 取「当前调用方有权看见的那一行商户」,取不到就拒绝 —— <b>先于任何写操作</b>。
+     *
+     * <p>两条腿的选择判据是 {@code LoginUser#getIsSuperuser},与
+     * {@code TenantInnerInterceptor#ignoreTable} 读的是**同一个开关**
+     * (那边 {@code user.getIsSuperuser() != null && user.getIsSuperuser()},
+     * 这里 {@code Boolean.TRUE.equals(...)} —— 对 null 的处置一致:都算非超管)。
+     *
+     * <p>★ {@code user == null} ⇒ 403(**默认拒绝**)。写成
+     * {@code Boolean.FALSE.equals(user.getIsSuperuser())} 之类的形状时,一旦主体取不到,
+     * 有的写法会把 null 归进「非超管」(对),有的会把整句反过来(错)—— 这里显式判空。
+     * 同款用例:{@code LeshouyingOrderControllerTest#page_无登录主体时传租户_也按非超管拒}。
+     *
+     * <p>★ 「非超管且 {@code tenantId == null}」**不是假想组合**:
+     * {@code UserDetailsServiceImpl#buildLoginUser} 把 {@code sys_user.tenant_id}(可空)
+     * 原样搬进 {@code LoginUser},而 {@code is_superuser} 是另一个开关。
+     * 这种主体在条件表上会被**跳过过滤**(没有租户可过滤)⇒ 若放行,它就能按 id 读到
+     * 任意租户的商户行、再拿那一行的商户号发出收款链接。{@code requireByIdForTenant}
+     * 的 403 就是拦这个的,而它必须发生在查库之前。
+     */
+    private LsyMerchantEntity 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())
+                ? merchantService.requireById(id)
+                : merchantService.requireByIdForTenant(id, me.getTenantId());
+    }
 }

+ 45 - 2
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenController.java

@@ -9,6 +9,7 @@ import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.response.Result;
 import com.payment.platform.module.payment.leshouying.client.LeshouyingApiException;
 import com.payment.platform.module.payment.leshouying.dto.*;
+import com.payment.platform.module.payment.leshouying.entity.LsyCheckoutLinkEntity;
 import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
 import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
 import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
@@ -37,7 +38,7 @@ import java.util.function.Supplier;
  * 乐收赢下游 OpenAPI —— 供平台租户调用。
  *
  * 认证:TenantApiKeyAuthFilter(Authorization: ApiKey + Signature HMAC-SHA256)。
- *      本控制器这 7 个路径逐条列于 TenantApiKeyAuthFilter.OPENAPI_ACCOUNT_PATHS;
+ *      本控制器这 8 个路径逐条列于 TenantApiKeyAuthFilter.OPENAPI_ACCOUNT_PATHS;
  *      认证判定按路径**前缀**(OPENAPI_PREFIXES),两者需一致。
  *
  * 租户身份从 request attribute 取(由 filter 注入),不信任请求体里的 tenant_id。
@@ -57,6 +58,8 @@ public class LeshouyingOpenController {
     private final LsyCancelService cancelService;
     private final LsyRefundService refundService;
     private final LsyMerchantService merchantService;
+    /** 收款链接:发放幂等。按 token 免登录解析那条腿在 {@code LeshouyingCheckoutController}。 */
+    private final LsyCheckoutLinkService linkService;
     /** 把上游报文按白名单投影成对外明细 —— 不要把上游响应整个透传给租户。 */
     private final LsyQueryDetailAssembler detailAssembler;
     private final LsyOrderMapper orderMapper;
@@ -140,6 +143,46 @@ public class LeshouyingOpenController {
         return toVo(orderService.createH5Order(order, dto.getPayType()), null);
     }
 
+    /**
+     * 取该商户的**收款链接** —— 给下游系统用的那条腿(2026-09-30)。
+     *
+     * <p>它解决的是:链接是**给顾客扫的**,而租户的收银台 / 公众号菜单 / 桌牌打印程序
+     * 也需要拿到它。只能从管理端点取的话,机器对接方就得先持有一个人类操作员的 JWT,
+     * 而本控制器这一层已经是「同一租户、同一身份」的入口。管理端那个按钮背后是同一条服务。
+     *
+     * <p>★ 幂等:同一个商户多次调用返回**同一条**链接({@code LsyCheckoutLinkService#issue})。
+     * 刻意的 —— 链接要印出来贴出去,每取一次换一个 token 等于让客户手上那张作废。
+     * 所以这是个会**写**库的端点(首次调用建那一行),与三个下单端点同形。
+     *
+     * <h2>租户边界</h2>
+     * 租户号只来自 {@code TenantApiKeyAuthFilter} 注入的 attribute({@link #tenantId()}),
+     * 请求体里没有任何租户字段;商户定位复用 {@link #requireMerchant} —— 三个下单端点与
+     * 本端点走的是**同一个分流**,两条腿都带租户谓词。★ 不在这里另写一份
+     * 「按商户号查一行」:两份副本漂移的后果是一条链接指到别租户的商户号上,
+     * 而钱进谁的账户正是这条链接唯一决定的事。
+     *
+     * @param dto 只有 {@code merchant_code}(选填)与 {@code enterprise_id}(只为响亮拒绝)
+     * @return {@code Result} 信封,{@code data} 是 {@link LsyCheckoutLinkVO}
+     *         (与**管理端**共用同一个 VO ⇒ 两处出去的键名必然一致)
+     * @throws BusinessException 401 无认证上下文;400 传了 {@code enterprise_id}、
+     *                           或该租户登记了多个商户号而没指定;404 指定的商户号不属于本租户
+     */
+    @PostMapping("/checkout/link")
+    public Result<LsyCheckoutLinkVO> checkoutLink(@Valid @RequestBody LsyCheckoutLinkCreateDTO dto) {
+        Long tenantId = tenantId();
+        LsyMerchantEntity merchant = requireMerchant(tenantId, dto.getMerchantCode(), dto.getEnterpriseId());
+        LsyCheckoutLinkEntity link = linkService.issue(
+                tenantId, merchant.getMchNo(), merchant.getMerchantName());
+
+        LsyCheckoutLinkVO vo = new LsyCheckoutLinkVO();
+        vo.setMchNo(merchant.getMchNo());
+        vo.setMerchantName(link.getMerchantName());
+        // ★ 整条 URL 来自 buildUrl(基址推导只有那一份)。控制器自己拼 = 第二个副本,
+        //   而两处漂移的后果是「管理端复制的链接」与「API 返回的链接」不是同一条。
+        vo.setUrl(linkService.buildUrl(link.getToken(), request));
+        return Result.ok(vo);
+    }
+
     @PostMapping("/order/query")
     public Result<Map<String, Object>> orderQuery(@Valid @RequestBody LsyOrderQueryDTO dto) {
         Long tenantId = tenantId();
@@ -262,7 +305,7 @@ public class LeshouyingOpenController {
     //   2) 挂在控制器上,ExceptionHandlerExceptionResolver 先查 handler 自身的处理器、
     //      再查 advice 缓存,**顺序确定**;改用 @ControllerAdvice + assignableTypes 的话,
     //      多个 advice 之间只按 @Order 排序、不按匹配度,是个静默的先后手问题。
-    // 代价是这 7 个端点共用同一个出口 —— 正合文档「一套出口、一套 message 风格」的要求。
+    // 代价是这 8 个端点共用同一个出口 —— 正合文档「一套出口、一套 message 风格」的要求。
 
     @ExceptionHandler(HttpMessageNotReadableException.class)
     @ResponseStatus(HttpStatus.BAD_REQUEST)

+ 24 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyCheckoutInfoVO.java

@@ -0,0 +1,24 @@
+package com.payment.platform.module.payment.leshouying.dto;
+
+import lombok.Data;
+
+/**
+ * 收款链接打开后,顾客页顶部要显示的那点信息。
+ *
+ * <p>★ <b>只有商户显示名一个字段,这是有意的。</b>打开这个链接的人没有登录态,
+ * 这个端点是**未认证**的,凡是从这里出去的东西都是送给「任何一个拿到链接的人」的。
+ * 所以:不给租户号、不给商户号、不给门店号、不给链接 id —— 顾客页一个字都用不到它们,
+ * 而它们每一个都是可以用来拼别的请求的原材料(本仓那条「雪号 id 必须转字符串出网」
+ * 记的是一回事的正面:**能不出的标识就不出**)。
+ *
+ * <p>★ 显示名是**发链接那一刻的快照**({@code pay_lsy_checkout_link.merchant_name}),
+ * 不是每次回查 {@code pay_lsy_merchant}:那张表 {@code mch_no} 在真库里有重复行
+ * (见 {@code LsyMerchantService.requireByMchNo} 的 javadoc),免登录路径上再挑一次行
+ * 就是自己给自己造一个「挑错了」的机会。
+ */
+@Data
+public class LsyCheckoutInfoVO {
+
+    /** 商户显示名;发链接时没写就是 null ⇒ 页面用兜底标题(「收款」),不显示空白标题。 */
+    private String merchantName;
+}

+ 34 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyCheckoutLinkCreateDTO.java

@@ -0,0 +1,34 @@
+package com.payment.platform.module.payment.leshouying.dto;
+
+import lombok.Data;
+
+/**
+ * 开放 API「取收款链接」的请求体。
+ *
+ * <p>只有两格,且都**没有租户维度** —— 租户号来自 ApiKey filter 注入的 request attribute,
+ * 请求体里连一个租户相关字段都没有(本仓通则:不信任报文里的任何租户字段)。
+ *
+ * <p>★ 键名出去是下划线式({@code merchant_code} / {@code enterprise_id}):
+ * 全局 {@code JacksonConfig} 的命名策略是 SNAKE_CASE。契约由
+ * {@code LsyOpenApiJsonContractTest#checkoutLinkRequestFields} 钉住。
+ */
+@Data
+public class LsyCheckoutLinkCreateDTO {
+
+    /**
+     * 选填:取哪个商户号的收款链接。语义与三个下单端点**逐字相同**
+     * (同一个 {@code LeshouyingOpenController#requireMerchant} 分流):
+     *
+     * <p>留空 ⇒ 该租户只登记了一个商户号时直接用它;登记了多个 ⇒ **报 400 要求指定**,
+     * 不由系统替你挑。★ 一条链接对应一个固定商户号(决定钱进谁的账户),
+     * 所以多商户租户**必须**自己挑,不能有「随便选一个」的兜底。
+     */
+    private String merchantCode;
+
+    /**
+     * ★ **只为「响亮拒绝」而声明,从不被读取。** 理由与三个下单 DTO 上那一格逐字相同:
+     * 本仓 {@code FAIL_ON_UNKNOWN_PROPERTIES} 是 true,删掉它老调用方报文里那个键会变成
+     * 500「服务器内部错误」;留着它才能走到那句能照做的 400 迁移提示。
+     */
+    private String enterpriseId;
+}

+ 41 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyCheckoutLinkVO.java

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

+ 36 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyCheckoutOrderVO.java

@@ -0,0 +1,36 @@
+package com.payment.platform.module.payment.leshouying.dto;
+
+import lombok.Data;
+
+/**
+ * 顾客页按下「去支付」之后拿到的东西 —— 一句话:**把浏览器送到哪去**。
+ *
+ * <p>★ {@code codeUrl} 是**上游**(乐收赢 → 支付宝/微信)给的支付链接本身,原样透传。
+ * 顾客页的动作只有一步 {@code location.href = code_url};我们**不**把它包成自己的短链,
+ * 也不做中转页 —— 多一跳就多一处能把顾客卡住的地方,而这一跳不带来任何东西。
+ *
+ * <p>★ 三个字段都是这一屏**用得到**的:{@code codeUrl} 要跳转,{@code totalAmount} 要在
+ * 跳转前再显示一次「应付 ¥x.xx」(顾客核对金额的最后一眼),{@code outTradeNo} 是
+ * 「付了但页面没反应」时顾客报给客服的凭据。除此之外不给 —— 同
+ * {@link LsyCheckoutInfoVO} 的理由:这个端点未认证,凡出去的都是公开的。
+ *
+ * <p>★ 报文键名下划线式({@code out_trade_no} / {@code code_url} / {@code total_amount}),
+ * 由全局 {@code JacksonConfig} 的 SNAKE_CASE 决定。
+ */
+@Data
+public class LsyCheckoutOrderVO {
+
+    /**
+     * 本单的号(我方生成,形如 {@code CS}+雪花)。
+     *
+     * <p>前缀 {@code CS} 与收银台的 {@code SK}、开放 API 那条腿的单号**都不共用** ——
+     * 拿到一个号就知道它从哪条腿来,排查时不必再去 join 一次 {@code api_key_id}。
+     */
+    private String outTradeNo;
+
+    /** 上游支付链接;下单未成功时这个端点回的是失败({@code Result.msg}),不会给空链接。 */
+    private String codeUrl;
+
+    /** 应付金额(分),与顾客在页面上输入的那个数相同。 */
+    private Long totalAmount;
+}

+ 40 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyCheckoutPayDTO.java

@@ -0,0 +1,40 @@
+package com.payment.platform.module.payment.leshouying.dto;
+
+import com.payment.platform.module.payment.leshouying.util.LsyAmountFloor;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+/**
+ * 顾客页(收款链接)的付款请求体。
+ *
+ * <p>★ <b>只有一个字段,而且是**唯一**一个由调用方决定的字段</b>:商户号、门店号、单号、
+ * 交易时间全部来自链接那一行与服务端(见 {@code LeshouyingCheckoutController})。
+ * 顾客面对的屏幕上只有一个金额输入框,这个 DTO 就是那块屏幕的形状。
+ *
+ * <p>★ 字段名与 {@code LsyCashierCreateDTO} 一致({@code amount}),报文键名一律下划线式
+ * (本仓 {@code JacksonConfig} 是 SNAKE_CASE)—— 两者是同一个概念的两个入口,
+ * 键名不一致只会让前端多一处翻译。
+ */
+@Data
+public class LsyCheckoutPayDTO {
+
+    /**
+     * 付款金额,单位**分**。
+     *
+     * <p>{@code @Min} 与收银台同值({@link LsyAmountFloor}):低于 1 元的单上游会以
+     * 「金额不足以支付手续费」回绝,而那句话该由我们说出来 —— 顾客看到的是我们能照做的话,
+     * 不是上游的内部错误码。
+     *
+     * <p>★ 控制器里**再判一次**,不是重复:本仓所有控制器用例都是**普通方法直调**,
+     * Spring 的参数校验一步都不参与。只挂注解的话,「金额下限」这条规则在
+     * {@code LeshouyingCheckoutControllerTest} 里没有任何守卫(收银台那条腿同样是两道)。
+     *
+     * <p>★ <b>没有上界</b> —— 与 {@code LsyCashierCreateDTO.amount} 同一条理由:
+     * 上游对单笔金额的上限我们**没有实测过**,编一个数字出来会变成一条我们自己也解释不清的
+     * 规则;而免登录入口更不该凭一个没量过的数字去拒绝一位正要付钱的顾客。
+     */
+    @NotNull
+    @Min(value = LsyAmountFloor.MIN, message = "amount " + LsyAmountFloor.MESSAGE)
+    private Long amount;
+}

+ 51 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/entity/LsyCheckoutLinkEntity.java

@@ -0,0 +1,51 @@
+package com.payment.platform.module.payment.leshouying.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import com.payment.platform.common.base.PaymentTenantBaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 乐收赢收款链接(一个商户一条)。
+ *
+ * <p>打开 {@code <base>/#/checkout/{token}} 的人输一个金额,服务端就用这一行里的
+ * {@code mch_no} 去发起一笔**真实主扫**({@code /v3/qrpay})。所以本行**不含金额、不含订单** ——
+ * 它只回答「这条链接收钱收到哪个商户名下」。
+ *
+ * <p><b>{@code token} 是免登录路径的唯一凭据</b>:打开链接的人没有登录态,我们只能按它定位。
+ * 全局唯一({@code uk_lsy_checkout_token})—— 见 {@code db/lsy-checkout-link.sql} 里那段
+ * 「为什么不能收窄成 (tenant_id, token)」。URL 里**只有** token,不带 {@code mch_no} 与租户号:
+ * 商户号属上游身份,本仓既有裁定是不给租户看的(见 {@code LsyMerchantService} 的出口文案)。
+ *
+ * <p>{@code merchantName} 是**快照**:顾客页顶部显示它。发放/重取链接时刷新一次,
+ * 所以商户改名后重取一次链接即可(不刷新也不会指错人,只是显示旧名)。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("pay_lsy_checkout_link")
+public class LsyCheckoutLinkEntity extends PaymentTenantBaseEntity {
+
+    /**
+     * 雪花ID(19位)超 JS 安全整数,序列化为字符串防前端精度丢失。
+     * 形态照 {@code LsyMerchantEntity}:注解必须挂在 getter 覆写上 —— 字段声明在基类,
+     * Java 无法对继承来的字段重新注解;子类里重声明 id 会与 MyBatis-Plus 的 @TableId 冲突。
+     */
+    @Override
+    @JsonSerialize(using = ToStringSerializer.class)
+    public Long getId() {
+        return super.getId();
+    }
+
+    /** 钱进哪个乐收赢商户,由它决定。 */
+    private String mchNo;
+
+    /** 链接令牌(32 位十六进制,{@code IdUtil.fastSimpleUUID()})。全局唯一。 */
+    private String token;
+
+    /** 商户显示名快照(发放/重取时刷新)。 */
+    private String merchantName;
+
+    // id / tenantId / status / createdTime / updatedTime / description 由基类提供
+}

+ 9 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/mapper/LsyCheckoutLinkMapper.java

@@ -0,0 +1,9 @@
+package com.payment.platform.module.payment.leshouying.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.payment.platform.module.payment.leshouying.entity.LsyCheckoutLinkEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface LsyCheckoutLinkMapper extends BaseMapper<LsyCheckoutLinkEntity> {
+}

+ 174 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyCheckoutLinkService.java

@@ -0,0 +1,174 @@
+package com.payment.platform.module.payment.leshouying.service;
+
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
+import com.payment.platform.module.payment.leshouying.entity.LsyCheckoutLinkEntity;
+import com.payment.platform.module.payment.leshouying.mapper.LsyCheckoutLinkMapper;
+import com.payment.platform.module.payment.leshouying.util.LsyLogSanitizer;
+import com.payment.platform.module.payment.leshouying.util.LsyStrings;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 收款链接:发放(幂等)与按 token 解析(免登录路径)。
+ *
+ * <h2>为什么链接是「一个商户一条、永久复用」</h2>
+ * 它是要印出来/贴出去的东西(桌牌、收银台、公众号菜单)。每取一次换一个 token 等于让
+ * 客户手上那张作废;而按 (tenant_id, mch_no) 唯一 ⇒ 重取返回同一条。
+ *
+ * <h2>★★ 免登录按 token 定位的安全依据(本仓通则的 (甲)·预防)</h2>
+ * 打开链接的人没有登录态,这条读**不带**租户条件({@code TenantInnerInterceptor} 对
+ * {@code pay_lsy_checkout_link} 在无认证时跳过过滤,见 {@code CONDITIONAL_TABLES})。
+ * 之所以合规:token 上有**全局唯一**索引,多行在 schema 层就建不出来 —— 这正是通则
+ * 「(甲) 只用有全局唯一约束的列定位」那一支。★ 别把它收窄成 (tenant_id, token):
+ * 收窄之后该索引对无认证查询提供**零**保护(同 {@code uk_lsy_order_out_trade_no} 那个陷阱)。
+ *
+ * <p>发放这条腿是**已认证**的(管理端 JWT / 开放 API 的 ApiKey),租户号一律来自认证上下文,
+ * 请求体里没有租户维度 ⇒ 不构成跨租户原语(与 {@code LsyMerchantService.save} 那类
+ * 「请求体给 tenantId」的写入口不同)。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class LsyCheckoutLinkService {
+
+    /** 可用状态。停用由运营直接改库(本批次没有停用入口),解析时按「不存在」处理。 */
+    static final String STATUS_ENABLED = "ENABLED";
+
+    /**
+     * 顾客页是 hash 路由({@code createWebHashHistory})⇒ 路径必须在 {@code #} 之后。
+     * 写成 {@code /checkout/{token}} 会命中静态路由表末尾的通配 404。
+     */
+    private static final String PATH_PREFIX = "/#/checkout/";
+
+    /** 未知 / 停用**共用一句**:不告诉打开链接的人「这条链接存在但被停用」。 */
+    private static final String MSG_INVALID = "收款链接无效或已停用";
+
+    private final LsyCheckoutLinkMapper linkMapper;
+    private final LeshouyingProperties props;
+
+    /**
+     * 取(必要时创建)某商户的收款链接。重复调用返回**同一条**。
+     *
+     * <p>顺带刷新 {@code merchant_name} 快照 —— 顾客页顶部显示的就是它。
+     *
+     * @throws BusinessException 403 租户为空;400 商户号空白;409 本租户内该商户号命中多行
+     *                           (不静默挑一行;唯一索引本应让这一态不可达,但本仓吃过
+     *                           「索引建不出来」的亏,所以照样响亮失败)
+     */
+    public LsyCheckoutLinkEntity issue(Long tenantId, String mchNo, String merchantName) {
+        if (tenantId == null) {
+            throw new BusinessException(403, "当前登录身份没有归属租户,无法生成收款链接,请联系管理员");
+        }
+        if (StrUtil.isBlank(mchNo)) {
+            throw new BusinessException(400, "商户号不能为空");
+        }
+
+        List<LsyCheckoutLinkEntity> rows = linkMapper.selectList(
+                new LambdaQueryWrapper<LsyCheckoutLinkEntity>()
+                        .eq(LsyCheckoutLinkEntity::getTenantId, tenantId)
+                        .eq(LsyCheckoutLinkEntity::getMchNo, mchNo));
+
+        if (rows.isEmpty()) {
+            LsyCheckoutLinkEntity row = new LsyCheckoutLinkEntity();
+            row.setTenantId(tenantId);
+            row.setMchNo(mchNo);
+            row.setToken(IdUtil.fastSimpleUUID());
+            row.setMerchantName(LsyStrings.blankToNull(merchantName));
+            // ★ 显式写状态,不靠建表默认值:不写的话实体里 status=null,而调用方紧接着
+            //   要拿这一行去拼 URL / 序列化给我方页面,null 会被读成「不可用」。
+            row.setStatus(STATUS_ENABLED);
+            linkMapper.insert(row);
+            return row;
+        }
+        if (rows.size() > 1) {
+            log.error("按 (租户, 商户号) 定位收款链接命中多行,拒绝继续: tenantId={}, mchNo={}, 命中 {} 行, ids={}",
+                    tenantId, LsyLogSanitizer.flatten(mchNo), rows.size(),
+                    rows.stream().map(LsyCheckoutLinkEntity::getId).toList());
+            throw new BusinessException(409, "该商户号在您的账户下不唯一,无法确定对应收款链接,请联系管理员处理");
+        }
+
+        LsyCheckoutLinkEntity row = rows.get(0);
+        String name = LsyStrings.blankToNull(merchantName);
+        if (!Objects.equals(row.getMerchantName(), name)) {
+            // ★ 用 wrapper 显式 SET 而不是 updateById:NOT_NULL 字段策略会把「要改成 null」
+            //   从列清单里剔掉,于是库里的旧名留着、内存里已是 null —— 一次静默分叉。
+            // ★ updated_time 必须显式写:update(null, wrapper) 不经过实体,
+            //   拿不到 AutoFillMetaObjectHandler 的 strictUpdateFill(同 save 的先例)。
+            linkMapper.update(null, new LambdaUpdateWrapper<LsyCheckoutLinkEntity>()
+                    .eq(LsyCheckoutLinkEntity::getId, row.getId())
+                    .set(LsyCheckoutLinkEntity::getMerchantName, name)
+                    .set(LsyCheckoutLinkEntity::getUpdatedTime, OffsetDateTime.now()));
+            row.setMerchantName(name);
+        }
+        return row;
+    }
+
+    /**
+     * 按 token 定位(**免登录路径**,顾客页的两个端点都从这里开始)。
+     *
+     * @throws BusinessException 404 —— 未知、空白、已停用**一律同一条 404**;
+     *                           不对调用方区分这三种
+     */
+    public LsyCheckoutLinkEntity requireByToken(String token) {
+        if (StrUtil.isBlank(token)) {
+            throw new BusinessException(404, MSG_INVALID);
+        }
+        LsyCheckoutLinkEntity row = linkMapper.selectOne(
+                new LambdaQueryWrapper<LsyCheckoutLinkEntity>()
+                        .eq(LsyCheckoutLinkEntity::getToken, token));
+        if (row == null) {
+            throw new BusinessException(404, MSG_INVALID);
+        }
+        if (!STATUS_ENABLED.equals(row.getStatus())) {
+            // 运营侧的闸(直接改库),所以留一条可捞的日志:顾客只会看到「链接无效」。
+            log.info("收款链接已停用,按不存在处理: linkId={}, status={}",
+                    row.getId(), LsyLogSanitizer.flatten(row.getStatus()));
+            throw new BusinessException(404, MSG_INVALID);
+        }
+        return row;
+    }
+
+    /**
+     * 拼给顾客/商户的完整 URL。
+     *
+     * <p>基址优先取配置 {@code leshouying.checkout-base-url}(留空 ⇒ 按当前请求推导)。
+     * 推导链:{@code X-Forwarded-Proto/Host}(nginx 反代时用)→ {@code Host} 头 →
+     * {@code scheme://serverName[:port]}。★ 推导这条腿的意义是「零配置可用」:
+     * 前端与后端在同一个公网域名下({@code VITE_API_BASE_URL} 与 {@code VITE_APP_BASE_API}
+     * 同源),所以调用方访问我们用的那个 Host 就是顾客要访问的 Host。
+     */
+    public String buildUrl(String token, HttpServletRequest request) {
+        String base = StrUtil.isNotBlank(props.getCheckoutBaseUrl())
+                ? props.getCheckoutBaseUrl()
+                : deriveBase(request);
+        return StrUtil.removeSuffix(base, "/") + PATH_PREFIX + token;
+    }
+
+    private static String deriveBase(HttpServletRequest req) {
+        String proto = StrUtil.blankToDefault(req.getHeader("X-Forwarded-Proto"), req.getScheme());
+        String forwardedHost = req.getHeader("X-Forwarded-Host");
+        if (StrUtil.isNotBlank(forwardedHost)) {
+            // 反代已经告诉我们对外的名字与协议 ⇒ 本地 serverPort 是**内网端口**,不能拼上去。
+            return proto + "://" + forwardedHost.trim();
+        }
+        String hostHeader = req.getHeader("Host");
+        if (StrUtil.isNotBlank(hostHeader)) {
+            // 容器里一定带 Host(且带端口时它自己就是对的形态)⇒ 原样用。
+            return proto + "://" + hostHeader.trim();
+        }
+        int port = req.getServerPort();
+        boolean defaultPort = ("https".equals(proto) && port == 443) || ("http".equals(proto) && port == 80);
+        return proto + "://" + req.getServerName() + (defaultPort ? "" : ":" + port);
+    }
+}

+ 74 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantService.java

@@ -331,6 +331,80 @@ public class LsyMerchantService {
         return rows.get(0);
     }
 
+    /**
+     * 按**主键**定位(管理端「取收款链接」的**超管那条腿**)。
+     *
+     * <p>★ 与 {@link #requireByIdForTenant} 是**并存**关系,不是替代:两个调用方
+     * (超管 / 租户用户)各走一条,选哪条由调用方按 {@code LoginUser#getIsSuperuser} 决定
+     * —— 那个开关**必须与 {@code TenantInnerInterceptor} 用的是同一个**
+     * (见 {@code LeshouyingMerchantController#requireMerchantForCurrentUser} 的长注释)。
+     *
+     * <p>★★ <b>本方法自己不带租户闸</b>,隔离完全依赖 {@code TenantInnerInterceptor}。
+     * 之所以可以这样,是因为它只该被**超管主体**调用 —— 而超管本来就 bypass 那道拦截器,
+     * 再写一条谓词也没有租户号可写(它跨租户看,{@code LoginUser.tenantId} 可以是 null)。
+     * 服务层**不做**身份判定:那个判定只能有一处(控制器),在这里再判一次就会出现
+     * 「两边用不同开关」的机会,而三种组合(一边放行一边过滤 / 反过来 / 都不判)
+     * 没有一种能在界面上看出来。⇒ 新增调用方时先回答:这个主体是不是超管?
+     * 不是的话必须走 {@link #requireByIdForTenant},多一条谓词不花钱。
+     *
+     * @param id 商户行主键
+     * @throws BusinessException 400 id 为空;404 查不到(或不在当前拦截器谓词范围内)
+     */
+    public LsyMerchantEntity requireById(Long id) {
+        if (id == null) {
+            throw new BusinessException(400, "商户 id 不能为空");
+        }
+        LsyMerchantEntity row = merchantMapper.selectById(id);
+        if (row == null) {
+            throw new BusinessException(404, "商户不存在");
+        }
+        return row;
+    }
+
+    /**
+     * 按**主键 + 租户**定位(管理端「取收款链接」的**租户那条腿**)。
+     *
+     * <p>租户号写进 WHERE,而不是「先 {@code selectById} 再在 Java 里比 {@code tenantId}」:
+     * 后者会把**别租户的那一行**先读进内存。本仓对同型问题已有裁定
+     * ({@code LeshouyingOnboardingController} 关于「不存在『先取行再比租户』这条路」的注释)。
+     * ★ 它与拦截器追加的那一条**重复**,是**故意**的:两道判据由两条独立的代码路径写出,
+     * 手写这条写错时(或拦截器配置漂移时)另一条还在。这**不是**「不写就查不到」——
+     * 别照此写成「不加谓词会被追加 tenant_id=0」那类说法(对
+     * {@code pay_lsy_merchant} 不成立,见 {@code TenantInnerInterceptor} 的条件表)。
+     *
+     * <p>★ 用 {@code selectOne} 而不是 {@code selectList} + 自判多行:
+     * 谓词里有**主键**,行数在结构上 ≤ 1。这与 {@link #requireByMchNoForTenant} 相反 ——
+     * 那边 (租户, 商户号) 没有唯一索引,所以它必须自己判 0 / 1 / 多行。
+     *
+     * <h2>404 不构成跨租户探针</h2>
+     * 同 {@link #requireByMchNoForTenant}:「不存在」与「存在但不属于本租户」应答一致(都 404),
+     * 否则它就成了「这个 id 在平台里存不存在」的枚举口子。
+     *
+     * @param id       商户行主键
+     * @param tenantId **必须来自认证上下文**。为 {@code null} ⇒ 403,不带着 null 往下走
+     *                 (理由同 {@link #requireByMchNoForTenant}:{@code eq(列, null)} 的行为
+     *                 不能靠「恰好失败」)
+     * @throws BusinessException 403 租户为空;400 id 为空;404 本租户内查不到
+     */
+    public LsyMerchantEntity requireByIdForTenant(Long id, Long tenantId) {
+        // 租户闸先于一切:它是本方法的**契约**(公开 API),不依赖调用方先检查一遍。
+        if (tenantId == null) {
+            throw new BusinessException(403, "当前登录身份没有归属租户,无法查询商户,请联系管理员");
+        }
+        if (id == null) {
+            throw new BusinessException(400, "商户 id 不能为空");
+        }
+
+        LsyMerchantEntity row = merchantMapper.selectOne(new LambdaQueryWrapper<LsyMerchantEntity>()
+                .eq(LsyMerchantEntity::getId, id)
+                .eq(LsyMerchantEntity::getTenantId, tenantId));
+
+        if (row == null) {
+            throw new BusinessException(404, "商户不存在");
+        }
+        return row;
+    }
+
     /**
      * 进件的**建行入口**:把上游刚建出来的商户落成 {@code pay_lsy_merchant} 的一行。
      *

+ 5 - 5
java/src/main/java/com/payment/platform/module/payment/leshouying/util/LsyAmountFloor.java

@@ -24,7 +24,7 @@ package com.payment.platform.module.payment.leshouying.util;
  * 规则该在**我们能解释的那一层**被说出来。
  *
  * <h2>覆盖范围(以及**不**覆盖什么)</h2>
- * 只覆盖**乐收赢通道的下单**四个入口:被扫 / 主扫 / H5(开放 API)与收银台。
+ * 只覆盖**乐收赢通道的下单**五个入口:被扫 / 主扫 / H5(开放 API)、收银台、顾客页(收款链接)。
  * <ul>
  *   <li>**退款金额不在内**:部分退款的金额天然可以低于 1 元(退 1.5 元里的一半)。</li>
  *   <li>**汇安融通道不在内**:那条通道没有这条规则,也不该由乐收赢的上游口径推出来
@@ -33,10 +33,10 @@ package com.payment.platform.module.payment.leshouying.util;
  *       我们自己也解释不清的规则。</li>
  * </ul>
  *
- * <h2>为什么要一个常量类而不是四处内联</h2>
- * 同 {@link LsyBizNoPattern}:四个 DTO 各写一份 {@code 100L} 与一份提示语,
- * 就是四处会各自漂的同一个数。**改一处必须改另一处**。
- * 「四处是不是都挂了、挂的是不是同一个数」由 {@code LsyAmountFloorTest} 钉住。
+ * <h2>为什么要一个常量类而不是各写各的</h2>
+ * 同 {@link LsyBizNoPattern}:五个 DTO 各写一份 {@code 100L} 与一份提示语,
+ * 就是五处会各自漂的同一个数。**改一处必须改另一处**。
+ * 「五处是不是都挂了、挂的是不是同一个数」由 {@code LsyAmountFloorTest} 钉住。
  *
  * <p>★ {@link #MIN} 与 {@link #MESSAGE} 之间**没有编译期的绑定**(注解的
  * {@code message} 要求编译期常量,拼不出来),所以两者漂开的可能是真实存在的

+ 48 - 0
java/src/main/resources/db/lsy-checkout-link.sql

@@ -0,0 +1,48 @@
+-- 乐收赢「收款链接」(checkout)—— 建表脚本
+-- 【注意】本脚本未经执行。本工程**未接 Flyway**(pom 无依赖、yml 无配置),
+--        迁移脚本由人工按版本号顺序执行 —— 执行不可逆,需用户放行。
+--
+-- 这张表存的是**一条链接怎么找到那个商户**,不存金额、不存订单 —— 每笔支付都是
+-- 打开链接的人当场输金额后新建的一张 `pay_lsy_order`(前缀 CS,见
+-- LeshouyingCheckoutController)。所以本表与订单是一对多,且**不随支付变化**。
+
+CREATE TABLE IF NOT EXISTS pay_lsy_checkout_link (
+    id              BIGINT       PRIMARY KEY,
+    tenant_id       BIGINT       NOT NULL,
+    mch_no          VARCHAR(32)  NOT NULL,
+    token           VARCHAR(64)  NOT NULL,
+    merchant_name   VARCHAR(128),
+    status          VARCHAR(16)  NOT NULL DEFAULT 'ENABLED',
+    -- description 这一列**不用**,但必须有:实体基类 PaymentBaseEntity 带 description,
+    -- MyBatis-Plus 生成 SELECT 时按 TableInfo 展开全列 ⇒ 库里缺这一列会让每次查询报「列不存在」。
+    description     VARCHAR(255),
+    created_time    TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+    updated_time    TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
+);
+
+-- ★ 全局唯一(不含 tenant_id)—— 这不是洁癖,是**免登录读取的唯一凭据**:
+--   打开链接的人没有登录态,我们只能拿 token 这一列去找那一行。按本仓通则
+--   (TenantInnerInterceptor 里 CONDITIONAL_TABLES 上方那段「怎么算满足」),
+--   无认证定位走 (甲)·预防 —— 只用**有全局唯一约束**的列定位,多行在 schema 层就建不出来。
+--   ★ 别把它收窄成 (tenant_id, token):那就等于把「未认证读不带租户条件」这件事
+--     忘掉了 —— 收窄之后这个索引对无认证查询提供**零**保护,恰是本仓
+--     uk_lsy_order_out_trade_no / uk_lsy_refund_out_refund_no 两条索引注释里记着的那个陷阱。
+CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_checkout_token
+    ON pay_lsy_checkout_link (token);
+
+-- 一个商户一条链接(管理端按钮点两次返回同一条 —— 链接要**永久复用**,
+-- 不该每点一次多一条、更不该让客户手上的旧链接失效)。
+-- 这条索引只服务于**已认证**的发放路径(那里一定带 tenant_id),不承担无认证定位。
+CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_checkout_tenant_mch
+    ON pay_lsy_checkout_link (tenant_id, mch_no);
+
+COMMENT ON TABLE pay_lsy_checkout_link IS '乐收赢收款链接(一商户一条;打开 /#/checkout/{token} 输金额后调真实主扫 /v3/qrpay)';
+COMMENT ON COLUMN pay_lsy_checkout_link.id            IS '主键ID(MyBatis-Plus ASSIGN_ID 雪花)';
+COMMENT ON COLUMN pay_lsy_checkout_link.tenant_id     IS '租户ID(发放时取自认证上下文,不来自请求体)';
+COMMENT ON COLUMN pay_lsy_checkout_link.mch_no        IS '乐收赢商户号(钱进哪个商户由它决定)';
+COMMENT ON COLUMN pay_lsy_checkout_link.token         IS '链接令牌(全局唯一;URL 里只有它,不暴露 mch_no/租户)';
+COMMENT ON COLUMN pay_lsy_checkout_link.merchant_name IS '商户显示名快照(发放/重取时刷新;顾客页顶部就显示它)';
+COMMENT ON COLUMN pay_lsy_checkout_link.status        IS '状态:ENABLED 可用 / DISABLED 停用(resolve 时校验,停用即 404)';
+COMMENT ON COLUMN pay_lsy_checkout_link.description   IS '未使用(列存在只为对齐实体基类 PaymentBaseEntity,见其上方注释)';
+COMMENT ON COLUMN pay_lsy_checkout_link.created_time  IS '创建时间';
+COMMENT ON COLUMN pay_lsy_checkout_link.updated_time  IS '更新时间';

+ 37 - 3
java/src/test/java/com/payment/platform/core/security/LeshouyingWiringTest.java

@@ -19,6 +19,7 @@ class LeshouyingWiringTest {
             "/payment/openapi/pay/barcode/pay",
             "/payment/openapi/pay/qrcode/create",
             "/payment/openapi/pay/h5/create",
+            "/payment/openapi/pay/checkout/link",
             "/payment/openapi/pay/order/query",
             "/payment/openapi/pay/order/close",
             "/payment/openapi/pay/refund",
@@ -29,7 +30,7 @@ class LeshouyingWiringTest {
     private final TenantApiKeyAuthFilter filter = new TenantApiKeyAuthFilter(null, null, null);
 
     /**
-     * 清单一致性核对:文档承诺的 7 条端点必须逐条出现在 OPENAPI_ACCOUNT_PATHS 里。
+     * 清单一致性核对:文档承诺的 8 条端点必须逐条出现在 OPENAPI_ACCOUNT_PATHS 里。
      *
      * ★ 这条**不守鉴权边界** —— 认证判定读的是 `OPENAPI_PREFIXES`(前缀),**不读本数组**
      *   (我实测:`grep -rn OPENAPI_ACCOUNT_PATHS java/src` ⇒ 生产代码零读取)。
@@ -78,12 +79,45 @@ class LeshouyingWiringTest {
         Set<String> tables = (Set<String>) f.get(null);
 
         for (String t : new String[]{"pay_lsy_order", "pay_lsy_refund",
-                                     "pay_lsy_merchant", "pay_lsy_notify_log"}) {
+                                     "pay_lsy_merchant", "pay_lsy_notify_log",
+                                     "pay_lsy_checkout_link"}) {
             assertTrue(tables.contains(t),
                     "未加入 CONDITIONAL_TABLES → 无认证场景查询被追加 tenant_id=0 → 回调回写静默失效: " + t);
         }
     }
 
+    /**
+     * 顾客页(收款链接)那条腿的两处**装配**前提 —— 都不是本类别的用例能覆盖的:
+     *
+     * <ol>
+     *   <li><b>白名单</b>:{@code /payment/checkout/**} 必须以 {@code /**} 形态放行
+     *       (存的是 Ant 模式,用 {@code contains(字面量)} 撞恒为 false ⇒ 用真匹配器,
+     *       同 {@code openapiPaths_areWhitelistedInSecurityConfig_matchingAccountPrecedent}
+     *       的写法)。漏了 ⇒ 顾客打开链接被 Spring Security 挡成 401/403,
+     *       而这条路径**本来就不该有登录态**。</li>
+     *   <li><b>不走 ApiKey 认证面</b>:它是顾客页,不是下游系统 ⇒
+     *       {@code TenantApiKeyAuthFilter.shouldNotFilter} 必须为 true(跳过)。
+     *       这条同时是一道反向守卫:哪天有人把顾客端点挪进
+     *       {@code /payment/openapi/**} 或往 {@code OPENAPI_PREFIXES} 里加前缀,
+     *       它立刻红 —— 那种挪动会把一条**免登录**路径变成「必须持有 ApiKey」。</li>
+     * </ol>
+     */
+    @Test
+    void checkoutPaths_areWhitelisted_andDoNotGoThroughApiKeyAuth() throws Exception {
+        Field f = SecurityConfig.class.getDeclaredField("WHITE_LIST");
+        f.setAccessible(true);
+        String[] whiteList = (String[]) f.get(null);
+
+        org.springframework.util.AntPathMatcher matcher = new org.springframework.util.AntPathMatcher();
+        for (String path : new String[]{"/payment/checkout/tok", "/payment/checkout/tok/orders"}) {
+            assertTrue(java.util.Arrays.stream(whiteList).anyMatch(w -> matcher.match(w, path)),
+                    "顾客页路径未加入 SecurityConfig.WHITE_LIST(会被挡成 403): " + path);
+        }
+
+        assertTrue(filter.shouldNotFilter(realRequest("/payment/checkout/tok")),
+                "顾客页不是下游系统,不该经 ApiKey 认证面(应当跳过该 filter)");
+    }
+
     /**
      * 钉住 OPENAPI 端点的白名单纵深防御与 /payment/openapi/account/** 同形。
      *
@@ -91,7 +125,7 @@ class LeshouyingWiringTest {
      *    (`/**`),不是字面量,所以拿具体路径去撞模式必须用真匹配器;
      *    用 Arrays.asList(whiteList).contains(path) 对 `/**` 形态恒为 false。
      *    该形态(`/payment/openapi/account/**`)已被生产验证。
-     * ② 它钉的是:这 7 条由 TenantApiKeyAuthFilter 认证的端点,与 account/** 享有
+     * ② 它钉的是:这 8 条由 TenantApiKeyAuthFilter 认证的端点,与 account/** 享有
      *    同一条白名单纵深防御 —— filter 万一因路径或装配问题漏跑,请求会走到
      *    Controller 拿明确响应,而不是被授权层丢一个看不出原因的空 403。
      */

+ 289 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCheckoutControllerTest.java

@@ -0,0 +1,289 @@
+package com.payment.platform.module.payment.leshouying.controller;
+
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.Result;
+import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.payment.leshouying.client.LeshouyingApiException;
+import com.payment.platform.module.payment.leshouying.dto.LsyCheckoutInfoVO;
+import com.payment.platform.module.payment.leshouying.dto.LsyCheckoutOrderVO;
+import com.payment.platform.module.payment.leshouying.dto.LsyCheckoutPayDTO;
+import com.payment.platform.module.payment.leshouying.entity.LsyCheckoutLinkEntity;
+import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
+import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
+import com.payment.platform.module.payment.leshouying.service.LsyCheckoutLinkService;
+import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
+import com.payment.platform.module.payment.leshouying.service.LsyOrderService;
+import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
+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.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.*;
+
+/**
+ * 顾客侧(免登录)两个端点。
+ *
+ * <p>本类钉住的都是**这条腿独有**的东西 —— 同形的落库装配在收银台那条腿上有自己的用例表:
+ * <ol>
+ *   <li><b>租户身份只能来自链接那一行</b>,且必须在**任何一次库操作之前**绑进
+ *       SecurityContext。这条路径上的三张表全在 {@code CONDITIONAL_TABLES} 里,
+ *       而拦截器对它们**无认证时是跳过过滤**的 ⇒ 不绑租户时整条路径跑在**没有任何租户谓词**
+ *       的状态下,正确性全押在手写的 WHERE 上;绑上之后拦截器会独立地再写一遍同一个边界。
+ *       ★ 这是**纵深防御**,不是「不绑就查不到」—— 别把这条读成原先有 bug;</li>
+ *   <li><b>单号前缀 {@code CS}</b>,且必须是雪花({@code (tenant_id,out_trade_no)} 唯一,
+ *       时间戳式自增会跨租户撞);</li>
+ *   <li><b>金额下限在控制器里再判一次</b>:本仓所有控制器用例都是普通方法直调,
+ *       Spring 的参数校验一步都不参与 ⇒ 只靠 DTO 注解的话,这一层没有任何守卫;</li>
+ *   <li><b>{@code notifyUrl} 有意留空</b>、{@code apiKeyId} 也留空(顾客页不是下游系统,
+ *       没有回调地址这回事;上游回调走平台级 notify-url,与订单这一列无关)。</li>
+ * </ol>
+ *
+ * <p>★ <b>本类覆盖不到、由别的用例守着的那半边</b>:{@code pay_lsy_checkout_link} 必须登记在
+ * {@code TenantInnerInterceptor.CONDITIONAL_TABLES}(漏登记 ⇒ 未认证查询被追加
+ * {@code tenant_id = 0} ⇒ 每条链接都「无效」)。那是一条装配事实,
+ * 钉它的是 {@code LeshouyingWiringTest},不是这里。
+ */
+@ExtendWith(MockitoExtension.class)
+class LeshouyingCheckoutControllerTest {
+
+    private static final Long TENANT = 42L;
+    private static final String MCH_NO = "Y52211090004";
+
+    @Mock private LsyCheckoutLinkService linkService;
+    @Mock private LsyMerchantService merchantService;
+    @Mock private LsyOrderService orderService;
+    @Mock private LsyOrderMapper orderMapper;
+
+    private LeshouyingCheckoutController controller;
+
+    @BeforeEach
+    void setUp() {
+        controller = new LeshouyingCheckoutController(linkService, merchantService, orderService, orderMapper);
+        SecurityContextHolder.clearContext();
+    }
+
+    @AfterEach
+    void tearDown() {
+        SecurityContextHolder.clearContext();
+    }
+
+    private static LsyCheckoutLinkEntity link() {
+        LsyCheckoutLinkEntity e = new LsyCheckoutLinkEntity();
+        e.setId(9L);
+        e.setTenantId(TENANT);
+        e.setMchNo(MCH_NO);
+        e.setToken("tok");
+        e.setMerchantName("小店");
+        e.setStatus("ENABLED");
+        return e;
+    }
+
+    private static LsyMerchantEntity merchant() {
+        LsyMerchantEntity m = new LsyMerchantEntity();
+        m.setId(5L);
+        m.setTenantId(TENANT);
+        m.setMchNo(MCH_NO);
+        m.setShopNo("SHOP01");
+        m.setMerchantName("小店");
+        return m;
+    }
+
+    private static LsyCheckoutPayDTO pay(Long cents) {
+        LsyCheckoutPayDTO d = new LsyCheckoutPayDTO();
+        d.setAmount(cents);
+        return d;
+    }
+
+    // ─────────────────────────── ① 读商户显示名 ───────────────────────────
+
+    @Test
+    void info_returnsMerchantNameSnapshot() {
+        when(linkService.requireByToken("tok")).thenReturn(link());
+
+        Result<?> r = controller.info("tok");
+
+        assertEquals(0, r.getCode(), "0 = 成功(Result.ok 的口径,不是 HTTP 200)");
+        assertNotNull(r.getData());
+        assertEquals("小店", ((LsyCheckoutInfoVO) r.getData()).getMerchantName());
+    }
+
+    @Test
+    void info_unknownLink_404_andNothingElseIsTouched() {
+        when(linkService.requireByToken("nope"))
+                .thenThrow(new BusinessException(404, "收款链接无效或已停用"));
+
+        BusinessException ex = assertThrows(BusinessException.class, () -> controller.info("nope"));
+
+        assertEquals(404, ex.getCode());
+        verifyNoInteractions(merchantService, orderService, orderMapper);
+    }
+
+    // ─────────────────────────── ② 下单 ───────────────────────────
+
+    @Test
+    void create_belowFloor_400_withTheSameHintAsEveryOtherEntry() {
+        when(linkService.requireByToken("tok")).thenReturn(link());
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> controller.create("tok", pay(99L)));
+
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMessage().contains("不能低于 1 元(100 分)"), "实际: " + ex.getMessage());
+        // ★ 一次库操作、一次上游调用都不许发生 —— 顾客只会看到一句能照做的话。
+        verifyNoInteractions(merchantService, orderService, orderMapper);
+    }
+
+    @Test
+    void create_nullAmount_400() {
+        when(linkService.requireByToken("tok")).thenReturn(link());
+
+        assertEquals(400, assertThrows(BusinessException.class,
+                () -> controller.create("tok", pay(null))).getCode());
+        verifyNoInteractions(merchantService, orderService, orderMapper);
+    }
+
+    @Test
+    void create_assemblesTheOrder_commitsIt_thenCallsTheRealQrPayLeg() {
+        when(linkService.requireByToken("tok")).thenReturn(link());
+        when(merchantService.requireByMchNoForTenant(MCH_NO, TENANT)).thenReturn(merchant());
+        when(orderService.createQrOrder(any(), any())).thenAnswer(inv -> {
+            LsyOrderEntity o = inv.getArgument(0);
+            o.setCodeUrl("https://qr.alipay.com/abc");
+            return null;
+        });
+
+        Result<?> r = controller.create("tok", pay(1234L));
+
+        ArgumentCaptor<LsyOrderEntity> captor = ArgumentCaptor.forClass(LsyOrderEntity.class);
+        verify(orderMapper).insert(captor.capture());
+        LsyOrderEntity o = captor.getValue();
+
+        // 归属与寻址:全部来自链接那一行 + 商户那一行,请求体里没有这两样
+        assertEquals(TENANT, o.getTenantId());
+        assertEquals(MCH_NO, o.getMchNo());
+        assertEquals("SHOP01", o.getShopNo(), "门店号随商户走(有就带上)");
+
+        // 单号:CS 前缀 + 雪花((tenant_id,out_trade_no) 唯一 ⇒ 不能是时间戳式自增)
+        assertNotNull(o.getOutTradeNo());
+        assertTrue(o.getOutTradeNo().startsWith("CS"), "实际: " + o.getOutTradeNo());
+        assertTrue(o.getOutTradeNo().substring(2).matches("\\d{15,25}"),
+                "前缀后面要是雪花ID(19 位上下),实际: " + o.getOutTradeNo());
+
+        // 主扫腿的形状
+        assertEquals("QRCODE", o.getPayScene());
+        assertNull(o.getPayType(), "聚合码不传 pay_type —— 上游按用户扫出来的码自动识别支付宝/微信");
+        assertNotNull(o.getPayTraceNo());
+        assertEquals(32, o.getPayTraceNo().length());
+        assertNotNull(o.getPayTime());
+        assertEquals(14, o.getPayTime().length(), "pay_time 形态 yyyyMMddHHmmss");
+
+        // 落库这一刻还没下单
+        assertEquals("CREATED", o.getStatus());
+        assertEquals(1234L, o.getTotalAmount());
+        assertEquals(0L, o.getRefundedAmount());
+        assertEquals(0, o.getQueryCount());
+        assertEquals("PENDING", o.getNotifyStatus());
+        assertEquals(0, o.getNotifyCount());
+
+        // 有意留空的两格
+        assertNull(o.getApiKeyId(), "顾客页不是下游系统,没有 ApiKey 身份");
+        assertNull(o.getNotifyUrl(), "有意留空:上游回调走平台级 notify-url;填成我们的回调入口会自环");
+
+        // 落库必须在装配之后(mch_no NOT NULL),下单必须在落库之后(createQrOrder 用 insert 生成的 id)
+        var inOrder = inOrder(orderMapper, orderService);
+        inOrder.verify(orderMapper).insert(any());
+        inOrder.verify(orderService).createQrOrder(any(), isNull());
+
+        LsyCheckoutOrderVO vo = (LsyCheckoutOrderVO) r.getData();
+        assertEquals(o.getOutTradeNo(), vo.getOutTradeNo());
+        assertEquals("https://qr.alipay.com/abc", vo.getCodeUrl(),
+                "给顾客的是**上游**的支付链接本身,不要包装成我们自己的地址");
+    }
+
+    @Test
+    void create_setsTenantContextForEveryDbOperationAndRestoresItAfterwards() {
+        when(linkService.requireByToken("tok")).thenReturn(link());
+        when(merchantService.requireByMchNoForTenant(MCH_NO, TENANT)).thenReturn(merchant());
+
+        List<Long> seen = new java.util.ArrayList<>();
+        when(orderMapper.insert(any())).thenAnswer(inv -> {
+            seen.add(currentTenantId());
+            return 1;
+        });
+        when(orderService.createQrOrder(any(), any())).thenAnswer(inv -> {
+            seen.add(currentTenantId());
+            ((LsyOrderEntity) inv.getArgument(0)).setCodeUrl("x");
+            return null;
+        });
+
+        controller.create("tok", pay(500L));
+
+        assertEquals(List.of(TENANT, TENANT), seen,
+                "落库与上游调用都必须跑在「该链接所属租户」的身份下 —— 得到的是拦截器那一条"
+                        + "独立写出的租户谓词(纵深防御:手写 WHERE 写错时另一条还在)");
+        assertNull(SecurityContextHolder.getContext().getAuthentication(),
+                "请求结束后必须把上下文还原(这是容器的线程,不是我们自己的线程)");
+    }
+
+    @Test
+    void create_missingMerchantRow_404_andNoUpstreamCall() {
+        when(linkService.requireByToken("tok")).thenReturn(link());
+        when(merchantService.requireByMchNoForTenant(MCH_NO, TENANT))
+                .thenThrow(new BusinessException(404, "商户号不存在: " + MCH_NO));
+
+        assertEquals(404, assertThrows(BusinessException.class,
+                () -> controller.create("tok", pay(500L))).getCode());
+        verifyNoInteractions(orderMapper, orderService);
+    }
+
+    @Test
+    void create_upstreamRejects_returnsBusinessFailure_andKeepsTheRow() {
+        when(linkService.requireByToken("tok")).thenReturn(link());
+        when(merchantService.requireByMchNoForTenant(MCH_NO, TENANT)).thenReturn(merchant());
+        when(orderService.createQrOrder(any(), any()))
+                .thenThrow(new LeshouyingApiException("NETWORK_ERROR", "网络异常", null));
+
+        Result<?> r = controller.create("tok", pay(500L));
+
+        assertEquals(500, r.getCode());
+        assertNotNull(r.getMsg());
+        // 这一行留在 CREATED 且没有 code_url:它不是死行,绝对过期兜底会收敛它
+        // (同收银台那条腿,不在这里额外清理)。
+        verify(orderMapper).insert(any());
+    }
+
+    @Test
+    void create_passesNullExpireMinutes_soTheConfiguredDefaultApplies() {
+        when(linkService.requireByToken("tok")).thenReturn(link());
+        when(merchantService.requireByMchNoForTenant(MCH_NO, TENANT)).thenReturn(merchant());
+        when(orderService.createQrOrder(any(), any())).thenReturn(null);
+
+        controller.create("tok", pay(500L));
+
+        // 有效期由配置(leshouying.default-qr-expire-minutes)决定,顾客页不给这个参数 ——
+        // 传一个数字进来就等于在**代码里**另立一份有效期。
+        verify(orderService).createQrOrder(any(), eq((Integer) null));
+        verify(orderService, never()).createQrOrder(any(), anyInt());
+    }
+
+    private static Long currentTenantId() {
+        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+        if (auth != null && auth.getPrincipal() instanceof LoginUser u) {
+            return u.getTenantId();
+        }
+        return null;
+    }
+}

+ 287 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingMerchantControllerTest.java

@@ -0,0 +1,287 @@
+package com.payment.platform.module.payment.leshouying.controller;
+
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.payment.leshouying.dto.LsyCheckoutLinkVO;
+import com.payment.platform.module.payment.leshouying.entity.LsyCheckoutLinkEntity;
+import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
+import com.payment.platform.module.payment.leshouying.service.LsyCheckoutLinkService;
+import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
+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.anyLong;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.*;
+
+/**
+ * 管理端「取收款链接」(商户页上那个按钮背后的端点)。
+ *
+ * <p>【本类只覆盖新增的这一个端点】另外三个({@code page} / {@code save} / {@code delete})
+ * 由 {@code LeshouyingAdminWiringTest} 按反射逐个映射方法守(认证面、@PreAuthorize、
+ * 权限串契约、身份门禁形状)。**不要**把那份职责搬到这里来 —— 那边的守卫覆盖面更宽,
+ * 搬过来只会得到一个更弱的副本。
+ *
+ * <p>【本类真正钉的是什么】这个端点的**归属判据**。它要同时服务两种调用方:
+ * <ul>
+ *   <li>租户用户:只能给自己那一行取(拿到的链接决定钱进哪个乐收赢商户号);</li>
+ *   <li>超管:可以给任意一行取(它是代运营登记商户号的那一方,
+ *       见 {@code LeshouyingMerchantController#save} 的 javadoc)。</li>
+ * </ul>
+ * 判据必须与 {@code TenantInnerInterceptor} **用同一个开关**({@code LoginUser#getIsSuperuser})——
+ * 用别的开关(例如「tenantId 是不是 null」)会出现「一边以为放行、一边仍在过滤」
+ * 或更坏的「一边以为在过滤、一边已经放行」,两种组合界面上都看不出来。
+ * 同款论证见 {@code LeshouyingOrderController#applyTenantFilter}。
+ *
+ * <p>【量具】本仓所有 {@code Leshouying*ControllerTest} 都是**普通方法直调**控制器
+ * (不起 Spring 上下文、{@code @PreAuthorize} 一步都不参与)—— 所以「哪个服务方法被调用、
+ * 传了什么租户号」只能靠 {@code verify} 读,而「注解写的是什么」只能靠反射读(见最后一条用例)。
+ */
+@ExtendWith(MockitoExtension.class)
+class LeshouyingMerchantControllerTest {
+
+    @Mock private LsyMerchantService merchantService;
+    @Mock private LsyCheckoutLinkService linkService;
+
+    private MockHttpServletRequest request;
+    private LeshouyingMerchantController controller;
+
+    @BeforeEach
+    void setUp() {
+        request = new MockHttpServletRequest("POST", "/api/v1/payment/collection/merchant/1/checkout-link");
+        controller = new LeshouyingMerchantController(merchantService, linkService, request);
+    }
+
+    /**
+     * ★ 必须清:{@code SecurityContextHolder} 是**线程本地且跨用例存活**的,
+     * 而 Surefire 默认在同一线程里跑完整个类。不清的话某条用例的 {@code login(null, true)}
+     * 会泄漏给后面每一条「不该放行」的用例,让它们集体变绿 ——
+     * 那种「全绿但什么也没验」的形态在本仓出现过,见
+     * {@code LeshouyingOrderControllerTest#clearSecurityContext}。
+     */
+    @AfterEach
+    void clearSecurityContext() {
+        SecurityContextHolder.clearContext();
+    }
+
+    // ────────────────────────────── 工具 ──────────────────────────────
+
+    /** 把当前主体放进 SecurityContext(照 {@code LeshouyingOrderControllerTest#login})。 */
+    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 LsyMerchantEntity merchant(long id, Long tenantId, String mchNo, String name) {
+        LsyMerchantEntity m = new LsyMerchantEntity();
+        m.setId(id);
+        m.setTenantId(tenantId);
+        m.setMchNo(mchNo);
+        m.setMerchantName(name);
+        m.setStatus("ENABLED");
+        return m;
+    }
+
+    private static LsyCheckoutLinkEntity link(String mchNo, String snapshotName, String token) {
+        LsyCheckoutLinkEntity l = new LsyCheckoutLinkEntity();
+        l.setId(9L);
+        l.setMchNo(mchNo);
+        l.setMerchantName(snapshotName);
+        l.setToken(token);
+        l.setStatus("ENABLED");
+        return l;
+    }
+
+    // ───────────────────────── 一、两种调用方,两条查法 ─────────────────────────
+
+    /**
+     * 租户用户:谓词里**显式**写上自己的租户号。
+     *
+     * <p>★ 这与拦截器追加的那一条**重复**,是**故意**的(纵深防御):两道判据由两条完全
+     * 独立的代码路径写出,手写的那一条写错时拦截器那一条还在。反过来说:本用例断言的
+     * 是「控制器把租户号交给了带谓词的那条查法」,**不等于**「没有它就会越权」——
+     * 别照此去写「不加就被追加 tenant_id=0」那类说法(那条机制对
+     * {@code pay_lsy_merchant} 不成立,见 {@code TenantInnerInterceptor} 的条件表)。
+     */
+    @Test
+    void 取链接_租户用户_走带租户谓词的那条查法_租户号取自己的() {
+        login(3L, false);
+        LsyMerchantEntity row = merchant(1L, 3L, "MCH003", "三号商户");
+        when(merchantService.requireByIdForTenant(1L, 3L)).thenReturn(row);
+        when(linkService.issue(3L, "MCH003", "三号商户")).thenReturn(link("MCH003", "三号商户", "tok3"));
+        when(linkService.buildUrl(same("tok3"), same(request))).thenReturn("https://pay.example.com/#/checkout/tok3");
+
+        LsyCheckoutLinkVO vo = controller.checkoutLink(1L).getData();
+
+        verify(merchantService).requireByIdForTenant(1L, 3L);
+        verify(merchantService, never()).requireById(anyLong());
+        assertEquals("https://pay.example.com/#/checkout/tok3", vo.getUrl(),
+                "URL 必须来自服务层的 buildUrl(基址推导那套逻辑只有一份,"
+                        + "控制器自己拼一个就是第二个副本)");
+    }
+
+    /**
+     * 超管:不写租户谓词,**租户号取自那一行**。
+     *
+     * <p>★ 这条是本端点存在两种调用方的**全部理由**:超管的 {@code LoginUser.tenantId}
+     * 可以是 null(它跨租户看,所以根本没有「自己的租户」可写),
+     * 于是若照 {@code LeshouyingCashierController#requireTenantId} 那样
+     * 「取不到租户就 403」,超管将**完全无法使用这个按钮** —— 而商户号的录入
+     * ({@code save})恰恰只有超管能做,让超管录完号却拿不到链接是没有道理的。
+     * ★ 这里显式断言 {@code requireByIdForTenant} **一次都没被调**:那是两条查法
+     * 不互相串味的正对照(少了它,一个「两条都调」的实现照样绿)。
+     */
+    @Test
+    void 取链接_超管_不写租户谓词_租户号取自那一行_所以没有归属租户也能用() {
+        login(null, true);
+        LsyMerchantEntity row = merchant(1L, 5L, "MCH005", "五号商户");
+        when(merchantService.requireById(1L)).thenReturn(row);
+        when(linkService.issue(5L, "MCH005", "五号商户")).thenReturn(link("MCH005", "五号商户", "tok5"));
+        when(linkService.buildUrl(same("tok5"), same(request))).thenReturn("https://pay.example.com/#/checkout/tok5");
+
+        LsyCheckoutLinkVO vo = controller.checkoutLink(1L).getData();
+
+        verify(merchantService).requireById(1L);
+        verify(merchantService, never()).requireByIdForTenant(anyLong(), any());
+        verify(linkService).issue(5L, "MCH005", "五号商户");
+        assertEquals("MCH005", vo.getMchNo(), "商户号要回给页面显示(操作员得知道自己在给谁取链接)");
+    }
+
+    // ───────────────────────── 二、闸:取不到归属就拒绝 ─────────────────────────
+
+    /**
+     * 非超管**且**没有归属租户 ⇒ 仍然走**带租户谓词**的那条查法,绝不落到超管那条。
+     *
+     * <p>★ 这不是假想的组合。写入认证主体的那条路径是
+     * {@code UserDetailsServiceImpl#buildLoginUser},它把 {@code sys_user.tenant_id}
+     * **原样**搬进 {@code LoginUser}(那一列可空),而 {@code is_superuser} 是另一个开关
+     * —— 两者互相独立。所以「非超管 + tenantId=null」是可达到的状态。
+     * ★ 更要紧的是:{@code TenantInnerInterceptor} 对这种主体在条件表上是**跳过过滤**
+     * (无租户可过滤)⇒ 若它落进超管那条腿,就能按 id 读到**任意租户**的商户行、
+     * 再拿那一行的商户号发出一条收款链接。这道分岔就是拦这个的。
+     *
+     * <p>【为什么这条只断言「走了哪条腿」,不断言 403】那句 403 是**服务层的契约**
+     * ({@code LsyMerchantService#requireByIdForTenant} 在 {@code tenantId == null} 时抛,
+     * 且发生在查库之前),证据在 {@code LsyMerchantServiceTest}(那里跑的是真实现)。
+     * 在这里把 mock 设成「抛 403」再断言收到 403,验的是**测试自己设的桩**,不是被测代码
+     * —— 所以本类只测控制器真正负责的那一件事:**分岔**。
+     * 两条腿都用 {@code never()} 互为正对照(少任何一条,「两条都调」或「调错了」都可能绿)。
+     */
+    @Test
+    void 取链接_非超管且没有归属租户_仍走带租户谓词的那条_绝不落到超管那条() {
+        login(null, false);
+        when(merchantService.requireByIdForTenant(1L, null)).thenReturn(merchant(1L, null, "MCH001", null));
+        when(linkService.issue(null, "MCH001", null)).thenReturn(link("MCH001", null, "tok1"));
+        when(linkService.buildUrl(same("tok1"), same(request))).thenReturn("https://pay.example.com/#/checkout/tok1");
+
+        controller.checkoutLink(1L);
+
+        verify(merchantService).requireByIdForTenant(1L, null);
+        verify(merchantService, never()).requireById(anyLong());
+    }
+
+    /**
+     * 取不到登录主体 ⇒ 同样 403(**默认拒绝**,不是默认放行)。
+     *
+     * <p>判据写成 {@code Boolean.FALSE.equals(user.getIsSuperuser())} 就会在这里放行 ——
+     * 因为 null 既不等于 TRUE 也不等于 FALSE 的写法有两种,其中一种把 null 归进「非超管」
+     * (对),另一种把整个判断反过来(错)。同款用例见
+     * {@code LeshouyingOrderControllerTest#page_无登录主体时传租户_也按非超管拒}。
+     */
+    @Test
+    void 取链接_取不到登录主体_403_且一条库都不查() {
+        SecurityContextHolder.clearContext();
+
+        BusinessException e = assertThrows(BusinessException.class, () -> controller.checkoutLink(1L));
+
+        assertEquals(403, e.getCode(), "取不到主体时必须拒绝,不能默认放行");
+        verifyNoInteractions(merchantService, linkService);
+    }
+
+    // ───────────────────────── 三、查不到就不发链接 ─────────────────────────
+
+    /**
+     * 别租户 / 不存在的商户行,**同一个 404**(不区分这两种 —— 与
+     * {@code LeshouyingCashierController#requireOrder} 同一条纪律),且**不生成链接**。
+     *
+     * <p>★ 断言 {@code verifyNoInteractions(linkService)} 而不是只看异常类型:
+     * 若实现在闸之前先 {@code issue(...)}(或先按商户号反查再比对),
+     * 那么「拒绝」只是**发晚了** —— 链接行已经落库、token 已经发出去。
+     * 同型的一处顺序断言见 {@code LeshouyingOnboardingControllerTest} 的
+     * {@code InOrder}(那边的评审判定「先 commit 后闸」等于拿一个还没证明属于本租户的
+     * 商户号去发起上游提交)。
+     */
+    @Test
+    void 取链接_商户查不到或不属于本租户_404_且一步都不碰链接服务() {
+        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 page} 用的那一条),
+     * 不新增第四个权限点 —— 因此**不需要新的菜单 SQL**:
+     * <ul>
+     *   <li>能看见商户页的人本来就能读到这一行的每一列({@code /page} 返回的是整个
+     *       {@code LsyMerchantEntity},含 {@code mchNo});本端点做的事是拿那一行的
+     *       商户号去换一条链接,没有多给出任何一项**读**的能力;</li>
+     *   <li>而「给哪个租户」这件事不由权限串决定,由上面那两道闸决定 ——
+     *       换一个更窄的权限串并不会让闸更严,只会多一个授不出来的串
+     *       ({@code LeshouyingAdminWiringTest} 腿③后半会因为菜单里找不到它而变红)。</li>
+     * </ul>
+     * ★ 路径写成字面量断言,是因为它同时是**前后端契约**:改这里而不同步前端 ⇒
+     * 按钮点了 404;前端改而不改这里 ⇒ 同上。反射读的是注解本身,不是本测试自写的常量。
+     */
+    @Test
+    void 取链接_接口形状_权限串复用商户list_路径是前端要调的那一条() throws Exception {
+        PreAuthorize pa = LeshouyingMerchantController.class
+                .getMethod("checkoutLink", Long.class)
+                .getAnnotation(PreAuthorize.class);
+        assertNotNull(pa, "端点必须有 @PreAuthorize(LeshouyingAdminWiringTest 腿②会逐个映射方法查这一条)");
+        assertEquals("@perm.hasAny('module_payment:leshouying:merchant:list')", pa.value(),
+                "权限串必须与 page 逐字相同(复用同一个权限点,不新增菜单 SQL)");
+
+        PostMapping pm = LeshouyingMerchantController.class
+                .getMethod("checkoutLink", Long.class)
+                .getAnnotation(PostMapping.class);
+        assertNotNull(pm, "取链接会**写**一行(首次调用要建链接行)⇒ 必须是 POST,不是 GET");
+        RequestMapping rm = LeshouyingMerchantController.class.getAnnotation(RequestMapping.class);
+        assertNotNull(rm, "类级 @RequestMapping 不得为空 —— 真实路径拼不出来");
+
+        // ★ 两段各自与**同一个字面量**比:分开断言(类级 == X、方法级 == Y)会在
+        //   「有人把两段一起改掉」时照样绿 —— 而那个改动正是前端 404 的成因。
+        assertEquals(List.of("/{id}/checkout-link"), List.of(pm.value()));
+        assertEquals("/payment/collection/merchant/{id}/checkout-link",
+                rm.value()[0] + pm.value()[0],
+                "前后端契约路径。前端商户页的操作列按这一条调;改这段而不同步前端 ⇒ 按钮点了 404");
+    }
+}

+ 4 - 2
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenApiErrorContractTest.java

@@ -9,6 +9,7 @@ import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
 import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
 import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
 import com.payment.platform.module.payment.leshouying.service.LsyCancelService;
+import com.payment.platform.module.payment.leshouying.service.LsyCheckoutLinkService;
 import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
 import com.payment.platform.module.payment.leshouying.service.LsyOrderService;
 import com.payment.platform.module.payment.leshouying.service.LsyQueryDetailAssembler;
@@ -71,6 +72,7 @@ class LeshouyingOpenApiErrorContractTest {
     @Mock private LsyCancelService cancelService;
     @Mock private LsyRefundService refundService;
     @Mock private LsyMerchantService merchantService;
+    @Mock private LsyCheckoutLinkService linkService;
     @Mock private LsyOrderMapper orderMapper;
 
     /** 真实例:本类要验的投影结果不能是 mock。 */
@@ -89,8 +91,8 @@ class LeshouyingOpenApiErrorContractTest {
         injected.setAttribute("openapi.apiKeyId", 10L);
 
         LeshouyingOpenController controller = new LeshouyingOpenController(orderService, queryService,
-                cancelService, refundService, merchantService, detailAssembler,
-                orderMapper, injected);
+                cancelService, refundService, merchantService, linkService,
+                detailAssembler, orderMapper, injected);
 
         // ★ 必须灌入**应用真实的 ObjectMapper**,否则本类测的是一个不存在的环境。
         //   `standaloneSetup` 默认用 `Jackson2ObjectMapperBuilder.json().build()`,它与生产有两处不同:

+ 91 - 3
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenControllerTest.java

@@ -3,6 +3,7 @@ package com.payment.platform.module.payment.leshouying.controller.open;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
 import com.payment.platform.module.payment.leshouying.dto.*;
+import com.payment.platform.module.payment.leshouying.entity.LsyCheckoutLinkEntity;
 import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
 import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
 import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
@@ -45,6 +46,7 @@ class LeshouyingOpenControllerTest {
     @Mock private LsyCancelService cancelService;
     @Mock private LsyRefundService refundService;
     @Mock private LsyMerchantService merchantService;
+    @Mock private LsyCheckoutLinkService linkService;
     @Mock private LsyOrderMapper orderMapper;
 
     /**
@@ -63,8 +65,8 @@ class LeshouyingOpenControllerTest {
         request.setAttribute("openapi.tenantId", TENANT);   // 模拟 TenantApiKeyAuthFilter 注入
         request.setAttribute("openapi.apiKeyId", 10L);
         controller = new LeshouyingOpenController(orderService, queryService,
-                cancelService, refundService, merchantService, detailAssembler,
-                orderMapper, request);
+                cancelService, refundService, merchantService, linkService,
+                detailAssembler, orderMapper, request);
     }
 
     private LsyMerchantEntity merchant() {
@@ -238,6 +240,92 @@ class LeshouyingOpenControllerTest {
                 "订单不再挂企业 —— 企业概念已于 2026-09-30 从乐收赢整个去掉");
     }
 
+    // ───────── 收款链接(2026-09-30:「checkout链接获取的按钮…链接和二维码都可以拿走直接用」)─────────
+    //
+    // 这一条腿的存在理由:链接是**给顾客扫的**,而下游系统(租户的收银台/公众号菜单/桌牌打印)
+    // 也需要拿到它 —— 只能从管理端点取的话,机器对接方就得持有一个人类操作员的 JWT。
+    // ★ 它是**已认证**的:租户号只来自 ApiKey filter 注入的 request attribute,请求体里没有租户维度。
+
+    private static LsyCheckoutLinkEntity link(String mchNo, String snapshotName, String token) {
+        LsyCheckoutLinkEntity l = new LsyCheckoutLinkEntity();
+        l.setMchNo(mchNo);
+        l.setMerchantName(snapshotName);
+        l.setToken(token);
+        l.setStatus("ENABLED");
+        return l;
+    }
+
+    /** 未指定商户号 ⇒ 与三个下单端点**同一条分流**(共用 {@code requireMerchant}),不是另写一份。 */
+    @Test
+    void checkoutLink_未指定商户号_用租户唯一那个商户_并按快照名发链接() {
+        LsyMerchantEntity m = merchant();
+        m.setMerchantName("示例商户");
+        when(merchantService.requireByTenant(TENANT)).thenReturn(m);
+        when(linkService.issue(TENANT, "Y52211090004", "示例商户"))
+                .thenReturn(link("Y52211090004", "示例商户", "tok-a"));
+        when(linkService.buildUrl(eq("tok-a"), same(request)))
+                .thenReturn("https://pay.example.com/#/checkout/tok-a");
+
+        LsyCheckoutLinkVO vo = controller.checkoutLink(new LsyCheckoutLinkCreateDTO()).getData();
+
+        verify(merchantService).requireByTenant(TENANT);
+        verify(merchantService, never()).requireByMchNoForTenant(any(), any());
+        assertEquals("Y52211090004", vo.getMchNo());
+        assertEquals("示例商户", vo.getMerchantName());
+        assertEquals("https://pay.example.com/#/checkout/tok-a", vo.getUrl(),
+                "URL 必须整条来自 buildUrl —— 控制器自己拼一个就是第二个副本,"
+                        + "两处漂移的后果是「管理端复制的链接」与「API 返回的链接」不是同一条");
+    }
+
+    /** 指定了商户号 ⇒ 按号精确匹配,且**不去碰**租户唯一那条腿(多商户租户必须能自己挑)。 */
+    @Test
+    void checkoutLink_指定商户号_按号精确匹配_绝不退回租户唯一那条() {
+        LsyMerchantEntity m = merchant();
+        m.setMerchantName("乙商户");
+        when(merchantService.requireByMchNoForTenant(eq("Y52211090004"), eq(TENANT))).thenReturn(m);
+        when(linkService.issue(TENANT, "Y52211090004", "乙商户"))
+                .thenReturn(link("Y52211090004", "乙商户", "tok-b"));
+
+        LsyCheckoutLinkCreateDTO dto = new LsyCheckoutLinkCreateDTO();
+        dto.setMerchantCode("Y52211090004");
+
+        controller.checkoutLink(dto);
+
+        verify(merchantService, never()).requireByTenant(any());
+        verify(linkService).issue(TENANT, "Y52211090004", "乙商户");
+    }
+
+    /**
+     * ★ 与三个下单端点**同一句**拒绝:老调用方还在发 {@code enterprise_id} ⇒ 400,
+     * 且**一张链接都不发**(静默忽略最坏 —— 调用方以为指定了企业,实际落到租户唯一那个商户号,
+     * 而这条链接是要印在桌牌上长期用的)。
+     */
+    @Test
+    void checkoutLink_withEnterpriseId_isRejectedLoudly_andIssuesNothing() {
+        LsyCheckoutLinkCreateDTO dto = new LsyCheckoutLinkCreateDTO();
+        dto.setEnterpriseId("ENT1");
+
+        BusinessException ex = assertThrows(BusinessException.class, () -> controller.checkoutLink(dto));
+
+        assertEquals(400, ex.getCode(), "实测: " + ex.getCode());
+        assertTrue(ex.getMessage().contains("merchant_code"), "实测: " + ex.getMessage());
+        verifyNoInteractions(merchantService, linkService);
+    }
+
+    /** 没有认证上下文 ⇒ 401,且一步都不查商户、不发链接(租户号是唯一来源,缺了就拒绝)。 */
+    @Test
+    void checkoutLink_missingTenantAttribute_throws401_andTouchesNothing() {
+        MockHttpServletRequest anon = new MockHttpServletRequest();
+        LeshouyingOpenController c = new LeshouyingOpenController(orderService,
+                queryService, cancelService, refundService, merchantService,
+                linkService, detailAssembler, orderMapper, anon);
+
+        assertThrows(BusinessException.class,
+                () -> c.checkoutLink(new LsyCheckoutLinkCreateDTO()));
+
+        verifyNoInteractions(merchantService, linkService);
+    }
+
     @Test
     void barcodePay_duplicateOutTradeNo_returnsExistingWithoutNewUpstreamCall() {
         LsyOrderEntity existing = new LsyOrderEntity();
@@ -278,7 +366,7 @@ class LeshouyingOpenControllerTest {
         MockHttpServletRequest anon = new MockHttpServletRequest();
         LeshouyingOpenController c = new LeshouyingOpenController(orderService,
                 queryService, cancelService, refundService, merchantService,
-                detailAssembler, orderMapper, anon);
+                linkService, detailAssembler, orderMapper, anon);
 
         LsyBarcodePayDTO dto = new LsyBarcodePayDTO();
         dto.setOutTradeNo("OUT001");

+ 34 - 19
java/src/test/java/com/payment/platform/module/payment/leshouying/dto/LsyAmountFloorTest.java

@@ -1,6 +1,7 @@
 package com.payment.platform.module.payment.leshouying.dto;
 
 import com.payment.platform.module.payment.leshouying.controller.LeshouyingCashierController;
+import com.payment.platform.module.payment.leshouying.controller.LeshouyingCheckoutController;
 import com.payment.platform.module.payment.leshouying.controller.open.LeshouyingOpenController;
 import jakarta.validation.ConstraintViolation;
 import jakarta.validation.Valid;
@@ -29,8 +30,8 @@ import static org.junit.jupiter.api.Assertions.*;
 /**
  * 「**所有**租户下单行为最低 1 元」这条规则的**入口**校验。
  *
- * <p>【为什么钉在这一层】金额是租户唯一能自己决定的那一格钱。四个下单入口把它接进来的地方
- * 就是这四个 DTO;控制器里再判一次也只是第二道。
+ * <p>【为什么钉在这一层】金额是租户唯一能自己决定的那一格钱。五个下单入口把它接进来的地方
+ * 就是这五个 DTO;控制器里再判一次也只是第二道。
  * 而本仓所有 {@code Leshouying*ControllerTest} 都是**普通方法直调**控制器
  * ({@code controller.barcodePay(dto)}),Spring 的参数校验**一步都不参与** ——
  * 把 DTO 上的约束整行删掉,那些用例一条都不会红。
@@ -38,7 +39,7 @@ import static org.junit.jupiter.api.Assertions.*;
  * 与 {@code LeshouyingOpenControllerTest.h5CreateDTO_unsupportedPayType_failsValidation}
  * 同一套写法(Hibernate Validator 在单测 classpath 上,已实测)。
  *
- * <p>【为什么四个入口写在同一个类里】规则说的是「所有」。拆到四个测试类里去的话,
+ * <p>【为什么五个入口写在同一个类里】规则说的是「所有」。拆到五个测试类里去的话,
  * 「只改了三处」不会有任何一条红 —— 而漏掉的那一处照样能下出 1 分钱的单,
  * 上游再回一句「金额不足以支付手续费」,操作员看到的是通道报错而不是我们的规则。
  * 那张表少一行,就是少一个入口。
@@ -68,9 +69,11 @@ class LsyAmountFloorTest {
     }
 
     /**
-     * 本仓的四个租户下单入口 —— 依据是全仓 grep {@code orderMapper.insert}:
+     * 本仓的五个租户下单入口 —— 依据是全仓 grep {@code orderMapper.insert}:
      * {@code LeshouyingOpenController} 三处(:89 被扫 / :118 主扫 / :145 H5)+
-     * {@code LeshouyingCashierController} 一处(:180 收银台)。
+     * {@code LeshouyingCashierController} 一处(:180 收银台)+
+     * {@code LeshouyingCheckoutController} 一处(顾客页收款链接,2026-09-30 新增;
+     * 单号前缀 {@code CS},与收银台的 {@code SK} 区分)。
      * 退款的金额**不在**这条规则里(部分退款的金额天然可以低于 1 元)。
      */
     private static Map<String, Entry> entries() {
@@ -109,6 +112,16 @@ class LsyAmountFloorTest {
                     return d;
                 }));
 
+        // 2026-09-30 新增:顾客页(收款链接)。**免登录**入口 —— 金额同样是顾客自己决定的,
+        // 所以它和另外四个受同一条规则约束(低于 1 元上游会回「金额不足以支付手续费」,
+        // 那句话该由我们说,而且这里连一个能解释上游错误码的人都没有)。
+        m.put("收款链接 POST /payment/checkout/{token}/orders", new Entry(
+                "LsyCheckoutPayDTO", "amount", "amount", cents -> {
+                    LsyCheckoutPayDTO d = new LsyCheckoutPayDTO();
+                    d.setAmount(cents);
+                    return d;
+                }));
+
         return m;
     }
 
@@ -119,9 +132,10 @@ class LsyAmountFloorTest {
      * 这里会以「少了一个入口」红,而不是静默地少测一个入口。
      */
     @Test
-    void 四个下单入口一个都不能少() {
+    void 五个下单入口一个都不能少() {
         assertEquals(
-                List.of("LsyBarcodePayDTO", "LsyQrCreateDTO", "LsyH5CreateDTO", "LsyCashierCreateDTO"),
+                List.of("LsyBarcodePayDTO", "LsyQrCreateDTO", "LsyH5CreateDTO", "LsyCashierCreateDTO",
+                        "LsyCheckoutPayDTO"),
                 entries().values().stream().map(Entry::bean).toList(),
                 "下单入口的清单被改动了。增删入口都要先回答:新入口的金额有没有下限?");
     }
@@ -131,10 +145,10 @@ class LsyAmountFloorTest {
      * 的话,约束一步都不会跑 —— 上面三条照样全绿,而 1 分钱的单照下。
      * 这条补的正是那半步。
      *
-     * <p>做法是**扫控制器**而不是再列一份名单:凡是形参类型是这四个 DTO 的
+     * <p>做法是**扫控制器**而不是再列一份名单:凡是形参类型是这五个 DTO 的
      * **HTTP handler**,它的那个形参必须挂 {@code @Valid}。
-     * 顺带断言「扫出来正好 4 个」—— 这样新增一个下单入口(新的金额型请求体)时,
-     * 这里会以「不是 4 个」红,而不是静默地不检查它。
+     * 顺带断言「扫出来正好 5 个」—— 这样新增一个下单入口(新的金额型请求体)时,
+     * 这里会以「不是 5 个」红,而不是静默地不检查它。
      *
      * <p>★ 「handler」的判据是**方法上挂着请求映射注解**,不是「public」也不只是参数类型:
      * 收银台那侧有一个同名的私有装配方法
@@ -144,13 +158,14 @@ class LsyAmountFloorTest {
      * 它当场红在这一条上(读数:expected &lt;[]&gt; but was &lt;[LeshouyingCashierController#newOrder(...)]&gt;)。
      */
     @Test
-    void 四个入口的请求体都挂了Valid() {
+    void 五个入口的请求体都挂了Valid() {
         Set<Class<?>> dtoTypes = Set.of(LsyBarcodePayDTO.class, LsyQrCreateDTO.class,
-                LsyH5CreateDTO.class, LsyCashierCreateDTO.class);
+                LsyH5CreateDTO.class, LsyCashierCreateDTO.class, LsyCheckoutPayDTO.class);
 
         List<String> missing = new ArrayList<>();
         List<String> found = new ArrayList<>();
-        for (Class<?> c : List.of(LeshouyingOpenController.class, LeshouyingCashierController.class)) {
+        for (Class<?> c : List.of(LeshouyingOpenController.class, LeshouyingCashierController.class,
+                LeshouyingCheckoutController.class)) {
             for (Method m : c.getDeclaredMethods()) {
                 if (!isHandler(m)) {
                     continue;
@@ -170,8 +185,8 @@ class LsyAmountFloorTest {
 
         assertEquals(List.of(), missing,
                 "这些下单入口的请求体没有 @Valid ⇒ DTO 上的金额下限一步都不会跑");
-        assertEquals(4, found.size(),
-                "扫出来的「金额型请求体」不是 4 个,而是 " + found
+        assertEquals(5, found.size(),
+                "扫出来的「金额型请求体」不是 5 个,而是 " + found
                         + " —— 下单入口清单变了,本类的表要跟着改(新入口的金额有没有下限?)");
     }
 
@@ -185,13 +200,13 @@ class LsyAmountFloorTest {
     // ───────────────────────────── 二、门槛以下 ─────────────────────────────
 
     /**
-     * 低于 1 元的四个值,在四个入口上**全部**要被拦下,且提示语要写清门槛。
+     * 低于 1 元的四个值,在五个入口上**全部**要被拦下,且提示语要写清门槛。
      *
      * <p>99 是边界内侧、1 是「用户说的 0.01 元」量级、0 与 -1 是原本 {@code @Positive}
      * 就在管的那两个值 —— 换注解时最容易顺手把负数一起放走。
      */
     @Test
-    void 低于一元_四个入口全部拦下并给出门槛提示() {
+    void 低于一元_五个入口全部拦下并给出门槛提示() {
         entries().forEach((where, e) -> {
             for (long cents : new long[]{99L, 1L, 0L, -1L}) {
                 Set<ConstraintViolation<Object>> violations = validator().validate(e.build().apply(cents));
@@ -215,7 +230,7 @@ class LsyAmountFloorTest {
      * 一个写反成 {@code @Min(101)} 或 {@code @Min(1000)} 的实现照样全绿。
      */
     @Test
-    void 一元及以上_四个入口全部放行() {
+    void 一元及以上_五个入口全部放行() {
         entries().forEach((where, e) -> {
             for (long cents : new long[]{FLOOR, 101L, 12345L}) {
                 Set<ConstraintViolation<Object>> violations = validator().validate(e.build().apply(cents));
@@ -237,7 +252,7 @@ class LsyAmountFloorTest {
      * {@code pay_lsy_order.total_amount} 是 NOT NULL,那时炸的是一句数据库约束错。
      */
     @Test
-    void 不传金额_四个入口仍然拦下() {
+    void 不传金额_五个入口仍然拦下() {
         entries().forEach((where, e) -> {
             Set<ConstraintViolation<Object>> violations = validator().validate(e.build().apply(null));
             assertNotNull(onField(violations, e.field()),

+ 25 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/dto/LsyOpenApiJsonContractTest.java

@@ -125,6 +125,31 @@ class LsyOpenApiJsonContractTest {
         assertEquals(Set.of("out_refund_no", "out_trade_no"), keysOf(d));
     }
 
+    /**
+     * 收款链接请求:{@code merchant_code} 选填(留空 ⇒ 租户唯一那个商户),
+     * {@code enterprise_id} **仍在**且仍是那句响亮拒绝 —— 与三个下单端点同一套。
+     */
+    @Test
+    void checkoutLinkRequestFields() {
+        LsyCheckoutLinkCreateDTO d = new LsyCheckoutLinkCreateDTO();
+        d.setMerchantCode("Y52211090004");
+        d.setEnterpriseId("E1");
+        assertEquals(Set.of("merchant_code", "enterprise_id"), keysOf(d));
+    }
+
+    /**
+     * 收款链接响应:与**管理端**那一个共用 {@link LsyCheckoutLinkVO}(同一份 VO,不是副本)
+     * ⇒ 两处出去的键名必然一致,文档只能写一份。
+     */
+    @Test
+    void checkoutLinkResponseFields() {
+        LsyCheckoutLinkVO vo = new LsyCheckoutLinkVO();
+        vo.setMchNo("Y52211090004");
+        vo.setMerchantName("示例商户");
+        vo.setUrl("https://pay.example.com/#/checkout/tok");
+        assertEquals(Set.of("mch_no", "merchant_name", "url"), keysOf(vo));
+    }
+
     /** 下单三接口的响应体(裸 VO,不带 Result 信封)。 */
     @Test
     void payResultResponseFields() {

+ 276 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyCheckoutLinkServiceTest.java

@@ -0,0 +1,276 @@
+package com.payment.platform.module.payment.leshouying.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
+import com.payment.platform.module.payment.leshouying.entity.LsyCheckoutLinkEntity;
+import com.payment.platform.module.payment.leshouying.mapper.LsyCheckoutLinkMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+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.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mock.web.MockHttpServletRequest;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.*;
+
+/**
+ * 收款链接服务:发放(幂等)、按 token 解析(免登录路径)、拼 URL。
+ *
+ * <p>本类钉的三件事都是**会静默出错**的那一类:
+ * <ol>
+ *   <li><b>重复点按钮不能换 token</b> —— 链接是印出去/贴出去的东西,换一次等于让客户手上那张失效;</li>
+ *   <li><b>停用的链接必须 404</b> —— {@code status} 列如果没人读,它就是本仓警告过的死字段;</li>
+ *   <li><b>拼出来的 URL 形态</b> —— 顾客侧是 hash 路由({@code createWebHashHistory}),
+ *       所以路径必须是 {@code /#/checkout/{token}},写成 {@code /checkout/{token}} 会 404;</li>
+ *   <li><b>安全论证依赖的 schema 事实写在脚本里</b> —— 免登录按 token 定位之所以合规,
+ *       唯一依据是「token 有全局唯一索引」(通则 (甲)·预防)。索引只写在 SQL 文件里,
+ *       所以这里直接读那个文件钉住它。</li>
+ * </ol>
+ */
+@ExtendWith(MockitoExtension.class)
+class LsyCheckoutLinkServiceTest {
+
+    private static final Long TENANT = 7L;
+    private static final String MCH_NO = "Y52211090004";
+
+    @Mock private LsyCheckoutLinkMapper linkMapper;
+    @Mock private LeshouyingProperties props;
+    private LsyCheckoutLinkService service;
+
+    @BeforeEach
+    void setUp() {
+        service = new LsyCheckoutLinkService(linkMapper, props);
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), LsyCheckoutLinkEntity.class);
+    }
+
+    private static LsyCheckoutLinkEntity row(Long id, String token, String status) {
+        LsyCheckoutLinkEntity e = new LsyCheckoutLinkEntity();
+        e.setId(id);
+        e.setTenantId(TENANT);
+        e.setMchNo(MCH_NO);
+        e.setToken(token);
+        e.setStatus(status);
+        e.setMerchantName("老名字");
+        return e;
+    }
+
+    // ─────────────────────────── 发放 ───────────────────────────
+
+    @Test
+    void issue_whenNoRowExists_insertsOneRowWithTokenAndEnabledStatus() {
+        when(linkMapper.selectList(any())).thenReturn(List.of());
+
+        LsyCheckoutLinkEntity created = service.issue(TENANT, MCH_NO, "测试商户");
+
+        ArgumentCaptor<LsyCheckoutLinkEntity> captor = ArgumentCaptor.forClass(LsyCheckoutLinkEntity.class);
+        verify(linkMapper).insert(captor.capture());
+        LsyCheckoutLinkEntity inserted = captor.getValue();
+
+        assertEquals(TENANT, inserted.getTenantId());
+        assertEquals(MCH_NO, inserted.getMchNo());
+        assertEquals("测试商户", inserted.getMerchantName());
+        assertEquals("ENABLED", inserted.getStatus(), "新链接必须是可用状态(status 从 DB 默认值改成显式写,见服务注释)");
+        assertNotNull(inserted.getToken());
+        assertEquals(32, inserted.getToken().length(), "fastSimpleUUID 形态:32 位十六进制");
+        assertTrue(inserted.getToken().matches("[0-9a-f]{32}"), "实际: " + inserted.getToken());
+        assertSame(inserted, created, "返回值必须是落库的那一行(调用方要拿它的 token 拼 URL)");
+    }
+
+    @Test
+    void issue_whenRowExists_returnsSameToken_andDoesNotInsert() {
+        LsyCheckoutLinkEntity existing = row(1L, "abcd1234abcd1234abcd1234abcd1234", "ENABLED");
+        when(linkMapper.selectList(any())).thenReturn(List.of(existing));
+
+        LsyCheckoutLinkEntity got = service.issue(TENANT, MCH_NO, "测试商户");
+
+        assertEquals("abcd1234abcd1234abcd1234abcd1234", got.getToken(), "重复取链接必须返回同一条 —— 链接要永久复用");
+        verify(linkMapper, never()).insert(any());
+    }
+
+    @Test
+    void issue_whenRowExists_refreshesMerchantNameSnapshot() {
+        when(linkMapper.selectList(any())).thenReturn(List.of(row(1L, "t", "ENABLED")));
+
+        LsyCheckoutLinkEntity got = service.issue(TENANT, MCH_NO, "新名字");
+
+        // 断言 SET 子句里有哪几列(值在 paramNameValuePairs 里,不在 SQL 文本里)——
+        // 同 LsyMerchantServiceTest 的写法。这里更要紧的是**不许出现 token**。
+        ArgumentCaptor<Wrapper<LsyCheckoutLinkEntity>> captor = updateWrapperCaptor();
+        verify(linkMapper).update(isNull(), captor.capture());
+        String sqlSet = ((AbstractWrapper<?, ?, ?>) captor.getValue()).getSqlSet();
+        Set<String> columns = Arrays.stream(sqlSet.split(","))
+                .map(s -> s.substring(0, s.indexOf('=')).trim())
+                .collect(Collectors.toSet());
+        assertEquals(Set.of("merchant_name", "updated_time"), columns,
+                "刷新显示名只许动这两列(updated_time 必须显式写:update(null, wrapper) 不过实体,拿不到自动填充)");
+        assertEquals("新名字", got.getMerchantName());
+        assertEquals(1L, got.getId());
+    }
+
+    @Test
+    void issue_whenNameUnchanged_doesNotWrite() {
+        // existing.merchantName = "老名字"
+        when(linkMapper.selectList(any())).thenReturn(List.of(row(1L, "t", "ENABLED")));
+
+        service.issue(TENANT, MCH_NO, "老名字");
+
+        verify(linkMapper, never()).update(any(), any());
+        verify(linkMapper, never()).updateById(any());
+    }
+
+    @SuppressWarnings("unchecked")
+    private static ArgumentCaptor<Wrapper<LsyCheckoutLinkEntity>> updateWrapperCaptor() {
+        return ArgumentCaptor.forClass(Wrapper.class);
+    }
+
+    @Test
+    void issue_nullTenant_throws403() {
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.issue(null, MCH_NO, "x"));
+        assertEquals(403, ex.getCode());
+        verify(linkMapper, never()).selectList(any());
+    }
+
+    @Test
+    void issue_blankMchNo_throws400() {
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.issue(TENANT, "  ", "x"));
+        assertEquals(400, ex.getCode());
+    }
+
+    @Test
+    void issue_multipleRows_throwsLoudly_insteadOfPickingOne() {
+        // 唯一索引 (tenant_id, mch_no) 在库上时这一态不可达;但它**建不出来**的成因
+        // 本仓已经吃过一次(mch_no 有重复行),所以这里钉住「多行一律响亮失败」。
+        when(linkMapper.selectList(any())).thenReturn(List.of(
+                row(1L, "a", "ENABLED"), row(2L, "b", "ENABLED")));
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.issue(TENANT, MCH_NO, "x"));
+        assertEquals(409, ex.getCode());
+        verify(linkMapper, never()).insert(any());
+    }
+
+    // ─────────────────────────── 按 token 解析(免登录) ───────────────────────────
+
+    @Test
+    void requireByToken_found_returnsRow() {
+        LsyCheckoutLinkEntity e = row(1L, "tok", "ENABLED");
+        when(linkMapper.selectOne(any())).thenReturn(e);
+
+        assertSame(e, service.requireByToken("tok"));
+    }
+
+    @Test
+    void requireByToken_unknown_throws404() {
+        when(linkMapper.selectOne(any())).thenReturn(null);
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.requireByToken("nope"));
+        assertEquals(404, ex.getCode(), "未知链接对顾客就是「这条链接打不开」,不该暴露更多");
+    }
+
+    @Test
+    void requireByToken_blank_throws404_withoutTouchingDb() {
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.requireByToken(" "));
+        assertEquals(404, ex.getCode());
+        verify(linkMapper, never()).selectOne(any());
+    }
+
+    @Test
+    void requireByToken_disabled_throws404() {
+        when(linkMapper.selectOne(any())).thenReturn(row(1L, "tok", "DISABLED"));
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.requireByToken("tok"));
+        assertEquals(404, ex.getCode(), "停用的链接必须当「不存在」处理(status 由 DBA 直接改,是运营侧的闸)");
+    }
+
+    // ─────────────────────────── 拼 URL ───────────────────────────
+
+    @Test
+    void buildUrl_default_derivesBaseFromRequest_andUsesHashRoute() {
+        when(props.getCheckoutBaseUrl()).thenReturn(null);
+        MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/payment/checkout/tok");
+        req.setScheme("https");
+        req.setServerName("qcsj88888.com");
+        req.setServerPort(443);
+        req.addHeader("Host", "qcsj88888.com");
+
+        String url = service.buildUrl("tok", req);
+
+        assertEquals("https://qcsj88888.com/#/checkout/tok", url,
+                "顾客侧是 hash 路由:路径必须是 /#/checkout/{token},写 /checkout/{token} 会 404");
+    }
+
+    @Test
+    void buildUrl_prefersForwardedHeaders_behindNginx() {
+        when(props.getCheckoutBaseUrl()).thenReturn(null);
+        MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/payment/checkout/tok");
+        req.setScheme("http");
+        req.setServerName("payment-api");
+        req.setServerPort(8080);
+        req.addHeader("X-Forwarded-Proto", "https");
+        req.addHeader("X-Forwarded-Host", "pay.example.com");
+
+        assertEquals("https://pay.example.com/#/checkout/tok", service.buildUrl("tok", req));
+    }
+
+    @Test
+    void buildUrl_nonDefaultPort_isKept() {
+        when(props.getCheckoutBaseUrl()).thenReturn(null);
+        MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/payment/checkout/tok");
+        req.setScheme("http");
+        req.setServerName("192.168.1.9");
+        req.setServerPort(8080);
+
+        assertEquals("http://192.168.1.9:8080/#/checkout/tok", service.buildUrl("tok", req));
+    }
+
+    @Test
+    void buildUrl_propertyOverride_wins_andTrailingSlashIsNormalized() {
+        when(props.getCheckoutBaseUrl()).thenReturn("https://pay.example.com/");
+        MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/payment/checkout/tok");
+        req.setServerName("ignored");
+        req.setScheme("http");
+
+        assertEquals("https://pay.example.com/#/checkout/tok", service.buildUrl("tok", req),
+                "yml 里配了就以它为准(反向代理只转发路径、Host 是我们的内网名时用得上)");
+    }
+
+    // ─────────────────────────── 安全论证的 schema 前提 ───────────────────────────
+
+    /**
+     * 免登录按 token 定位之所以**合规**,唯一依据是「token 有全局唯一约束」(通则的 (甲)·预防)。
+     * 那条约束只存在于交付给用户的建表脚本里 —— 脚本里没有它,等于这段安全论证是空的。
+     * 所以这里不读代码,直接读那份脚本。
+     */
+    @Test
+    void schemaScript_declaresGlobalUniqueIndexOnToken() throws Exception {
+        Path sql = Path.of("src/main/resources/db/lsy-checkout-link.sql");
+        assertTrue(Files.exists(sql), "找不到建表脚本(本用例读的是相对 java/ 的仓库路径): " + sql.toAbsolutePath());
+        String text = new String(Files.readAllBytes(sql), StandardCharsets.UTF_8);
+
+        assertTrue(text.matches("(?s).*CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_checkout_token\\s+ON pay_lsy_checkout_link \\(token\\).*"),
+                "token 的**全局**唯一索引不见了。别把它收窄成 (tenant_id, token):"
+                        + "免登录查询不带租户条件,收窄后该索引提供零保护(同 uk_lsy_order_out_trade_no 那个陷阱)。");
+    }
+}

+ 106 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantServiceTest.java

@@ -1293,6 +1293,112 @@ class LsyMerchantServiceTest {
                 .collect(Collectors.toSet());
     }
 
+    // ────────── requireById / requireByIdForTenant(2026-09-30 管理端取收款链接) ──────────
+
+    /**
+     * 两条腿的分工:{@code requireById} 是**主键单键**查(超管那条腿,
+     * 它没有租户号可写进谓词),{@code requireByIdForTenant} 是**主键 + 租户**查。
+     *
+     * <p>【为什么这里的断言必须落在 wrapper 上】{@code selectOne} 是 mock,返回什么由用例
+     * 自己 stub —— 断言返回值等于自己刚设的值恒真。唯一能证明「谓词里写了哪些列」的读数是
+     * **去看 wrapper 绑的值**(同款手法见本类 {@code capturedSelectList} 那一组)。
+     * 而「{@code requireById} 走的是 {@code selectById}」这件事更进一步:
+     * 那个方法**根本不收 wrapper**,所以「没有租户谓词」是结构性的、不是断言出来的。
+     */
+    @Test
+    void requireByIdForTenant_writesBothThePrimaryKeyAndTheTenantIntoTheWhere() {
+        when(merchantMapper.selectOne(any())).thenReturn(row(7L));
+
+        LsyMerchantEntity got = service.requireByIdForTenant(7L, 3L);
+
+        assertEquals(7L, got.getId());
+        AbstractWrapper<?, ?, ?> w = capturedSelectOne();
+        assertEquals(7L, boundPredicateValue(w, "id"),
+                "定位列必须是主键本身。实测: " + w.getSqlSegment());
+        assertEquals(3L, boundPredicateValue(w, "tenant_id"),
+                "★ 租户号必须**写进 WHERE**,不是「先 selectById 再在 Java 里比」——"
+                        + "后者会把别租户的那一行先读进内存。实测: " + w.getSqlSegment());
+    }
+
+    /**
+     * 正对照:{@code requireById}(超管那条腿)**不得**带任何租户谓词 ——
+     * 它没有租户号可带,而带上一个恒假/恒真的条件会让超管取不到行或取错行。
+     *
+     * <p>★ 两条用例成对才有意义:只测「带租户」的话,把 {@code requireById} 也改成
+     * 带租户(超管 {tenantId=null} 时那条路会静默查不到任何行)照样绿。
+     */
+    @Test
+    void requireById_queriesByPrimaryKeyAlone_noTenantPredicateAtAll() {
+        when(merchantMapper.selectById(7L)).thenReturn(row(7L));
+
+        assertEquals(7L, service.requireById(7L).getId());
+
+        verify(merchantMapper).selectById(7L);
+        verify(merchantMapper, never()).selectOne(any());
+    }
+
+    /**
+     * {@code tenantId == null} ⇒ 403,且**一条库都不查**。
+     *
+     * <p>★ 这条闸不能只落在控制器上:控制器把 {@code me.getTenantId()}(可以是 null)
+     * 原样交给本方法,而「非超管 + tenantId=null」的主体在
+     * {@code TenantInnerInterceptor} 的条件表上会被**跳过过滤**(没有租户可过滤)——
+     * 放它下去就等于把「按 id 读任意租户的商户行」开出去。
+     * 所以它是**本方法的契约**(公开 API),不依赖调用方先检查一遍。
+     */
+    @Test
+    void requireByIdForTenant_nullTenant_throws403_andQueriesNothing() {
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.requireByIdForTenant(7L, null));
+
+        assertEquals(403, e.getCode());
+        verifyNoInteractions(merchantMapper);
+    }
+
+    /** {@code id == null} ⇒ 400(不是去查一个 {@code id IS NULL} 的库)。 */
+    @Test
+    void requireByIdForTenant_nullId_throws400_andQueriesNothing() {
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.requireByIdForTenant(null, 3L));
+
+        assertEquals(400, e.getCode());
+        verifyNoInteractions(merchantMapper);
+    }
+
+    /**
+     * 「查不到」与「存在但不属于本租户」应答**一致**(都是 404 + 同一句文案)——
+     * 否则这个端点就成了「某个 id 在平台里存不存在」的枚举口子。
+     */
+    @Test
+    void requireByIdForTenant_notVisibleInThisTenant_throws404() {
+        when(merchantMapper.selectOne(any())).thenReturn(null);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.requireByIdForTenant(7L, 3L));
+
+        assertEquals(404, e.getCode());
+        assertEquals("商户不存在", e.getMessage(),
+                "文案不得区分「不存在」与「不属于你」—— 区分开就是跨租户探针");
+    }
+
+    /** {@code requireById} 查不到同样 404(超管那条腿的出口一致)。 */
+    @Test
+    void requireById_missing_throws404() {
+        when(merchantMapper.selectById(7L)).thenReturn(null);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.requireById(7L));
+
+        assertEquals(404, e.getCode());
+    }
+
+    /** 定位查询用的 wrapper(`any()` 会把「按哪些键查」整个吃掉,必须单独取出来看)。 */
+    private AbstractWrapper<?, ?, ?> capturedSelectOne() {
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Wrapper<LsyMerchantEntity>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(merchantMapper).selectOne(captor.capture());
+        return (AbstractWrapper<?, ?, ?>) captor.getValue();
+    }
+
     // ──────────────── updateReviewStatusByMchNo 用的夹具与解析 ────────────────
 
     /** 定位查询实际用掉的 wrapper(`any()` 会把「按哪个键查」整个吃掉,所以必须单独取出来看)。 */