Преглед изворни кода

feat(汇安融): 收款链接(一)—— 链接存储与顾客页两个免登录端点

对标乐收赢收款链接(checkout):一个商户一条永久链接,顾客免登录打开、自己输
金额,我们当场建一笔主扫单并把上游 pay_url 交还浏览器。本刀是后端顾客侧
(存储 + 服务 + 两个端点);管理端入口与装配接线在后续提交。

存储
- db/har-checkout-link.sql:pay_har_checkout_link 建表件(★ 部署前必须先跑,
  本工程未接 Flyway)。token 单独建**全局唯一**索引 —— 免登录按 token 定位的
  唯一依据,注释里写明别收窄成 (tenant_id, token);另一个 (tenant_id,
  merchant_code) 唯一索引只服务已认证的发放路径(一商户恒一条)。
- HarCheckoutLinkEntity / Mapper / HarCheckoutLinkService:issue 幂等(重取刷新
  merchant_name 快照;显式 SET 是为了能把名字改成 null,NOT_NULL 策略会吞)、
  requireByToken(未知/空白/停用一律同一条 404)、buildUrl(har.checkout-base-url
  优先,留空则按 X-Forwarded-Proto/Host → Host 推导,零配置可用)。

寻址(管理端「取收款链接」两道闸的两条腿,本刀只落服务层)
- HarMerchantService.requireById(超管那条腿,不带租户谓词)/ requireByIdForTenant
  (租户号进 WHERE;null 租户 403 且先于查库;两条腿 404 应答一致,不构成跨租户
  探针)。
- HarProperties.checkoutBaseUrl:新增一个可空配置项,加它就不必碰
  application.yml(留空即零配置可用)。

顾客页
- HarCheckoutController(/payment/har/checkout,免登录):GET /{token} 只回商户
  显示名;POST /{token}/orders 四步固定 —— 解析 token → 金额下限 → 绑租户
  (setContext(scopedTo(链接行.tenantId)),库操作前、finally 还原)→ 本地闸 →
  下单腿 HarOrderService.create,最后交还 order_no/pay_url/price。
- 金额下限 1 元:HarAmountFloor(用户 2026-10-06 裁定「下限还是1」,与乐收赢同
  口径的业务线,**不是**上游限制的转述)。只挂顾客页这条腿 —— 收银台/开放 API/
  后台仍是 0.01,类注释里写明这个不对称是有意的。控制器里再判一次(本仓控制器
  用例都是普通方法直调,Spring 参数校验不参与),边界含等号(1 元整放行)。
- 上游失败收两类:BusinessException(上游明确拒绝,create 已把本地行收尾成
  「8 交易关闭」)与 HarApiException(网络/解析/通道未启用,create 不捕它)——
  都只回一句「支付发起失败,请稍后重试」,上游原话一个字不出给顾客。
- 本地闸的 404 特意放在 catch 之外:商户配置没了要报 404 不是 500。
- 标题固定「扫码收款」,不拿商户名当标题:merchant_name 是 VARCHAR(128) 而
  title 是 @Size(64),长名商户每一笔支付都会在本地校验处 400。

验证
- 新增 HarCheckoutLinkServiceTest 16 条、HarCheckoutControllerTest 13 条、
  HarAmountFloorTest 2 条,HarMerchantServiceTest 追加 7 条;每组都先看到失败
  (找不到符号)再写实现。
- 本刀四条 mvn -Dtest 全绿:16/16、24/24、13/13、2/2。

尚未接线(下一刀,缺了它顾客页打不开)
- SecurityConfig.WHITE_LIST 还没有 /payment/har/checkout/**;
- TenantInnerInterceptor.CONDITIONAL_TABLES 还没有 pay_har_checkout_link
  (漏登记 ⇒ 未认证查询被追加 tenant_id = 0 ⇒ 每条链接都「无效」,日志无痕);
- 管理端「收款二维码」入口与前端顾客页均未做;jar/dist 未重打。
alphaH пре 1 недеља
родитељ
комит
2109cb6ca1
15 измењених фајлова са 1511 додато и 0 уклоњено
  1. 10 0
      java/src/main/java/com/payment/platform/module/payment/har/config/HarProperties.java
  2. 272 0
      java/src/main/java/com/payment/platform/module/payment/har/controller/HarCheckoutController.java
  3. 23 0
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarCheckoutInfoVO.java
  4. 33 0
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarCheckoutOrderVO.java
  5. 46 0
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarCheckoutPayDTO.java
  6. 49 0
      java/src/main/java/com/payment/platform/module/payment/har/entity/HarCheckoutLinkEntity.java
  7. 10 0
      java/src/main/java/com/payment/platform/module/payment/har/mapper/HarCheckoutLinkMapper.java
  8. 180 0
      java/src/main/java/com/payment/platform/module/payment/har/service/HarCheckoutLinkService.java
  9. 58 0
      java/src/main/java/com/payment/platform/module/payment/har/service/HarMerchantService.java
  10. 50 0
      java/src/main/java/com/payment/platform/module/payment/har/util/HarAmountFloor.java
  11. 50 0
      java/src/main/resources/db/har-checkout-link.sql
  12. 318 0
      java/src/test/java/com/payment/platform/module/payment/har/controller/HarCheckoutControllerTest.java
  13. 280 0
      java/src/test/java/com/payment/platform/module/payment/har/service/HarCheckoutLinkServiceTest.java
  14. 90 0
      java/src/test/java/com/payment/platform/module/payment/har/service/HarMerchantServiceTest.java
  15. 42 0
      java/src/test/java/com/payment/platform/module/payment/har/util/HarAmountFloorTest.java

+ 10 - 0
java/src/main/java/com/payment/platform/module/payment/har/config/HarProperties.java

@@ -34,6 +34,16 @@ public class HarProperties {
     /** 汇安融公钥,X.509 纯 Base64 */
     private String platformPublicKey;
 
+    /**
+     * 收款链接的基址(收款链接功能用;形如 {@code https://pay.example.com},不含尾斜杠)。
+     *
+     * <p>**留空即零配置可用**:{@code HarCheckoutLinkService#buildUrl} 会按当前请求的
+     * {@code X-Forwarded-Proto/Host} → {@code Host} 头 → {@code scheme://server:port} 推导。
+     * 只有当反向代理只转发路径、Host 头是我们的内网名时才需要配它 ——
+     * 与乐收赢的 {@code leshouying.checkout-base-url} 同一条口径。
+     */
+    private String checkoutBaseUrl;
+
     /** 异步回调企业商户的线程数上限 */
     private int callbackPoolSize = 4;
 

+ 272 - 0
java/src/main/java/com/payment/platform/module/payment/har/controller/HarCheckoutController.java

@@ -0,0 +1,272 @@
+package com.payment.platform.module.payment.har.controller;
+
+import cn.hutool.core.util.StrUtil;
+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.har.client.HarApiException;
+import com.payment.platform.module.payment.har.dto.HarCheckoutInfoVO;
+import com.payment.platform.module.payment.har.dto.HarCheckoutOrderVO;
+import com.payment.platform.module.payment.har.dto.HarCheckoutPayDTO;
+import com.payment.platform.module.payment.har.dto.HarOrderCreateDTO;
+import com.payment.platform.module.payment.har.dto.HarOrderVO;
+import com.payment.platform.module.payment.har.entity.HarCheckoutLinkEntity;
+import com.payment.platform.module.payment.har.service.HarCheckoutLinkService;
+import com.payment.platform.module.payment.har.service.HarMerchantService;
+import com.payment.platform.module.payment.har.service.HarOrderService;
+import com.payment.platform.module.payment.har.util.HarAmountFloor;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+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.math.BigDecimal;
+
+/**
+ * 汇安融顾客页(收款链接)—— <b>免登录</b>的两个端点:读商户名 / 按顾客输入的金额
+ * 发起一次主扫支付。
+ *
+ * <h2>它解决什么</h2>
+ * 汇安融的主扫是**一码一收**(每笔都要先下单、由上游发一个只对这笔有效的 {@code pay_url},
+ * 同收银台那条腿),所以「贴一张静态码在墙上」在这条通道里做不到。折中办法是贴一个
+ * **我们自己的**链接:链接与商户绑定、可无限次使用,顾客打开后自己输金额,我们当场建一笔单、
+ * 把上游返回的 {@code pay_url} 交还给浏览器去跳转。这个控制器就是那个链接的落点。
+ * (逐条对标乐收赢的 {@code LeshouyingCheckoutController},用户需求:「和 lsy 一样的 checkout」。)
+ *
+ * <h2>★★ 免登录:凭什么安全</h2>
+ * 打开链接的人没有登录态,本控制器**两个端点都不带任何认证**({@code SecurityConfig.WHITE_LIST}
+ * 里放行 {@code /payment/har/checkout/**} —— 漏了这一条,未认证请求会被 JWT 过滤器拦下)。
+ * 安全依据是两件事,缺一不可:
+ * <ol>
+ *   <li><b>定位键有全局唯一约束</b>:顾客页只能用 URL 里那一串 {@code token} 说话,而
+ *       {@code uk_har_checkout_token} 单独建在 {@code token} 上(**不是** {@code (tenant_id, token)})
+ *       ⇒ 「同一个 token 命中两行」在 schema 层建不出来。这是本仓通则的 **(甲)·预防**,
+ *       论证与「别把它收窄」写在 {@link HarCheckoutLinkService} 的类 javadoc 与建表脚本里。</li>
+ *   <li><b>token 是我们生成的 32 位随机十六进制</b>({@code IdUtil.fastSimpleUUID},
+ *       122 bit 熵),不是可枚举的序号 ⇒ 拿到别人的链接只能靠拿到那一串字符。</li>
+ * </ol>
+ * <p>★ 一条同层的硬约束:{@code pay_har_checkout_link} <b>必须</b>登记在
+ * {@code TenantInnerInterceptor.CONDITIONAL_TABLES}。漏登记 ⇒ 拦截器给这条查询追加
+ * {@code AND tenant_id = 0}(未认证时 {@code getTenantId()} 返回 0)⇒ <b>每条链接都查不到</b>,
+ * 表现是顾客页一律「链接无效」,而日志里一个字都没有。
+ *
+ * <h2>★★ 租户身份怎么来的:从链接那一行,而且要在库操作之前绑好</h2>
+ * 顾客页的请求里没有任何租户维度,唯一的来源是 {@link HarCheckoutLinkEntity#getTenantId()}。
+ * 定位到那一行之后、**任何一次属于该租户的库操作之前**,本类把租户写进
+ * {@code SecurityContext}(见 {@link #scopedTo}),请求结束再**还原**。
+ *
+ * <p>这条路径上的三张表({@code pay_har_checkout_link} / {@code pay_har_merchant} /
+ * {@code pay_har_order})**全都在 CONDITIONAL_TABLES 里**,而拦截器对它们
+ * **无认证时是「跳过过滤」**({@code ignoreTable} 返回 true)—— 也就是说,不绑租户时
+ * 整条路径跑在**完全没有租户谓词**的状态下,正确性全部押在我们手写的每一个 WHERE 上。
+ * 绑上之后,拦截器会**独立地再写一遍**同一个边界。★ 这**不是**「不绑就查不到」:
+ * 不绑时拦截器跳过过滤,查询照样命中 —— 这里绑的是**纵深防御**,不是修一个已经坏掉的东西。
+ *
+ * <p>★ 还原用 {@code setContext(previous)}(整个上下文换回去),不是
+ * {@code setAuthentication(null)}:后者在「本来就没有认证」之外还抹掉了上下文里可能有的
+ * 别的东西。而且它在 {@code finally} 里 —— 这是容器的线程,不是我们自己的线程。
+ *
+ * <h2>与收银台那条腿的关系:共用**同一条**下单腿(与乐收赢不同)</h2>
+ * 乐收赢的解法是三个入口各自一份落库装配(它的 javadoc 明说「有意不合并」);HAR 这边
+ * 所有入口共用**一条**下单腿 {@link HarOrderService#create} —— 本类不装配实体、不碰
+ * mapper、不生成单号,只准备一个 {@link HarOrderCreateDTO}。随之而来的三点:
+ * <ul>
+ *   <li><b>本地闸判两道</b>:下面显式调一次 {@code requireByTenantAndCode},{@code create}
+ *       内部还会按同一口径再查一次(分支①)。这次显式调用**不是**重复劳动:它排在订单侧
+ *       任何动作之前,而且让「商户配置没了」这条出口保持 **404** —— 少了它,那个
+ *       404 会被下面「上游失败」的 catch 折叠成 500,两种处境的操作员改法完全不同;</li>
+ *   <li><b>上游失败收两类</b>:{@code BusinessException}(上游明确拒绝;{@code create} 已把
+ *       本地行收尾成「8 交易关闭」)与 {@code HarApiException}(网络/解析/通道未启用;
+ *       {@code create} **不捕它**,会一路穿出服务层)。两类对顾客是同一件事:这次没发起成功;</li>
+ *   <li><b>两个常数各持一份</b>:{@link #DEFAULT_TITLE} 与 {@link #PAY_TIMEOUT_MINUTES}
+ *       与 {@code HarCashierService} 同值但各写一份(跨包引用一个常数不如让它显式重复;
+ *       改了这边要一起看那边)。</li>
+ * </ul>
+ * <p>★ 不加 {@code @ConditionalOnProperty}:HAR 的总开关 {@code har.enabled} 只在
+ * {@code HarClient} 里闸上游调用(关着时顾客看到的是「支付发起失败」,与收银台同一个形态)
+ * —— 跟乐收赢不同,那边的开关决定整个模块装不装配。同 {@code HarCashierController}。
+ *
+ * <h2>端点清单</h2>
+ * <table border="1">
+ *   <caption>两个端点都不带权限串(免登录)</caption>
+ *   <tr><th>方法</th><th>完整路径</th><th>用途</th></tr>
+ *   <tr><td>GET</td><td>{@code /payment/har/checkout/{token}}</td><td>顾客页开屏:显示商户名</td></tr>
+ *   <tr><td>POST</td><td>{@code /payment/har/checkout/{token}/orders}</td>
+ *       <td>顾客按下「付款」:建单 → 上游下单 → 回 {@code pay_url}</td></tr>
+ * </table>
+ */
+@Slf4j
+@Tag(name = "汇安融-收款链接")
+@RestController
+@RequestMapping("/payment/har/checkout")
+@RequiredArgsConstructor
+public class HarCheckoutController {
+
+    /**
+     * 主扫单固定支付时限(分钟)。与 {@code HarCashierService.PAY_TIMEOUT_MINUTES} 同值
+     * (上游取值 2..60,默认值未公开 —— 给一个确定值好过把未知的默认值留给现场)。
+     */
+    private static final int PAY_TIMEOUT_MINUTES = 15;
+
+    /**
+     * 上游 subject(订单标题)的固定文案。
+     *
+     * <p>★ <b>不能拿商户名当标题</b>:{@code merchant_name} 是 {@code VARCHAR(128)},
+     * 而 {@code HarOrderCreateDTO.title} 是 {@code @Size(max = 64)} —— 长名商户的每一笔
+     * 支付都会在本地校验处 400,而顾客看到的会是一个与金额毫无关系的报错。
+     */
+    private static final String DEFAULT_TITLE = "扫码收款";
+
+    /**
+     * 上游那一跳失败时给顾客的一句话。
+     *
+     * <p>不含上游词汇、不含错误码、不教顾客「稍后重试」以外的事 —— 顾客能做且只能做的
+     * 就是再按一次(再按会生成**新的单号**,旧行既不占资金也不再被使用,重试完全安全)。
+     */
+    private static final String MSG_PAY_FAILED = "支付发起失败,请稍后重试";
+
+    private final HarCheckoutLinkService linkService;
+    private final HarMerchantService merchantService;
+    private final HarOrderService orderService;
+
+    // ──────────────────────────── 1. 开屏:显示商户名 ────────────────────────────
+
+    /**
+     * 顾客页开屏读一次,拿商户显示名。
+     *
+     * <p>这里只碰 {@code pay_har_checkout_link} 一张表,**不绑租户上下文**:按 token 定位是
+     * 未认证读,唯一依据是那个全局唯一索引(类 javadoc 第 1 条)。返回值里故意只有显示名,
+     * 见 {@link HarCheckoutInfoVO}。
+     *
+     * @throws BusinessException 404 —— 未知 / 空白 / 已停用一律同一条(由服务层统一给出)
+     */
+    @GetMapping("/{token}")
+    @Operation(summary = "顾客页开屏:读商户显示名(免登录)")
+    public Result<HarCheckoutInfoVO> info(@PathVariable String token) {
+        HarCheckoutLinkEntity link = linkService.requireByToken(token);
+        HarCheckoutInfoVO vo = new HarCheckoutInfoVO();
+        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> —— 本地闸必须排在**任何上游调用之前**:
+     *       反了不是「同样安全」,那等于拿一个还没证明属于本租户的商户编号去发起一次上游调用
+     *       (同收银台与开放 API 那条腿的规矩);</li>
+     *   <li><b>还原上下文</b>({@code finally})。</li>
+     * </ol>
+     *
+     * <p>★ <b>金额下限判两道</b>:{@link HarCheckoutPayDTO} 上的 {@code @DecimalMin}
+     * (真请求走这条)与下面的显式判断(本仓所有控制器用例都是普通方法直调,Spring 的参数
+     * 校验一步都不参与 ⇒ 只挂注解的话这条规则在测试里没有任何守卫)。文案两边同源,
+     * 都是 {@link HarAmountFloor#MESSAGE}。
+     *
+     * <p>★ <b>本地闸那条 404 在 catch 之外</b>:商户行没了时它直接穿出去;进到 catch 里的
+     * 只有下单腿自己的失败(见类 javadoc「上游失败收两类」)。
+     */
+    @PostMapping("/{token}/orders")
+    @Operation(summary = "顾客页下单:按输入金额建主扫单并回支付链接(免登录)")
+    public Result<HarCheckoutOrderVO> create(@PathVariable String token,
+                                             @Valid @RequestBody HarCheckoutPayDTO dto) {
+        HarCheckoutLinkEntity link = linkService.requireByToken(token);
+
+        BigDecimal price = dto == null ? null : dto.getPrice();
+        if (price == null || price.compareTo(HarAmountFloor.MIN) < 0) {
+            // ★ 这里**不做**任何库操作、不打上游:顾客只会看到一句能照做的话。
+            throw new BusinessException(400, "price " + HarAmountFloor.MESSAGE);
+        }
+
+        SecurityContext previous = SecurityContextHolder.getContext();
+        try {
+            SecurityContextHolder.setContext(scopedTo(link.getTenantId()));
+
+            // 本地闸(第二道;create 内部还会按同一口径再查一次)。
+            // 特意放在下面那个 catch 之外:商户配置没了要报 404,不是 500。
+            merchantService.requireByTenantAndCode(link.getTenantId(), link.getMerchantCode());
+
+            HarOrderCreateDTO create = new HarOrderCreateDTO();
+            create.setMerchantCode(link.getMerchantCode());
+            create.setPrice(price);
+            create.setTitle(DEFAULT_TITLE);
+            create.setTradeType("alipay_un_stage");
+            create.setBiz("har_ali_un_stage");
+            create.setPayTimeout(PAY_TIMEOUT_MINUTES);
+            // 企业号与 apiKey 有意留空:顾客页不是下游系统,归属只来自上面绑的那个租户。
+
+            HarOrderVO vo;
+            try {
+                vo = orderService.create(create, null, null);
+            } catch (BusinessException | HarApiException e) {
+                // 上游明确拒绝(本地行已由 create 收尾)与通道层失败(网络/解析/未启用)
+                // 对顾客是同一件事,只回一句能照做的话。
+                log.warn("收款链接下单未成功: linkId={}, merchantCode={}, 原因={}",
+                        link.getId(), link.getMerchantCode(), e.getMessage());
+                return Result.of(500, MSG_PAY_FAILED);
+            }
+            if (vo == null || StrUtil.isBlank(vo.getPayUrl())) {
+                // 上游受理了但响应里没有支付链接(解析失败):主扫的全部意义就是那条链接,
+                // 没有它这笔单在顾客页上是一张画不出来的二维码。
+                log.warn("收款链接下单未拿到支付链接: linkId={}, orderNo={}",
+                        link.getId(), vo == null ? null : vo.getOrderNo());
+                return Result.of(500, MSG_PAY_FAILED);
+            }
+            return Result.ok(toVo(vo));
+        } finally {
+            SecurityContextHolder.setContext(previous);
+        }
+    }
+
+    // ──────────────────────────── 租户上下文 ────────────────────────────
+
+    /**
+     * 造一个**只带租户**的上下文:没有角色、没有权限、{@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 HarCheckoutOrderVO toVo(HarOrderVO o) {
+        HarCheckoutOrderVO vo = new HarCheckoutOrderVO();
+        vo.setOrderNo(o.getOrderNo());
+        vo.setPayUrl(o.getPayUrl());
+        vo.setPrice(o.getPrice());
+        return vo;
+    }
+}

+ 23 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarCheckoutInfoVO.java

@@ -0,0 +1,23 @@
+package com.payment.platform.module.payment.har.dto;
+
+import lombok.Data;
+
+/**
+ * 收款链接打开后,顾客页顶部要显示的那点信息。
+ *
+ * <p>★ <b>只有商户显示名一个字段,这是有意的。</b>打开这个链接的人没有登录态,
+ * 这个端点是**未认证**的,凡是从这里出去的东西都是送给「任何一个拿到链接的人」的。
+ * 所以:不给租户号、不给商户编号、不给门店、不给链接 id —— 顾客页一个字都用不到它们,
+ * 而它们每一个都是可以用来拼别的请求的原材料(同 {@code LsyCheckoutInfoVO})。
+ *
+ * <p>★ 显示名是**发链接那一刻的快照**({@code pay_har_checkout_link.merchant_name}),
+ * 不每次回查 {@code pay_har_merchant}:免登录路径上少读一张表,而且商户改名后旧链接
+ * 显示旧名是**正确**的 —— 重取链接(管理端点)时快照会刷新
+ * (见 {@code HarCheckoutLinkService#issue})。
+ */
+@Data
+public class HarCheckoutInfoVO {
+
+    /** 商户显示名;发链接时没写就是 null ⇒ 页面用兜底标题(「收款」),不显示空白标题。 */
+    private String merchantName;
+}

+ 33 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarCheckoutOrderVO.java

@@ -0,0 +1,33 @@
+package com.payment.platform.module.payment.har.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 顾客页按下「付款」之后拿到的东西 —— 一句话:**把浏览器送到哪去**。
+ *
+ * <p>★ {@code payUrl} 是**上游**(汇安融 → 支付宝)给的支付链接本身,原样透传。
+ * 顾客页的动作只有一步 {@code location.href = pay_url};我们**不**把它包成自己的短链,
+ * 也不做中转页 —— 多一跳就多一处能把顾客卡住的地方,而这一跳不带来任何东西。
+ *
+ * <p>★ 三个字段都是这一屏**用得到**的:{@code payUrl} 要跳转,{@code price} 要在跳转前
+ * 再显示一次「应付 ¥x.xx」(顾客核对金额的最后一眼),{@code orderNo} 是「付了但页面
+ * 没反应」时顾客报给客服的凭据。除此之外不给 —— 同 {@link HarCheckoutInfoVO} 的理由:
+ * 这个端点未认证,凡出去的都是公开的。
+ *
+ * <p>★ 报文键名下划线式({@code order_no} / {@code pay_url} / {@code price}),
+ * 由全局 {@code JacksonConfig} 的 SNAKE_CASE 决定。
+ */
+@Data
+public class HarCheckoutOrderVO {
+
+    /** 本单的号(由下单腿 {@code HarOrderService#create} 生成;顾客页不生成也不给这个字段)。 */
+    private String orderNo;
+
+    /** 上游支付链接;下单未成功时这个端点回的是失败({@code Result.msg}),不会给空链接。 */
+    private String payUrl;
+
+    /** 应付金额(**元**),与顾客在页面上输入的那个数相同。 */
+    private BigDecimal price;
+}

+ 46 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarCheckoutPayDTO.java

@@ -0,0 +1,46 @@
+package com.payment.platform.module.payment.har.dto;
+
+import com.payment.platform.module.payment.har.util.HarAmountFloor;
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 顾客页(收款链接)的付款请求体。
+ *
+ * <p>★ <b>只有一个字段,而且是**唯一**一个由调用方决定的字段</b>:商户编号、标题、
+ * 交易方式、支付时限全部来自链接那一行与服务端(见 {@code HarCheckoutController})。
+ * 顾客面对的屏幕上只有一个金额输入框,这个 DTO 就是那块屏幕的形状。
+ *
+ * <p>★ 字段名沿用 HAR 通道的 {@code price}/**元**,不照抄乐收赢顾客页的
+ * {@code amount}/分:同一条通道里 {@code HarOrderCreateDTO} 与
+ * {@code HarCashierCreateDTO} 都叫 price 且都是元,这里另立一个名字或另一种单位,
+ * 只会让同一条链上出现两套算法。报文键名一律下划线式(本仓 {@code JacksonConfig}
+ * 是 SNAKE_CASE;{@code price} 一个词,两种命名法下同名)。
+ *
+ * <p>★ 下限 1 元({@link HarAmountFloor})不是上游限制的转述 —— 覆盖范围与理由
+ * 见那个类。
+ *
+ * <p>★ 控制器里**再判一次**,不是重复:本仓所有控制器用例都是**普通方法直调**,
+ * Spring 的参数校验一步都不参与。只挂注解的话,「金额下限」这条规则在
+ * {@code HarCheckoutControllerTest} 里没有任何守卫(收银台那条腿同样是两道)。
+ *
+ * <p>★ <b>没有上界</b> —— 同 {@code LsyCheckoutPayDTO}:上游对单笔金额的上限我们
+ * **没有实测过**,编一个数字出来会变成一条我们自己也解释不清的规则;
+ * 而免登录入口更不该凭一个没量过的数字去拒绝一位正要付钱的顾客。
+ */
+@Data
+public class HarCheckoutPayDTO {
+
+    /**
+     * 付款金额,单位**元**(HAR 全通道如此)。
+     *
+     * <p>{@code @DecimalMin} 与控制器里那道显式判断同源({@link HarAmountFloor})。
+     * 下限**含等号**:1 元整是一笔合法的单。
+     */
+    @NotNull(message = "金额不能为空")
+    @DecimalMin(value = HarAmountFloor.MIN_VALUE, message = "price " + HarAmountFloor.MESSAGE)
+    private BigDecimal price;
+}

+ 49 - 0
java/src/main/java/com/payment/platform/module/payment/har/entity/HarCheckoutLinkEntity.java

@@ -0,0 +1,49 @@
+package com.payment.platform.module.payment.har.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.payment.platform.common.base.PaymentTenantBaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 汇安融收款链接 —— 一个(租户, 商户编号)一条,token 是顾客页的唯一定位键。
+ *
+ * <p>形态对标 {@code LsyCheckoutLinkEntity},字段只有三格({@code merchant_code} /
+ * {@code token} / {@code merchant_name})。id / tenantId / status / description /
+ * createdTime / updatedTime 来自基类 {@link PaymentTenantBaseEntity}。
+ *
+ * <p>★ 本实体**有意不挂** {@code @JsonSerialize(ToStringSerializer)}(乐收赢那份挂了):
+ * 本仓对外的两条出口(管理端 {@code HarCheckoutLinkVO}、顾客页 {@code HarCheckoutInfoVO})
+ * **都不带 id/租户号/商户号以外的标识**,实体本身从不出网 ⇒ 挂它只会是一处
+ * 谁也不读的装饰(同族裁定见 {@code HarMerchantEntity})。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("pay_har_checkout_link")
+public class HarCheckoutLinkEntity extends PaymentTenantBaseEntity {
+
+    /**
+     * 上游分配的商户编号(钱进哪个商户由它决定)。
+     *
+     * <p>列名 {@code merchant_code} 与 {@code pay_har_merchant} 的同名列一致 ——
+     * 这里是**快照还是引用**要看清楚:本列存的是发放那一刻的商户号,顾客页下单时
+     * 拿它回 {@code pay_har_merchant} 查一遍({@code requireByTenantAndCode}),
+     * 查不到就拒 —— 商户配置被删/改号之后旧链接不会把钱引到别处,只会停下报错。
+     */
+    private String merchantCode;
+
+    /**
+     * 链接令牌,**全局唯一**({@code uk_har_checkout_token})。
+     *
+     * <p>顾客页(免登录)只能拿它说话。全局唯一是那条免登录读的**唯一**安全依据
+     * (通则 (甲)·预防),别把索引收窄成 (tenant_id, token)。
+     */
+    private String token;
+
+    /**
+     * 商户显示名快照(发放/重取时刷新)。顾客页顶部就显示它。
+     *
+     * <p>可能为 null(发放时商户行没填名字):页面用兜底标题,不显示空白标题。
+     */
+    private String merchantName;
+}

+ 10 - 0
java/src/main/java/com/payment/platform/module/payment/har/mapper/HarCheckoutLinkMapper.java

@@ -0,0 +1,10 @@
+package com.payment.platform.module.payment.har.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.payment.platform.module.payment.har.entity.HarCheckoutLinkEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+/** 收款链接表({@code pay_har_checkout_link})—— 只有基础 CRUD,无自定义 SQL。 */
+@Mapper
+public interface HarCheckoutLinkMapper extends BaseMapper<HarCheckoutLinkEntity> {
+}

+ 180 - 0
java/src/main/java/com/payment/platform/module/payment/har/service/HarCheckoutLinkService.java

@@ -0,0 +1,180 @@
+package com.payment.platform.module.payment.har.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.har.config.HarProperties;
+import com.payment.platform.module.payment.har.entity.HarCheckoutLinkEntity;
+import com.payment.platform.module.payment.har.mapper.HarCheckoutLinkMapper;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 汇安融收款链接:发放(幂等)与按 token 解析(免登录路径)。
+ *
+ * <p>逐条对标 {@code LsyCheckoutLinkService}(用户需求:「和 lsy 一样的 checkout」),
+ * 差异只有两处通道事实:定位列是 {@code merchant_code}(HAR 没有 mch_no),
+ * 路径前缀带 {@code /har/} 段。
+ *
+ * <h2>为什么链接是「一个商户一条、永久复用」</h2>
+ * 它是要印出来/贴出去的东西(桌牌、收银台、公众号菜单)。每取一次换一个 token 等于让
+ * 客户手上那张作废;而按 (tenant_id, merchant_code) 唯一 ⇒ 重取返回同一条。
+ *
+ * <h2>★★ 免登录按 token 定位的安全依据(本仓通则的 (甲)·预防)</h2>
+ * 打开链接的人没有登录态,这条读**不带**租户条件({@code TenantInnerInterceptor} 对
+ * {@code pay_har_checkout_link} 在无认证时跳过过滤,见 {@code CONDITIONAL_TABLES})。
+ * 之所以合规:token 上有**全局唯一**索引,多行在 schema 层就建不出来 —— 这正是通则
+ * 「(甲) 只用有全局唯一约束的列定位」那一支。★ 别把它收窄成 (tenant_id, token):
+ * 收窄之后该索引对无认证查询提供**零**保护(同 {@code uk_lsy_order_out_trade_no} 那个陷阱)。
+ *
+ * <p>发放这条腿是**已认证**的(管理端 JWT,见 {@code HarMerchantController#checkoutLink}),
+ * 租户号一律取自那一行商户的 {@code tenantId},请求体里没有租户维度
+ * ⇒ 不构成跨租户原语(与 {@code HarMerchantService.save} 那类「请求体给 tenantId」的写入口不同)。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class HarCheckoutLinkService {
+
+    /** 可用状态。停用由运营直接改库(本批次没有停用入口),解析时按「不存在」处理。 */
+    static final String STATUS_ENABLED = "ENABLED";
+
+    /**
+     * 顾客页是 hash 路由({@code createWebHashHistory})⇒ 路径必须在 {@code #} 之后。
+     * 写成 {@code /checkout/har/{token}} 会命中静态路由表末尾的通配 404。
+     *
+     * <p>★ {@code /har/} 这一段把本通道与乐收赢的 {@code /#/checkout/{token}} 分开:
+     * 两条路由都是两段/三段的区别,落在前端两张页面上,互不遮蔽。
+     */
+    private static final String PATH_PREFIX = "/#/checkout/har/";
+
+    /** 未知 / 停用**共用一句**:不告诉打开链接的人「这条链接存在但被停用」。 */
+    private static final String MSG_INVALID = "收款链接无效或已停用";
+
+    private final HarCheckoutLinkMapper linkMapper;
+    private final HarProperties props;
+
+    /**
+     * 取(必要时创建)某商户的收款链接。重复调用返回**同一条**。
+     *
+     * <p>顺带刷新 {@code merchant_name} 快照 —— 顾客页顶部显示的就是它。
+     *
+     * @throws BusinessException 403 租户为空;400 商户编号空白;409 本租户内该商户编号命中多行
+     *                           (不静默挑一行;唯一索引本应让这一态不可达,但本仓吃过
+     *                           「索引建不出来」的亏,所以照样响亮失败)
+     */
+    public HarCheckoutLinkEntity issue(Long tenantId, String merchantCode, String merchantName) {
+        if (tenantId == null) {
+            throw new BusinessException(403, "当前登录身份没有归属租户,无法生成收款链接,请联系管理员");
+        }
+        if (StrUtil.isBlank(merchantCode)) {
+            throw new BusinessException(400, "商户编号不能为空");
+        }
+
+        List<HarCheckoutLinkEntity> rows = linkMapper.selectList(
+                new LambdaQueryWrapper<HarCheckoutLinkEntity>()
+                        .eq(HarCheckoutLinkEntity::getTenantId, tenantId)
+                        .eq(HarCheckoutLinkEntity::getMerchantCode, merchantCode));
+
+        if (rows.isEmpty()) {
+            HarCheckoutLinkEntity row = new HarCheckoutLinkEntity();
+            row.setTenantId(tenantId);
+            row.setMerchantCode(merchantCode);
+            row.setToken(IdUtil.fastSimpleUUID());
+            // 空 ⇒ null,不 trim:口径同 LsyStrings.blankToNull(那个类在乐收赢包下,
+            // 不为这一行跨模块引;表达式逐字一致,同 HarMerchantService.save 的先例)。
+            row.setMerchantName(StrUtil.isBlank(merchantName) ? null : merchantName);
+            // ★ 显式写状态,不靠建表默认值:不写的话实体里 status=null,而调用方紧接着
+            //   要拿这一行去拼 URL / 序列化给我方页面,null 会被读成「不可用」。
+            row.setStatus(STATUS_ENABLED);
+            linkMapper.insert(row);
+            return row;
+        }
+        if (rows.size() > 1) {
+            log.error("按 (租户, 商户编号) 定位收款链接命中多行,拒绝继续: tenantId={}, merchantCode={}, 命中 {} 行, ids={}",
+                    tenantId, merchantCode, rows.size(),
+                    rows.stream().map(HarCheckoutLinkEntity::getId).toList());
+            throw new BusinessException(409, "该商户编号在您的账户下不唯一,无法确定对应收款链接,请联系管理员处理");
+        }
+
+        HarCheckoutLinkEntity row = rows.get(0);
+        String name = StrUtil.isBlank(merchantName) ? null : 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<HarCheckoutLinkEntity>()
+                    .eq(HarCheckoutLinkEntity::getId, row.getId())
+                    .set(HarCheckoutLinkEntity::getMerchantName, name)
+                    .set(HarCheckoutLinkEntity::getUpdatedTime, OffsetDateTime.now()));
+            row.setMerchantName(name);
+        }
+        return row;
+    }
+
+    /**
+     * 按 token 定位(**免登录路径**,顾客页的两个端点都从这里开始)。
+     *
+     * @throws BusinessException 404 —— 未知、空白、已停用**一律同一条 404**;
+     *                           不对调用方区分这三种
+     */
+    public HarCheckoutLinkEntity requireByToken(String token) {
+        if (StrUtil.isBlank(token)) {
+            throw new BusinessException(404, MSG_INVALID);
+        }
+        HarCheckoutLinkEntity row = linkMapper.selectOne(
+                new LambdaQueryWrapper<HarCheckoutLinkEntity>()
+                        .eq(HarCheckoutLinkEntity::getToken, token));
+        if (row == null) {
+            throw new BusinessException(404, MSG_INVALID);
+        }
+        if (!STATUS_ENABLED.equals(row.getStatus())) {
+            // 运营侧的闸(直接改库),所以留一条可捞的日志:顾客只会看到「链接无效」。
+            log.info("收款链接已停用,按不存在处理: linkId={}, status={}", row.getId(), row.getStatus());
+            throw new BusinessException(404, MSG_INVALID);
+        }
+        return row;
+    }
+
+    /**
+     * 拼给顾客/商户的完整 URL。
+     *
+     * <p>基址优先取配置 {@code har.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);
+    }
+}

+ 58 - 0
java/src/main/java/com/payment/platform/module/payment/har/service/HarMerchantService.java

@@ -94,6 +94,64 @@ public class HarMerchantService {
                 .last("LIMIT 1"));
     }
 
+    /**
+     * 按**主键**定位(管理端「取收款链接」的**超管那条腿**)。
+     *
+     * <p>超管 bypass 了租户拦截器,它跨租户看,所以这条腿不带租户谓词 ——
+     * 「凭什么保证租户读不到别人的行」由调用方按 {@code LoginUser#getIsSuperuser}
+     * 分流(见 {@code HarMerchantController#requireMerchantForCurrentUser})。
+     *
+     * @throws BusinessException 400 id 为空;404 查不到
+     */
+    public HarMerchantEntity requireById(Long id) {
+        if (id == null) {
+            throw new BusinessException(400, "商户 id 不能为空");
+        }
+        HarMerchantEntity row = merchantMapper.selectById(id);
+        if (row == null) {
+            throw new BusinessException(404, "商户配置不存在");
+        }
+        return row;
+    }
+
+    /**
+     * 按**主键 + 租户**定位(管理端「取收款链接」的**租户那条腿**)。
+     *
+     * <p>租户号写进 WHERE,而不是「先 {@code selectById} 再在 Java 里比 {@code tenantId}」:
+     * 后者会把**别租户的那一行**先读进内存。
+     * ★ 它与拦截器追加的那一条**重复**,是**故意**的:两道判据由两条独立代码路径写出,
+     * 手写这条写错时(或拦截器配置漂移时)另一条还在。
+     *
+     * <p>★ 用 {@code selectOne} 而不是 {@code selectList} + 自判多行:谓词里有**主键**,
+     * 行数在结构上 ≤ 1(与 {@link #requireByTenantAndCode} 相反 —— 那边 (租户, 商户号)
+     * 没有唯一索引,只能自己判)。
+     *
+     * <p>404 不构成跨租户探针:「不存在」与「存在但不属于本租户」应答一致(都 404)。
+     *
+     * @param id       商户行主键
+     * @param tenantId **必须来自认证上下文**。为 {@code null} ⇒ 403,且**先于查库**:
+     *                 这种主体在条件表上会被跳过过滤 ⇒ 放行等于开放按 id 读任意租户的行。
+     * @throws BusinessException 403 租户为空;400 id 为空;404 查不到(含别租户)
+     */
+    public HarMerchantEntity requireByIdForTenant(Long id, Long tenantId) {
+        // 租户闸先于一切:它是本方法的**契约**(公开 API),不依赖调用方先检查一遍。
+        if (tenantId == null) {
+            throw new BusinessException(403, "当前登录身份没有归属租户,无法查询商户配置,请联系管理员");
+        }
+        if (id == null) {
+            throw new BusinessException(400, "商户 id 不能为空");
+        }
+
+        HarMerchantEntity row = merchantMapper.selectOne(new LambdaQueryWrapper<HarMerchantEntity>()
+                .eq(HarMerchantEntity::getId, id)
+                .eq(HarMerchantEntity::getTenantId, tenantId));
+
+        if (row == null) {
+            throw new BusinessException(404, "商户配置不存在");
+        }
+        return row;
+    }
+
     /**
      * 无配置直接抛业务异常,供下单/建店等必需场景使用(租户级、未指定商户编号)。
      *

+ 50 - 0
java/src/main/java/com/payment/platform/module/payment/har/util/HarAmountFloor.java

@@ -0,0 +1,50 @@
+package com.payment.platform.module.payment.har.util;
+
+import java.math.BigDecimal;
+
+/**
+ * 汇安融**顾客页(收款链接)下单金额的下限**:1 元。
+ *
+ * <h2>这条规则的来路</h2>
+ * 2026-10-06 用户对收款链接设计的裁定:<i>「下限还是1」</i> —— 与乐收赢那条
+ * {@code LsyAmountFloor}(1 元)同一口径的业务线。
+ * ★ 它**不是**上游(汇安融)限制的转述:汇安融通道上我们**没有实测过任何金额下限**,
+ * 这个数字来自我们自己的业务判断(同 {@code LsyAmountFloor}:那边 1 元的依据也只是
+ * 「1 分被上游拒」这一个点加业务判断,不是上游给的门槛)。
+ *
+ * <h2>覆盖范围(以及**不**覆盖什么)</h2>
+ * <ul>
+ *   <li>只覆盖**顾客页**(收款链接)这一条腿 —— 挂着它的字段见
+ *       {@code HarCheckoutPayDTO#price};</li>
+ *   <li><b>收银台、开放 API、后台下单不在内</b>:它们仍按 {@code @DecimalMin("0.01")}
+ *       ({@code HarOrderCreateDTO} / {@code HarCashierCreateDTO})。这个不对称是**有意**的:
+ *       本常数是对**免登录**入口的裁定(谁拿到链接都能打开),改口径要一起看那三个入口;</li>
+ *   <li><b>退款金额不在内</b>:部分退款的金额天然可以低于 1 元;</li>
+ *   <li><b>上限仍然没有</b>:同乐收赢的理由 —— 没量过的数字不编一份出来。</li>
+ * </ul>
+ *
+ * <h2>为什么是常量类而不是各写各的</h2>
+ * 同 {@code LsyAmountFloor}:注解的 {@code value}/{@code message} 要求编译期常量,
+ * 与 {@link #MIN} 之间**没有编译期绑定** —— 漂开了不报错、不告警,只是顾客看到的提示
+ * 与实际闸门对不上。管这件事的是 {@code HarAmountFloorTest}。
+ */
+public final class HarAmountFloor {
+
+    /** 下限本身,单位**元**(HAR 全通道如此)。供 {@code @DecimalMin(value = ...)} 用。 */
+    public static final String MIN_VALUE = "1";
+
+    /** {@link #MIN_VALUE} 的数值形态,供控制器里那道显式判断用。 */
+    public static final BigDecimal MIN = new BigDecimal(MIN_VALUE);
+
+    /**
+     * 与 {@link #MIN} 配套的提示语。
+     *
+     * <p>各字段自行前缀自己的字段名,如 {@code "price " + MESSAGE} ——
+     * 同 {@code LsyAmountFloor} 的做法。最终由 {@code GlobalExceptionHandler}
+     * 渲染成 {@code price: price 不能低于 1 元}。
+     */
+    public static final String MESSAGE = "不能低于 1 元";
+
+    private HarAmountFloor() {
+    }
+}

+ 50 - 0
java/src/main/resources/db/har-checkout-link.sql

@@ -0,0 +1,50 @@
+-- 汇安融「收款链接」(checkout)—— 建表脚本
+-- 【注意】本脚本未经执行。本工程**未接 Flyway**(pom 无依赖、yml 无配置),
+--        迁移脚本由人工按版本号顺序执行 —— 执行不可逆,需用户放行。
+--        ★ 部署前必须先跑本脚本,否则管理端点取链接与顾客页开屏都会报「表不存在」。
+--
+-- 这张表存的是**一条链接怎么找到那个商户**,不存金额、不存订单 —— 每笔支付都是
+-- 打开链接的人当场输金额后新建的一张 `pay_har_order`(下单腿见
+-- HarCheckoutController → HarOrderService#create)。所以本表与订单是一对多,
+-- 且**不随支付变化**。形态对标 pay_lsy_checkout_link(乐收赢同款功能)。
+
+CREATE TABLE IF NOT EXISTS pay_har_checkout_link (
+    id              BIGINT       PRIMARY KEY,
+    tenant_id       BIGINT       NOT NULL,
+    merchant_code   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_har_checkout_token
+    ON pay_har_checkout_link (token);
+
+-- 一个商户一条链接(管理端按钮点两次返回同一条 —— 链接要**永久复用**,
+-- 不该每点一次多一条、更不该让客户手上的旧链接失效)。
+-- 这条索引只服务于**已认证**的发放路径(那里一定带 tenant_id),不承担无认证定位。
+CREATE UNIQUE INDEX IF NOT EXISTS uk_har_checkout_tenant_mch
+    ON pay_har_checkout_link (tenant_id, merchant_code);
+
+COMMENT ON TABLE pay_har_checkout_link IS '汇安融收款链接(一商户一条;打开 /#/checkout/har/{token} 输金额后当场建主扫单)';
+COMMENT ON COLUMN pay_har_checkout_link.id            IS '主键ID(MyBatis-Plus ASSIGN_ID 雪花)';
+COMMENT ON COLUMN pay_har_checkout_link.tenant_id     IS '租户ID(发放时取自商户行,不来自请求体)';
+COMMENT ON COLUMN pay_har_checkout_link.merchant_code IS '汇安融商户编号(钱进哪个商户由它决定;下单时回 pay_har_merchant 复核一遍)';
+COMMENT ON COLUMN pay_har_checkout_link.token         IS '链接令牌(全局唯一;URL 里只有它,不暴露商户号/租户)';
+COMMENT ON COLUMN pay_har_checkout_link.merchant_name IS '商户显示名快照(发放/重取时刷新;顾客页顶部就显示它)';
+COMMENT ON COLUMN pay_har_checkout_link.status        IS '状态:ENABLED 可用 / DISABLED 停用(解析时校验,停用即 404)';
+COMMENT ON COLUMN pay_har_checkout_link.description   IS '未使用(列存在只为对齐实体基类 PaymentBaseEntity,见其上方注释)';
+COMMENT ON COLUMN pay_har_checkout_link.created_time  IS '创建时间';
+COMMENT ON COLUMN pay_har_checkout_link.updated_time  IS '更新时间';

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

@@ -0,0 +1,318 @@
+package com.payment.platform.module.payment.har.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.har.client.HarApiException;
+import com.payment.platform.module.payment.har.dto.HarCheckoutInfoVO;
+import com.payment.platform.module.payment.har.dto.HarCheckoutOrderVO;
+import com.payment.platform.module.payment.har.dto.HarCheckoutPayDTO;
+import com.payment.platform.module.payment.har.dto.HarOrderCreateDTO;
+import com.payment.platform.module.payment.har.dto.HarOrderVO;
+import com.payment.platform.module.payment.har.entity.HarCheckoutLinkEntity;
+import com.payment.platform.module.payment.har.service.HarCheckoutLinkService;
+import com.payment.platform.module.payment.har.service.HarMerchantService;
+import com.payment.platform.module.payment.har.service.HarOrderService;
+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.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.*;
+
+/**
+ * 顾客页(收款链接)两个端点。
+ *
+ * <p>本类钉住的都是**这条腿独有**的东西 —— 同形的落库装配在收银台那条腿上有自己的用例表:
+ * <ol>
+ *   <li><b>租户身份只能来自链接那一行</b>,且必须在**任何一次库操作之前**绑进
+ *       SecurityContext。这条路径上的三张表({@code pay_har_checkout_link} /
+ *       {@code pay_har_merchant} / {@code pay_har_order})全在
+ *       {@code TenantInnerInterceptor.CONDITIONAL_TABLES} 里,而拦截器对它们
+ *       <b>无认证时是跳过过滤</b>的 ⇒ 不绑租户时整条路径跑在**没有任何租户谓词**的状态下;
+ *       绑上之后拦截器会独立地再写一遍同一个边界。
+ *       ★ 这是**纵深防御**,不是「不绑就查不到」—— 别把这条读成原先有 bug;</li>
+ *   <li><b>请求体里只有价格一个字段</b>:商户编号 / 标题 / 交易方式 / 支付时限全部来自
+ *       链接那一行与服务端常数。企业号与 apiKey 传 null —— 顾客页不是下游系统;</li>
+ *   <li><b>金额下限在控制器里再判一次</b>:本仓所有控制器用例都是普通方法直调,
+ *       Spring 的参数校验一步都不参与 ⇒ 只靠 DTO 注解的话,这一层没有任何守卫。
+ *       且下限是<b>含等号</b>的(1 元整放行);</li>
+ *   <li><b>上游失败只回一句顾客能照做的话</b>:上游原话(「下单失败: …」「调用上游失败: …」)
+ *       一个字都不许出给顾客。上游失败在 HAR 会分成**两类**抛出({@code BusinessException}
+ *       = 上游明确拒绝,{@code HarApiException} = 网络/解析/通道未启用且 {@code create}
+ *       不捕它),两类都要收住。</li>
+ * </ol>
+ *
+ * <p>★ <b>本类覆盖不到、由别的用例守着的那半边</b>:{@code pay_har_checkout_link} 必须登记在
+ * {@code TenantInnerInterceptor.CONDITIONAL_TABLES}、{@code /payment/har/checkout/**} 必须在
+ * {@code SecurityConfig.WHITE_LIST}(漏登记 ⇒ 未认证请求被 JWT 过滤器拦下)。
+ * 那是装配事实,钉它的是 {@code HarCheckoutWiringTest},不是这里。
+ */
+@ExtendWith(MockitoExtension.class)
+class HarCheckoutControllerTest {
+
+    private static final Long TENANT = 42L;
+    private static final String MCH = "M001";
+
+    @Mock private HarCheckoutLinkService linkService;
+    @Mock private HarMerchantService merchantService;
+    @Mock private HarOrderService orderService;
+
+    private HarCheckoutController controller;
+
+    @BeforeEach
+    void setUp() {
+        controller = new HarCheckoutController(linkService, merchantService, orderService);
+        SecurityContextHolder.clearContext();
+    }
+
+    @AfterEach
+    void tearDown() {
+        SecurityContextHolder.clearContext();
+    }
+
+    private static HarCheckoutLinkEntity link() {
+        HarCheckoutLinkEntity e = new HarCheckoutLinkEntity();
+        e.setId(9L);
+        e.setTenantId(TENANT);
+        e.setMerchantCode(MCH);
+        e.setToken("tok");
+        e.setMerchantName("小店");
+        e.setStatus("ENABLED");
+        return e;
+    }
+
+    private static HarCheckoutPayDTO pay(String yuan) {
+        HarCheckoutPayDTO d = new HarCheckoutPayDTO();
+        d.setPrice(yuan == null ? null : new BigDecimal(yuan));
+        return d;
+    }
+
+    private static HarOrderVO paidVo(String payUrl) {
+        HarOrderVO vo = new HarOrderVO();
+        vo.setOrderNo("OR123");
+        vo.setPayUrl(payUrl);
+        vo.setPrice(new BigDecimal("12.34"));
+        return vo;
+    }
+
+    // ─────────────────────────── ① 读商户显示名 ───────────────────────────
+
+    @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("小店", ((HarCheckoutInfoVO) 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);
+    }
+
+    // ─────────────────────────── ② 下单 ───────────────────────────
+
+    @Test
+    void create_belowFloor_400_withTheSameHintAsEveryOtherEntry() {
+        when(linkService.requireByToken("tok")).thenReturn(link());
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> controller.create("tok", pay("0.99")));
+
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMessage().contains("不能低于 1 元"), "实际: " + ex.getMessage());
+        // ★ 一次商户查询、一次下单都不许发生 —— 顾客只会看到一句能照做的话。
+        verifyNoInteractions(merchantService, orderService);
+    }
+
+    @Test
+    void create_nullPrice_400() {
+        when(linkService.requireByToken("tok")).thenReturn(link());
+
+        assertEquals(400, assertThrows(BusinessException.class,
+                () -> controller.create("tok", pay(null))).getCode());
+        verifyNoInteractions(merchantService, orderService);
+    }
+
+    /** 空报文(没有 body)也是 400,不是 NPE —— 免登录入口不该把一场异常泄漏成 500 */
+    @Test
+    void create_nullBody_400_notNpe() {
+        when(linkService.requireByToken("tok")).thenReturn(link());
+
+        assertEquals(400, assertThrows(BusinessException.class,
+                () -> controller.create("tok", null)).getCode());
+        verifyNoInteractions(merchantService, orderService);
+    }
+
+    /** 下限**含等号**:1 元整是一笔合法的单(挡的是 0.99 这一类,不是 1 元)。 */
+    @Test
+    void create_floorExactlyOneYuan_isAccepted() {
+        when(linkService.requireByToken("tok")).thenReturn(link());
+        when(orderService.create(any(), any(), any())).thenReturn(paidVo("https://pay/x"));
+
+        Result<?> r = controller.create("tok", pay("1"));
+
+        assertEquals(0, r.getCode(), "1 元整必须放行");
+        ArgumentCaptor<HarOrderCreateDTO> captor = ArgumentCaptor.forClass(HarOrderCreateDTO.class);
+        verify(orderService).create(captor.capture(), isNull(), isNull());
+        assertEquals(0, new BigDecimal("1").compareTo(captor.getValue().getPrice()));
+    }
+
+    @Test
+    void create_assemblesTheOrderFromTheLinkRow_andDelegatesWithNullEnterpriseAndApiKey() {
+        when(linkService.requireByToken("tok")).thenReturn(link());
+        when(orderService.create(any(), any(), any())).thenReturn(paidVo("https://pay/x"));
+
+        controller.create("tok", pay("12.34"));
+
+        ArgumentCaptor<HarOrderCreateDTO> captor = ArgumentCaptor.forClass(HarOrderCreateDTO.class);
+        verify(orderService).create(captor.capture(), isNull(), isNull());
+        HarOrderCreateDTO create = captor.getValue();
+
+        // 寻址:商户编号来自链接那一行,请求体里没有它
+        assertEquals(MCH, create.getMerchantCode());
+
+        // 金额原样带下去(元)
+        assertEquals(0, new BigDecimal("12.34").compareTo(create.getPrice()));
+
+        // 主扫腿的形状(与收银台同值;两条腿各持一份常数,见控制器 javadoc 的差异说明)
+        assertEquals("扫码收款", create.getTitle(),
+                "标题是固定文案:merchant_name 是 VARCHAR(128) 而 title 是 @Size(64),"
+                        + "拿商户名当标题会让长名商户的每一笔支付在本地校验处 400");
+        assertEquals("alipay_un_stage", create.getTradeType());
+        assertEquals("har_ali_un_stage", create.getBiz());
+        assertEquals(15, create.getPayTimeout().intValue());
+
+        // 有意留空的两格
+        assertNull(create.getEnterpriseId(), "顾客页没有企业维度:显式商户编号即租户级寻址");
+        assertNull(create.getAuthCode(), "没有付款码 ⇒ 主扫(precreate),不是被扫(pay)");
+    }
+
+    @Test
+    void create_setsTenantContextForEveryDbOperationAndRestoresItAfterwards() {
+        when(linkService.requireByToken("tok")).thenReturn(link());
+
+        List<Long> seen = new ArrayList<>();
+        when(merchantService.requireByTenantAndCode(TENANT, MCH)).thenAnswer(inv -> {
+            seen.add(currentTenantId());
+            return null;
+        });
+        when(orderService.create(any(), any(), any())).thenAnswer(inv -> {
+            seen.add(currentTenantId());
+            return paidVo("https://pay/x");
+        });
+
+        controller.create("tok", pay("5"));
+
+        assertEquals(List.of(TENANT, TENANT), seen,
+                "本地闸与下单腿都必须跑在「该链接所属租户」的身份下 —— 得到的是拦截器那一条"
+                        + "独立写出的租户谓词(纵深防御:手写 WHERE 写错时另一条还在)");
+        assertNull(SecurityContextHolder.getContext().getAuthentication(),
+                "请求结束后必须把上下文还原(这是容器的线程,不是我们自己的线程)");
+    }
+
+    /**
+     * 链接指向的商户行没了(删配置/改号)⇒ 404 且**不发单**。
+     *
+     * <p>本地闸排在订单侧任何动作之前;它也让这条出口保持 404,而不是被下面
+     * 「上游失败」的 catch 折叠成 500 —— 两种处境的操作员改法完全不同。
+     */
+    @Test
+    void create_missingMerchantRow_404_andNoOrderLeg_andContextRestored() {
+        when(linkService.requireByToken("tok")).thenReturn(link());
+        when(merchantService.requireByTenantAndCode(TENANT, MCH))
+                .thenThrow(new BusinessException(404, "商户编号不存在或不属于当前租户: " + MCH));
+
+        assertEquals(404, assertThrows(BusinessException.class,
+                () -> controller.create("tok", pay("5"))).getCode());
+        verifyNoInteractions(orderService);
+        assertNull(SecurityContextHolder.getContext().getAuthentication(),
+                "抛出的那条路径也要还原上下文(finally)");
+    }
+
+    @Test
+    void create_upstreamRejects_returnsBusinessFailure_withoutUpstreamWording() {
+        when(linkService.requireByToken("tok")).thenReturn(link());
+        when(orderService.create(any(), any(), any()))
+                .thenThrow(new BusinessException("下单失败: 上游返回 40004: 参数错误"));
+
+        Result<?> r = controller.create("tok", pay("5"));
+
+        assertEquals(500, r.getCode());
+        assertEquals("支付发起失败,请稍后重试", r.getMsg());
+        assertFalse(r.getMsg().contains("下单失败"), "上游的原话一个字都不许出给顾客");
+    }
+
+    /**
+     * 通道层失败(网络/解析/未启用)走的是 {@code HarApiException} —— {@code create}
+     * **不捕它**,它会一路穿出服务层。对顾客与「上游明确拒绝」是同一件事:这次没发起成功,
+     * 再按一次(再按会生成新的单号,重试完全安全)。
+     */
+    @Test
+    void create_upstreamTransportFailure_returnsBusinessFailure_too() {
+        when(linkService.requireByToken("tok")).thenReturn(link());
+        when(orderService.create(any(), any(), any()))
+                .thenThrow(new HarApiException("调用上游失败: connection reset"));
+
+        Result<?> r = controller.create("tok", pay("5"));
+
+        assertEquals(500, r.getCode());
+        assertEquals("支付发起失败,请稍后重试", r.getMsg());
+    }
+
+    @Test
+    void create_blankPayUrl_returns500() {
+        when(linkService.requireByToken("tok")).thenReturn(link());
+        when(orderService.create(any(), any(), any())).thenReturn(paidVo(null));
+
+        Result<?> r = controller.create("tok", pay("5"));
+
+        assertEquals(500, r.getCode(), "主扫的全部意义就是那条链接,没有它这笔单在页面上画不出来");
+        assertEquals("支付发起失败,请稍后重试", r.getMsg());
+    }
+
+    @Test
+    void create_success_returnsOrderNoPayUrlAndPrice() {
+        when(linkService.requireByToken("tok")).thenReturn(link());
+        when(orderService.create(any(), any(), any())).thenReturn(paidVo("https://pay/x"));
+
+        Result<?> r = controller.create("tok", pay("12.34"));
+
+        assertEquals(0, r.getCode());
+        HarCheckoutOrderVO vo = (HarCheckoutOrderVO) r.getData();
+        assertEquals("OR123", vo.getOrderNo());
+        assertEquals("https://pay/x", vo.getPayUrl(),
+                "给顾客的是**上游**的支付链接本身,不要包装成我们自己的地址");
+        assertEquals(0, new BigDecimal("12.34").compareTo(vo.getPrice()));
+    }
+
+    private static Long currentTenantId() {
+        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+        if (auth != null && auth.getPrincipal() instanceof LoginUser u) {
+            return u.getTenantId();
+        }
+        return null;
+    }
+}

+ 280 - 0
java/src/test/java/com/payment/platform/module/payment/har/service/HarCheckoutLinkServiceTest.java

@@ -0,0 +1,280 @@
+package com.payment.platform.module.payment.har.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.har.config.HarProperties;
+import com.payment.platform.module.payment.har.entity.HarCheckoutLinkEntity;
+import com.payment.platform.module.payment.har.mapper.HarCheckoutLinkMapper;
+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>形态逐条对标 {@code LsyCheckoutLinkServiceTest},差异只有两处**通道事实**:
+ * 定位列是 {@code merchant_code}(HAR 没有 mch_no),以及路径前缀带
+ * {@code /har/} 段({@code /#/checkout/har/{token}},与乐收赢那条并存不冲突)。
+ *
+ * <p>本类钉的四件事都是**会静默出错**的那一类:
+ * <ol>
+ *   <li><b>重复点按钮不能换 token</b> —— 链接是印出去/贴出去的东西,换一次等于让客户手上那张失效;</li>
+ *   <li><b>停用的链接必须 404</b> —— {@code status} 列如果没人读,它就是本仓警告过的死字段;</li>
+ *   <li><b>拼出来的 URL 形态</b> —— 顾客侧是 hash 路由({@code createWebHashHistory}),
+ *       写成 {@code /checkout/har/{token}} 会命中静态路由表末尾的通配 404;</li>
+ *   <li><b>安全论证依赖的 schema 事实写在脚本里</b> —— 免登录按 token 定位之所以合规,
+ *       唯一依据是「token 有全局唯一索引」(通则 (甲)·预防)。索引只写在 SQL 文件里,
+ *       所以这里直接读那个文件钉住它。</li>
+ * </ol>
+ */
+@ExtendWith(MockitoExtension.class)
+class HarCheckoutLinkServiceTest {
+
+    private static final Long TENANT = 7L;
+    private static final String MERCHANT_CODE = "M001";
+
+    @Mock private HarCheckoutLinkMapper linkMapper;
+    @Mock private HarProperties props;
+    private HarCheckoutLinkService service;
+
+    @BeforeEach
+    void setUp() {
+        service = new HarCheckoutLinkService(linkMapper, props);
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), HarCheckoutLinkEntity.class);
+    }
+
+    private static HarCheckoutLinkEntity row(Long id, String token, String status) {
+        HarCheckoutLinkEntity e = new HarCheckoutLinkEntity();
+        e.setId(id);
+        e.setTenantId(TENANT);
+        e.setMerchantCode(MERCHANT_CODE);
+        e.setToken(token);
+        e.setStatus(status);
+        e.setMerchantName("老名字");
+        return e;
+    }
+
+    // ─────────────────────────── 发放 ───────────────────────────
+
+    @Test
+    void issue_whenNoRowExists_insertsOneRowWithTokenAndEnabledStatus() {
+        when(linkMapper.selectList(any())).thenReturn(List.of());
+
+        HarCheckoutLinkEntity created = service.issue(TENANT, MERCHANT_CODE, "测试商户");
+
+        ArgumentCaptor<HarCheckoutLinkEntity> captor = ArgumentCaptor.forClass(HarCheckoutLinkEntity.class);
+        verify(linkMapper).insert(captor.capture());
+        HarCheckoutLinkEntity inserted = captor.getValue();
+
+        assertEquals(TENANT, inserted.getTenantId());
+        assertEquals(MERCHANT_CODE, inserted.getMerchantCode());
+        assertEquals("测试商户", inserted.getMerchantName());
+        assertEquals("ENABLED", inserted.getStatus(), "新链接必须是可用状态(显式写,不靠建表默认值)");
+        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() {
+        HarCheckoutLinkEntity existing = row(1L, "abcd1234abcd1234abcd1234abcd1234", "ENABLED");
+        when(linkMapper.selectList(any())).thenReturn(List.of(existing));
+
+        HarCheckoutLinkEntity got = service.issue(TENANT, MERCHANT_CODE, "测试商户");
+
+        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")));
+
+        HarCheckoutLinkEntity got = service.issue(TENANT, MERCHANT_CODE, "新名字");
+
+        // 断言 SET 子句里有哪几列(值在 paramNameValuePairs 里,不在 SQL 文本里)。
+        // 这里更要紧的是**不许出现 token**。
+        ArgumentCaptor<Wrapper<HarCheckoutLinkEntity>> 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, MERCHANT_CODE, "老名字");
+
+        verify(linkMapper, never()).update(any(), any());
+        verify(linkMapper, never()).updateById(any());
+    }
+
+    @SuppressWarnings("unchecked")
+    private static ArgumentCaptor<Wrapper<HarCheckoutLinkEntity>> updateWrapperCaptor() {
+        return ArgumentCaptor.forClass(Wrapper.class);
+    }
+
+    @Test
+    void issue_nullTenant_throws403() {
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.issue(null, MERCHANT_CODE, "x"));
+        assertEquals(403, ex.getCode());
+        verify(linkMapper, never()).selectList(any());
+    }
+
+    @Test
+    void issue_blankMerchantCode_throws400() {
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.issue(TENANT, "  ", "x"));
+        assertEquals(400, ex.getCode());
+    }
+
+    @Test
+    void issue_multipleRows_throwsLoudly_insteadOfPickingOne() {
+        // 唯一索引 (tenant_id, merchant_code) 在库上时这一态不可达;但索引「建不出来」
+        // 的成因本仓已经吃过一次(乐收赢 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, MERCHANT_CODE, "x"));
+        assertEquals(409, ex.getCode());
+        verify(linkMapper, never()).insert(any());
+    }
+
+    // ─────────────────────────── 按 token 解析(免登录) ───────────────────────────
+
+    @Test
+    void requireByToken_found_returnsRow() {
+        HarCheckoutLinkEntity 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/har/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/har/tok", url,
+                "顾客侧是 hash 路由:路径必须是 /#/checkout/har/{token},写 /checkout/har/{token} 会 404");
+    }
+
+    @Test
+    void buildUrl_prefersForwardedHeaders_behindNginx() {
+        when(props.getCheckoutBaseUrl()).thenReturn(null);
+        MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/payment/har/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/har/tok", service.buildUrl("tok", req));
+    }
+
+    @Test
+    void buildUrl_nonDefaultPort_isKept() {
+        when(props.getCheckoutBaseUrl()).thenReturn(null);
+        MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/payment/har/checkout/tok");
+        req.setScheme("http");
+        req.setServerName("192.168.1.9");
+        req.setServerPort(8080);
+
+        assertEquals("http://192.168.1.9:8080/#/checkout/har/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/har/checkout/tok");
+        req.setServerName("ignored");
+        req.setScheme("http");
+
+        assertEquals("https://pay.example.com/#/checkout/har/tok", service.buildUrl("tok", req),
+                "yml 里配了 har.checkout-base-url 就以它为准(反代只转发路径、Host 是内网名时用得上)");
+    }
+
+    // ─────────────────────────── 安全论证的 schema 前提 ───────────────────────────
+
+    /**
+     * 免登录按 token 定位之所以**合规**,唯一依据是「token 有全局唯一约束」(通则的 (甲)·预防)。
+     * 那条约束只存在于交付给用户的建表脚本里 —— 脚本里没有它,等于这段安全论证是空的。
+     * 所以这里不读代码,直接读那份脚本。
+     */
+    @Test
+    void schemaScript_declaresGlobalUniqueIndexOnToken() throws Exception {
+        Path sql = Path.of("src/main/resources/db/har-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_har_checkout_token\\s+ON pay_har_checkout_link \\(token\\).*"),
+                "token 的**全局**唯一索引不见了。别把它收窄成 (tenant_id, token):"
+                        + "免登录查询不带租户条件,收窄后该索引提供零保护(同 uk_lsy_order_out_trade_no 那个陷阱)。");
+    }
+}

+ 90 - 0
java/src/test/java/com/payment/platform/module/payment/har/service/HarMerchantServiceTest.java

@@ -337,6 +337,96 @@ class HarMerchantServiceTest {
         verify(merchantMapper, never()).selectOne(any());
     }
 
+    // ==================== 按主键寻址(管理端「取收款链接」两道闸的两条腿) ====================
+
+    /**
+     * 超管那条腿:按主键直查。
+     *
+     * <p>用 {@code selectById} 而不是自己拼 wrapper:谓词里就是主键,行数在结构上 ≤ 1
+     * (与 {@link HarMerchantService#requireByIdForTenant} 那边的论证同源)。
+     */
+    @Test
+    void requireById_found_returnsRow() {
+        HarMerchantEntity row = merchant(11L, "M001");
+        when(merchantMapper.selectById(11L)).thenReturn(row);
+
+        assertSame(row, service.requireById(11L));
+    }
+
+    @Test
+    void requireById_nullId_throws400_withoutQuerying() {
+        BusinessException e = assertThrows(BusinessException.class, () -> service.requireById(null));
+
+        assertEquals(400, e.getCode(), "缺 id 是入参问题,实测消息: " + e.getMessage());
+        verify(merchantMapper, never()).selectById(any());
+    }
+
+    @Test
+    void requireById_missing_throws404() {
+        when(merchantMapper.selectById(11L)).thenReturn(null);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.requireById(11L));
+
+        assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
+    }
+
+    /**
+     * 租户那条腿:租户为 null ⇒ **403 且先于查库**。
+     *
+     * <p>「非超管且没有归属租户」不是假想组合:{@code sys_user.tenant_id} 可空。
+     * 这种主体在条件表上会被跳过过滤(没有租户可过滤)⇒ 若放行,它就能按 id 读到
+     * 任意租户的商户行、再拿那一行的商户号发出收款链接。所以这道闸必须在查库之前。
+     */
+    @Test
+    void requireByIdForTenant_nullTenant_throws403_withoutQuerying() {
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.requireByIdForTenant(11L, null));
+
+        assertEquals(403, e.getCode(), "实测消息: " + e.getMessage());
+        verify(merchantMapper, never()).selectOne(any());
+    }
+
+    /**
+     * 租户号必须写进 WHERE,而不是「先 selectById 再把别租户的行读进内存再比」。
+     *
+     * <p>★ 它与拦截器追加的那一条**重复**,是**故意**的:两道判据由两条独立代码路径写出,
+     * 手写这条写错时另一条还在(同 {@code LsyMerchantService#requireByIdForTenant} 的论证)。
+     */
+    @Test
+    void requireByIdForTenant_isScopedByTenantInWhere() {
+        when(merchantMapper.selectOne(any())).thenReturn(null);
+
+        assertThrows(BusinessException.class, () -> service.requireByIdForTenant(11L, 7L));
+
+        @SuppressWarnings({ "unchecked", "rawtypes" })
+        ArgumentCaptor<LambdaQueryWrapper<HarMerchantEntity>> cap =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(merchantMapper).selectOne(cap.capture());
+        String where = cap.getValue().getSqlSegment();
+        assertTrue(where.contains("tenant_id"), "实测 WHERE: " + where);
+        assertTrue(where.contains("id"), "实测 WHERE: " + where);
+    }
+
+    /** 「不存在」与「存在但不是我租户的」应答**一致**(都 404),否则它就是跨租户 id 探针 */
+    @Test
+    void requireByIdForTenant_unknownOrForeign_throws404() {
+        when(merchantMapper.selectOne(any())).thenReturn(null);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.requireByIdForTenant(11L, 7L));
+
+        assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
+    }
+
+    @Test
+    void requireByIdForTenant_nullId_throws400() {
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.requireByIdForTenant(null, 7L));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        verify(merchantMapper, never()).selectOne(any());
+    }
+
     private HarMerchantEntity merchant(Long id, String code) {
         HarMerchantEntity e = new HarMerchantEntity();
         e.setId(id);

+ 42 - 0
java/src/test/java/com/payment/platform/module/payment/har/util/HarAmountFloorTest.java

@@ -0,0 +1,42 @@
+package com.payment.platform.module.payment.har.util;
+
+import com.payment.platform.module.payment.har.dto.HarCheckoutPayDTO;
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.NotNull;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 下限常量与挂了它的那个字段必须同源。
+ *
+ * <p>★ 注解的 {@code value}/{@code message} 要求**编译期常量**,
+ * {@link HarAmountFloor#MIN_VALUE} / {@link HarAmountFloor#MESSAGE} 之间
+ * **没有编译期绑定** —— 两者漂开不报错、不告警,只是顾客看到的提示与实际闸门对不上
+ * (同 {@code LsyAmountFloorTest} 的存在理由)。新增入口 DTO 时在下面的用例里加一行。
+ */
+class HarAmountFloorTest {
+
+    @Test
+    void checkoutPayDtoAnnotationsAreBoundToTheFloorConstants() throws Exception {
+        Field price = HarCheckoutPayDTO.class.getDeclaredField("price");
+
+        assertNotNull(price.getAnnotation(NotNull.class), "价格必填");
+
+        DecimalMin min = price.getAnnotation(DecimalMin.class);
+        assertNotNull(min, "顾客页的价格必须有下限注解");
+        assertEquals(HarAmountFloor.MIN_VALUE, min.value(),
+                "@DecimalMin 的值必须就是 HarAmountFloor.MIN_VALUE");
+        assertEquals("price " + HarAmountFloor.MESSAGE, min.message(),
+                "提示语必须由常量拼出,不能另写一份");
+    }
+
+    /** 下限就是 1 元整(用户裁定「下限还是1」);含等号由控制器那条腿的用例钉住 */
+    @Test
+    void theFloorItselfIsOneYuan() {
+        assertEquals(0, new BigDecimal("1").compareTo(HarAmountFloor.MIN));
+    }
+}