ソースを参照

feat(统一支付): 公开 H5 支付中间页与签名建单

alphaH 1 週間 前
親
コミット
6c009d1728
20 ファイル変更、1864 行追加、6 行削除
  1. 486 0
      frontend/public/pay/unified-h5.html
  2. 21 0
      frontend/src/views/module_payment/apikey/docs/unified-openapi.md
  3. 5 1
      java/src/main/java/com/payment/platform/core/security/JwtAuthFilter.java
  4. 6 0
      java/src/main/java/com/payment/platform/core/security/SecurityConfig.java
  5. 9 0
      java/src/main/java/com/payment/platform/module/payment/unified/config/UnifiedPayProperties.java
  6. 17 1
      java/src/main/java/com/payment/platform/module/payment/unified/controller/UnifiedCashierController.java
  7. 72 0
      java/src/main/java/com/payment/platform/module/payment/unified/controller/UnifiedH5Controller.java
  8. 9 0
      java/src/main/java/com/payment/platform/module/payment/unified/dto/vo/UnifiedH5LinkVO.java
  9. 58 0
      java/src/main/java/com/payment/platform/module/payment/unified/dto/vo/UnifiedH5OrderVO.java
  10. 2 0
      java/src/main/java/com/payment/platform/module/payment/unified/enums/UnifiedSource.java
  11. 333 0
      java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedH5Service.java
  12. 1 1
      java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedOrderService.java
  13. 11 0
      java/src/main/java/com/payment/platform/module/payment/unified/util/UnifiedChannelExtraAssembler.java
  14. 62 0
      java/src/main/java/com/payment/platform/module/payment/unified/util/UnifiedH5Token.java
  15. 3 1
      java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedAdminGuardTest.java
  16. 16 2
      java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedCashierControllerTest.java
  17. 157 0
      java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedH5ControllerTest.java
  18. 100 0
      java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedH5PageContractTest.java
  19. 407 0
      java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedH5ServiceTest.java
  20. 89 0
      java/src/test/java/com/payment/platform/module/payment/unified/util/UnifiedH5TokenTest.java

+ 486 - 0
frontend/public/pay/unified-h5.html

@@ -0,0 +1,486 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<meta name="referrer" content="no-referrer">
+<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src http: https:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'">
+<title>支付</title>
+<style>
+  :root {
+    --bg: #f4f5f7;
+    --card: #ffffff;
+    --text: #1f2329;
+    --muted: #6b7280;
+    --line: #e5e7eb;
+    --brand: #1677ff;
+    --brand-press: #0f5fd6;
+    --danger: #d93026;
+    --radius: 14px;
+  }
+  * { box-sizing: border-box; }
+  html, body { margin: 0; padding: 0; }
+  body {
+    min-height: 100vh;
+    background: var(--bg);
+    color: var(--text);
+    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
+      "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
+    font-size: 15px;
+    line-height: 1.5;
+    padding: env(safe-area-inset-top) 16px calc(24px + env(safe-area-inset-bottom));
+  }
+  main {
+    max-width: 420px;
+    margin: 32px auto 0;
+  }
+  h1 {
+    font-size: 18px;
+    font-weight: 600;
+    margin: 0 0 16px;
+    text-align: center;
+  }
+  .card {
+    background: var(--card);
+    border: 1px solid var(--line);
+    border-radius: var(--radius);
+    padding: 20px;
+    box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
+  }
+  .summary { margin: 0 0 18px; }
+  .summary > div {
+    display: flex;
+    justify-content: space-between;
+    gap: 16px;
+    padding: 9px 0;
+    border-bottom: 1px solid var(--line);
+  }
+  .summary > div:last-child { border-bottom: 0; }
+  dt { color: var(--muted); flex: none; }
+  dd {
+    margin: 0;
+    text-align: right;
+    word-break: break-all;
+    font-variant-numeric: tabular-nums;
+  }
+  button {
+    width: 100%;
+    border: 0;
+    border-radius: 10px;
+    background: var(--brand);
+    color: #fff;
+    font-size: 16px;
+    font-weight: 600;
+    padding: 12px 16px;
+    cursor: pointer;
+  }
+  button:active { background: var(--brand-press); }
+  button:disabled { opacity: 0.6; cursor: default; }
+  #copy-code {
+    width: auto;
+    margin-top: 10px;
+    padding: 7px 14px;
+    font-size: 13px;
+    background: transparent;
+    color: var(--brand);
+    border: 1px solid var(--brand);
+    border-radius: 8px;
+  }
+  #copy-code:active { background: #eef4ff; }
+  .hint { margin: 12px 0 0; color: var(--muted); font-size: 13px; }
+  .error { margin: 0; color: var(--danger); }
+  #pay-image {
+    display: block;
+    width: min(240px, 100%);
+    height: auto;
+    margin: 4px auto 10px;
+    border-radius: 8px;
+  }
+  #pay-image-hint { margin: 0 0 12px; text-align: center; color: var(--muted); }
+  #pay-link {
+    display: block;
+    margin: 4px 0 12px;
+    padding: 12px 16px;
+    text-align: center;
+    text-decoration: none;
+    color: #fff;
+    background: var(--brand);
+    border-radius: 10px;
+    font-weight: 600;
+  }
+  #pay-link:active { background: var(--brand-press); }
+  #code-block { margin: 4px 0 12px; }
+  #pay-code {
+    display: block;
+    padding: 10px 12px;
+    background: #f8fafc;
+    border: 1px solid var(--line);
+    border-radius: 8px;
+    font-size: 12px;
+    line-height: 1.45;
+    word-break: break-all;
+    white-space: pre-wrap;
+  }
+</style>
+</head>
+<body>
+<main>
+  <h1>收银台</h1>
+
+  <section id="mode-loading" class="card">
+    <p class="hint">加载中…</p>
+  </section>
+
+  <section id="mode-error" class="card" hidden>
+    <p class="error">链接不完整或已失效,请返回商户页面重新发起支付。</p>
+  </section>
+
+  <section id="mode-create" class="card" hidden>
+    <dl class="summary">
+      <div><dt>商户号</dt><dd id="create-mch-no"></dd></div>
+      <div><dt>订单号</dt><dd id="create-out-trade-no"></dd></div>
+      <div><dt>金额</dt><dd id="create-amount"></dd></div>
+      <div><dt>标题</dt><dd id="create-subject"></dd></div>
+      <div><dt>支付方式</dt><dd id="create-way-code"></dd></div>
+    </dl>
+    <button id="confirm-pay" type="button">确认支付</button>
+    <p class="hint" id="create-error" hidden></p>
+  </section>
+
+  <section id="mode-result" class="card" hidden>
+    <dl class="summary">
+      <div><dt>订单号</dt><dd id="result-order-no"></dd></div>
+      <div><dt>金额</dt><dd id="result-amount"></dd></div>
+      <div><dt>标题</dt><dd id="result-subject"></dd></div>
+      <div><dt>状态</dt><dd id="result-status"></dd></div>
+    </dl>
+    <div id="pay-panel">
+      <a id="pay-link" target="_blank" rel="noopener noreferrer" hidden>去支付</a>
+      <img id="pay-image" alt="支付二维码" hidden>
+      <p id="pay-image-hint" hidden>请使用手机扫码支付</p>
+      <div id="code-block" hidden>
+        <code id="pay-code"></code>
+        <button id="copy-code" type="button">复制</button>
+      </div>
+      <p id="pay-other" hidden>请返回商户页面完成支付</p>
+    </div>
+    <p class="hint" id="result-error" hidden></p>
+  </section>
+</main>
+
+<script>
+(function () {
+  "use strict";
+
+  var API_BASE = location.origin + "/api/v1";
+  var H5_WAY_CODES = ["QR_CASHIER", "ALI_QR", "WX_NATIVE", "WX_H5", "ALI_WAP", "ALI_PC", "ALI_APP", "WX_APP"];
+  var CREATE_KEYS = ["mch_no", "out_trade_no", "amount", "subject", "way_code", "sign"];
+  var BODY_KEYS = CREATE_KEYS.concat(["body", "return_url", "notify_url", "ext_param"]);
+  var STOPPED_STATUS = ["2", "3", "4", "5", "6"];
+  var STATUS_TEXT = {
+    "0": "订单生成,等待支付",
+    "1": "支付中",
+    "2": "支付成功",
+    "3": "支付失败",
+    "4": "已撤销",
+    "5": "已退款",
+    "6": "订单关闭"
+  };
+  var POLL_INTERVAL_MS = 3000;
+  var POLL_MAX_MS = 30 * 60 * 1000;
+  var STORAGE_KEY = "unified_h5_order";
+  var MODES = ["mode-loading", "mode-error", "mode-create", "mode-result"];
+
+  var params = new URLSearchParams(location.search);
+  var currentOrderNo = null;
+  var currentToken = null;
+  var pollTimer = null;
+  var pollStartedAt = 0;
+
+  function show(mode) {
+    MODES.forEach(function (name) {
+      document.getElementById(name).hidden = name !== mode;
+    });
+  }
+
+  function setText(id, value) {
+    var node = document.getElementById(id);
+    if (node) {
+      node.textContent = value == null ? "" : String(value);
+    }
+  }
+
+  function formatAmount(raw) {
+    var fen = Number(raw);
+    if (raw === null || raw === "" || !isFinite(fen)) {
+      return raw == null ? "" : String(raw);
+    }
+    return (fen / 100).toFixed(2) + " 元";
+  }
+
+  function isSafeHttpUrl(raw) {
+    if (!raw) {
+      return false;
+    }
+    try {
+      var parsed = new URL(raw);
+      return parsed.protocol === "http:" || parsed.protocol === "https:";
+    } catch (e) {
+      return false;
+    }
+  }
+
+  function readStored() {
+    try {
+      var raw = sessionStorage.getItem(STORAGE_KEY);
+      if (!raw) {
+        return null;
+      }
+      var parsed = JSON.parse(raw);
+      if (!parsed || !parsed.orderNo || !parsed.token) {
+        return null;
+      }
+      return parsed;
+    } catch (e) {
+      return null;
+    }
+  }
+
+  function writeStored(value) {
+    try {
+      sessionStorage.setItem(STORAGE_KEY, JSON.stringify(value));
+    } catch (e) {
+      /* 私密模式:读写抛异常时页面照常工作,只是刷新后不能恢复上一单 */
+    }
+  }
+
+  function hasCreateParams() {
+    return CREATE_KEYS.every(function (key) {
+      var value = params.get(key);
+      return value !== null && value.trim() !== "";
+    });
+  }
+
+  // ────────────────────────── 建单模式 ──────────────────────────
+
+  function startCreate() {
+    setText("create-mch-no", params.get("mch_no"));
+    setText("create-out-trade-no", params.get("out_trade_no"));
+    setText("create-amount", formatAmount(params.get("amount")));
+    setText("create-subject", params.get("subject"));
+    setText("create-way-code", params.get("way_code"));
+    show("mode-create");
+    document.getElementById("confirm-pay").addEventListener("click", submitCreate);
+  }
+
+  function submitCreate() {
+    var button = document.getElementById("confirm-pay");
+    if (button.disabled) {
+      return;
+    }
+    button.disabled = true;
+    var errorBox = document.getElementById("create-error");
+    errorBox.hidden = true;
+
+    var body = {};
+    BODY_KEYS.forEach(function (key) {
+      var value = params.get(key);
+      if (value !== null) {
+        body[key] = value;
+      }
+    });
+
+    fetch(API_BASE + "/payment/h5/unified/order", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify(body),
+      credentials:"omit"
+    }).then(function (response) {
+      return response.json().catch(function () {
+        return {};
+      }).then(function (json) {
+        return { ok: response.ok, json: json };
+      });
+    }).then(function (out) {
+      var json = out.json || {};
+      var data = json.data || {};
+      if (!out.ok || json.code !== 0 || !data.order_no || !data.token) {
+        throw new Error(json.msg || "支付创建失败,请稍后重试");
+      }
+      writeStored({ orderNo: data.order_no, token: data.token });
+      startResult(data.order_no, data.token, data);
+    }).catch(function (error) {
+      button.disabled = false;
+      errorBox.textContent = (error && error.message) || "支付创建失败,请稍后重试";
+      errorBox.hidden = false;
+    });
+  }
+
+  // ────────────────────────── 结果模式 ──────────────────────────
+
+  function startResult(orderNo, token, initialData) {
+    currentOrderNo = orderNo;
+    currentToken = token;
+    pollStartedAt = Date.now();
+    setText("result-order-no", orderNo);
+    show("mode-result");
+    hidePayNodes();
+    if (initialData) {
+      renderOrder(initialData);
+    } else {
+      setText("result-amount", "");
+      setText("result-subject", "");
+      setText("result-status", "查询中…");
+    }
+    stopPolling();
+    if (initialData && STOPPED_STATUS.indexOf(String(initialData.trade_status)) >= 0) {
+      return;
+    }
+    pollTimer = setInterval(poll, POLL_INTERVAL_MS);
+    if (!initialData) {
+      poll();
+    }
+  }
+
+  function stopPolling() {
+    if (pollTimer) {
+      clearInterval(pollTimer);
+      pollTimer = null;
+    }
+  }
+
+  function poll() {
+    if (!currentOrderNo || !currentToken) {
+      return;
+    }
+    if (Date.now() - pollStartedAt > POLL_MAX_MS) {
+      stopPolling();
+      return;
+    }
+    var url = API_BASE + "/payment/h5/unified/order/"
+      + encodeURIComponent(currentOrderNo) + "?token=" + encodeURIComponent(currentToken);
+    fetch(url, {
+      method: "GET",
+      credentials:"omit"
+    }).then(function (response) {
+      return response.json().catch(function () {
+        return {};
+      });
+    }).then(function (json) {
+      if (!json || json.code !== 0 || !json.data) {
+        throw new Error((json && json.msg) || "查询订单失败");
+      }
+      renderOrder(json.data);
+      if (STOPPED_STATUS.indexOf(String(json.data.trade_status)) >= 0) {
+        stopPolling();
+      }
+    }).catch(function (error) {
+      var errorBox = document.getElementById("result-error");
+      errorBox.textContent = (error && error.message) || "网络异常,稍后自动重试";
+      errorBox.hidden = false;
+    });
+  }
+
+  function renderOrder(data) {
+    if (!data) {
+      return;
+    }
+    var errorBox = document.getElementById("result-error");
+    errorBox.hidden = true;
+    setText("result-order-no", data.order_no);
+    setText("result-amount", formatAmount(data.amount));
+    setText("result-subject", data.subject);
+    setText("result-status", STATUS_TEXT[String(data.trade_status)] || "");
+    renderPayData(data.pay_data_type, data.pay_data);
+  }
+
+  function hidePayNodes() {
+    document.getElementById("pay-link").hidden = true;
+    document.getElementById("pay-image").hidden = true;
+    document.getElementById("pay-image-hint").hidden = true;
+    document.getElementById("code-block").hidden = true;
+    document.getElementById("pay-other").hidden = true;
+  }
+
+  function renderPayData(type, raw) {
+    var kind = type ? String(type) : "";
+    var value = raw ? String(raw) : "";
+    hidePayNodes();
+
+    if ((kind === "payUrl" || kind === "payurl") && isSafeHttpUrl(value)) {
+      var link = document.getElementById("pay-link");
+      link.href = value;
+      link.hidden = false;
+      return;
+    }
+    if (kind === "codeImgUrl" && isSafeHttpUrl(value)) {
+      var image = document.getElementById("pay-image");
+      image.src = value;
+      image.hidden = false;
+      document.getElementById("pay-image-hint").hidden = false;
+      return;
+    }
+    if (kind === "codeUrl") {
+      setText("pay-code", value);
+      document.getElementById("code-block").hidden = false;
+      return;
+    }
+    document.getElementById("pay-other").hidden = false;
+  }
+
+  function copyText(text) {
+    var button = document.getElementById("copy-code");
+    var done = function () {
+      button.textContent = "已复制";
+    };
+    if (navigator.clipboard && navigator.clipboard.writeText) {
+      navigator.clipboard.writeText(text).then(done).catch(function () {
+        fallbackCopy(text, done);
+      });
+      return;
+    }
+    fallbackCopy(text, done);
+  }
+
+  function fallbackCopy(text, done) {
+    var area = document.createElement("textarea");
+    area.value = text;
+    area.setAttribute("readonly", "");
+    area.style.position = "fixed";
+    area.style.opacity = "0";
+    document.body.appendChild(area);
+    area.select();
+    try {
+      document.execCommand("copy");
+      done();
+    } catch (e) {
+      /* 复制失败时保留原文,用户可手动选中 */
+    }
+    document.body.removeChild(area);
+  }
+
+  document.getElementById("copy-code").addEventListener("click", function () {
+    copyText(document.getElementById("pay-code").textContent || "");
+  });
+
+  // ────────────────────────── 模式选择(优先级固定) ──────────────────────────
+
+  var urlOrderNo = params.get("order_no");
+  var urlToken = params.get("token");
+
+  if (urlOrderNo && urlToken) {
+    startResult(urlOrderNo, urlToken, null);
+  } else if (hasCreateParams()) {
+    startCreate();
+  } else {
+    var stored = readStored();
+    if (stored && stored.orderNo && stored.token) {
+      startResult(stored.orderNo, stored.token, null);
+    } else {
+      show("mode-error");
+    }
+  }
+})();
+</script>
+</body>
+</html>

+ 21 - 0
frontend/src/views/module_payment/apikey/docs/unified-openapi.md

@@ -88,6 +88,27 @@ POST /payment/openapi/unified/order/create
 >
 > **`pay_data` 的渲染安全**:`pay_data` 是上游原样返回的内容(二维码 URL / 表单 HTML / 跳转链接)。渲染前**必须校验协议,只允许 `http/https`**;表单 HTML 只能放进受信页面,不要直接插进你的站点 DOM(防 XSS)。
 
+### 2.1 H5 托管支付页
+
+公开页面固定地址:`https://qcsj88888.com/pay/unified-h5.html`。适合没有自有收银台、或需要把支付链接直接发给顾客的场景。
+
+**链接参数**
+
+| 键 | 必填 | 说明 |
+|---|---|---|
+| `mch_no` | 是 | 上游商户号,全局唯一 |
+| `out_trade_no` | 是 | 调用方订单号,≤64;幂等键 |
+| `amount` | 是 | 分,正则 `\d{1,15}` 且 > 0 |
+| `subject` | 是 | 标题,≤128 |
+| `way_code` | 是 | 仅支持 `QR_CASHIER`、`ALI_QR`、`WX_NATIVE`、`WX_H5`、`ALI_WAP`、`ALI_PC`、`ALI_APP`、`WX_APP` |
+| `sign` | 是 | 除 `sign` 外全部非空参数的 MD5 签名(32 位大写) |
+
+可选 `body`、`return_url`、`notify_url`、`ext_param`(各 ≤512)也可直接放进链接;页面会把链接上实际存在的字段原样带进建单请求。**链接不接受** `channel_extra`,出现未知参数一律拒绝。
+
+**页面流程**:打开链接只展示订单摘要,**点「确认支付」才建单**(防止链接预览/爬虫提前建单);建单成功后页面每 3 秒查询一次本单结果,`trade_status` 为 `2/3/4/5/6`(成功、失败、撤销、退款、关闭)时停止轮询,最长 30 分钟。
+
+**后台生成链接**:`GET /payment/unified/cashier/order/{orderNo}/h5-link`,需要 `module_payment:unified:cashier:list` 权限。返回 `{"url": "..."}`,只带 6 个必填参数(`notify_url`/`ext_param` 已在订单行上,不重复暴露)。
+
 ---
 
 ## 3. 订单查询

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

@@ -54,7 +54,11 @@ public class JwtAuthFilter extends OncePerRequestFilter {
         "/system/auth/sms-code", "/system/auth/captcha/get", "/system/auth/logout",
         "/system/auth/token/refresh", "/system/auth/auto-login",
         "/system/user/register", "/system/user/forget/password",
-        "/payment/notify/health", "/payment/notify/alipay"
+        "/payment/notify/health", "/payment/notify/alipay",
+        // 统一支付公开 H5 页(Task 24):轮询用 ?token= 传 HMAC 令牌,不是 JWT;
+        // 不在本集合里会被 extractToken 当坏 JWT 判 401(SecurityConfig 的白名单只管授权层,
+        // 拦不住这里的解析)。isWhitelisted 用 uri.contains,前缀即可覆盖建单与轮询两条路径。
+        "/payment/h5/unified"
     );
 
     @Override

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

@@ -94,6 +94,12 @@ public class SecurityConfig {
             // 身份同样不是认证来的:token 全局唯一(uk_har_checkout_token),
             // 且是 32 位随机十六进制(见 HarCheckoutLinkService)。
             "/payment/har/checkout/**",
+            // 统一支付公开 H5 支付页接口(Task 24):免登录页面接口,身份来自签名/token,
+            // 不走 ApiKey filter。建单的身份是请求参数里的 MD5 签名(appSecret 只在服务端),
+            // 查询的身份是每单一签的轮询令牌(HMAC-SHA256,见 UnifiedH5Token)。
+            // ★ **不要**登记进 TenantApiKeyAuthFilter.OPENAPI_PREFIXES:那是 API Key 认证面,
+            //   与这里的签名建单不是同一套;两边混用会把这几个端点变成另一种认证形态。
+            "/payment/h5/unified/**",
             "/v3/api-docs/**",
             "/swagger-ui/**",
             "/swagger-ui.html",

+ 9 - 0
java/src/main/java/com/payment/platform/module/payment/unified/config/UnifiedPayProperties.java

@@ -27,6 +27,15 @@ public class UnifiedPayProperties {
     /** 我们给上游的回调基址(含 context-path),拼路径见 {@link #notifyOrderUrl()}。 */
     private String notifyBaseUrl = "https://qcsj88888.com/api/v1";
 
+    /**
+     * 公开 H5 托管支付页地址(Task 24)。
+     *
+     * <p>上游需要把这个**固定 URL** 配置成支付中间页;后台「发 H5 链接」在它后面拼参数。
+     * Vite 把 {@code frontend/public/pay/unified-h5.html} 原样复制到 {@code dist/pay/},
+     * 部署侧必须保证 {@code /pay/} 是静态路径、不落到 SPA 的 {@code index.html}。
+     */
+    private String h5PageUrl = "https://qcsj88888.com/pay/unified-h5.html";
+
     /** 连接超时(毫秒)。 */
     private int connectTimeoutMs = 3000;
 

+ 17 - 1
java/src/main/java/com/payment/platform/module/payment/unified/controller/UnifiedCashierController.java

@@ -5,9 +5,11 @@ 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.unified.dto.req.UnifiedOrderCreateCommand;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedH5LinkVO;
 import com.payment.platform.module.payment.unified.dto.vo.UnifiedOrderVO;
 import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
 import com.payment.platform.module.payment.unified.service.UnifiedCashierService;
+import com.payment.platform.module.payment.unified.service.UnifiedH5Service;
 import com.payment.platform.module.payment.unified.service.UnifiedOrderService;
 import com.payment.platform.module.payment.unified.util.UnifiedTenantGuard;
 import jakarta.servlet.http.HttpServletRequest;
@@ -24,11 +26,12 @@ import org.springframework.web.bind.annotation.RestController;
 /**
  * 后台统一支付收银台(Task 16)。
  *
- * <p>两个端点都在第一行取租户守卫;下单的三个请求侧默认值:
+ * <p>三个端点都在第一行取租户守卫;下单的三个请求侧默认值:
  * {@code outTradeNo} 必填(400)、{@code expiredTime} 缺省 300 秒、
  * {@code clientIp} 取传输层来源(不让页面自报)。
  *
  * <p>视图一律用开放口径 {@link UnifiedOrderVO#open}:收银台页面不需要、也不应拿到内部主键。
+ * {@code h5-link}(Task 24)只为已有订单**生成**公开页面链接,不暴露密钥。
  */
 @RestController
 @RequestMapping("/payment/unified/cashier")
@@ -40,6 +43,7 @@ public class UnifiedCashierController {
 
     private final UnifiedCashierService cashierService;
     private final UnifiedOrderService orderService;
+    private final UnifiedH5Service h5Service;
     private final UnifiedTenantGuard guard;
     private final HttpServletRequest request;
 
@@ -67,4 +71,16 @@ public class UnifiedCashierController {
         UnifiedOrderEntity order = cashierService.snapshot(orderNo, user.getTenantId());
         return Result.ok(UnifiedOrderVO.open(order, orderService.refundableAmount(order)));
     }
+
+    /**
+     * 为已有订单生成公开 H5 支付页链接(Task 24)。
+     *
+     * <p>只带 6 个必填参数;租户外的单与不存在同一应答(404)。
+     */
+    @GetMapping("/order/{orderNo}/h5-link")
+    @PreAuthorize("@perm.hasAny('module_payment:unified:cashier:list')")
+    public Result<UnifiedH5LinkVO> h5Link(@PathVariable(name = "orderNo") String orderNo) {
+        LoginUser user = guard.require();
+        return Result.ok(new UnifiedH5LinkVO(h5Service.linkForOrder(orderNo, user.getTenantId())));
+    }
 }

+ 72 - 0
java/src/main/java/com/payment/platform/module/payment/unified/controller/UnifiedH5Controller.java

@@ -0,0 +1,72 @@
+package com.payment.platform.module.payment.unified.controller;
+
+import cn.hutool.core.util.StrUtil;
+import com.payment.platform.common.response.Result;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedH5OrderVO;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import com.payment.platform.module.payment.unified.service.UnifiedH5Service;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+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.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * 公开 H5 托管支付页的两个端点(Task 24)。
+ *
+ * <p>★ 没有 {@code @PreAuthorize},也没有登录态:建单的身份来自请求参数里的 MD5 签名,
+ * 查询的身份来自每单一签的轮询令牌。放行只靠 {@code SecurityConfig.WHITE_LIST},
+ * 且**不得**登记进 {@code TenantApiKeyAuthFilter.OPENAPI_PREFIXES} —— 那是 API Key
+ * 认证面,与这里不是同一套。
+ *
+ * <p>视图一律 {@link UnifiedH5OrderVO#open}:不返回内部主键、租户归属与上游单号。
+ */
+@RestController
+@RequestMapping("/payment/h5/unified")
+@RequiredArgsConstructor
+public class UnifiedH5Controller {
+
+    private final UnifiedH5Service h5Service;
+    private final HttpServletRequest request;
+
+    /**
+     * 顾客点击「确认支付」后建单。请求体是扁平 JSON 对象,值全按字符串处理
+     * (金额也按字符串接收,服务层再做 {@code \d{1,15}} 校验)。
+     */
+    @PostMapping("/order")
+    public Result<UnifiedH5OrderVO> create(@RequestBody Map<String, String> params) {
+        UnifiedH5Service.Created created = h5Service.create(params, clientIp(request));
+        return Result.ok(UnifiedH5OrderVO.open(created.order(), created.token()));
+    }
+
+    /** 结果页轮询。令牌不合法与订单不存在同一应答(404)。 */
+    @GetMapping("/order/{orderNo}")
+    public Result<UnifiedH5OrderVO> snapshot(
+            @PathVariable(name = "orderNo") String orderNo,
+            @RequestParam(name = "token", required = false) String token) {
+        UnifiedOrderEntity order = h5Service.snapshot(orderNo, token);
+        // 轮询响应不回显令牌:调用方已经持有它,少一处暴露面
+        return Result.ok(UnifiedH5OrderVO.open(order, null));
+    }
+
+    /**
+     * 顾客的真实 IP。与 {@code HarCheckoutController#clientIp} /
+     * {@code TenantApiKeyAuthFilter} 同形:四个请求头(取第一跳)+ {@code unknown} 兜底。
+     */
+    private static String clientIp(HttpServletRequest request) {
+        String[] headers = {"X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP"};
+        for (String header : headers) {
+            String ip = request.getHeader(header);
+            if (StrUtil.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) {
+                return ip.split(",")[0].trim();
+            }
+        }
+        return request.getRemoteAddr();
+    }
+}

+ 9 - 0
java/src/main/java/com/payment/platform/module/payment/unified/dto/vo/UnifiedH5LinkVO.java

@@ -0,0 +1,9 @@
+package com.payment.platform.module.payment.unified.dto.vo;
+
+/**
+ * 后台为已有订单生成的 H5 支付链接(Task 24)。
+ *
+ * @param url 带 6 个必填参数与签名的页面地址
+ */
+public record UnifiedH5LinkVO(String url) {
+}

+ 58 - 0
java/src/main/java/com/payment/platform/module/payment/unified/dto/vo/UnifiedH5OrderVO.java

@@ -0,0 +1,58 @@
+package com.payment.platform.module.payment.unified.dto.vo;
+
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import lombok.Data;
+
+import java.time.OffsetDateTime;
+
+/**
+ * 公开 H5 支付页的订单视图(Task 24)。
+ *
+ * <p>字段集合是**契约**:13 个,恰为顾客页需要的最小面。不包含内部主键、租户归属、
+ * 上游单号、回调配置与任何密钥 —— 页面是公开的,多一个字段就是多一处泄露面。
+ *
+ * <p>全局 Jackson 配置是 {@code SNAKE_CASE}({@code JacksonConfig}),故 JSON 里看到的是
+ * {@code order_no}/{@code trade_status}/{@code pay_data} 等;页面按 snake_case 读取。
+ */
+@Data
+public class UnifiedH5OrderVO {
+
+    private String orderNo;
+
+    /** 轮询令牌;只在建单响应里给,查询响应为空。 */
+    private String token;
+
+    private String outTradeNo;
+    private String mchNo;
+    private String wayCode;
+    private Long amount;
+    private String subject;
+    private String tradeStatus;
+    private String payDataType;
+    private String payData;
+    private String errCode;
+    private String errMsg;
+    private OffsetDateTime createdTime;
+
+    /** 公开视图;{@code token} 为 null 时表示本次响应不带令牌(轮询响应)。 */
+    public static UnifiedH5OrderVO open(UnifiedOrderEntity e, String token) {
+        if (e == null) {
+            return null;
+        }
+        UnifiedH5OrderVO vo = new UnifiedH5OrderVO();
+        vo.setOrderNo(e.getOrderNo());
+        vo.setToken(token);
+        vo.setOutTradeNo(e.getOutTradeNo());
+        vo.setMchNo(e.getMchNo());
+        vo.setWayCode(e.getWayCode());
+        vo.setAmount(e.getAmount());
+        vo.setSubject(e.getSubject());
+        vo.setTradeStatus(e.getTradeStatus());
+        vo.setPayDataType(e.getPayDataType());
+        vo.setPayData(e.getPayData());
+        vo.setErrCode(e.getErrCode());
+        vo.setErrMsg(e.getErrMsg());
+        vo.setCreatedTime(e.getCreatedTime());
+        return vo;
+    }
+}

+ 2 - 0
java/src/main/java/com/payment/platform/module/payment/unified/enums/UnifiedSource.java

@@ -6,11 +6,13 @@ package com.payment.platform.module.payment.unified.enums;
  * <ul>
  *   <li>{@link #OPENAPI} —— 租户经开放 API(API Key)发起;</li>
  *   <li>{@link #CASHIER} —— 收银台页面发起;</li>
+ *   <li>{@link #H5} —— 公开 H5 托管支付页发起(凭签名链接建单,无登录态);</li>
  *   <li>{@link #ADMIN} —— 后台管理端发起(下单路径**拒绝**此来源,见订单服务)。</li>
  * </ul>
  */
 public enum UnifiedSource {
     OPENAPI,
     CASHIER,
+    H5,
     ADMIN
 }

+ 333 - 0
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedH5Service.java

@@ -0,0 +1,333 @@
+package com.payment.platform.module.payment.unified.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.payment.unified.client.UnifiedSigner;
+import com.payment.platform.module.payment.unified.config.UnifiedPayProperties;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedOrderCreateCommand;
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import com.payment.platform.module.payment.unified.enums.UnifiedOrderStatus;
+import com.payment.platform.module.payment.unified.enums.UnifiedSource;
+import com.payment.platform.module.payment.unified.mapper.UnifiedOrderMapper;
+import com.payment.platform.module.payment.unified.util.UnifiedChannelExtraAssembler;
+import com.payment.platform.module.payment.unified.util.UnifiedH5Token;
+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.stereotype.Service;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.OffsetDateTime;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * 公开 H5 托管支付页(Task 24):签名建单、轮询快照、后台发链接。
+ *
+ * <p>这条腿**没有登录态**,三段身份来源各不相同:
+ * <ul>
+ *   <li><b>建单</b>:链接里的 MD5 签名({@link UnifiedSigner#verifyNotification})证明
+ *       调用方持有该商户的 {@code appSecret};商户不存在/验签失败/停用对外同一句话,
+ *       不构成商户号探针。</li>
+ *   <li><b>轮询</b>:每单一签的令牌({@link UnifiedH5Token}),单不存在/令牌错/配置没了
+ *       对外统一 404「订单不存在」。</li>
+ *   <li><b>发链接</b>:后台登录身份 + 租户({@code findOrderNo(tenantId, ...)}),
+ *       跨租户与不存在同一应答。</li>
+ * </ul>
+ *
+ * <p>公开建单端点收的是 {@code Map},Bean Validation 一步都不参与 —— 长度与金额上限
+ * 必须在本类里自己校验,口径与 {@link UnifiedOrderCreateCommand} 的 {@code @Size} 逐字一致。
+ *
+ * <p>★ 日志只记 {@code mch_no}/单号与原因,不记 {@code subject}/{@code body}/
+ * {@code return_url}/{@code notify_url}/{@code ext_param}/令牌。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class UnifiedH5Service {
+
+    /** H5 单固定过期时间(秒)。 */
+    private static final int EXPIRED_SECONDS = 1800;
+
+    /** 同一次页面轮询的节流窗(秒),与收银台快照同口径。 */
+    private static final long SNAPSHOT_THROTTLE_SECONDS = 3;
+
+    private static final String MSG_INCOMPLETE = "支付链接参数不完整";
+    private static final String MSG_INVALID_LINK = "支付链接无效或已过期";
+    private static final String MSG_ORDER_NOT_FOUND = "订单不存在";
+    private static final String MSG_CREATE_FAILED = "支付创建失败,请稍后重试";
+
+    /** H5 页接受的键。多一个键(尤其 {@code channel_extra})一律 400。 */
+    private static final Set<String> ALLOWED_KEYS = Set.of(
+            "mch_no", "out_trade_no", "amount", "subject", "way_code",
+            "body", "return_url", "notify_url", "ext_param", "sign");
+
+    private static final Pattern AMOUNT = Pattern.compile("\\d{1,15}");
+
+    private final UnifiedOrderMapper orderMapper;
+    private final UnifiedOrderService orderService;
+    private final UnifiedMerchantService merchantService;
+    private final UnifiedPayProperties properties;
+
+    /**
+     * 公开建单:参数校验 → 商户 + 验签 + 启停 → 订单主流程({@link UnifiedSource#H5})。
+     *
+     * @param params   页面传来的单值表(值全按字符串处理)
+     * @param clientIp 传输层来源(控制器取四个请求头后传入)
+     * @throws BusinessException 400 参数不完整 / 链接无效;500 建单失败
+     */
+    public Created create(Map<String, String> params, String clientIp) {
+        Map<String, String> p = params == null ? Map.of() : params;
+
+        for (String key : p.keySet()) {
+            if (!ALLOWED_KEYS.contains(key)) {
+                throw incomplete();
+            }
+        }
+
+        String mchNo = p.get("mch_no");
+        if (StrUtil.isBlank(mchNo)) {
+            throw incomplete();
+        }
+        String outTradeNo = required(p.get("out_trade_no"), 64);
+        long amount = parseAmount(p.get("amount"));
+        String subject = required(p.get("subject"), 128);
+        String wayCode = p.get("way_code");
+        if (StrUtil.isBlank(wayCode) || !UnifiedChannelExtraAssembler.H5_SUPPORTED.contains(wayCode)) {
+            throw incomplete();
+        }
+        String body = optional(p.get("body"), 512);
+        String returnUrl = optional(p.get("return_url"), 512);
+        String notifyUrl = optional(p.get("notify_url"), 512);
+        String extParam = optional(p.get("ext_param"), 512);
+
+        UnifiedMerchantEntity cfg;
+        try {
+            cfg = merchantService.requireByMchNo(mchNo);
+        } catch (BusinessException e) {
+            log.warn("[统一支付H5] 建单被拒: mch_no={}, 原因=商户不存在或编号为空", mchNo);
+            throw new BusinessException(400, MSG_INVALID_LINK);
+        }
+        if (!UnifiedSigner.verifyNotification(p, cfg.getAppSecret())) {
+            log.warn("[统一支付H5] 建单被拒: mch_no={}, 原因=验签失败", mchNo);
+            throw new BusinessException(400, MSG_INVALID_LINK);
+        }
+        try {
+            merchantService.requireEnabled(cfg);
+        } catch (BusinessException e) {
+            log.warn("[统一支付H5] 建单被拒: mch_no={}, 原因=商户停用", mchNo);
+            throw new BusinessException(400, MSG_INVALID_LINK);
+        }
+
+        UnifiedOrderCreateCommand cmd = new UnifiedOrderCreateCommand();
+        cmd.setMchNo(mchNo);
+        cmd.setOutTradeNo(outTradeNo);
+        cmd.setAmount(amount);
+        cmd.setWayCode(wayCode);
+        cmd.setSubject(subject);
+        cmd.setBody(body);
+        cmd.setReturnUrl(returnUrl);
+        cmd.setNotifyUrl(notifyUrl);
+        cmd.setExtParam(extParam);
+        cmd.setExpiredTime(EXPIRED_SECONDS);
+        cmd.setClientIp(clientIp);
+        // channelExtra 有意不设置:H5 页不接受额外参数,值也不该出现在这条链路上
+
+        UnifiedOrderEntity order;
+        SecurityContext previous = SecurityContextHolder.getContext();
+        try {
+            // 上游通知可能在下单响应前就到,租户上下文必须覆盖整个下单体
+            SecurityContextHolder.setContext(scopedTo(cfg.getTenantId()));
+            order = orderService.create(cmd, cfg.getTenantId(), null, UnifiedSource.H5);
+        } catch (BusinessException e) {
+            if (e.getCode() == 400) {
+                throw e;
+            }
+            log.warn("[统一支付H5] 建单失败: mch_no={}, 原因={}", mchNo, e.getMessage());
+            throw new BusinessException(500, MSG_CREATE_FAILED);
+        } catch (RuntimeException e) {
+            log.warn("[统一支付H5] 建单异常: mch_no={}, 原因={}", mchNo, e.getMessage());
+            throw new BusinessException(500, MSG_CREATE_FAILED);
+        } finally {
+            SecurityContextHolder.setContext(previous);
+        }
+
+        return new Created(order, UnifiedH5Token.issue(cfg.getAppSecret(), order.getOrderNo()));
+    }
+
+    /**
+     * 公开轮询快照。
+     *
+     * <p>非 {@code PENDING} 直接返回;仍在等待时 3 秒节流窗内的重复轮询不打上游;
+     * 窗口外先同步再重载。上游失败只记 warn 并返回库内行 —— 顾客页不因一次上游失败变 500。
+     *
+     * @throws BusinessException 404 订单不存在(含令牌错/商户配置缺失,同一应答)
+     */
+    public UnifiedOrderEntity snapshot(String orderNo, String token) {
+        UnifiedOrderEntity order;
+        try {
+            order = orderService.requireByOrderNo(orderNo);
+        } catch (BusinessException e) {
+            throw notFound();
+        }
+
+        UnifiedMerchantEntity cfg;
+        try {
+            cfg = merchantService.requireById(order.getMerchantId());
+        } catch (BusinessException e) {
+            throw notFound();
+        }
+
+        if (!UnifiedH5Token.verify(cfg.getAppSecret(), orderNo, token)) {
+            throw notFound();
+        }
+
+        if (!UnifiedOrderStatus.PENDING.contains(order.getTradeStatus())) {
+            return order;
+        }
+        OffsetDateTime lastQueryTime = order.getLastQueryTime();
+        if (lastQueryTime != null
+                && lastQueryTime.isAfter(OffsetDateTime.now().minusSeconds(SNAPSHOT_THROTTLE_SECONDS))) {
+            return order;
+        }
+
+        SecurityContext previous = SecurityContextHolder.getContext();
+        try {
+            SecurityContextHolder.setContext(scopedTo(cfg.getTenantId()));
+            orderService.syncFromUpstream(order);
+        } catch (Exception e) {
+            log.warn("[统一支付H5] 轮询上游失败,返回库内行: order_no={}, 原因={}",
+                    orderNo, e.getMessage());
+            return order;
+        } finally {
+            SecurityContextHolder.setContext(previous);
+        }
+
+        UnifiedOrderEntity reloaded = orderMapper.selectOne(
+                new LambdaQueryWrapper<UnifiedOrderEntity>()
+                        .eq(UnifiedOrderEntity::getOrderNo, orderNo));
+        return reloaded == null ? order : reloaded;
+    }
+
+    /**
+     * 后台为已有订单生成 H5 链接。
+     *
+     * @param tenantId 当前登录身份的归属租户(超管可为 null,按全局单号查)
+     * @throws BusinessException 404 订单不存在(含跨租户);400 商户已停用
+     */
+    public String linkForOrder(String orderNo, Long tenantId) {
+        UnifiedOrderEntity order = orderService.findByOrderNo(tenantId, orderNo);
+        if (order == null) {
+            throw notFound();
+        }
+        UnifiedMerchantEntity cfg = merchantService.requireById(order.getMerchantId());
+        merchantService.requireEnabled(cfg);
+        return buildLink(order, cfg);
+    }
+
+    /**
+     * 拼页面链接:只带 6 个必填参数,{@code notify_url}/{@code ext_param} 已在订单行上,
+     * 不放进链接(减少暴露面)。
+     */
+    public String buildLink(UnifiedOrderEntity order, UnifiedMerchantEntity cfg) {
+        Map<String, String> signed = new LinkedHashMap<>();
+        signed.put("mch_no", cfg.getMchNo());
+        signed.put("out_trade_no", order.getOutTradeNo());
+        signed.put("amount", order.getAmount() == null ? null : String.valueOf(order.getAmount()));
+        signed.put("subject", order.getSubject());
+        signed.put("way_code", order.getWayCode());
+
+        Map<String, String> query = new LinkedHashMap<>(signed);
+        query.put("sign", UnifiedSigner.sign(signed, cfg.getAppSecret()));
+
+        StringBuilder url = new StringBuilder(properties.getH5PageUrl());
+        char separator = url.indexOf("?") >= 0 ? '&' : '?';
+        for (Map.Entry<String, String> entry : query.entrySet()) {
+            url.append(separator)
+                    .append(encode(entry.getKey()))
+                    .append('=')
+                    .append(encode(entry.getValue()));
+            separator = '&';
+        }
+        return url.toString();
+    }
+
+    // ────────────────────────────── 校验 ──────────────────────────────
+
+    private static String required(String value, int maxLength) {
+        if (StrUtil.isBlank(value) || value.length() > maxLength) {
+            throw incomplete();
+        }
+        return value;
+    }
+
+    private static String optional(String value, int maxLength) {
+        if (value != null && value.length() > maxLength) {
+            throw incomplete();
+        }
+        return value;
+    }
+
+    /** 分:{@code \d{1,15}} 且 &gt; 0;前导零允许(上游按数值处理)。 */
+    private static long parseAmount(String value) {
+        if (value == null || !AMOUNT.matcher(value).matches()) {
+            throw incomplete();
+        }
+        long amount;
+        try {
+            amount = Long.parseLong(value);
+        } catch (NumberFormatException e) {
+            throw incomplete();
+        }
+        if (amount <= 0) {
+            throw incomplete();
+        }
+        return amount;
+    }
+
+    private static BusinessException incomplete() {
+        return new BusinessException(400, MSG_INCOMPLETE);
+    }
+
+    private static BusinessException notFound() {
+        return new BusinessException(404, MSG_ORDER_NOT_FOUND);
+    }
+
+    private static String encode(String value) {
+        return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
+    }
+
+    // ────────────────────────────── 租户上下文 ──────────────────────────────
+
+    /**
+     * 造一个**只带租户**的上下文:没有角色、没有权限、{@code isSuperuser=false}。
+     *
+     * <p>与 {@code HarCheckoutController#scopedTo} 同形(本仓约定:顾客页的租户上下文
+     * 各持一份私有副本,不跨通道复用类)。{@code isSuperuser} 必须显式 false:
+     * 拦截器的第一条就是超管直通,留 null 会让「不过滤」由 null 的语义顺手决定。
+     */
+    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;
+    }
+
+    /** 建单结果:订单行 + 该单的轮询令牌(令牌不落库)。 */
+    public record Created(UnifiedOrderEntity order, String token) {
+    }
+}

+ 1 - 1
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedOrderService.java

@@ -104,7 +104,7 @@ public class UnifiedOrderService {
      * @param cmd      入参;{@code channelExtra} 的值只在此方法的调用栈内使用
      * @param tenantId 认证上下文/API Key 给出的租户;必须非空
      * @param apiKey   开放 API 调用方 Key(收银台为 null)
-     * @param source   OPENAPI / CASHIER;ADMIN 不允许下单
+     * @param source   可下单来源是 OPENAPI / CASHIER / H5;ADMIN 不允许下单
      * @throws BusinessException 400 参数/来源;409 幂等冲突;404 商户未配置;
      *                           500 上游失败(本地单保留)或金额不符
      */

+ 11 - 0
java/src/main/java/com/payment/platform/module/payment/unified/util/UnifiedChannelExtraAssembler.java

@@ -38,6 +38,17 @@ public final class UnifiedChannelExtraAssembler {
             "WX_BAR", "WX_JSAPI", "WX_LITE", "WX_APP",
             "WX_H5", "WX_NATIVE", "YSF_BAR", "YSF_JSAPI");
 
+    /**
+     * 公开 H5 支付页可用的 wayCode(Task 24)。
+     *
+     * <p>判据是「这条支付方式不需要 {@code channelExtra}」:H5 页**不接受**额外参数,
+     * 需要付款码/openid/buyerUserId 的方式只能走开放 API。页面里的 {@code H5_WAY_CODES}
+     * 必须与本集合逐字相等({@code UnifiedH5PageContractTest} 机械比对)。
+     */
+    public static final Set<String> H5_SUPPORTED = Set.of(
+            "QR_CASHIER", "ALI_QR", "WX_NATIVE", "WX_H5",
+            "ALI_WAP", "ALI_PC", "ALI_APP", "WX_APP");
+
     private static final Set<String> BARCODE = Set.of("AUTO_BAR", "ALI_BAR", "WX_BAR", "YSF_BAR");
     private static final Set<String> WX_JSAPI_LIKE = Set.of("WX_JSAPI", "WX_LITE");
     private static final Set<String> PAY_TYPE_CODE = Set.of("QR_CASHIER", "ALI_QR", "WX_NATIVE");

+ 62 - 0
java/src/main/java/com/payment/platform/module/payment/unified/util/UnifiedH5Token.java

@@ -0,0 +1,62 @@
+package com.payment.platform.module.payment.unified.util;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.Base64;
+
+/**
+ * 公开 H5 支付页的轮询令牌(Task 24)。
+ *
+ * <p>顾客页没有登录态,读某一单状态只能凭这串令牌。它是<b>确定性</b>的:
+ * {@code Base64URL-无填充( HMAC-SHA256( appSecret, "unified-h5:" + orderNo ) )},
+ * 因此不需要新增表/列,也不需要发令牌时落库。
+ *
+ * <p>★ 前缀 {@code unified-h5:} 是域分隔:同一把 {@code appSecret} 在别处(例如签名)
+ * 的用途不能与这里互相推导。改前缀等于让所有已发出的链接失效。
+ *
+ * <p>比较走 {@link MessageDigest#isEqual}(常量时间);长度不同也走同一条路径,
+ * 不在比较前按长度提前返回。
+ */
+public final class UnifiedH5Token {
+
+    private static final String HMAC_SHA256 = "HmacSHA256";
+    private static final String PREFIX = "unified-h5:";
+
+    private UnifiedH5Token() {
+    }
+
+    /**
+     * 签发某单的轮询令牌。
+     *
+     * @param appSecret 商户密钥;空白时抛 {@link IllegalStateException}(失败即关闭)
+     * @param orderNo   平台订单号
+     */
+    public static String issue(String appSecret, String orderNo) {
+        if (appSecret == null || appSecret.isBlank()) {
+            throw new IllegalStateException("appSecret 为空,无法签发 H5 轮询令牌");
+        }
+        try {
+            Mac mac = Mac.getInstance(HMAC_SHA256);
+            mac.init(new SecretKeySpec(appSecret.getBytes(StandardCharsets.UTF_8), HMAC_SHA256));
+            byte[] digest = mac.doFinal(
+                    (PREFIX + orderNo).getBytes(StandardCharsets.UTF_8));
+            return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
+        } catch (Exception e) {
+            // 每个 JRE 都必须提供 HmacSHA256;走到这里说明运行环境损坏
+            throw new IllegalStateException("JVM 不支持 HmacSHA256", e);
+        }
+    }
+
+    /**
+     * 校验令牌。空白令牌、密钥缺失、令牌不匹配一律 false。
+     */
+    public static boolean verify(String appSecret, String orderNo, String token) {
+        if (appSecret == null || appSecret.isBlank() || token == null || token.isBlank()) {
+            return false;
+        }
+        byte[] expected = issue(appSecret, orderNo).getBytes(StandardCharsets.UTF_8);
+        return MessageDigest.isEqual(expected, token.getBytes(StandardCharsets.UTF_8));
+    }
+}

+ 3 - 1
java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedAdminGuardTest.java

@@ -9,6 +9,7 @@ import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
 import com.payment.platform.module.payment.unified.entity.UnifiedRefundEntity;
 import com.payment.platform.module.payment.unified.enums.UnifiedSource;
 import com.payment.platform.module.payment.unified.service.UnifiedCashierService;
+import com.payment.platform.module.payment.unified.service.UnifiedH5Service;
 import com.payment.platform.module.payment.unified.service.UnifiedOrderService;
 import com.payment.platform.module.payment.unified.service.UnifiedRefundService;
 import com.payment.platform.module.payment.unified.util.UnifiedTenantGuard;
@@ -52,6 +53,7 @@ class UnifiedAdminGuardTest {
     @Mock private UnifiedOrderService orderService;
     @Mock private UnifiedRefundService refundService;
     @Mock private UnifiedCashierService cashierService;
+    @Mock private UnifiedH5Service h5Service;
 
     private final UnifiedTenantGuard guard = new UnifiedTenantGuard();
 
@@ -65,7 +67,7 @@ class UnifiedAdminGuardTest {
                 new MockHttpServletRequest("POST", "/payment/unified/cashier/order");
         request.setRemoteAddr("203.0.113.9");
         cashierController = new UnifiedCashierController(
-                cashierService, orderService, guard, request);
+                cashierService, orderService, h5Service, guard, request);
     }
 
     @AfterEach

+ 16 - 2
java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedCashierControllerTest.java

@@ -12,6 +12,7 @@ import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
 import com.payment.platform.module.payment.unified.enums.UnifiedSource;
 import com.payment.platform.module.payment.unified.mapper.UnifiedOrderMapper;
 import com.payment.platform.module.payment.unified.service.UnifiedCashierService;
+import com.payment.platform.module.payment.unified.service.UnifiedH5Service;
 import com.payment.platform.module.payment.unified.service.UnifiedOrderService;
 import com.payment.platform.module.payment.unified.util.UnifiedTenantGuard;
 import org.apache.ibatis.builder.MapperBuilderAssistant;
@@ -66,6 +67,7 @@ class UnifiedCashierControllerTest {
 
     @Mock private UnifiedCashierService cashierService;
     @Mock private UnifiedOrderService orderService;
+    @Mock private UnifiedH5Service h5Service;
     @Mock private UnifiedTenantGuard guard;
     @Mock private UnifiedOrderMapper orderMapper;
 
@@ -76,7 +78,7 @@ class UnifiedCashierControllerTest {
     @BeforeEach
     void setUp() {
         request = new MockHttpServletRequest("POST", "/payment/unified/cashier/order");
-        controller = new UnifiedCashierController(cashierService, orderService, guard, request);
+        controller = new UnifiedCashierController(cashierService, orderService, h5Service, guard, request);
         service = new UnifiedCashierService(orderMapper, orderService);
         // 初始化 lambda 元数据缓存,使 wrapper.getSqlSegment() 在无 Spring 上下文的单测中可用
         MybatisConfiguration configuration = new MybatisConfiguration();
@@ -353,7 +355,7 @@ class UnifiedCashierControllerTest {
     }
 
     @Test
-    void twoMappings_andPermissions_areLiteral() throws Exception {
+    void threeMappings_andPermissions_areLiteral() throws Exception {
         PreAuthorize createPa = UnifiedCashierController.class
                 .getMethod("create", UnifiedOrderCreateCommand.class)
                 .getAnnotation(PreAuthorize.class);
@@ -377,5 +379,17 @@ class UnifiedCashierControllerTest {
                 .getAnnotation(GetMapping.class);
         assertNotNull(get, "轮询是 GET");
         assertEquals("/order/{orderNo}", get.value()[0]);
+
+        PreAuthorize h5LinkPa = UnifiedCashierController.class
+                .getMethod("h5Link", String.class)
+                .getAnnotation(PreAuthorize.class);
+        assertNotNull(h5LinkPa, "发 H5 链接端点必须有 @PreAuthorize");
+        assertEquals("@perm.hasAny('module_payment:unified:cashier:list')", h5LinkPa.value());
+
+        GetMapping h5LinkGet = UnifiedCashierController.class
+                .getMethod("h5Link", String.class)
+                .getAnnotation(GetMapping.class);
+        assertNotNull(h5LinkGet, "发 H5 链接是 GET");
+        assertEquals("/order/{orderNo}/h5-link", h5LinkGet.value()[0]);
     }
 }

+ 157 - 0
java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedH5ControllerTest.java

@@ -0,0 +1,157 @@
+package com.payment.platform.module.payment.unified.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.payment.platform.core.security.JwtAuthFilter;
+import com.payment.platform.core.security.SecurityConfig;
+import com.payment.platform.core.security.TenantApiKeyAuthFilter;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedH5OrderVO;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 公开 H5 端点(Task 24)的契约与认证面。
+ *
+ * <p>两个端点都**没有** {@code @PreAuthorize}:身份不是登录态,而是请求里的 MD5 签名
+ * (建单)与每单一签的轮询令牌(查询)。放行只靠 {@code SecurityConfig.WHITE_LIST},
+ * 且**不得**被 {@code TenantApiKeyAuthFilter} 认领 —— 那条路径是 API Key 认证,两套面不能混。
+ *
+ * <p>读数全部走反射读真实数组/字段,不在测试里复制实现常量。
+ */
+class UnifiedH5ControllerTest {
+
+    private static final String PREFIX = "/payment/h5/unified";
+
+    @Test
+    void classPath_isTheUpstreamContract() {
+        RequestMapping rm = UnifiedH5Controller.class.getAnnotation(RequestMapping.class);
+
+        assertNotNull(rm, "类级 @RequestMapping 不得为空");
+        assertEquals(PREFIX, rm.value()[0], "上游配置的页面固定调用这条路径");
+    }
+
+    @Test
+    void twoMappings_areLiteral() throws Exception {
+        PostMapping post = UnifiedH5Controller.class
+                .getMethod("create", Map.class)
+                .getAnnotation(PostMapping.class);
+        assertNotNull(post, "建单是 POST");
+        assertEquals("/order", post.value()[0]);
+
+        GetMapping get = UnifiedH5Controller.class
+                .getMethod("snapshot", String.class, String.class)
+                .getAnnotation(GetMapping.class);
+        assertNotNull(get, "轮询是 GET");
+        assertEquals("/order/{orderNo}", get.value()[0]);
+    }
+
+    @Test
+    void endpoints_haveNoPreAuthorize() throws Exception {
+        assertNull(UnifiedH5Controller.class
+                        .getMethod("create", Map.class)
+                        .getAnnotation(PreAuthorize.class),
+                "公开建单端点不得挂 @PreAuthorize");
+        assertNull(UnifiedH5Controller.class
+                        .getMethod("snapshot", String.class, String.class)
+                        .getAnnotation(PreAuthorize.class),
+                "公开轮询端点不得挂 @PreAuthorize");
+    }
+
+    @Test
+    void whiteList_permitsH5_butApiKeyFilterMustNotClaimIt() throws Exception {
+        List<String> whiteList = Arrays.asList(readStringArray(SecurityConfig.class, "WHITE_LIST"));
+        List<String> prefixes = Arrays.asList(
+                readStringArray(TenantApiKeyAuthFilter.class, "OPENAPI_PREFIXES"));
+
+        assertTrue(whiteList.contains(PREFIX + "/**"),
+                "WHITE_LIST 未放行 " + PREFIX + "/**,公开页面会在授权层被拦成空 403。读数: " + whiteList);
+        assertTrue(prefixes.stream().noneMatch(p -> p.startsWith("/payment/h5")),
+                "H5 是签名/token 认证面,不得被 ApiKey filter 认领。读数: " + prefixes);
+
+        // 正对照:证明读到的确实是那两个真数组,而不是空壳。
+        assertTrue(whiteList.contains("/payment/openapi/unified/**"), "量具读错白名单: " + whiteList);
+        assertTrue(prefixes.contains("/payment/openapi/unified"), "量具读错前缀表: " + prefixes);
+    }
+
+    /**
+     * 轮询 URL 用 {@code ?token=} 传 HMAC 令牌,而 {@code JwtAuthFilter#extractToken}
+     * 会把 query 里的 {@code token} 当 JWT 解析 —— 不在它自己的白名单里,合法轮询会被判成
+     * 坏 JWT 直接 401。{@code SecurityConfig.WHITE_LIST} 只影响授权层,拦不住这一步。
+     */
+    @Test
+    void jwtFilter_whitelistsH5_soPollingTokenIsNotParsedAsJwt() throws Exception {
+        Set<String> paths = readStringSet(JwtAuthFilter.class, "WHITE_LIST_PATHS");
+
+        assertTrue(paths.contains(PREFIX),
+                "JwtAuthFilter.WHITE_LIST_PATHS 缺少 " + PREFIX + ",轮询令牌会被当坏 JWT 判 401。读数: " + paths);
+        // 正对照:证明读到的确实是过滤器的真集合,而不是空壳。
+        assertTrue(paths.contains("/payment/notify/health"), "量具读错 JwtAuthFilter 白名单: " + paths);
+    }
+
+    @Test
+    void orderVo_hasExactlyTheThirteenContractFields() {
+        Set<String> fields = Arrays.stream(UnifiedH5OrderVO.class.getDeclaredFields())
+                .filter(f -> !Modifier.isStatic(f.getModifiers()))
+                .map(Field::getName)
+                .collect(Collectors.toSet());
+
+        assertEquals(Set.of(
+                        "orderNo", "token", "outTradeNo", "mchNo", "wayCode", "amount",
+                        "subject", "tradeStatus", "payDataType", "payData",
+                        "errCode", "errMsg", "createdTime"),
+                fields, "H5 VO 字段集合必须与契约逐字一致");
+    }
+
+    @Test
+    void orderVo_serialization_leaksNoInternalFields() throws Exception {
+        UnifiedOrderEntity entity = new UnifiedOrderEntity();
+        entity.setId(1234567890123456789L);
+        entity.setTenantId(2L);
+        entity.setOrderNo("UP1");
+        entity.setOutTradeNo("C1");
+        entity.setMchNo("M001");
+        entity.setWayCode("QR_CASHIER");
+        entity.setAmount(100L);
+        entity.setSubject("测试订单");
+        entity.setTradeStatus("0");
+
+        String json = new ObjectMapper()
+                .writeValueAsString(UnifiedH5OrderVO.open(entity, "tok"));
+
+        assertFalse(json.contains("\"id\""), "不得暴露内部主键: " + json);
+        assertFalse(json.contains("tenant_id"), "不得暴露租户归属: " + json);
+        assertFalse(json.contains("tenantId"), "不得暴露租户归属: " + json);
+        assertFalse(json.contains("app_secret"), "不得暴露密钥: " + json);
+        assertFalse(json.contains("appSecret"), "不得暴露密钥: " + json);
+    }
+
+    private static String[] readStringArray(Class<?> owner, String fieldName) throws Exception {
+        Field f = owner.getDeclaredField(fieldName);
+        f.setAccessible(true);
+        return (String[]) f.get(null);
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Set<String> readStringSet(Class<?> owner, String fieldName) throws Exception {
+        Field f = owner.getDeclaredField(fieldName);
+        f.setAccessible(true);
+        return (Set<String>) f.get(null);
+    }
+}

+ 100 - 0
java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedH5PageContractTest.java

@@ -0,0 +1,100 @@
+package com.payment.platform.module.payment.unified.controller;
+
+import com.payment.platform.module.payment.unified.util.UnifiedChannelExtraAssembler;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 公开 H5 页面的静默失效防线(Task 24)。
+ *
+ * <p>页面是**单文件、无外部资源**的静态页,直接暴露给顾客浏览器。这份测试只钉那些
+ * 一旦漂移就会变成安全或功能事故的字符串:
+ * <ul>
+ *   <li>没有 {@code innerHTML}、没有外部 script/link、没有密钥或渠道敏感字段名;</li>
+ *   <li>referrer 不回传、CSP 关掉默认加载面;</li>
+ *   <li>页面认的 {@code way_code} 集合与后端 {@code H5_SUPPORTED} **逐字相等**。</li>
+ * </ul>
+ *
+ * <p>Surefire 的工作目录是模块根 {@code java/},故用相对路径读前端源码。
+ */
+class UnifiedH5PageContractTest {
+
+    private static final Path PAGE = Path.of("../frontend/public/pay/unified-h5.html");
+
+    @Test
+    void page_hasNoReferrerAndCspMetas() throws IOException {
+        String html = html();
+
+        assertTrue(html.contains("name=\"referrer\""), "缺 referrer meta");
+        assertTrue(html.contains("no-referrer"), "referrer 必须为 no-referrer");
+        for (String directive : List.of(
+                "default-src 'none'",
+                "img-src http: https:",
+                "style-src 'unsafe-inline'",
+                "script-src 'unsafe-inline'",
+                "connect-src 'self'",
+                "base-uri 'none'",
+                "form-action 'none'")) {
+            assertTrue(html.contains(directive), "CSP 缺指令: " + directive);
+        }
+    }
+
+    @Test
+    void page_containsNoRiskTokensOrExternalResources() throws IOException {
+        String html = html();
+
+        for (String banned : List.of(
+                "innerHTML", "app_secret", "appSecret", "channelExtra", "openid",
+                "buyerUserId", "<script src=", "<link ")) {
+            assertFalse(html.contains(banned), "页面不得出现: " + banned);
+        }
+    }
+
+    @Test
+    void page_wiresTheH5EndpointsAndSafeRendering() throws IOException {
+        String html = html();
+
+        assertTrue(html.contains("/payment/h5/unified/order"),
+                "页面必须调用 H5 建单/轮询端点");
+        assertTrue(html.contains("rel=\"noopener noreferrer\""),
+                "跳转链接必须带 noopener noreferrer");
+        assertTrue(html.contains("credentials:\"omit\""),
+                "fetch 必须显式不带凭据");
+    }
+
+    @Test
+    void H5_WAY_CODES_equalsBackendSet() throws IOException {
+        String html = html();
+        Matcher block = Pattern.compile("H5_WAY_CODES\\s*=\\s*\\[([^\\]]*)\\]").matcher(html);
+        assertTrue(block.find(), "页面里找不到 H5_WAY_CODES 数组");
+
+        List<String> codes = new ArrayList<>();
+        Matcher quoted = Pattern.compile("\"([A-Z0-9_]+)\"").matcher(block.group(1));
+        while (quoted.find()) {
+            codes.add(quoted.group(1));
+        }
+
+        assertEquals(UnifiedChannelExtraAssembler.H5_SUPPORTED.size(), codes.size(),
+                "数组元素数量不符,实测: " + codes);
+        assertEquals(UnifiedChannelExtraAssembler.H5_SUPPORTED, new LinkedHashSet<>(codes),
+                "页面 way_code 集合必须与后端 H5_SUPPORTED 逐字相等,实测: " + codes);
+    }
+
+    private static String html() throws IOException {
+        assertTrue(Files.isRegularFile(PAGE), "页面文件不存在: " + PAGE.toAbsolutePath());
+        return Files.readString(PAGE, StandardCharsets.UTF_8).replace("\r\n", "\n");
+    }
+}

+ 407 - 0
java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedH5ServiceTest.java

@@ -0,0 +1,407 @@
+package com.payment.platform.module.payment.unified.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.payment.unified.client.UnifiedSigner;
+import com.payment.platform.module.payment.unified.config.UnifiedPayProperties;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedOrderCreateCommand;
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import com.payment.platform.module.payment.unified.enums.UnifiedSource;
+import com.payment.platform.module.payment.unified.mapper.UnifiedOrderMapper;
+import com.payment.platform.module.payment.unified.util.UnifiedH5Token;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+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.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.time.OffsetDateTime;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * 公开 H5 建单/轮询/发链接(Task 24)。
+ *
+ * <p>三条腿各自守的东西不同:
+ * <ul>
+ *   <li><b>建单</b>:公开端点收的是 {@code Map},Bean Validation 一步都不参与 ——
+ *       长度、金额、未知键全部由服务层自己拦;商户不存在/验签失败/停用对外**同一句**话,
+ *       不构成商户号探针。</li>
+ *   <li><b>轮询</b>:令牌错、单不存在、商户配置没了对外都是 404「订单不存在」;
+ *       上游一次失败不能让顾客页变 500。</li>
+ *   <li><b>发链接</b>:只带 6 个必填参数,租户外的单与不存在同一应答。</li>
+ * </ul>
+ */
+@ExtendWith(MockitoExtension.class)
+class UnifiedH5ServiceTest {
+
+    private static final String SECRET = "test-secret";
+    private static final String MCH_NO = "M001";
+    private static final long MERCHANT_ID = 7L;
+
+    @Mock private UnifiedOrderMapper orderMapper;
+    @Mock private UnifiedOrderService orderService;
+    @Mock private UnifiedMerchantService merchantService;
+
+    private UnifiedPayProperties properties;
+    private UnifiedH5Service service;
+    private UnifiedMerchantEntity cfg;
+
+    @BeforeEach
+    void setUp() {
+        properties = new UnifiedPayProperties();
+        service = new UnifiedH5Service(orderMapper, orderService, merchantService, properties);
+
+        cfg = new UnifiedMerchantEntity();
+        cfg.setId(MERCHANT_ID);
+        cfg.setTenantId(2L);
+        cfg.setMchNo(MCH_NO);
+        cfg.setAppSecret(SECRET);
+        cfg.setStatus(UnifiedMerchantService.STATUS_ENABLED);
+
+        // 初始化 lambda 元数据缓存,使 wrapper.getSqlSegment() 在无 Spring 上下文的单测中可用
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
+        TableInfoHelper.initTableInfo(assistant, UnifiedOrderEntity.class);
+    }
+
+    @AfterEach
+    void clearSecurityContext() {
+        SecurityContextHolder.clearContext();
+    }
+
+    // ────────────────────────────── 建单 ──────────────────────────────
+
+    @Test
+    void create_validSignedRequest_delegatesToOrderCreate_withH5Source() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg);
+        UnifiedOrderEntity created = order("0");
+        AtomicReference<Long> scopedTenant = new AtomicReference<>();
+        when(orderService.create(any(), eq(2L), isNull(), eq(UnifiedSource.H5))).thenAnswer(inv -> {
+            Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+            assertNotNull(auth, "建单调用时必须有租户上下文");
+            assertTrue(auth.getPrincipal() instanceof LoginUser, "主体必须是 LoginUser");
+            scopedTenant.set(((LoginUser) auth.getPrincipal()).getTenantId());
+            return created;
+        });
+
+        SecurityContext previous = SecurityContextHolder.createEmptyContext();
+        SecurityContextHolder.setContext(previous);
+
+        UnifiedH5Service.Created result = service.create(signed(unsignedParams()), "203.0.113.9");
+
+        assertSame(created, result.order());
+        assertEquals(UnifiedH5Token.issue(SECRET, "UP1"), result.token());
+
+        ArgumentCaptor<UnifiedOrderCreateCommand> cap =
+                ArgumentCaptor.forClass(UnifiedOrderCreateCommand.class);
+        verify(orderService).create(cap.capture(), eq(2L), isNull(), eq(UnifiedSource.H5));
+        UnifiedOrderCreateCommand cmd = cap.getValue();
+        assertEquals(1800, cmd.getExpiredTime(), "H5 单过期时间固定 1800 秒");
+        assertEquals(100L, cmd.getAmount());
+        assertEquals("QR_CASHIER", cmd.getWayCode());
+        assertEquals("测试订单", cmd.getSubject());
+        assertEquals("C1", cmd.getOutTradeNo());
+        assertEquals(MCH_NO, cmd.getMchNo());
+        assertEquals("商品描述", cmd.getBody());
+        assertEquals("https://merchant.example/return", cmd.getReturnUrl());
+        assertEquals("https://merchant.example/notify", cmd.getNotifyUrl());
+        assertEquals("X1", cmd.getExtParam());
+        assertEquals("203.0.113.9", cmd.getClientIp());
+        assertNull(cmd.getChannelExtra(), "H5 页不接受 channelExtra");
+        assertEquals(2L, scopedTenant.get(), "建单必须在商户归属租户的上下文里执行");
+        assertSame(previous, SecurityContextHolder.getContext(), "调用后必须还原原 SecurityContext");
+    }
+
+    @Test
+    void create_badSignature_is400_withoutTouchingOrderService() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg);
+        Map<String, String> params = signed(unsignedParams());
+        params.put("sign", "00000000000000000000000000000000");
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.create(params, "203.0.113.9"));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        assertEquals("支付链接无效或已过期", e.getMessage(), "不区分原因,不构成商户号探针");
+        verifyNoInteractions(orderService);
+    }
+
+    @Test
+    void create_unknownMerchant_is400_withSameMessage() {
+        when(merchantService.requireByMchNo(MCH_NO))
+                .thenThrow(new BusinessException(404, "商户配置不存在: " + MCH_NO));
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.create(signed(unsignedParams()), "203.0.113.9"));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        assertEquals("支付链接无效或已过期", e.getMessage());
+        verifyNoInteractions(orderService);
+    }
+
+    @Test
+    void create_disabledMerchant_is400_withSameMessage() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg);
+        doThrow(new BusinessException(400, "商户已停用")).when(merchantService).requireEnabled(cfg);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.create(signed(unsignedParams()), "203.0.113.9"));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        assertEquals("支付链接无效或已过期", e.getMessage());
+        verifyNoInteractions(orderService);
+    }
+
+    @Test
+    void create_wayCodeOutsideH5Supported_is400_beforeAnyMerchantCall() {
+        Map<String, String> params = unsignedParams();
+        params.put("way_code", "ALI_BAR");
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.create(signed(params), "203.0.113.9"));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        assertEquals("支付链接参数不完整", e.getMessage());
+        verifyNoInteractions(merchantService, orderService);
+    }
+
+    @Test
+    void create_badAmounts_are400() {
+        for (String amount : List.of("0", "-1", "1.5", "abc", "")) {
+            Map<String, String> params = unsignedParams();
+            params.put("amount", amount);
+
+            BusinessException e = assertThrows(BusinessException.class,
+                    () -> service.create(signed(params), "203.0.113.9"), "amount=" + amount);
+
+            assertEquals(400, e.getCode(), "amount=" + amount + " 实测消息: " + e.getMessage());
+        }
+        verifyNoInteractions(merchantService, orderService);
+    }
+
+    @Test
+    void create_unknownChannelExtraKey_is400() {
+        Map<String, String> params = signed(unsignedParams());
+        params.put("channel_extra", "{}");
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.create(params, "203.0.113.9"));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        assertEquals("支付链接参数不完整", e.getMessage());
+        verifyNoInteractions(merchantService, orderService);
+    }
+
+    @Test
+    void create_tooLongFields_are400_beforeAnyMerchantCall() {
+        Map<String, Integer> limits = new LinkedHashMap<>();
+        limits.put("out_trade_no", 64);
+        limits.put("subject", 128);
+        limits.put("body", 512);
+        limits.put("return_url", 512);
+        limits.put("notify_url", 512);
+        limits.put("ext_param", 512);
+
+        for (Map.Entry<String, Integer> entry : limits.entrySet()) {
+            Map<String, String> params = unsignedParams();
+            params.put(entry.getKey(), "x".repeat(entry.getValue() + 1));
+
+            BusinessException e = assertThrows(BusinessException.class,
+                    () -> service.create(signed(params), "203.0.113.9"), entry.getKey());
+
+            assertEquals(400, e.getCode(),
+                    entry.getKey() + " 超长必须 400,实测消息: " + e.getMessage());
+        }
+        verifyNoInteractions(merchantService, orderService);
+    }
+
+    // ────────────────────────────── 轮询 ──────────────────────────────
+
+    @Test
+    void snapshot_badToken_is404_withoutUpstreamCall() {
+        when(orderService.requireByOrderNo("UP1")).thenReturn(order("0"));
+        when(merchantService.requireById(MERCHANT_ID)).thenReturn(cfg);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.snapshot("UP1", "not-a-valid-token"));
+
+        assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
+        assertEquals("订单不存在", e.getMessage());
+        verify(orderService, never()).syncFromUpstream(any());
+    }
+
+    @Test
+    void snapshot_nonPendingStatuses_returnRowWithoutUpstreamCall() {
+        for (String status : List.of("2", "3", "4", "5", "6")) {
+            UnifiedOrderEntity row = order(status);
+            row.setLastQueryTime(OffsetDateTime.now().minusMinutes(5));
+            when(orderService.requireByOrderNo("UP1")).thenReturn(row);
+            when(merchantService.requireById(MERCHANT_ID)).thenReturn(cfg);
+
+            assertSame(row, service.snapshot("UP1", UnifiedH5Token.issue(SECRET, "UP1")),
+                    "status=" + status);
+        }
+        verify(orderService, never()).syncFromUpstream(any());
+    }
+
+    @Test
+    void snapshot_pendingWithin3s_returnsWithoutUpstreamCall() {
+        UnifiedOrderEntity recent = order("0");
+        recent.setLastQueryTime(OffsetDateTime.now().minusSeconds(1));
+        when(orderService.requireByOrderNo("UP1")).thenReturn(recent);
+        when(merchantService.requireById(MERCHANT_ID)).thenReturn(cfg);
+
+        assertSame(recent, service.snapshot("UP1", UnifiedH5Token.issue(SECRET, "UP1")));
+        verify(orderService, never()).syncFromUpstream(any());
+    }
+
+    @Test
+    void snapshot_stalePending_syncsOnceThenReloads() {
+        UnifiedOrderEntity stale = order("0");
+        stale.setLastQueryTime(OffsetDateTime.now().minusSeconds(10));
+        UnifiedOrderEntity fresh = order("2");
+        when(orderService.requireByOrderNo("UP1")).thenReturn(stale);
+        when(merchantService.requireById(MERCHANT_ID)).thenReturn(cfg);
+        when(orderMapper.selectOne(any())).thenReturn(fresh);
+
+        UnifiedOrderEntity result = service.snapshot("UP1", UnifiedH5Token.issue(SECRET, "UP1"));
+
+        verify(orderService).syncFromUpstream(stale);
+        assertSame(fresh, result, "同步后必须返回重载的库内行,而不是同步前的旧对象");
+    }
+
+    @Test
+    void snapshot_upstreamFailure_returnsRowInsteadOf500() {
+        UnifiedOrderEntity stale = order("0");
+        stale.setLastQueryTime(OffsetDateTime.now().minusSeconds(10));
+        when(orderService.requireByOrderNo("UP1")).thenReturn(stale);
+        when(merchantService.requireById(MERCHANT_ID)).thenReturn(cfg);
+        doThrow(new RuntimeException("上游超时")).when(orderService).syncFromUpstream(stale);
+
+        assertSame(stale, service.snapshot("UP1", UnifiedH5Token.issue(SECRET, "UP1")),
+                "顾客页轮询不因一次上游失败变 500");
+    }
+
+    // ────────────────────────────── 发链接 ──────────────────────────────
+
+    @Test
+    void linkForOrder_otherTenantOrMissingOrder_is404() {
+        when(orderService.findByOrderNo(2L, "UP1")).thenReturn(null);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.linkForOrder("UP1", 2L));
+
+        assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
+        assertEquals("订单不存在", e.getMessage());
+        verifyNoInteractions(merchantService);
+    }
+
+    @Test
+    void linkForOrder_foundOrder_checksMerchantAndReturnsLink() {
+        UnifiedOrderEntity row = order("0");
+        when(orderService.findByOrderNo(2L, "UP1")).thenReturn(row);
+        when(merchantService.requireById(MERCHANT_ID)).thenReturn(cfg);
+
+        String url = service.linkForOrder("UP1", 2L);
+
+        assertTrue(url.startsWith(properties.getH5PageUrl()), "实测: " + url);
+        verify(merchantService).requireEnabled(cfg);
+    }
+
+    @Test
+    void buildLink_carriesExactlySixParams_andSignVerifies() {
+        String url = service.buildLink(order("0"), cfg);
+
+        assertTrue(url.startsWith(properties.getH5PageUrl()), "实测: " + url);
+        Map<String, String> parsed = parseQuery(url);
+        assertEquals(Set.of("mch_no", "out_trade_no", "amount", "subject", "way_code", "sign"),
+                parsed.keySet(), "链接只带 6 个必填参数,实测: " + parsed);
+        assertEquals(MCH_NO, parsed.get("mch_no"));
+        assertEquals("C1", parsed.get("out_trade_no"));
+        assertEquals("100", parsed.get("amount"));
+        assertEquals("测试订单", parsed.get("subject"));
+        assertEquals("QR_CASHIER", parsed.get("way_code"));
+        assertTrue(UnifiedSigner.verifyNotification(parsed, SECRET),
+                "链接里的 sign 必须能通过 verifyNotification,实测: " + parsed.get("sign"));
+    }
+
+    // ────────────────────────────── 工具 ──────────────────────────────
+
+    private static Map<String, String> unsignedParams() {
+        Map<String, String> params = new LinkedHashMap<>();
+        params.put("mch_no", MCH_NO);
+        params.put("out_trade_no", "C1");
+        params.put("amount", "100");
+        params.put("subject", "测试订单");
+        params.put("way_code", "QR_CASHIER");
+        params.put("body", "商品描述");
+        params.put("return_url", "https://merchant.example/return");
+        params.put("notify_url", "https://merchant.example/notify");
+        params.put("ext_param", "X1");
+        return params;
+    }
+
+    private static Map<String, String> signed(Map<String, String> params) {
+        Map<String, String> copy = new LinkedHashMap<>(params);
+        copy.put("sign", UnifiedSigner.sign(copy, SECRET));
+        return copy;
+    }
+
+    private static UnifiedOrderEntity order(String status) {
+        UnifiedOrderEntity entity = new UnifiedOrderEntity();
+        entity.setId(11L);
+        entity.setTenantId(2L);
+        entity.setMerchantId(MERCHANT_ID);
+        entity.setOrderNo("UP1");
+        entity.setOutTradeNo("C1");
+        entity.setMchNo(MCH_NO);
+        entity.setWayCode("QR_CASHIER");
+        entity.setAmount(100L);
+        entity.setSubject("测试订单");
+        entity.setTradeStatus(status);
+        return entity;
+    }
+
+    private static Map<String, String> parseQuery(String url) {
+        int at = url.indexOf('?');
+        assertTrue(at > 0, "链接必须带查询串,实测: " + url);
+        Map<String, String> parsed = new LinkedHashMap<>();
+        for (String pair : url.substring(at + 1).split("&")) {
+            int eq = pair.indexOf('=');
+            assertTrue(eq > 0, "查询串片段没有 =,实测: " + pair);
+            parsed.put(URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8),
+                    URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8));
+        }
+        return parsed;
+    }
+}

+ 89 - 0
java/src/test/java/com/payment/platform/module/payment/unified/util/UnifiedH5TokenTest.java

@@ -0,0 +1,89 @@
+package com.payment.platform.module.payment.unified.util;
+
+import org.junit.jupiter.api.Test;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * H5 轮询令牌(Task 24)。
+ *
+ * <p>令牌是**唯一**能读某一单状态的凭据,没有登录态、没有额外表:任何差异都必须拒绝,
+ * 包括长度不同(走同一条字节比较路径,不靠字符串长度提前返回)。
+ *
+ * <p>最后一条用例用 {@link Mac} 独立重算,把「{@code unified-h5:} 前缀 + HMAC-SHA256 +
+ * Base64URL 无填充」这三件事钉死在实现之外的量具上。
+ */
+class UnifiedH5TokenTest {
+
+    private static final String SECRET = "s3cr3t-for-test";
+    private static final String ORDER_NO = "UP1234567890123456789";
+
+    @Test
+    void issue_isDeterministic() {
+        assertEquals(UnifiedH5Token.issue(SECRET, ORDER_NO),
+                UnifiedH5Token.issue(SECRET, ORDER_NO),
+                "同一密钥与单号必须签发同一个令牌");
+    }
+
+    @Test
+    void verify_acceptsIssuedToken() {
+        assertTrue(UnifiedH5Token.verify(SECRET, ORDER_NO, UnifiedH5Token.issue(SECRET, ORDER_NO)));
+    }
+
+    @Test
+    void verify_rejectsTamperedToken() {
+        String token = UnifiedH5Token.issue(SECRET, ORDER_NO);
+        char head = token.charAt(0) == 'A' ? 'B' : 'A';
+        String tampered = head + token.substring(1);
+
+        assertFalse(UnifiedH5Token.verify(SECRET, ORDER_NO, tampered), "篡改一位必须拒绝");
+    }
+
+    @Test
+    void verify_rejectsTokenForAnotherOrder() {
+        assertFalse(UnifiedH5Token.verify(SECRET, "UP-OTHER",
+                UnifiedH5Token.issue(SECRET, ORDER_NO)),
+                "换单号必须是另一个令牌,不能互认");
+    }
+
+    @Test
+    void verify_rejectsTokenUnderAnotherSecret() {
+        assertFalse(UnifiedH5Token.verify("another-secret", ORDER_NO,
+                UnifiedH5Token.issue(SECRET, ORDER_NO)),
+                "换密钥必须拒绝");
+    }
+
+    @Test
+    void verify_rejectsBlankToken() {
+        assertFalse(UnifiedH5Token.verify(SECRET, ORDER_NO, null));
+        assertFalse(UnifiedH5Token.verify(SECRET, ORDER_NO, ""));
+        assertFalse(UnifiedH5Token.verify(SECRET, ORDER_NO, "   "));
+    }
+
+    @Test
+    void token_is43CharsOfUrlSafeAlphabet() {
+        String token = UnifiedH5Token.issue(SECRET, ORDER_NO);
+
+        assertTrue(token.matches("[A-Za-z0-9_-]{43}"), "实测: " + token);
+    }
+
+    /** 独立量具:不复用实现里的任何常量拼装方式。 */
+    @Test
+    void token_pinsPrefixAndBase64UrlWithoutPadding() throws Exception {
+        Mac mac = Mac.getInstance("HmacSHA256");
+        mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+        byte[] raw = mac.doFinal(("unified-h5:" + ORDER_NO).getBytes(StandardCharsets.UTF_8));
+        String expected = Base64.getUrlEncoder().withoutPadding().encodeToString(raw);
+
+        assertEquals(expected, UnifiedH5Token.issue(SECRET, ORDER_NO),
+                "必须等于 HMAC-SHA256(appSecret, \"unified-h5:\"+orderNo) 的 Base64URL 无填充");
+        assertEquals(43, expected.length());
+    }
+}