6 Revize 96c6d0a55f ... ed24480f4a

Autor SHA1 Zpráva Datum
  alphaH ed24480f4a fix(乐收赢): 收款链接 token 换 CSPRNG —— 与 HAR 同一处订正 před 1 týdnem
  alphaH b97a0e9311 fix(汇安融): 收款链接评审修复 —— 并发幂等、顾客语 404、token 换 CSPRNG před 1 týdnem
  alphaH 3ae6bf8a9d feat(汇安融): 收款链接(四)—— 前端:顾客页 + 商户页收款二维码入口 před 1 týdnem
  alphaH 8b4619ba3a feat(汇安融): 收款链接(三)—— 装配接线:白名单 + 条件表 + 守卫测试 před 1 týdnem
  alphaH b00a83485c feat(汇安融): 收款链接(二)—— 管理端「取收款链接」端点 před 1 týdnem
  alphaH 2109cb6ca1 feat(汇安融): 收款链接(一)—— 链接存储与顾客页两个免登录端点 před 1 týdnem
30 změnil soubory, kde provedl 3377 přidání a 11 odebrání
  1. 39 0
      frontend/src/api/module_payment/har.ts
  2. 7 2
      frontend/src/plugins/permission.ts
  3. 11 0
      frontend/src/router/index.ts
  4. 857 0
      frontend/src/views/checkout/har/index.vue
  5. 165 3
      frontend/src/views/module_payment/har/merchant/index.vue
  6. 6 0
      java/src/main/java/com/payment/platform/core/security/SecurityConfig.java
  7. 9 1
      java/src/main/java/com/payment/platform/core/tenant/TenantInnerInterceptor.java
  8. 10 0
      java/src/main/java/com/payment/platform/module/payment/har/config/HarProperties.java
  9. 301 0
      java/src/main/java/com/payment/platform/module/payment/har/controller/HarCheckoutController.java
  10. 117 0
      java/src/main/java/com/payment/platform/module/payment/har/controller/HarMerchantController.java
  11. 23 0
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarCheckoutInfoVO.java
  12. 42 0
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarCheckoutLinkVO.java
  13. 33 0
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarCheckoutOrderVO.java
  14. 46 0
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarCheckoutPayDTO.java
  15. 49 0
      java/src/main/java/com/payment/platform/module/payment/har/entity/HarCheckoutLinkEntity.java
  16. 10 0
      java/src/main/java/com/payment/platform/module/payment/har/mapper/HarCheckoutLinkMapper.java
  17. 217 0
      java/src/main/java/com/payment/platform/module/payment/har/service/HarCheckoutLinkService.java
  18. 58 0
      java/src/main/java/com/payment/platform/module/payment/har/service/HarMerchantService.java
  19. 50 0
      java/src/main/java/com/payment/platform/module/payment/har/util/HarAmountFloor.java
  20. 8 2
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCheckoutController.java
  21. 1 1
      java/src/main/java/com/payment/platform/module/payment/leshouying/entity/LsyCheckoutLinkEntity.java
  22. 7 1
      java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyCheckoutLinkService.java
  23. 50 0
      java/src/main/resources/db/har-checkout-link.sql
  24. 130 0
      java/src/test/java/com/payment/platform/core/security/HarCheckoutWiringTest.java
  25. 346 0
      java/src/test/java/com/payment/platform/module/payment/har/controller/HarCheckoutControllerTest.java
  26. 285 0
      java/src/test/java/com/payment/platform/module/payment/har/controller/HarMerchantControllerTest.java
  27. 340 0
      java/src/test/java/com/payment/platform/module/payment/har/service/HarCheckoutLinkServiceTest.java
  28. 90 0
      java/src/test/java/com/payment/platform/module/payment/har/service/HarMerchantServiceTest.java
  29. 42 0
      java/src/test/java/com/payment/platform/module/payment/har/util/HarAmountFloorTest.java
  30. 28 1
      java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyCheckoutLinkServiceTest.java

+ 39 - 0
frontend/src/api/module_payment/har.ts

@@ -50,6 +50,25 @@ export interface HarMerchantSaveParams {
   description?: string;
 }
 
+/**
+ * 收款链接(后端 HarCheckoutLinkVO)—— 商户页「收款二维码」弹窗的三格。
+ *
+ * ★ 只认这三格,与后端 VO 逐字段对应(租户号、主键、token 都不回给页面 —— 见那个 VO 的 javadoc)。
+ * ★ `merchant_name` 是**链接行上的快照**,不是商户行那一列:商户改过名但还没重新取过
+ *   链接时两者会不同,而操作员看到的名字必须与顾客打开链接时看到的一样。
+ * ★ `url` 整条来自后端 `HarCheckoutLinkService#buildUrl`。**不要**用 `merchant_code`
+ *   或域名自己拼:基址推导只有那一份,拼第二个副本的后果是「复制的链接」与
+ *   「二维码里的链接」不是同一条。
+ */
+export interface HarCheckoutLinkVO {
+  /** 汇安融商户编号 —— 操作员据此确认取的是哪一家 */
+  merchant_code: string;
+  /** 商户显示名(**快照**)。可能为 null:商户行没填名字时不编一个出来 */
+  merchant_name?: string | null;
+  /** 完整收款链接(形如 `https://<域名>/#/checkout/har/<token>`) */
+  url: string;
+}
+
 /** 门店(后端 HarShopVO) */
 export interface HarShopVO {
   id?: string;
@@ -280,6 +299,26 @@ const HarAPI = {
     });
   },
 
+  /**
+   * 取(必要时创建)该商户的**收款链接** —— 商户页操作列那个「收款二维码」按钮。
+   *
+   * ★ 是 **POST** 不是 GET:首次调用会**写一行**(`pay_har_checkout_link`)。这是刻意的 ——
+   *   链接要印出来/贴出去,每取一次换一个 token 等于让客户手上那张作废,
+   *   所以同一个商户反复调用返回**同一条**(服务层的幂等,见 `HarCheckoutLinkService#issue`)。
+   *   前端**不要**自作主张缓存结果或改成 GET。
+   *
+   * ★ `id` 是**字符串** —— 雪花 id 超 JS 安全整数(同 merchantDelete 的说明)。
+   *
+   * ★ 权限串复用商户 `list`(不新增权限点 ⇒ **不需要新的菜单 SQL**),所以能看见这一页的
+   *   角色都点得动;租户也只能取到**自己名下**那一行(后端两道数据闸)。
+   */
+  checkoutLink(id: string) {
+    return request<ApiResponse<HarCheckoutLinkVO>>({
+      url: `${API_PATH}/merchant/${id}/checkout-link`,
+      method: "post",
+    });
+  },
+
   // ---------- 门店 ----------
 
   /** 门店列表(分页) */

+ 7 - 2
frontend/src/plugins/permission.ts

@@ -11,11 +11,16 @@ import { usePermissionStore, useUserStore, useEnterpriseStore } from "@/store";
  *   哪天有人在 `/checkout/...` 下加一个管理端页面,它会在无人察觉的情况下对未登录用户开放。
  *   按名字放行时,「公开」必须是一次**显式**的声明(路由表里那个 `name`)。
  * ★ 也不要按 token 形状收紧(`/^\/checkout\/[0-9a-f]{32}$/`):那会把前端钉死在后端
- *   `IdUtil.fastSimpleUUID()` 这一种令牌形状上,换一种生成方式就等于「所有链接都打不开」。
+ *   令牌生成器**当下**的形状上(现在是 `IdUtil.simpleUUID()` 的 32 位十六进制),
+ *   换一种生成方式就等于「所有链接都打不开」。
  * ★ 只放**顾客页**,`/login` 仍走下面的精确清单 —— 它在已登录时要重定向到首页,
  *   语义不同,别合并。
+ *
+ * ★ 第二个名字 `HarCheckout` 是汇安融通道的同款顾客页(`/#/checkout/har/<token>`,
+ *   路径多一段 `har` 字面量)。加它是一次**显式声明** —— 这正是上面第一条注释要的效果:
+ *   按名字放行时,新公开页面必须有人在这里写一行,而不会因为路径恰好长得像就自动开放。
  */
-const PUBLIC_ROUTE_NAMES = new Set(["Checkout"]);
+const PUBLIC_ROUTE_NAMES = new Set(["Checkout", "HarCheckout"]);
 
 function isPublicPage(to: RouteLocationNormalized): boolean {
   return to.matched.some((r) => typeof r.name === "string" && PUBLIC_ROUTE_NAMES.has(r.name));

+ 11 - 0
frontend/src/router/index.ts

@@ -51,6 +51,17 @@ export const constantRoutes: RouteRecordRaw[] = [
     meta: { hidden: true, title: "收款" },
     component: () => import("@/views/checkout/index.vue"),
   },
+  {
+    // 汇安融顾客页(收款链接 `/#/checkout/har/<token>` 的落点)—— 免登录,同上一段。
+    // ★ 为什么是**三段**路径而乐收赢是两段:`HarCheckoutLinkService#buildUrl` 的
+    //   PATH_PREFIX 是 `/#/checkout/har/`,`har` 是路径里的**字面段**,不是参数。
+    //   若按乐收赢写成 `checkout/:token`,`har` 会被当成 token ⇒ 顾客页拿着 "har"
+    //   去查链接,一律「链接无效」。
+    path: "/checkout/har/:token",
+    name: "HarCheckout",
+    meta: { hidden: true, title: "收款" },
+    component: () => import("@/views/checkout/har/index.vue"),
+  },
   {
     path: "/404",
     name: "404",

+ 857 - 0
frontend/src/views/checkout/har/index.vue

@@ -0,0 +1,857 @@
+<template>
+  <div class="checkout">
+    <!-- ① 载入中:占位的形状照着真内容量(商户名 / 标签 / 金额 / 键盘块),不给任何文案
+         —— 顾客看到的第一次「有内容」必须是真内容 -->
+    <template v-if="phase === 'loading'">
+      <header class="panel">
+        <div class="skeleton skeleton--name"></div>
+        <div class="skeleton skeleton--label"></div>
+        <div class="skeleton skeleton--amount"></div>
+      </header>
+      <div class="pad-skeleton"></div>
+    </template>
+
+    <!-- ② 链接不可用:未知 / 空 / 已停用**共用同一句**(服务端就是这么回的,
+         不告诉打开链接的人「这条链接存在但被停用」)。这一步**没有任何操作**可做,
+         所以不放按钮 —— 放一个点了没用的按钮比不放更糟。 -->
+    <template v-else-if="phase === 'invalid'">
+      <header class="panel">
+        <h1 class="panel__name">收款</h1>
+      </header>
+      <div class="notice">
+        <p class="notice__title">收款链接无效或已停用</p>
+        <p class="notice__hint">请向收款方重新获取链接,或直接扫描对方出示的收款码。</p>
+      </div>
+    </template>
+
+    <!-- ③ 正常:商户名 + 金额 + 键盘(最右那一格就是「付款」)。
+         顾客第一眼要确认的是「钱付给谁」,所以商户名排在最上面一行。 -->
+    <template v-else>
+      <header class="panel">
+        <!-- ★ 商户名可能为 null(发链接时那一行没填名字)⇒ 用兜底标题,
+             不显示一个空白标题。文案取自后端 HarCheckoutInfoVO 的注释。 -->
+        <h1 class="panel__name">{{ merchantName || "收款" }}</h1>
+        <p class="panel__label">金额</p>
+
+        <!-- 金额那行同时是**显示**:顾客不需要点它。`inputmode="none"` —— 点它也不弹系统
+             键盘(键盘就在下面),而桌面上的实体键盘照样能直接打字。 -->
+        <div class="amount" :class="{ 'amount--error': !!hint, 'amount--shake': shaking }">
+          <span class="amount__symbol">¥</span>
+          <input
+            ref="amountEl"
+            v-model="amountText"
+            class="amount__input"
+            type="text"
+            inputmode="none"
+            autocomplete="off"
+            aria-label="付款金额(元)"
+            placeholder="请输入金额"
+            :disabled="submitting || redirecting"
+            @input="onInput"
+            @keyup.enter="submit"
+          />
+        </div>
+      </header>
+
+      <!-- 垫在中间的那块空白:把键盘压到屏幕下沿,金额与键盘之间不插别的东西 -->
+      <div class="spacer"></div>
+
+      <p class="hint" :class="{ 'hint--error': !!hint }">
+        {{ hint || `最低 ${MIN_FEN / 100} 元` }}
+      </p>
+
+      <!-- 键盘:4 列,最右一列上面是退格、下面是纵跨三行的「付款」。
+           ★ 付款键**长在键盘里**,不再另起一个通栏按钮 —— 顾客本来就要做两件事
+             (输金额、付款),两件事就长在同一块板上,中间不插第二个可点的东西。
+           ★ 「0」横跨两格:这是国内收银台键盘的固定形状,照做即可。 -->
+      <div class="pad" role="group" aria-label="金额键盘">
+        <button
+          v-for="k in PAD_KEYS"
+          :key="k.key"
+          type="button"
+          class="pad__key"
+          :class="{ 'pad__key--pay': k.pay, 'pad__key--wide': k.wide }"
+          :disabled="submitting || redirecting"
+          :aria-label="
+            k.key === BACKSPACE ? '退格' : k.pay ? '付款' : k.key === '.' ? '小数点' : k.key
+          "
+          @click="press(k.key)"
+        >
+          <!-- 退格用内联 SVG,不用 `⌫` 那个码位:它在部分安卓字体里会掉成方框 -->
+          <svg v-if="k.key === BACKSPACE" class="pad__icon" viewBox="0 0 24 24" aria-hidden="true">
+            <path
+              d="M9 5h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9l-5.6-7z"
+              fill="none"
+              stroke="currentColor"
+              stroke-width="1.7"
+              stroke-linejoin="round"
+            />
+            <path
+              d="M11.4 9.6l4.8 4.8m0-4.8l-4.8 4.8"
+              fill="none"
+              stroke="currentColor"
+              stroke-width="1.7"
+              stroke-linecap="round"
+            />
+          </svg>
+          <template v-else>{{ k.pay ? (submitting ? "…" : "付款") : k.key }}</template>
+        </button>
+      </div>
+    </template>
+
+    <!-- 跳转中:盖满整屏。顾客此刻已经不该再碰键盘了 —— 键本来就 disabled,
+         再留一个看得见的键盘只会诱他去点。顾客还能看到「付多少」,
+         跳走之后这页就不归我们管了。 -->
+    <div v-if="redirecting" class="redirect">
+      <div class="redirect__spinner"></div>
+      <p class="redirect__text">正在前往支付页面,应付 <strong>¥{{ fenToYuan(paidFen) }}</strong></p>
+      <p v-if="paidOutTradeNo" class="redirect__no">订单号 {{ paidOutTradeNo }}</p>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+/**
+ * 汇安融顾客页 —— 收款链接 `/#/checkout/har/<token>` 的落点。**免登录**。
+ * 逐条对标乐收赢同款页面(`views/checkout/index.vue`),差异只有下面这几处:
+ *
+ * 【与乐收赢那份的差异(都是通道契约,不是风格)】
+ *   · 接口前缀 `/payment/har/checkout/...`;路由多一段 `har` **字面量**
+ *     (后端 `HarCheckoutLinkService#buildUrl` 的 PATH_PREFIX 是 `/#/checkout/har/`);
+ *   · 金额单位是**元**(乐收赢是分):提交发 `{ price: <元> }`,回显读 `price`;
+ *     HAR 全通道如此(同收银台/开放 API)。
+ *   · 下单应答里是 `pay_url` / `order_no`(乐收赢是 `code_url` / `out_trade_no`)。
+ *
+ * 【它做什么】三件事,顺序固定:显示商户名 → 顾客输一个金额 → 发起支付并跳到上游收银台。
+ * 没有第四件。「不含其他功能」是需求原文:这一屏面对的是正要付钱的人,
+ * 每多一个可点的东西就多一次「我该按哪个」的犹豫。
+ *
+ * 【为什么不用 `@/utils/request`】那个实例的两个拦截器都是给**管理端**写的,在顾客页会闯祸:
+ *   · 请求拦截器会自动附上 `Authorization`(这台浏览器里可能存着一个管理端令牌 ——
+ *     顾客页是免登录的,带上它没有意义,徒增一个「谁的令牌」的变量);
+ *   · 响应拦截器用 `ElMessage` **弹窗**报错,且对非 GET 的成功响应弹「操作成功」。
+ *     顾客页的错误必须**写在页面上**(顾客不会去读一个 3 秒后消失的浮层,
+ *     而那正是他付款失败的那一刻)。
+ * 所以这里自建一个实例,**不挂任何拦截器**,每个响应都自己看 `code`。
+ *
+ * 【为什么是 hash 路由】`createWebHashHistory` —— 后端拼的链接形如
+ * `<base>/#/checkout/har/<token>`(`HarCheckoutLinkService#buildUrl` 的 PATH_PREFIX)。
+ * 拼成 `/checkout/har/<token>` 会命中静态路由表末尾的通配 404。
+ */
+defineOptions({
+  name: "HarCheckout",
+  inheritAttrs: false,
+});
+
+import { onMounted, onUnmounted, ref } from "vue";
+import { useRoute } from "vue-router";
+import axios from "axios";
+
+/**
+ * 收款金额下限,单位**分**(100 分 = 1 元)。与后端 `HarAmountFloor.MIN` 是同一个数
+ * —— **改一处必须改另一处**(那条业务线是用户 2026-10-06 裁定的「下限还是1」,
+ * 只挂顾客页这条腿;收银台/后台仍是 0.01)。
+ *
+ * 【为什么顾客页也要拦一道】后端那道是真的闸(这条腿谁都能调),这里只是把话说在
+ * 顾客按下按钮**之前**:不拦的话他收到的是一句报文措辞
+ * (`price 不能低于 1 元`),而那是一位正要付钱的人。
+ */
+const MIN_FEN = 100;
+
+/**
+ * 顾客页的浏览器标题。**有意与后台不同**。
+ *
+ * 【为什么不能靠全局那支】全站标题由 `main.ts` 的 `setTitleAndFavicon()` 从
+ * `sys_web_title`(库里的系统配置)取,值是**管理端**的名字(「公司管理」)——
+ * 那是给操作员看的,不该出现在一位正要付钱的顾客的标签页上。
+ * 而且那个函数是**异步**的(要先等一次配置请求),谁先跑完不确定 ⇒
+ * 光在这里写还不够,`main.ts` 那侧也加了一道同名判据(`hash.startsWith("#/checkout/")`,
+ * 汇安融这条路径也在它覆盖范围内 —— 见那里的注释)。
+ */
+const PAGE_TITLE = "Payment Platform";
+
+type Phase = "loading" | "invalid" | "ready";
+
+/** 只看 `code` 的最小信封形状。`Result.ok` 的成功码是 **0**(不是 200)。 */
+interface Envelope<T> {
+  code?: number;
+  msg?: string;
+  data?: T;
+}
+
+/**
+ * 顾客页专用实例:相对基址(与页面同源),**无拦截器**。
+ * ★ 超时给了 20 秒:这一步背后是「我们建单 + 上游下单」两次网络往返,
+ *   而顾客多半在移动网络下。
+ */
+const http = axios.create({
+  baseURL: import.meta.env.VITE_APP_BASE_API,
+  timeout: 20000,
+  headers: { "Content-Type": "application/json;charset=utf-8" },
+});
+
+const route = useRoute();
+const token = String(route.params.token ?? "");
+
+const phase = ref<Phase>("loading");
+const merchantName = ref<string | null>(null);
+const amountText = ref("");
+const hint = ref("");
+const submitting = ref(false);
+const redirecting = ref(false);
+const shaking = ref(false);
+const paidFen = ref(0);
+const paidOutTradeNo = ref("");
+const amountEl = ref<HTMLInputElement>();
+
+// ────────────────────────────── 金额 ──────────────────────────────
+
+/**
+ * 键盘的键序与摆位。`BACKSPACE` / `PAY` 是两种**非数字**键,各自单独给一个值,
+ * 模板据此换成图标或文案;`wide` / `pay` 只影响栅格占位(见 .pad)。
+ *
+ * ★ 这两样(`0` 横跨两格、`付款` 纵跨最右三行)不是排版偏好,是国内收银台键盘的
+ *   固定形状 —— 形状对了,顾客不用读文案就知道怎么按。
+ */
+const BACKSPACE = "back";
+const PAY = "pay";
+
+interface PadKey {
+  key: string;
+  /** 「0」横跨两格 */
+  wide?: boolean;
+  /** 纵跨最右三行的「付款」键 */
+  pay?: boolean;
+}
+
+const PAD_KEYS: PadKey[] = [
+  { key: "1" },
+  { key: "2" },
+  { key: "3" },
+  { key: BACKSPACE },
+  { key: "4" },
+  { key: "5" },
+  { key: "6" },
+  { key: PAY, pay: true },
+  { key: "7" },
+  { key: "8" },
+  { key: "9" },
+  { key: "0", wide: true },
+  { key: "." },
+];
+
+/**
+ * 整数位上限。**键盘让这件事变成必然**:按住一个键不放,位数会一直被推上去,
+ * 而金额那行的字号是跟视口放大的 —— 位数一多,那行字就顶出纸面(输入框自己会横向滚,
+ * 顾客看到的是一个被裁掉头部的金额)。
+ * 7 位 = 999 万元,够任何线下收款;这个数还与 `--mono` 的字宽对得上(见 .amount__input)。
+ * 上限只写在 `onInput` 这一处 —— 纸键盘与实体键盘共用同一条过滤。
+ */
+const MAX_INT_DIGITS = 7;
+
+/**
+ * 输入过滤:纸键盘与物理键盘**唯一**的入口。
+ * 只留数字与一个小数点,小数位最多两位(多打的那一位直接不显示,而不是事后报错);
+ * 顺手去掉多余的整数前导零("05" → "5"),免得金额行印出个 `05`;
+ * 再把开头那一个孤零零的小数点补成 `0.` —— 顾客先按 `.` 再按数字,要的是 `0.5` 而不是 `.5`
+ * (`yuanToFen` 本来就吃得下 `.5`,但金额行上顶着一个光秃秃的 `.` 是不对的)。
+ */
+function onInput() {
+  const cleaned = amountText.value
+    .replace(/[^\d.]/g, "")
+    .replace(/^(\d*\.?\d{0,2}).*$/, "$1")
+    .replace(/^0+(?=\d)/, "")
+    .replace(/^\./, "0.");
+  const [intPart = "", decPart] = cleaned.split(".");
+  amountText.value =
+    intPart.length > MAX_INT_DIGITS
+      ? intPart.slice(0, MAX_INT_DIGITS) + (decPart === undefined ? "" : `.${decPart}`)
+      : cleaned;
+  hint.value = "";
+  // 顾客一动键盘,上一条错误提示的手感就结束了(见 reject 的注释)
+  shaking.value = false;
+}
+
+/**
+ * 键盘按下了一格:数字键接一位、退格键删一位、付款键就是提交。
+ *
+ * ★ 过滤与上限**只有 `onInput` 一处** —— 两边各写一份,迟早出现
+ *   「物理键盘打不进去、点键盘能打进去」这类只在一种输入下成立的缺陷。
+ */
+function press(key: string) {
+  if (submitting.value || redirecting.value) {
+    return;
+  }
+  if (key === PAY) {
+    submit();
+    return;
+  }
+  amountText.value =
+    key === BACKSPACE ? amountText.value.slice(0, -1) : amountText.value + key;
+  onInput();
+}
+
+/**
+ * 校验没过:把话说在页面上,并让金额行抖一下。
+ *
+ * ★ 「抖」不能靠每次失败都重挂一次 CSS 动画(同一个类连着挂两次,动画不会重放)——
+ *   这里只负责**置真**,由 `onInput()`(顾客的下一次动作)置假:顾客不动它就一直为真,
+ *   也不会再抖第二下,正好。
+ */
+function reject(message: string) {
+  hint.value = message;
+  shaking.value = true;
+  amountEl.value?.focus();
+}
+
+/**
+ * 元 → 分。**按字符串拆,不做浮点乘法。**
+ *
+ * ★ `Math.round(Number("19.99") * 100)` 在这里恰好也对(误差远小于 0.5),但它是**碰巧**对:
+ *   这条式子对「多少分」这个整数结果的正确性,依赖 IEEE754 那条误差上界,而
+ *   `"1.005"` 这类三位小数的结果就取决于先 round 还是先乘。拆字符串没有这个问题,
+ *   而且顺手把「不是合法金额」的输入在**同一个函数**里判掉(`null`)。
+ *
+ * @returns 分;`null` = 输入不是合法金额(空、多个小数点、超过两位小数)
+ */
+function yuanToFen(text: string): number | null {
+  // 容忍「打到一半」的输入,不把它当错误:末尾只挂一个小数点("5.")。
+  // 也吃省略整数位的 ".5"(`Number("")` 是 0,所以拆分那一步天然处理得了),
+  // 但那条**不来自界面** —— `onInput` 会把开头的小数点补成 `0.`,只有直接调用本函数的
+  // 代码才可能递进来。留着它是因为这里本来就是个纯函数,不必依赖调用方的形态。
+  const s = text.trim().replace(/\.$/, "");
+  if (!/^(\d+(\.\d{1,2})?|\.\d{1,2})$/.test(s)) {
+    return null;
+  }
+  const [intPart, decPart = ""] = s.split(".");
+  const fen = Number(intPart) * 100 + Number((decPart + "00").slice(0, 2));
+  // 17 位数字以上已经超出安全整数 —— 这种输入只可能是误触,别拿它去建单
+  return Number.isSafeInteger(fen) ? fen : null;
+}
+
+/** 分 → 元(显示用,固定两位小数)。 */
+function fenToYuan(fen: number): string {
+  return (fen / 100).toFixed(2);
+}
+
+// ────────────────────────────── 两跳 ──────────────────────────────
+
+/** 开屏:拿商户显示名。拿不到(404 / 网络错)都进 `invalid`,**不区分** —— 见模板上的注释。 */
+async function loadInfo() {
+  try {
+    const res = await http.get<Envelope<{ merchant_name?: string | null }>>(
+      `/payment/har/checkout/${encodeURIComponent(token)}`
+    );
+    if (res.data?.code !== 0) {
+      phase.value = "invalid";
+      return;
+    }
+    merchantName.value = res.data.data?.merchant_name ?? null;
+    phase.value = "ready";
+    // ★ 这里**故意不**自动聚焦金额框:键盘已经印在纸上了,自动聚焦只会在手机上
+    //   弹出一块系统软键盘,正好盖住刚画好的那半张纸。
+    //   (聚焦还剩一个用途:校验没过时把光标放回去,好让桌面上的实体键盘能接着打字。)
+  } catch {
+    // ★ 网络不通也归到 `invalid` 是**有意的取舍**:这条腿的失败里,
+    //   「链接坏了」远多于「网络坏了」,而顾客对两者的反应是同一个动作
+    //   (找收款方)。给一句「网络异常,请重试」会让他在一个**永远不会成功**的
+    //   链接上反复重试。真·网络问题在支付那一步会给出可照做的提示。
+    phase.value = "invalid";
+  }
+}
+
+/** 按下「付款」:建单 → 上游下单 → 把浏览器送到上游给的链接。 */
+async function submit() {
+  if (submitting.value || redirecting.value) {
+    return;
+  }
+  const fen = yuanToFen(amountText.value);
+  if (fen === null) {
+    reject("请输入正确的金额,最多两位小数");
+    return;
+  }
+  if (fen < MIN_FEN) {
+    reject(`收款金额最低 ${MIN_FEN / 100} 元`);
+    return;
+  }
+
+  submitting.value = true;
+  hint.value = "";
+  try {
+    // ★ 金额以**元**发送(HAR 全通道如此)。数字由分换算回来(`fenToYuan` 是
+    //   `toFixed(2)` 的字符串再转数)—— 与收银台那条腿同一个形状
+    //   (`Number((fen / 100).toFixed(2))`),不直接做 `fen / 100` 的浮点除法。
+    const res = await http.post<
+      Envelope<{ order_no?: string; pay_url?: string; price?: number | string | null }>
+    >(`/payment/har/checkout/${encodeURIComponent(token)}/orders`, {
+      price: Number(fenToYuan(fen)),
+    });
+
+    const payUrl = res.data?.data?.pay_url;
+    if (res.data?.code !== 0 || !payUrl) {
+      // 服务端的话是可以照做的(例如「支付发起失败,请稍后重试」),原样显示。
+      // 拿不到 pay_url 而 code 是 0 属于契约被破坏,同样当失败处理 —— 否则顾客会停在
+      // 一个「按钮点了没反应」的页面上,而控制台里什么都没有。
+      hint.value = res.data?.msg || "支付发起失败,请稍后重试";
+      return;
+    }
+
+    // ★ 跳转前把金额与单号显示出来:这是顾客核对金额的最后一眼,
+    //   单号是他「付了但页面没反应」时报给客服的凭据(见 HarCheckoutOrderVO 的注释)。
+    //   金额优先用服务端回显的那一个(订单的真正金额);回显不可解析时退回首部算出的 fen。
+    paidFen.value = echoedYuanToFen(res.data.data?.price) ?? fen;
+    paidOutTradeNo.value = res.data.data?.order_no ?? "";
+    redirecting.value = true;
+
+    // ★ 直接送到上游给的链接,**不包短链、不做中转页** —— 多一跳就多一处能把顾客卡住的地方。
+    //   给一帧让上面的提示先渲染出来(否则这一句谁也看不见,页面直接白了)。
+    await new Promise((r) => window.setTimeout(r, 80));
+    window.location.href = payUrl;
+  } catch {
+    hint.value = "网络异常,请检查网络后重试";
+  } finally {
+    submitting.value = false;
+  }
+}
+
+/**
+ * 服务端回显的 `price`(**元**,JSON 里可能是 number)→ 分。
+ *
+ * ★ 走字符串拆分(复用 `yuanToFen`),不做浮点乘法 —— 与提交方向同一条理由。
+ *   回显值是两位小数(就是我们自己发上去的那个数)时恒可解析;万一不是,返回 `null`
+ *   由调用处退回首部算出的 fen,而不是显示一个来路不明的金额。
+ */
+function echoedYuanToFen(price: number | string | null | undefined): number | null {
+  if (price === null || price === undefined) {
+    return null;
+  }
+  return yuanToFen(String(price));
+}
+
+/**
+ * 进页面时换成顾客页自己的标题,**离开时还原**。
+ *
+ * ★ 还原不是洁癖:这是一个 SPA,浏览器后退键会把人送回后台那一屏,
+ *   而 `main.ts` 只在应用启动时设一次标题 —— 不还原的话,后台的标签页会一直挂着
+ *   「Payment Platform」,而那个名字对操作员没有任何意义。
+ * ★ 自己之前的标题由**自己**存一份再写回(不是写死一个常量回填):将来若全站标题
+ *   改成从别处来,这里不需要跟着改。
+ */
+let titleBefore: string | null = null;
+
+onMounted(() => {
+  titleBefore = document.title;
+  document.title = PAGE_TITLE;
+  loadInfo();
+});
+
+onUnmounted(() => {
+  if (titleBefore !== null) {
+    document.title = titleBefore;
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+/* ══════════════════════════════════════════════════════════════════════
+   顾客页的视觉参照是**国内收银台**(微信/支付宝那一路的扫码付款页)。
+   本页与乐收赢同款页面(`views/checkout/index.vue`)用**同一份**样式 ——
+   两条通道的顾客页面对的是同一件事,形状不该有两副。
+
+   2026-09-30 按用户给的码实扫了一页当基准(`https://p.eycard.cn/p?qr=rE3Erq`,
+   要用微信内置浏览器的 UA 才给渲染),390×844 下量到的读数:
+     · 底色 rgba(0,0,0,.02);商户名 16.6px/700 #333
+     · 「金额」标签 14.6px/700;¥ 27px/700;占位文字 30px/500 #dedede
+     · 金额数字 35.4px/700;键 87×52、圆角 5.2px、白底
+     · 键投影 0 2px 2px rgb(0 0 0 / 18%);键上的数字 20.8px/700 #000
+     · 「付款」键 87×166 —— 纵跨最右三行;「0」横跨两格;退格在右上角
+
+   前几版走的是「一张收据」:深桌面、撕口、条码、编号、作废印章。那些都是**比喻**,
+   每个比喻都要额外画一样东西,加起来就成了「设计过的海报」,而不是一个收银台。
+   这一版照抄参照页的是**比例与克制**,不是它的配色:主色仍是我们自己的 --brand。
+   ══════════════════════════════════════════════════════════════════════ */
+.checkout {
+  --brand: #4080ff;      /* = settings.ts 的 themeColor。整页只有「付款」键用它 */
+  --ink: #333;           /* 主文字(照参照页量的 #333,不是纯黑) */
+  --ink-soft: #8a8f99;   /* 次要文字:提示行 */
+  --placeholder: #dedede;
+  --page: #f2f3f5;       /* 底。参照页是 rgba(0,0,0,.02)≈#fafafa,这里压深一点:
+                            白块压在 #fafafa 上几乎分不出边界 */
+  --danger: #e34d59;     /* 校验失败。与后台 Element Plus 的 danger 同色 */
+  --key-shadow: 0 2px 2px rgb(0 0 0 / 18%), 0 1px 1px rgb(0 0 0 / 16%);
+  /* 键行高。定成变量是因为**两处**要用它:键盘自己,和加载态骨架
+     (骨架高度必须等于键区高度,否则回复一到整页会跳一下)。
+     宽屏那段会覆盖它 —— 见文件末尾的 @media。 */
+  --key-row: 66px;
+
+  /* ★ 宽度必须在这里定死,不能留给内容去决定。祖先链 html/body/#app 三层**全是
+     display:flex** ⇒ 这一层是个 flex 项,而 flex 项的 min-width:auto 落在 min-content 上:
+     内容一宽就把它顶出去,在 390 的手机上实测 scrollWidth 410(右侧半个按钮在屏幕外)。
+     width:100% 给定尺(这个页面本来就该铺满),min-width:0 允许它跟着容器收缩。 */
+  width: 100%;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  box-sizing: border-box;
+  min-height: 100vh;
+  /* 中文用系统字体 —— 顾客多半在移动网络下,不引外链字体 */
+  font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB",
+    "Microsoft YaHei", "Noto Sans SC", sans-serif;
+  color: var(--ink);
+  background: var(--page);
+
+  /* ── 宽屏:整页收成一根手机宽的柱子居中 ──
+     这一屏真正的入口是扫码,顾客一定在手机上;但**商户自己会拿电脑点开这条链接试**,
+     而不管的话 1440 下键盘就铺满整屏(实测键宽 350px),一眼就是「没管过桌面」。
+     柱子宽度取 480:比任何手机的竖屏都宽,所以 390~430 这一段**一个像素都不变**
+     (实测 390 下 panel/pad/键 的框与未加这段时逐格相同,scrollWidth 仍是 390)。
+     祖先链 html/body/#app 全是 flex ⇒ 本层是 flex 项,align-items:center 是它的交叉轴对齐,
+     子项横向居中;柱子给子项而不是给自己,页面底色才是出血的(自己收窄会在两侧留一道白边)。 */
+  align-items: center;
+
+  > *:not(.redirect) {
+    width: 100%;
+    max-width: 480px;
+  }
+}
+
+/* ── 顶部信息块:商户名 +「金额」+ 金额行,三样压在同一块白底上 ──
+   参照页也是这么排的:商户名下面直接跟「金额」标签,中间不加分隔线。 */
+.panel {
+  padding: 14px 16px 18px;
+  background: #fff;
+}
+
+.panel__name {
+  margin: 0;
+  font-size: 17px;
+  font-weight: 700;
+  line-height: 1.35;
+  word-break: break-all;
+}
+
+.panel__label {
+  margin: 18px 0 0;
+  font-size: 15px;
+  font-weight: 700;
+}
+
+/* ── 金额行:¥ 小一号、数字大,左对齐 ── */
+.amount {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-top: 6px;
+  min-height: 62px;
+}
+
+.amount__symbol {
+  font-size: 32px;
+  font-weight: 700;
+}
+
+/* ★ 数字用 tabular-nums(**不是**等宽字体栈):位数变化时字宽不变,光标不跳。
+   参照页的金额也是系统字体加粗 —— 等宽字体是前几版「收据」那个比喻带进来的。 */
+.amount__input {
+  flex: 1;
+  min-width: 0;
+  padding: 0;
+  font-family: inherit;
+  font-size: 46px;
+  font-weight: 700;
+  color: var(--ink);
+  font-variant-numeric: tabular-nums;
+  background: transparent;
+  border: none;
+  outline: none;
+
+  /* 占位比真值小一号、细一档 —— 参照页就是这么分的(30px/500 对 35px/700,
+     这里是它那个比例放大后的 36px/500 对 46px/700) */
+  &::placeholder {
+    font-size: 36px;
+    font-weight: 500;
+    color: var(--placeholder);
+  }
+
+  /* iOS 上 disabled 会把字压成灰的,而这一格是顾客刚输入的金额,不许变淡 */
+  &:disabled {
+    color: var(--ink);
+    -webkit-text-fill-color: var(--ink);
+    opacity: 1;
+  }
+}
+
+/* 校验没过:¥ 与金额一起变红。只红这一行 —— 键盘不参与报错。 */
+.amount--error .amount__symbol,
+.amount--error .amount__input {
+  color: var(--danger);
+}
+
+/* 垫在中间的那块空白:把键盘压到屏幕下沿 */
+.spacer {
+  flex: 1;
+  min-height: 16px;
+}
+
+/* 提示行:安静时是「最低 1 元」,出错时是被拒的原因。
+   位置就在键盘正上方 —— 与参照页那行品牌字同格,顾客的眼从键盘往上一格就能读到。 */
+.hint {
+  min-height: 19px;
+  margin: 0 16px 10px;
+  font-size: 13px;
+  text-align: center;
+  color: var(--ink-soft);
+}
+
+.hint--error {
+  color: var(--danger);
+}
+
+/* ── 键盘:4 列 × 4 行,最右一列上面退格、下面「付款」 ──
+   ★ 白键压在同色底上、彼此留缝:键是**浮起来的卡片**,不是格线。
+   前几版画的是出血的表格线,那是「印在纸上的表」那个比喻带来的;
+   国内收银台一律是这种方块键。 */
+.pad {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  grid-auto-rows: var(--key-row);
+  gap: 6px;
+  padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
+}
+
+.pad__key {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
+  font-family: inherit;
+  font-size: 24px;
+  font-weight: 700;
+  color: #000;
+  cursor: pointer;
+  background: #fff;
+  border: none;
+  border-radius: 5px;
+  box-shadow: var(--key-shadow);
+  /* 触摸端:去掉系统那层灰色高光(它会在键上留一块脏印)、去掉长按选中,
+     也去掉双击缩放带来的那 300ms 迟疑 */
+  -webkit-tap-highlight-color: transparent;
+  touch-action: manipulation;
+  user-select: none;
+  transition: background 0.12s;
+
+  &:active:not(:disabled) {
+    background: #f0f0f0;
+  }
+
+  &:disabled {
+    cursor: default;
+    opacity: 0.5;
+  }
+
+  &:focus-visible {
+    outline: 2px solid var(--brand);
+    outline-offset: -2px;
+  }
+}
+
+.pad__key--wide {
+  grid-column: span 2;
+}
+
+/* 「付款」:纵跨最右三行。整页唯一的亮色块,也是唯一一个非白键。 */
+.pad__key--pay {
+  grid-column: 4;
+  grid-row: 2 / span 3;
+  font-size: 21px;
+  color: #fff;
+  background: var(--brand);
+
+  /* 禁用态不用 opacity:蓝底压到 50% 会变成一块脏灰蓝。参照页的做法是换成同一个色的
+     浅版(它那个绿按钮禁用时就是浅绿)。 */
+  &:disabled {
+    background: #a8c4ff;
+    opacity: 1;
+  }
+
+  &:active:not(:disabled) {
+    background: #3572e6;
+  }
+}
+
+.pad__icon {
+  width: 26px;
+  height: 26px;
+  color: #333;
+}
+
+/* ── 宽屏:空白交给键盘吃掉 ──
+   这一屏在桌面上没有「拇指够不够得着」这回事,中间那块空白唯一的用处就是「别太挤」;
+   可不管它的话它会跟着显示器一起长 —— 柱子收到 480 之后,1440×900 实测空出 428px,
+   整页像一条被拉长的收据。让它长在键盘上:键一大,这块空自然就短了。
+
+   行高取 clamp 而不是定值。定 112 的话,1440×700 这种半屏窗口上内容会到 675 而窗口
+   只有 700 —— 再矮一点就平白长出滚动条;12vh 让它跟着窗口缩。下限 66 就是手机那一档
+   (窗口再矮也不小于手机),上限 112 = 键宽(键 112 宽时正好见方,再高就拉长了)。
+   键上的字要一起放大,否则一格 112 里蹲着个 24px 的数字,看着发虚。
+
+   ★ 这一段只挂在 ≥600px。390×844 下逐格不变:键仍 89×66、scrollHeight 仍 844
+   (实测,改前改后同一批读数)。 */
+@media (min-width: 600px) {
+  .checkout {
+    --key-row: clamp(66px, 12vh, 112px);
+  }
+
+  .pad__key {
+    font-size: 30px;
+  }
+
+  .pad__key--pay {
+    font-size: 26px;
+  }
+
+  .pad__icon {
+    width: 34px;
+    height: 34px;
+  }
+}
+
+/* ── 跳转中:盖满整屏 ── */
+.redirect {
+  position: fixed;
+  inset: 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 0 32px;
+  text-align: center;
+  background: #fff;
+}
+
+.redirect__spinner {
+  width: 22px;
+  height: 22px;
+  margin-bottom: 12px;
+  border: 2px solid #d8e4ff;
+  border-top-color: var(--brand);
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+.redirect__text {
+  margin: 0;
+  font-size: 14px;
+  color: var(--ink);
+}
+
+.redirect__no {
+  margin: 8px 0 0;
+  font-size: 12px;
+  color: var(--ink-soft);
+  word-break: break-all;
+}
+
+/* ── 链接不可用:一句话 + 一条可照做的建议。没有别的可点 ── */
+.notice {
+  margin: 40px 24px 0;
+  text-align: center;
+}
+
+.notice__title {
+  margin: 0 0 10px;
+  font-size: 17px;
+  font-weight: 600;
+}
+
+.notice__hint {
+  margin: 0;
+  font-size: 13px;
+  line-height: 1.7;
+  color: var(--ink-soft);
+}
+
+/* ── 载入占位:形状照着真内容量(名字 / 标签 / 金额 / 键盘块)。
+     占位与真内容差得远的话,回复一到整页会跳一下。 ── */
+.skeleton {
+  background: #eef0f2;
+  border-radius: 3px;
+  animation: shimmer 1.6s ease-in-out infinite;
+}
+
+.skeleton--name {
+  width: 52%;
+  height: 17px;
+}
+
+.skeleton--label {
+  width: 12%;
+  height: 15px;
+  margin-top: 18px;
+}
+
+.skeleton--amount {
+  width: 42%;
+  height: 46px;
+  margin-top: 10px;
+}
+
+/* 键盘那块占位压在屏幕下沿(margin-top:auto)。高度对齐的是**键区**、不是整个键盘块:
+   4 行 + 3 个 6px 缝 = 4×行高 + 18,正好是真键盘第一排键的上沿到最后一排键的下沿。
+   键盘块自己的上下各 8px 是贴着页面底色的留白,占位不该连它一起画 —— 那样会差 16px。
+   用 `var(--key-row)` 而不是写死:宽屏下行高会变,写死的话骨架与真键区就对不上了。 */
+.pad-skeleton {
+  height: calc(var(--key-row) * 4 + 18px);
+  margin: auto 8px calc(8px + env(safe-area-inset-bottom));
+  background: #e9ebee;
+  border-radius: 5px;
+  animation: shimmer 1.6s ease-in-out infinite;
+}
+
+@keyframes shimmer {
+  50% {
+    opacity: 0.5;
+  }
+}
+
+/* 校验没过:金额行左右抖一下。用动画而不是 JS 逐帧 —— 它在 reduced-motion 下被一并
+   关掉,而信息不丢:那时金额变红、提示那句照样写在键盘上方。 */
+@keyframes shake {
+  20% {
+    transform: translateX(-5px);
+  }
+
+  45% {
+    transform: translateX(4px);
+  }
+
+  70% {
+    transform: translateX(-3px);
+  }
+
+  90% {
+    transform: translateX(2px);
+  }
+}
+
+.amount--shake {
+  animation: shake 0.36s cubic-bezier(0.36, 0.07, 0.19, 0.97);
+}
+
+/* ★ 减少动效:只关掉动的东西,元素停在**静态形态**(占位是满不透明的),不是消失
+   —— 这一点靠的是动画只写在关键帧里,而不是另写一套覆盖规则。 */
+@media (prefers-reduced-motion: reduce) {
+  .amount--shake,
+  .skeleton,
+  .pad-skeleton,
+  .redirect__spinner {
+    animation: none;
+  }
+}
+</style>

+ 165 - 3
frontend/src/views/module_payment/har/merchant/index.vue

@@ -115,8 +115,13 @@
             {{ row.updated_time ? dayjs(row.updated_time).format("YYYY-MM-DD HH:mm:ss") : "-" }}
           </template>
         </el-table-column>
-        <el-table-column label="操作" width="150" fixed="right">
+        <!-- ★ 「收款二维码」对**两种身份都显示**:后端那个端点挂的是权限串(复用本页 list),
+             而且两道数据闸只允许取到自己名下的那一行 —— 租户给自己取链接正是它的主用途。
+             这与旁边两个按钮(只认超管身份)是两种门禁,别照着其中一个去改另一个,
+             见后端 `HarMerchantController` 的类 javadoc。 -->
+        <el-table-column label="操作" width="220" fixed="right">
           <template #default="{ row }">
+            <el-button size="small" @click="openLink(row)">收款二维码</el-button>
             <el-button v-if="isSuperAdmin" size="small" type="primary" @click="openEdit(row)">
               编辑
             </el-button>
@@ -206,14 +211,51 @@
         <el-button type="primary" :loading="saving" @click="handleSubmit">保存</el-button>
       </template>
     </el-dialog>
+
+    <!-- 收款链接(复制 / 二维码 / 下载)—— 一切以「拿走直接用」为准:
+         操作员拿到它是要去打印桌牌、贴到收银台、发给顾客的,所以三件都要在场。 -->
+    <el-dialog v-model="linkVisible" title="收款链接" width="620px">
+      <el-alert
+        type="success"
+        :closable="false"
+        style="margin-bottom: 16px"
+        title="这条链接长期有效、可以无限次使用。把它打印成桌牌、贴到收银台,或直接用微信/支付宝扫码——顾客打开后自己输入金额即可付款。"
+      />
+      <el-form label-width="90px">
+        <el-form-item label="商户">
+          <span>{{ link.merchant_code }}</span>
+          <span v-if="link.merchant_name" class="link-name">({{ link.merchant_name }})</span>
+        </el-form-item>
+        <el-form-item label="链接">
+          <div class="link-row">
+            <el-input :model-value="link.url" readonly />
+            <div class="qrcode-wrapper">
+              <!-- ★ 尺寸给到 240:这一块是要**被扫**的,160 在小屏手机上难扫;
+                   下载出来的 PNG 与屏幕上这块是同一个函数画的,不会两副样子。 -->
+              <canvas ref="qrcodeCanvas" class="qrcode-canvas"></canvas>
+            </div>
+          </div>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button :disabled="!link.url" @click="handleCopyLink">复制链接</el-button>
+        <el-button :disabled="!link.url" @click="handleDownloadQr">下载二维码</el-button>
+        <el-button type="primary" @click="linkVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted, reactive, ref } from "vue";
+import { computed, nextTick, onMounted, reactive, ref } from "vue";
 import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
 import dayjs from "dayjs";
-import HarAPI, { type HarMerchantSaveParams, type HarMerchantVO } from "@/api/module_payment/har";
+import QRCode from "qrcode";
+import HarAPI, {
+  type HarCheckoutLinkVO,
+  type HarMerchantSaveParams,
+  type HarMerchantVO,
+} from "@/api/module_payment/har";
 import TenantAPI, { type TenantTable } from "@/api/module_system/tenant";
 import { useEnterpriseStore, useUserStore } from "@/store";
 
@@ -371,6 +413,92 @@ async function handleDelete(row: HarMerchantVO) {
   load();
 }
 
+// ────────────────────────── 收款链接 ──────────────────────────
+
+/**
+ * 链接弹窗的三格。**只认这三格**,与后端 `HarCheckoutLinkVO` 逐字段对应
+ * (其余一概不取:租户号、主键、token 都不回给页面 —— 见那个 VO 的 javadoc)。
+ *
+ * ★ `merchant_name` 是**链接行上的快照**,不是表格里那一列:商户改过名但还没重新取过链接时
+ *   两者会不同,而操作员看到的名字必须与顾客打开链接时看到的一样。
+ * ★ `url` 整条来自后端 `buildUrl`。**不要**在这里用 `merchant_code` 或域名自己拼:
+ *   基址推导只有那一份,拼第二个副本的后果是「复制的链接」与「二维码里的链接」不是同一条。
+ */
+const link = ref<Partial<HarCheckoutLinkVO>>({});
+const linkVisible = ref(false);
+const qrcodeCanvas = ref<HTMLCanvasElement>();
+
+/**
+ * 打开链接弹窗:**先取数据、拿到之后再开窗**。
+ *
+ * ★ 顺序是有意的:这个端点是 POST 且会**写一行**(首次调用建链接),
+ *   先开窗会让操作员盯着一个空框等一次网络往返,失败时更是一个空弹窗配一句 toast。
+ *   取不到就压根不弹 —— 列表页上的 toast 已经把原因说清楚了。
+ */
+async function openLink(row: HarMerchantVO) {
+  link.value = {};
+  try {
+    const res = await HarAPI.checkoutLink(row.id as string);
+    // 两层 data:响应拦截器 `return response`,**不解包**(同 load 的既有形状)。
+    link.value = res.data.data || {};
+  } catch {
+    // 拦截器已经用服务端的原话弹过 toast(404 商户不存在 / 403 无归属租户)。
+    // 这里什么都不做:留在列表页,操作员可以直接再点一次。
+    return;
+  }
+  linkVisible.value = true;
+  await nextTick();
+  await drawQr();
+}
+
+/** 画二维码。★ 传**整条 url**(不是 token)—— 顾客扫出来的必须就是那条链接。 */
+async function drawQr() {
+  if (!qrcodeCanvas.value || !link.value.url) {
+    return;
+  }
+  try {
+    await QRCode.toCanvas(qrcodeCanvas.value, link.value.url, { width: 240, margin: 1 });
+  } catch {
+    // 画不出来 = 这一屏少了「扫码」这条腿,必须说出来,不能只留在控制台。
+    // 链接本身还在输入框里,复制那条腿不受影响。
+    ElMessage.error("二维码生成失败,请改用「复制链接」");
+  }
+}
+
+async function handleCopyLink() {
+  try {
+    await navigator.clipboard.writeText(link.value.url || "");
+    ElMessage.success("收款链接已复制");
+  } catch {
+    // 非 https / 无剪贴板权限时 `navigator.clipboard` 不可用(或写入被拒),
+    // 输入框本身是可全选的,所以这里给一句能照做的提示。
+    ElMessage.warning("复制失败,请手动选中链接复制");
+  }
+}
+
+/**
+ * 下载二维码 PNG。
+ *
+ * ★ 从**同一份数据**(`link.value.url`)重画一张,而不是把屏幕上那块 canvas 截图:
+ *   屏幕上的 canvas 受 `devicePixelRatio` 与 CSS 尺寸影响,截出来的图在打印场景里
+ *   会忽大忽小;`toDataURL` 给的是一张尺寸确定的位图,桌牌打印要的正是这个。
+ */
+async function handleDownloadQr() {
+  if (!link.value.url) {
+    return;
+  }
+  try {
+    const dataUrl = await QRCode.toDataURL(link.value.url, { width: 600, margin: 2 });
+    const a = document.createElement("a");
+    a.href = dataUrl;
+    // 文件名带上商户编号:一次导几张时不会互相覆盖,也不会分不清哪张是哪家。
+    a.download = `收款码_${link.value.merchant_code || "商户"}.png`;
+    a.click();
+  } catch {
+    ElMessage.error("二维码下载失败,请改用「复制链接」");
+  }
+}
+
 onMounted(async () => {
   load();
   try {
@@ -402,4 +530,38 @@ onMounted(async () => {
   justify-content: space-between;
   align-items: center;
 }
+
+/* 链接 + 二维码并排:输入框可伸缩,二维码固定 240(见模板里那段尺寸说明) */
+.link-row {
+  display: flex;
+  gap: 16px;
+  align-items: flex-start;
+  width: 100%;
+}
+
+.link-row :deep(.el-input) {
+  flex: 1;
+  min-width: 0;
+}
+
+.qrcode-wrapper {
+  flex: none;
+  padding: 4px;
+  background: #fff;
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+/* ★ 显式与 `drawQr` 里那个 `width: 240` 同值:canvas 的**显示尺寸**由这两行决定,
+   而它的位图尺寸由那个参数决定。两者不一致时二维码看着正常、扫起来却可能因缩放发虚。 */
+.qrcode-canvas {
+  display: block;
+  width: 240px;
+  height: 240px;
+}
+
+.link-name {
+  margin-left: 4px;
+  color: var(--el-text-color-secondary);
+}
 </style>

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

@@ -79,6 +79,12 @@ public class SecurityConfig {
             // ★ 放行的是**授权层**;租户隔离仍由 TenantInnerInterceptor 的条件表
             //   (pay_lsy_checkout_link 已登记)+ 控制器里显式绑定的租户上下文负责。
             "/payment/checkout/**",
+            // 汇安融收款链接(顾客页,免登录)—— 与上一行同形,但**路径前缀不同**
+            // (/payment/har/... 而非 /payment/checkout/...),上面那条 Ant 模式匹配不到它,
+            // 必须单独登记(HarCheckoutWiringTest 把这条差异写成了前提复核)。
+            // 身份同样不是认证来的:token 全局唯一(uk_har_checkout_token),
+            // 且是 32 位随机十六进制(见 HarCheckoutLinkService)。
+            "/payment/har/checkout/**",
             "/v3/api-docs/**",
             "/swagger-ui/**",
             "/swagger-ui.html",

+ 9 - 1
java/src/main/java/com/payment/platform/core/tenant/TenantInnerInterceptor.java

@@ -224,8 +224,16 @@ public class TenantInnerInterceptor extends TenantLineInnerInterceptor {
                                      //     否则「未认证 + LIMIT 1」就是本清单禁止的静默挑行。
             "pay_har_merchant",      // 汇安融商户配置(下单时按 enterprise_id 反查,开放API无登录态)
             "pay_har_shop",          // 汇安融门店(开放API创建/查询无登录态)
-            "pay_har_order"          // 汇安融订单(上游通知回调 + 定时轮询均无认证上下文;
+            "pay_har_order",         // 汇安融订单(上游通知回调 + 定时轮询均无认证上下文;
                                      //   订单号为我方生成的雪花ID,全局唯一,无跨租户风险)
+            "pay_har_checkout_link"  // 收款链接(顾客页按 token 定位,**免登录**)。与上一段
+                                     //   pay_lsy_checkout_link 同形,逐条论证不再重抄;
+                                     //   ★ 差异只有一处:这里的唯一索引叫 uk_har_checkout_token,
+                                     //     同样**单独**建在 token 上(不是 (tenant_id, token))。
+                                     //   ★ 漏登记 ⇒ 未认证查询被追加 AND tenant_id = 0
+                                     //     ⇒ **每条链接都查不到**,表现是顾客页一律「链接无效」,
+                                     //     而且日志里一个字都没有。
+                                     //   ★ 定位用 selectOne 是**允许**的:因为走了 (甲)。
     );
 
     public TenantInnerInterceptor() {

+ 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;
 

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

@@ -0,0 +1,301 @@
+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 是 CSPRNG 生成的 32 位随机十六进制</b>({@code IdUtil.simpleUUID}
+ *       = {@code UUID.randomUUID().toString(true)},走 {@code SecureRandom}),
+ *       不是可枚举的序号 ⇒ 拿到别人的链接只能靠拿到那一串字符。
+ *       <br>★ 2026-10-06 评审订正:此前这里写的是 {@code IdUtil.fastSimpleUUID}「122 bit 熵」,
+ *       两头都失实 —— 那个方法走 {@code RandomUtil.getRandom()} = {@code ThreadLocalRandom}
+ *       (48 位 LCG),而 122 是 UUIDv4 的**格式位**,本身不是不可预测性论证。两者产出的
+ *       字符串**形态一模一样**(这正是当初写错还看不出来的原因),生成器已换成 simpleUUID。</li>
+ * </ol>
+ * <p>★ 一条同层的硬约束:{@code pay_har_checkout_link} <b>必须</b>登记在
+ * {@code TenantInnerInterceptor.CONDITIONAL_TABLES}。漏登记 ⇒ 拦截器给这条查询追加
+ * {@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 = "支付发起失败,请稍后重试";
+
+    /**
+     * 本地闸拦下时给顾客的一句话(2026-10-06 评审 F3)。
+     *
+     * <p>本地闸抛的是**管理端**的话({@code "商户编号不存在或不属于当前租户: M001"}),
+     * 它写给我们自己的操作员是恰当的,那条消息在取链接、收银台几条腿上共用、**不能改**;
+     * 但这条腿的出口是没有登录态的顾客页,前端把 {@code res.msg} 原样上屏 ⇒ 不拦一层的话,
+     * 顾客会读到「租户」「商户编号」这些内部词汇加一串内部编号。
+     *
+     * <p>措辞里带「联系收款方」:对顾客而言这里没有自助可做(重按也没用,商户配置不会自愈),
+     * 唯一能推动的是收款方 —— 与 {@link #MSG_PAY_FAILED}(重按有用)是两种不同的指引。
+     */
+    private static final String MSG_MERCHANT_UNAVAILABLE = "收款方暂时无法收款,请稍后重试或联系收款方";
+
+    private final HarCheckoutLinkService linkService;
+    private final HarMerchantService merchantService;
+    private final HarOrderService orderService;
+
+    // ──────────────────────────── 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>(有意):商户行没了时它是 404,
+     * 不会被折叠成 500 —— 两种处境的操作员改法完全不同。但它的**文案换了一层**:
+     * 闸抛的是管理端措辞,这里就地拦下换成 {@link #MSG_MERCHANT_UNAVAILABLE} 再抛 404
+     * (2026-10-06 评审 F3:前端把 {@code res.msg} 原样显示给顾客)。
+     * 进到「上游失败」那个 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。
+            // 但闸抛的是**管理端**措辞(还有商户编号),这条腿的出口是顾客页 ⇒ 就地换成顾客语,
+            // 状态码照旧 404。
+            try {
+                merchantService.requireByTenantAndCode(link.getTenantId(), link.getMerchantCode());
+            } catch (BusinessException e) {
+                log.warn("收款链接下单被本地闸拦下: linkId={}, merchantCode={}, 原因={}",
+                        link.getId(), link.getMerchantCode(), e.getMessage());
+                throw new BusinessException(404, MSG_MERCHANT_UNAVAILABLE);
+            }
+
+            HarOrderCreateDTO create = new HarOrderCreateDTO();
+            create.setMerchantCode(link.getMerchantCode());
+            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;
+    }
+}

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

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

+ 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;
+}

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

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

+ 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> {
+}

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

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

+ 8 - 2
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCheckoutController.java

@@ -53,8 +53,14 @@ import java.time.format.DateTimeFormatter;
  *       {@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>
+ *   <li><b>token 是 CSPRNG 生成的 32 位随机十六进制</b>({@code IdUtil.simpleUUID}
+ *       = {@code UUID.randomUUID().toString(true)},走 {@code SecureRandom}),
+ *       不是可枚举的序号 ⇒ 拿到别人的链接只能靠拿到那一串字符。
+ *       <br>★ 2026-10-06 订正(与 HAR 收款链接同一处):此前这里写的是
+ *       {@code IdUtil.fastSimpleUUID}「122 bit 熵」,两头都失实 —— 那个方法走
+ *       {@code RandomUtil.getRandom()} = {@code ThreadLocalRandom}(48 位 LCG),
+ *       而 122 是 UUIDv4 的**格式位**,本身不是不可预测性论证。两者产出的字符串
+ *       **形态一模一样**(这正是当初写错还看不出来的原因),生成器已换成 simpleUUID。</li>
  * </ol>
  * <p>★ 一条同层的硬约束:{@code pay_lsy_checkout_link} <b>必须</b>登记在
  * {@code TenantInnerInterceptor.CONDITIONAL_TABLES}。漏登记 ⇒ 拦截器给这条查询追加

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

@@ -41,7 +41,7 @@ public class LsyCheckoutLinkEntity extends PaymentTenantBaseEntity {
     /** 钱进哪个乐收赢商户,由它决定。 */
     private String mchNo;
 
-    /** 链接令牌(32 位十六进制,{@code IdUtil.fastSimpleUUID()})。全局唯一。 */
+    /** 链接令牌(32 位十六进制,{@code IdUtil.simpleUUID()},CSPRNG)。全局唯一。 */
     private String token;
 
     /** 商户显示名快照(发放/重取时刷新)。 */

+ 7 - 1
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyCheckoutLinkService.java

@@ -83,7 +83,13 @@ public class LsyCheckoutLinkService {
             LsyCheckoutLinkEntity row = new LsyCheckoutLinkEntity();
             row.setTenantId(tenantId);
             row.setMchNo(mchNo);
-            row.setToken(IdUtil.fastSimpleUUID());
+            // ★ 必须是 CSPRNG 那一支(2026-10-06 订正,与 HAR 收款链接同一处):
+            //   simpleUUID() = UUID.randomUUID().toString(true) —— 走 SecureRandom,32 位 hex;
+            //   而 fastSimpleUUID() 走 RandomUtil.getRandom() = **ThreadLocalRandom**(48 位 LCG),
+            //   两者**字符串形态完全一样**(这正是当初写错还看不出来的原因),但免登录路径全靠
+            //   这串字符猜不到(类 javadoc「(甲)·预防」第 2 条)。别改回 fastSimpleUUID。
+            //   ★ 已发出的 token 不受影响:库里旧值不动、照用,这里只影响新插入的行。
+            row.setToken(IdUtil.simpleUUID());
             row.setMerchantName(LsyStrings.blankToNull(merchantName));
             // ★ 显式写状态,不靠建表默认值:不写的话实体里 status=null,而调用方紧接着
             //   要拿这一行去拼 URL / 序列化给我方页面,null 会被读成「不可用」。

+ 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 '更新时间';

+ 130 - 0
java/src/test/java/com/payment/platform/core/security/HarCheckoutWiringTest.java

@@ -0,0 +1,130 @@
+package com.payment.platform.core.security;
+
+import com.payment.platform.core.tenant.TenantInnerInterceptor;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.util.AntPathMatcher;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 汇安融收款链接(顾客页)那条腿的**装配**守卫 —— 与 {@code LeshouyingWiringTest} 同形。
+ *
+ * <p>这里钉的每一条都是「模块内的用例覆盖不到、漏改后症状又都长得不像自己」的东西:
+ * <ul>
+ *   <li><b>白名单</b>:{@code SecurityConfig.WHITE_LIST} 少了 {@code /payment/har/checkout/**}
+ *       ⇒ 顾客打开链接被 Spring Security 挡成 401/403,而这条路径**本来就不该有登录态**。
+ *       ★ 别指望乐收赢那条 {@code /payment/checkout/**} 兜住 —— 两者前缀不同,
+ *       Ant 模式匹配不到(本类第一条用例里有这条前提复核,写死)。</li>
+ *   <li><b>条件表</b>:{@code TenantInnerInterceptor.CONDITIONAL_TABLES} 少了
+ *       {@code pay_har_checkout_link} ⇒ 未认证查询被追加 {@code AND tenant_id = 0}
+ *       ⇒ **每条链接都查不到**,表现是顾客页一律「链接无效」,而日志里一个字都没有。</li>
+ *   <li><b>认证面</b>:它是顾客页,不是下游系统 ⇒ {@code TenantApiKeyAuthFilter.shouldNotFilter}
+ *       必须为 true(跳过)。反向守卫:哪天有人把顾客端点挪进 {@code /payment/openapi/**}
+ *       或往 {@code OPENAPI_PREFIXES} 里加前缀,它立刻红 —— 那种挪动会把一条**免登录**
+ *       路径变成「必须持有 ApiKey」。</li>
+ * </ul>
+ */
+class HarCheckoutWiringTest {
+
+    /** 顾客页的两个真实形状路径(开屏 / 下单)。 */
+    private static final String[] CHECKOUT_PATHS = {
+            "/payment/har/checkout/tok",
+            "/payment/har/checkout/tok/orders",
+    };
+
+    /** 被测 filter;依赖字段在 shouldNotFilter 路径上都不被触碰,故可传 null(同 LeshouyingWiringTest)。 */
+    private final TenantApiKeyAuthFilter filter = new TenantApiKeyAuthFilter(null, null, null);
+
+    /**
+     * 顾客页两条路径必须被白名单放行。
+     *
+     * <p>★ 用真 Ant 匹配器撞**模式**,不是 {@code Arrays.asList(...).contains(字面量)}
+     * —— 白名单条目存的是 {@code /**} 形态,contains 对它恒为 false(同 LeshouyingWiringTest
+     * 里那段「近似代理」说明)。顺带一条前提复核:乐收赢那条 {@code /payment/checkout/**}
+     * **匹配不到**本前缀,所以这一条登记是**独立的、不可省的**(删掉它本用例立刻红,
+     * 而不是靠邻居兜住)。
+     */
+    @Test
+    void checkoutPaths_areWhitelisted_matchingAntPattern() throws Exception {
+        AntPathMatcher matcher = new AntPathMatcher();
+        // 前提复核:乐收赢那条白名单条目覆盖不到 /payment/har/checkout/**,本登记不可省
+        assertFalse(matcher.match("/payment/checkout/**", "/payment/har/checkout/tok"),
+                "前提复核:若这条成立于将来某次改写,说明前缀形态变了,本用例的独立断言要重审");
+
+        for (String path : CHECKOUT_PATHS) {
+            assertTrue(whitelisted(path),
+                    "顾客页路径未加入 SecurityConfig.WHITE_LIST(会被挡成 403): " + path);
+        }
+    }
+
+    /**
+     * 反面对照:管理端(要登录)的汇安融路径**不得**被白名单覆盖。
+     *
+     * <p>★ 否定式读数配正对照:先用一条**确实被放行**的同前缀路径证明匹配器与读取姿势是好的
+     * ({@code /payment/openapi/har/**} 命中),再断言管理端两条不被命中 ——
+     * 否则「没命中」可能只是量具坏了(本仓吃过这个亏)。
+     */
+    @Test
+    void adminHarEndpoints_areNotWhitelisted() throws Exception {
+        assertTrue(whitelisted("/payment/openapi/har/barcode/pay"),
+                "正对照:同前缀的开放接口确实被放行 —— 证明匹配器读取姿势是好的");
+
+        assertFalse(whitelisted("/payment/har/merchant"),
+                "管理端商户页必须登录:若有人把白名单写宽成 /payment/har/**,这条变红");
+        assertFalse(whitelisted("/payment/har/merchant/1/checkout-link"),
+                "管理端「取收款链接」必须登录(它是发放腿,租户号从登录主体推)");
+    }
+
+    @Test
+    void conditionalTables_containHarCheckoutLink() throws Exception {
+        Field f = TenantInnerInterceptor.class.getDeclaredField("CONDITIONAL_TABLES");
+        f.setAccessible(true);
+        @SuppressWarnings("unchecked")
+        Set<String> tables = (Set<String>) f.get(null);
+
+        for (String t : new String[]{"pay_har_merchant", "pay_har_shop", "pay_har_order",
+                                     "pay_har_checkout_link"}) {
+            assertTrue(tables.contains(t),
+                    "未加入 CONDITIONAL_TABLES → 无认证场景查询被追加 tenant_id=0 → 顾客页一律「链接无效」且日志无痕: " + t);
+        }
+    }
+
+    @Test
+    void checkoutPaths_doNotGoThroughApiKeyAuth() {
+        for (String path : CHECKOUT_PATHS) {
+            assertTrue(filter.shouldNotFilter(realRequest(path)),
+                    "顾客页不是下游系统,不该经 ApiKey 认证面(应当跳过该 filter): " + path);
+        }
+    }
+
+    // ────────────────────────────── 工具 ──────────────────────────────
+
+    /** 白名单是否覆盖该路径(读生产常量 + 真 Ant 匹配器,同 LeshouyingWiringTest 的写法)。 */
+    private static boolean whitelisted(String path) throws Exception {
+        Field f = SecurityConfig.class.getDeclaredField("WHITE_LIST");
+        f.setAccessible(true);
+        String[] whiteList = (String[]) f.get(null);
+        AntPathMatcher matcher = new AntPathMatcher();
+        return Arrays.stream(whiteList).anyMatch(w -> matcher.match(w, path));
+    }
+
+    /**
+     * 构造**真实容器形状**的请求:servletPath 不含 context-path,requestURI 含。
+     * ★ 若两者相等(旧写法),测试就分辨不出被测代码用的是哪个访问器 ——
+     *   同 LeshouyingWiringTest#realRequest。
+     */
+    private static MockHttpServletRequest realRequest(String path) {
+        MockHttpServletRequest req = new MockHttpServletRequest("POST", path);
+        req.setContextPath("/api/v1");
+        req.setServletPath(path);
+        req.setRequestURI("/api/v1" + path);
+        assertNotEquals(req.getServletPath(), req.getRequestURI(),
+                "mock 必须是真实容器形状:servletPath ≠ requestURI,否则测不出访问器用错");
+        return req;
+    }
+}

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

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

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

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

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

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

+ 28 - 1
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyCheckoutLinkServiceTest.java

@@ -1,5 +1,6 @@
 package com.payment.platform.module.payment.leshouying.service;
 
+import cn.hutool.core.util.IdUtil;
 import com.baomidou.mybatisplus.core.MybatisConfiguration;
 import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
 import com.baomidou.mybatisplus.core.conditions.Wrapper;
@@ -13,6 +14,7 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.springframework.mock.web.MockHttpServletRequest;
@@ -89,11 +91,36 @@ class LsyCheckoutLinkServiceTest {
         assertEquals("测试商户", inserted.getMerchantName());
         assertEquals("ENABLED", inserted.getStatus(), "新链接必须是可用状态(status 从 DB 默认值改成显式写,见服务注释)");
         assertNotNull(inserted.getToken());
-        assertEquals(32, inserted.getToken().length(), "fastSimpleUUID 形态:32 位十六进制");
+        assertEquals(32, inserted.getToken().length(), "simpleUUID 形态:32 位十六进制");
         assertTrue(inserted.getToken().matches("[0-9a-f]{32}"), "实际: " + inserted.getToken());
         assertSame(inserted, created, "返回值必须是落库的那一行(调用方要拿它的 token 拼 URL)");
     }
 
+    /**
+     * token 的随机源必须是 CSPRNG(2026-10-06,与 HAR 收款链接同一处订正)。
+     *
+     * <p>这是**唯一**能钉住这件事的形态:{@code fastSimpleUUID()}(ThreadLocalRandom,48 位 LCG)
+     * 与 {@code simpleUUID()}(SecureRandom)产出的字符串**一模一样**(都是 32 位 hex)——
+     * 上面那个形状断言对「换错生成器」永远是绿的,而免登录路径的安全性全押在
+     * 「这串字符猜不到」上(类 javadoc「(甲)·预防」第 2 条)。所以这里不测形状,
+     * 直接钉住**调的是哪一个方法**(形态同 HarCheckoutLinkServiceTest)。
+     */
+    @Test
+    void issue_tokenComesFromSimpleUuid_notFastSimpleUuid() {
+        when(linkMapper.selectList(any())).thenReturn(List.of());
+
+        try (MockedStatic<IdUtil> idUtil = mockStatic(IdUtil.class)) {
+            idUtil.when(IdUtil::simpleUUID).thenReturn("0123456789abcdef0123456789abcdef");
+
+            LsyCheckoutLinkEntity created = service.issue(TENANT, MCH_NO, "测试商户");
+
+            idUtil.verify(IdUtil::simpleUUID);
+            idUtil.verify(IdUtil::fastSimpleUUID, never());
+            assertEquals("0123456789abcdef0123456789abcdef", created.getToken(),
+                    "生成器的返回值必须原样落到那一行上");
+        }
+    }
+
     @Test
     void issue_whenRowExists_returnsSameToken_andDoesNotInsert() {
         LsyCheckoutLinkEntity existing = row(1L, "abcd1234abcd1234abcd1234abcd1234", "ENABLED");