Explorar el Código

feat(汇安融): 扫码收款收银台 —— 直接取码主扫 + 3 秒轮询监听本单

对标乐收赢收银台(collection/cashier):操作员只填金额(+选填备注),点一次
就拿到收款链接并画出二维码,页面每 3 秒问一次后端直到出结果。下单与查询两条
腿原样复用 HarOrderService(create / syncFromUpstream / getByOrderNo),不新
增任何上游调用。

后端
- POST /payment/har/cashier/qrcode:装配主扫单(trade_type=alipay_un_stage、
  biz=har_ali_un_stage、pay_timeout=15)交给既有下单腿;归属只来自登录身份
  (报文里没有企业号),无租户身份直接 403。
- GET /payment/har/cashier/{orderNo}:终态短路;非终态且距上次查上游超过 3 秒
  才问一次,不沿用通道那套 30 秒起步的退避游标(柜台前等不起);查询抛错降级
  成按本地快照返回,一次上游抖动不变成操作员屏幕上的报错。
- 上游受理但响应里没有 pay_url ⇒ 500「二维码生成失败」:主扫的全部意义就是那
  条链接,缺了它在界面上是一张画不出来的二维码。
- 未知字段或坏 JSON ⇒ 400 并回显字段名,而不是兜底的 500「服务器内部错误」。

与乐收赢收银台的三处有意不同
- 金额单位是元(HAR 全通道如此),下限 0.01。
- 没有撤销端点:HAR 上游没有关单接口,页面上「结束」是纯前端动作,界面如实
  写明「上游这一单不会被关闭,顾客仍可能扫码付款」。
- 没有倒计时:precreate 响应里没有到期字段,编一个倒计时就是编数字。

前端
- views/module_payment/har/cashier/index.vue:input/waiting/done 三态、单一
  apply() 入口、QRCode.toCanvas 260px、轮询带 silent 与失败退避(3s→15s)、
  跨 await 的 stillMine 守卫、卸载与 keep-alive 失活都停表(激活时若仍在等待
  则把轮询接上)。defineOptions name = payment-har-cashier,与菜单 route_name
  逐字相同,keep-alive 才生效。
- api/module_payment/har.ts:cashierQr / cashierStatus 两个函数 + 入参类型。
- 多商户租户显示商户编号选择器(读商户列表失败则藏起,单商户租户不受影响)。

菜单 SQL(未执行,交用户)
- db/har-cashier-menu.sql:在既有「汇安融通道」目录(锚 route_name=
  'payment-har')下新增页面「扫码收款」与按钮「发起收款」,只授 role 1 ——
  2026-10-05 口径下 role 2 在 HAR 子树里连目录都不持有,只授子行等于没授
  (buildMenuTree 会静默丢弃父不在集合里的子节点)。脚本末尾备好放开段与
  收回段。

验证
- 新增 HarCashierServiceTest 11 条、HarCashierControllerTest 6 条,均先看到
  失败再写实现。
- 全量 mvn test:1099 条,0 失败,BUILD SUCCESS。
- vue-tsc --noEmit:两个新文件零诊断(仓库基线 289 行既有诊断,与本次无关)。

未做 / 待办
- pay_url 至今没被一部真实手机扫过,上线后需小额实弹验一次「取码→扫→变绿」。
- 尚未重新打 jar 与 dist;菜单 SQL 尚未执行。
alphaH hace 1 semana
padre
commit
96c6d0a55f

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

@@ -157,6 +157,24 @@ export interface HarOrderCreateParams {
   auth_code?: string;
 }
 
+/**
+ * 收银台取码入参(后端 HarCashierCreateDTO)。
+ *
+ * ★ 金额单位是**元** —— HAR 全通道如此;乐收赢收银台那份是分(`amount`),别串了。
+ * ★ 报文里**没有企业号**:归属只来自登录身份(与乐收赢收银台同一条口径)。
+ */
+export interface HarCashierQrParams {
+  /** 金额(元),下限 0.01 */
+  price: number;
+  /** 选填。备注即上游 subject(订单标题);留空后端用「扫码收款」 */
+  remark?: string;
+  /**
+   * 一个租户配了多个商户编号时必须指定;不指定而后端发现多条 ⇒ 400
+   * (钱进哪个商户账号必须说清楚,不允许服务端随便挑一条)。
+   */
+  merchant_code?: string;
+}
+
 /** 订单列表查询条件 — 起止日期为 yyyy-MM-dd */
 export interface HarOrderQueryParams {
   enterprise_id?: string;
@@ -359,6 +377,34 @@ const HarAPI = {
       data: { reason },
     });
   },
+
+  // ---------- 收银台(扫码收款)----------
+
+  /**
+   * 收银台取码(主扫,同步调上游 precreate)。
+   *
+   * ★ 金额单位是**元**(乐收赢那份是分),下限 0.01。
+   * ★ 「归属」不在报文里,只来自登录身份 —— 调用方传不了、也不该传企业号。
+   */
+  cashierQr(data: HarCashierQrParams) {
+    return request<ApiResponse<HarOrderVO>>({
+      url: `${API_PATH}/cashier/qrcode`,
+      method: "post",
+      data,
+    });
+  },
+
+  /**
+   * 收银台查本单;非终态时后端按 3s 节流主动问一次上游。
+   * silent = 轮询用,失败不弹 toast(一次上游抖动不该盖住二维码)。
+   */
+  cashierStatus(orderNo: string, silent = false) {
+    return request<ApiResponse<HarOrderVO>>({
+      url: `${API_PATH}/cashier/${orderNo}`,
+      method: "get",
+      silent,
+    });
+  },
 };
 
 export default HarAPI;

+ 453 - 0
frontend/src/views/module_payment/har/cashier/index.vue

@@ -0,0 +1,453 @@
+<template>
+  <div class="app-container cashier">
+    <!-- ① 还没开始:金额(必填)+ 备注 + 商户(多商户租户才出现) -->
+    <el-card v-if="phase === 'input'" shadow="never" class="pane">
+      <div class="ask">收多少钱?</div>
+
+      <div class="amount-line">
+        <span class="unit">¥</span>
+        <el-input
+          ref="amountEl"
+          v-model="amountText"
+          class="amount-box"
+          size="large"
+          placeholder="0.00"
+          @keyup.enter="start"
+        />
+        <span class="unit">元</span>
+      </div>
+
+      <el-input
+        v-model="remark"
+        class="remark-box"
+        size="large"
+        maxlength="64"
+        show-word-limit
+        placeholder="备注(选填)—— 上游订单标题就是这行字"
+        @keyup.enter="start"
+      />
+
+      <!-- 本租户配了多个商户编号才出现:钱进哪个账户必须由人指定,服务端不替谁挑 -->
+      <el-select
+        v-if="merchants.length > 1"
+        v-model="merchantCode"
+        class="merchant-box"
+        size="large"
+        placeholder="选择商户编号(本租户配了多个)"
+      >
+        <el-option
+          v-for="m in merchants"
+          :key="m.merchant_code"
+          :label="m.merchant_name ? `${m.merchant_name}(${m.merchant_code})` : m.merchant_code"
+          :value="m.merchant_code"
+        />
+      </el-select>
+
+      <el-button type="primary" size="large" class="go" :loading="creating" @click="start">
+        收 款
+      </el-button>
+      <div v-if="inputHint" class="warn">{{ inputHint }}</div>
+    </el-card>
+
+    <!-- ② 等顾客扫码 -->
+    <el-card v-else-if="phase === 'waiting'" shadow="never" class="pane">
+      <div class="wait-title">请让顾客扫码支付</div>
+      <!-- 金额在二维码**旁边**、大字显示:这是操作员核对「有没有多打一个 0」的那一眼 -->
+      <div class="wait-amount">¥ {{ yuan(order?.price) }}</div>
+
+      <div class="qr-wrap">
+        <canvas ref="qrEl" />
+      </div>
+
+      <!-- ★ 没有倒计时:上游 precreate 响应里没有到期字段,编一个就是编数字 -->
+      <div class="countdown">正在等待顾客付款…</div>
+
+      <el-button size="large" @click="reset">结 束</el-button>
+      <div class="hint">
+        顾客不付了、这一屏不要了,才点结束。上游这一单不会被关闭,顾客仍可能扫码付款
+      </div>
+    </el-card>
+
+    <!-- ③ 出结果了 -->
+    <el-card v-else shadow="never" class="pane">
+      <div class="result" :class="result.tone">
+        <div class="result-icon">{{ result.icon }}</div>
+        <div class="result-title">{{ result.title }}</div>
+        <div v-if="result.money" class="result-money">¥ {{ result.money }}</div>
+        <div v-if="result.detail" class="result-detail">{{ result.detail }}</div>
+      </div>
+      <el-button type="primary" size="large" class="go" @click="reset">再收一笔</el-button>
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+// ★ 组件名必须与菜单 route_name **逐字相同**:keep-alive 的 `include` 匹配的是**组件名**,
+//   而 tags-view 推进 `cachedViews` 的是**路由名**。不写这一行,`<script setup>` 会按
+//   文件名推断成 "index" ⇒ keep_alive 永不生效(且不报错)。
+//   取值 `payment-har-cashier` 与交付脚本 `db/har-cashier-menu.sql` 的
+//   `route_name='payment-har-cashier'` 两处逐字一致(乐收赢踩过,`bf09016`)。
+defineOptions({
+  name: "payment-har-cashier",
+  inheritAttrs: false,
+});
+
+import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref } from "vue";
+// ElInput 是为了 `InstanceType<typeof ElInput>` —— 模板里的 `<el-input>` 由自动导入解析,
+// 但脚本里取组件实例类型必须真 import(照 collection/cashier/index.vue 的写法)。
+import { ElInput, ElMessage } from "element-plus";
+import QRCode from "qrcode";
+import HarAPI, { type HarMerchantVO, type HarOrderVO } from "@/api/module_payment/har";
+
+/**
+ * 汇安融扫码收款 —— 一个金额框、一个按钮、一张码。骨架对标乐收赢收银台
+ * (`module_payment/collection/cashier/index.vue`),四处**有意不同**:
+ *
+ *   1. 金额单位是**元**(HAR 全通道如此,乐收赢是分);
+ *   2. **没有撤销** —— HAR 上游没有关单接口(`HarClient` 只有 precreate/pay/query/refund),
+ *      页面上那个「结束」是纯前端动作,界面必须如实说出「上游这一单不会关闭」;
+ *   3. **没有倒计时** —— 上游响应里没有到期字段(编一个就是编数字);
+ *   4. 查询请求带 `silent` —— 一次上游抖动不该盖住屏幕上的二维码。
+ *
+ * 【这一页不监听任何推送】「顾客付完变绿」靠的是每 3 秒问一次后端
+ * (后端按同一节流窗口转发给上游,见 HarCashierService)。
+ */
+const PENDING = new Set(["0"]);
+
+/** 轮询间隔,与后端 `HarCashierService.POLL_INTERVAL_SECONDS` 同值(改一处要改另一处) */
+const POLL_MS = 3000;
+/** 连不上时把间隔拉长到多少为止(请求是 silent 的,拉长只是为了别硬打后端) */
+const POLL_MS_MAX = 15000;
+
+/**
+ * 收款金额下限,单位**元**。与后端 `HarCashierCreateDTO.price` 的 @DecimalMin("0.01")
+ * 是同一个数 —— **改一处必须改另一处**。
+ *
+ * 【为什么这一页也要拦一道】后端那道是真的闸;这里只是把话说在操作员按下去**之前**:
+ * 不拦的话他收到的是一句报文措辞(`price: 金额必须大于 0`),而屏幕上正站着顾客。
+ */
+const MIN_YUAN = 0.01;
+
+type Phase = "input" | "waiting" | "done";
+
+const phase = ref<Phase>("input");
+const amountText = ref("");
+const remark = ref("");
+const merchantCode = ref("");
+const merchants = ref<HarMerchantVO[]>([]);
+const creating = ref(false);
+const inputHint = ref("");
+const order = ref<HarOrderVO | null>(null);
+// 组件实例类型照本仓既有写法取,不自己手搓 `{ focus?: () => void }`
+// —— 后者在 vue-tsc 的模板 ref 检查下会被判成与 ElInput 实例不兼容。
+const amountEl = ref<InstanceType<typeof ElInput>>();
+const qrEl = ref<HTMLCanvasElement>();
+
+let pollTimer: number | undefined;
+let failStreak = 0;
+
+/** 金额显示:VO 里是 BigDecimal(元),可能是串也可能是数,统一成两位小数 */
+function yuan(v?: string | number | null) {
+  if (v == null || v === "") return "-";
+  const n = Number(v);
+  return Number.isFinite(n) ? n.toFixed(2) : String(v);
+}
+
+/** 终态 → 屏幕上那几行字。**表外的状态不猜**(见下面的 computed)。 */
+const RESULT: Record<string, { title: string; tone: string; icon: string; detail?: string }> = {
+  "1": { title: "收款成功", tone: "ok", icon: "✓" },
+  "2": { title: "已全额退款", tone: "muted", icon: "—", detail: "这一笔后来被全额退款了" },
+  "4": { title: "退款中", tone: "muted", icon: "—", detail: "退款是否到账,以订单页为准" },
+  "8": { title: "交易关闭", tone: "muted", icon: "—", detail: "这一笔没有收到钱,可以重新发起" },
+};
+
+const result = computed(() => {
+  const st = order.value?.trade_status ?? "";
+  // 成功时上游会带回**实收**(rcv_amount);它为空才退回应收。
+  const money = st === "1" ? yuan(order.value?.rcv_amount ?? order.value?.price) : null;
+  const known = RESULT[st];
+  if (known) return { ...known, money };
+  // 表外的状态:如实把码给出来,不编一句人话 —— 编错了比不认识更糟。
+  return { title: st || "已结束", tone: "muted", icon: "—", money, detail: null };
+});
+
+/** 把服务端那一次应答铺到屏幕上。**唯一的入口** —— 轮询与下单两处都走它。 */
+function apply(vo: HarOrderVO | null | undefined) {
+  if (!vo) return;
+  order.value = vo;
+  if (!PENDING.has(vo.trade_status)) {
+    stopTimers();
+    phase.value = "done";
+  }
+}
+
+async function drawQr() {
+  const url = order.value?.pay_url;
+  if (!qrEl.value || !url) return;
+  try {
+    await QRCode.toCanvas(qrEl.value, url, { width: 260, margin: 2 });
+  } catch {
+    // 画不出来 = 顾客扫不了。这件事必须说出来,不能只留在控制台。
+    ElMessage.error("二维码显示失败,请结束这一笔后重试");
+  }
+}
+
+function schedulePoll(ms: number) {
+  window.clearTimeout(pollTimer);
+  pollTimer = window.setTimeout(poll, ms);
+}
+
+function stopTimers() {
+  window.clearTimeout(pollTimer);
+  pollTimer = undefined;
+}
+
+/**
+ * 本租户的商户编号,只为「配了多条时让操作员选一条」。
+ *
+ * 读不到(角色没有 `module_payment:har:merchant:list`)就把选择器藏起来:
+ * 单商户租户不需要它;多商户租户会在点「收款」时拿到后端那句 400 的人话。
+ */
+async function loadMerchants() {
+  try {
+    const res: any = await HarAPI.merchantList({ page_no: 1, page_size: 100 });
+    merchants.value = res.data.data?.list || res.data.data?.items || [];
+  } catch {
+    merchants.value = [];
+  }
+}
+
+async function start() {
+  inputHint.value = "";
+  // 输入是元,判下限用分的整数算(`Math.round(Number(x) * 100)` 而不是直接比小数,
+  // 避免浮点:0.01 元的比较不该由二进制浮点决定)。
+  const yuanInput = Number(amountText.value);
+  const fen = Math.round(yuanInput * 100);
+  if (!Number.isFinite(fen) || fen <= 0) {
+    inputHint.value = "请先填一个大于 0 的金额";
+    amountEl.value?.focus();
+    return;
+  }
+  if (yuanInput < MIN_YUAN) {
+    inputHint.value = `收款金额最低 ${MIN_YUAN} 元`;
+    amountEl.value?.focus();
+    return;
+  }
+  if (merchants.value.length > 1 && !merchantCode.value) {
+    inputHint.value = "本租户配了多个商户编号,请先选一个";
+    return;
+  }
+
+  creating.value = true;
+  try {
+    const res: any = await HarAPI.cashierQr({
+      price: Number((fen / 100).toFixed(2)),
+      remark: remark.value.trim() || undefined,
+      merchant_code: merchantCode.value || undefined,
+    });
+    // ★ 两层 data:响应拦截器 `return response`,**不解包**(同乐收赢收银台的实测)。
+    //   写成 res.data 会把整个 {code,msg,data} 信封当成订单 —— 页面不报错,只是永远空白。
+    apply(res.data.data);
+    if (phase.value === "input") {
+      // apply 见到的还是 "0" ⇒ 没把它推进 done,正常开局
+      phase.value = "waiting";
+      await nextTick();
+      await drawQr();
+      failStreak = 0;
+      schedulePoll(POLL_MS);
+    }
+  } catch {
+    // 拦截器已经用服务端的原话弹过 toast 了(例如「二维码生成失败,请重新发起收款」)。
+    // 这里什么都不做:留在输入屏,金额还在框里,操作员直接再按一次即可。
+  } finally {
+    creating.value = false;
+  }
+}
+
+async function poll() {
+  if (phase.value !== "waiting" || !order.value) return;
+  // ★ 记下**发请求那一刻**是哪一个单。应答回来时屏幕上可能已经不是这一单了
+  //   (操作员按了「再收一笔」)—— 那时把上一单的状态铺上去,
+  //   页面会莫名其妙地从输入屏跳到结果屏,而且显示的是**上一笔**的金额。
+  //   一个 `await` 就是一个可以换人的缝,跨过缝的读数必须重新问一次「还是不是同一单」。
+  const no = order.value.order_no;
+  const stillMine = () => phase.value === "waiting" && order.value?.order_no === no;
+  try {
+    // silent:查单失败不弹 toast(后端那一跳本就降级成「按本地状态返回」,
+    // 真失败时也就是一次网络抖动)—— 不能让它盖住二维码。
+    const res: any = await HarAPI.cashierStatus(no, true);
+    if (!stillMine()) return;
+    failStreak = 0;
+    apply(res.data.data);
+    if (phase.value === "waiting") schedulePoll(POLL_MS);
+  } catch {
+    if (!stillMine()) return;
+    failStreak += 1;
+    schedulePoll(Math.min(POLL_MS * (failStreak + 1), POLL_MS_MAX));
+  }
+}
+
+function reset() {
+  stopTimers();
+  failStreak = 0;
+  order.value = null;
+  amountText.value = "";
+  remark.value = "";
+  inputHint.value = "";
+  // merchantCode 不清:柜台一般就一个商户,再收一笔不该逼他重选
+  phase.value = "input";
+  nextTick(() => amountEl.value?.focus());
+}
+
+onMounted(() => {
+  amountEl.value?.focus();
+  loadMerchants();
+});
+// 离开页面必须停表:否则 keep-alive 之下的旧定时器会一直问下去
+//(而且问的是上一个操作员那一单)。keep-alive 走的是**失活**不是卸载,两处都要挂。
+onBeforeUnmount(stopTimers);
+onDeactivated(stopTimers);
+// 回到页面时若还停在等待屏,把轮询接上(停表那一半的对称动作)
+onActivated(() => {
+  if (phase.value === "waiting") poll();
+});
+</script>
+
+<style scoped lang="scss">
+.cashier {
+  display: flex;
+  justify-content: center;
+}
+
+.pane {
+  width: 520px;
+  text-align: center;
+  padding: 8px 0 4px;
+}
+
+.ask {
+  font-size: 20px;
+  color: #303133;
+  margin-bottom: 18px;
+}
+
+.amount-line {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+
+  .unit {
+    font-size: 26px;
+    color: #606266;
+  }
+
+  .amount-box {
+    width: 300px;
+
+    :deep(.el-input__inner) {
+      font-size: 30px;
+      height: 64px;
+      text-align: center;
+      font-variant-numeric: tabular-nums;
+    }
+  }
+}
+
+.remark-box {
+  width: 420px;
+  margin-top: 16px;
+}
+
+.merchant-box {
+  width: 420px;
+  margin-top: 16px;
+}
+
+.go {
+  width: 420px;
+  height: 52px;
+  font-size: 20px;
+  letter-spacing: 6px;
+  margin-top: 22px;
+}
+
+.warn {
+  margin-top: 12px;
+  color: #e6a23c;
+  font-size: 13px;
+}
+
+.hint {
+  margin-top: 10px;
+  color: #909399;
+  font-size: 13px;
+}
+
+.wait-title {
+  font-size: 20px;
+  color: #303133;
+}
+
+.wait-amount {
+  font-size: 34px;
+  font-weight: 600;
+  color: #303133;
+  margin: 6px 0 14px;
+  font-variant-numeric: tabular-nums;
+}
+
+.qr-wrap {
+  display: inline-block;
+  padding: 10px;
+  border: 1px solid #ebeef5;
+  background: #fff;
+  line-height: 0;
+}
+
+.countdown {
+  margin: 14px 0;
+  color: #909399;
+  font-size: 14px;
+  font-variant-numeric: tabular-nums;
+}
+
+.result {
+  padding: 18px 0 6px;
+
+  .result-icon {
+    font-size: 56px;
+    line-height: 1;
+  }
+
+  .result-title {
+    font-size: 24px;
+    margin-top: 10px;
+  }
+
+  .result-money {
+    font-size: 34px;
+    font-weight: 600;
+    margin-top: 8px;
+    font-variant-numeric: tabular-nums;
+  }
+
+  .result-detail {
+    margin-top: 10px;
+    color: #909399;
+    font-size: 14px;
+  }
+
+  &.ok {
+    color: #67c23a;
+  }
+
+  &.bad {
+    color: #f56c6c;
+  }
+
+  &.muted {
+    color: #909399;
+  }
+}
+</style>

+ 75 - 0
java/src/main/java/com/payment/platform/module/payment/har/controller/HarCashierController.java

@@ -0,0 +1,75 @@
+package com.payment.platform.module.payment.har.controller;
+
+import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException;
+import com.payment.platform.common.response.Result;
+import com.payment.platform.module.payment.har.dto.HarCashierCreateDTO;
+import com.payment.platform.module.payment.har.dto.HarOrderVO;
+import com.payment.platform.module.payment.har.service.HarCashierService;
+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.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+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.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 汇安融扫码收款(管理端收银台)。
+ *
+ * <p>两个端点:取码(POST /qrcode)与查本单(GET /{orderNo})。行为全在
+ * {@link HarCashierService},本类只负责出口形态。
+ *
+ * <p>权限串与交付脚本 {@code db/har-cashier-menu.sql} 的 permission 列逐字相同
+ * ({@code PermissionCheck.hasAny} 是全等匹配,差一个字符即 403)。
+ */
+@Slf4j
+@Tag(name = "汇安融-扫码收款")
+@RestController
+@RequestMapping("/payment/har/cashier")
+@RequiredArgsConstructor
+public class HarCashierController {
+
+    private final HarCashierService service;
+
+    @PostMapping("/qrcode")
+    @Operation(summary = "扫码收款取码(主扫,调用上游 precreate)")
+    @PreAuthorize("@perm.hasAny('module_payment:har:cashier:create')")
+    public Result<HarOrderVO> createQr(@Valid @RequestBody HarCashierCreateDTO dto) {
+        return Result.ok(service.createQr(dto));
+    }
+
+    @GetMapping("/{orderNo}")
+    @Operation(summary = "扫码收款查本单(非终态时主动查上游并回写)")
+    @PreAuthorize("@perm.hasAny('module_payment:har:cashier:list')")
+    public Result<HarOrderVO> status(@PathVariable(name = "orderNo") String orderNo) {
+        return Result.ok(service.status(orderNo));
+    }
+
+    /**
+     * 未知字段/坏 JSON ⇒ 400 + 一句人话,而不是兜底的 500「服务器内部错误」。
+     *
+     * <p>依据同 {@code LeshouyingCashierController.handleUnreadableBody}:
+     * 本仓 {@code FAIL_ON_UNKNOWN_PROPERTIES} 为 true,写错一个键名的调用方
+     * 收到「服务器内部错误」会先去排查我们的服务。属性名过滤控制字符后再回显
+     * (防日志注入),与乐收赢那边的 LsyLogSanitizer 同一目的。
+     */
+    @ExceptionHandler(HttpMessageNotReadableException.class)
+    @ResponseStatus(HttpStatus.BAD_REQUEST)
+    public Result<?> handleUnreadableBody(HttpMessageNotReadableException e) {
+        String detail = e.getCause() instanceof UnrecognizedPropertyException upe
+                ? "未知字段 \"" + upe.getPropertyName().replaceAll("\\p{Cntrl}", "")
+                        + "\",报文里的字段名一律用下划线式,请核对"
+                : "请求体不是合法 JSON";
+        log.warn("[汇安融收银台] 请求体解析失败: {}", detail);
+        return Result.of(400, detail);
+    }
+}

+ 37 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarCashierCreateDTO.java

@@ -0,0 +1,37 @@
+package com.payment.platform.module.payment.har.dto;
+
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 收银台取码入参。
+ *
+ * <p>只有金额是必填 —— 操作员唯一必须输入的东西。备注进上游 subject(订单标题)。
+ * 企业号**不在报文里**:归属只来自认证上下文(与乐收赢收银台同一条口径)。
+ */
+@Data
+public class HarCashierCreateDTO {
+
+    /**
+     * 金额(**元** —— HAR 全通道如此,不像乐收赢拿分)。
+     * 下限取 0.01,与后台下单({@link HarOrderCreateDTO#getPrice()})同口径。
+     */
+    @NotNull(message = "金额不能为空")
+    @DecimalMin(value = "0.01", message = "金额必须大于 0")
+    private BigDecimal price;
+
+    /** 选填。备注即上游 subject(订单标题);留空用「扫码收款」 */
+    @Size(max = 64, message = "备注最长 64 位")
+    private String remark;
+
+    /**
+     * 选填。一个租户配了多个商户编号时必须指定(与
+     * {@code HarMerchantService.requireByTenant} 的 400 口径一致:钱进哪个账户必须说清楚)。
+     * 取值必须是**本租户**的商户编号。
+     */
+    private String merchantCode;
+}

+ 141 - 0
java/src/main/java/com/payment/platform/module/payment/har/service/HarCashierService.java

@@ -0,0 +1,141 @@
+package com.payment.platform.module.payment.har.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.payment.har.dto.HarCashierCreateDTO;
+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.HarOrderEntity;
+import com.payment.platform.module.payment.har.enums.HarOrderStatus;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Service;
+
+import java.time.OffsetDateTime;
+
+/**
+ * 汇安融扫码收款(收银台)。
+ *
+ * <p>只做三件事:把「金额(+备注)」装配成一笔**主扫**订单、按单号回状态、
+ * 页面轮询时按节流主动问一次上游。下单与查询两条腿原样复用
+ * {@link HarOrderService}({@code create} / {@code syncFromUpstream} / {@code getByOrderNo}),
+ * 本类不新增任何上游调用。
+ *
+ * <p>【与乐收赢收银台的关系】行为对标 {@code LeshouyingCashierController},
+ * 但三处**有意不同**:
+ * <ol>
+ *   <li>金额单位是**元**(HAR 全通道如此,乐收赢是分);</li>
+ *   <li>没有「撤销」端点 —— HAR 上游没有关单接口({@code HarClient} 只有
+ *       precreate/pay/query/refund),页面上那个「结束」是纯前端动作;</li>
+ *   <li>没有到期倒计时 —— 上游 precreate 响应里只有 pay_url / order_no /
+ *       third_order_no,没有到期字段,编一个倒计时就是编数字。</li>
+ * </ol>
+ *
+ * <p>【租户口径】归属只来自认证上下文({@code requireTenantId});
+ * 查单的租户过滤由 {@code TenantInnerInterceptor} 施加(与 {@code openQuery} /
+ * {@code detail} 同一口径)—— 非超管主体下拦截器会追加 tenant_id 谓词,
+ * 超管本就能看全量(订单管理页同样如此)。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class HarCashierService {
+
+    /**
+     * 页面轮询间隔(秒)—— 与前端 POLL_MS=3000 同值。
+     *
+     * <p>收银台页面开着期间,上游查询的节奏由此常数决定,**不**沿用通道的退避游标
+     * ({@code next_query_time}):顾客站在柜台前等结果,30 秒起步的退避太慢;
+     * 页面关掉后该单回到通道自己的退避节奏({@code syncFromUpstream} 每次都会重排游标)。
+     */
+    static final long POLL_INTERVAL_SECONDS = 3;
+
+    /**
+     * 主扫单固定支付时限(分钟)。上游取值 2..60,默认值上游未公开
+     * ({@code HarOrderCreateDTO.payTimeout} 的注释:「单位以上游为准」)——
+     * 给一个确定值好过把一个未知的默认值留给现场。
+     */
+    static final int PAY_TIMEOUT_MINUTES = 15;
+
+    /** 备注留空时上游 subject 的兜底文案(subject 必填,不能带空串下去)。 */
+    static final String DEFAULT_TITLE = "扫码收款";
+
+    private final HarOrderService orderService;
+
+    /**
+     * 取码:装配一笔主扫订单并交给既有下单腿(本地落库 → precreate → 回填 pay_url)。
+     *
+     * <p>上游明确拒绝时 {@code create} 自己会抛(并已把本地行收尾成「8 交易关闭」),
+     * 这里只补一条它的盲区:上游受理了、但响应里没有 pay_url(解析失败)——
+     * 主扫的全部意义就是那条链接,没有它这笔单在界面上是一张画不出来的二维码。
+     */
+    public HarOrderVO createQr(HarCashierCreateDTO dto) {
+        requireTenantId();
+
+        HarOrderCreateDTO create = new HarOrderCreateDTO();
+        create.setPrice(dto.getPrice());
+        create.setMerchantCode(dto.getMerchantCode());
+        create.setTitle(StrUtil.isBlank(dto.getRemark()) ? DEFAULT_TITLE : dto.getRemark());
+        create.setTradeType("alipay_un_stage");
+        create.setBiz("har_ali_un_stage");
+        create.setPayTimeout(PAY_TIMEOUT_MINUTES);
+
+        // 归属只来自认证上下文:企业号留空(走租户级商户映射),也不挂 apiKey。
+        HarOrderVO vo = orderService.create(create, null, null);
+        if (vo == null || StrUtil.isBlank(vo.getPayUrl())) {
+            throw new BusinessException(500, "二维码生成失败,请重新发起收款");
+        }
+        return vo;
+    }
+
+    /** 按单号回本单快照;非终态且过了节流窗口时先问一次上游。 */
+    public HarOrderVO status(String orderNo) {
+        requireTenantId();
+        HarOrderEntity order = orderService.getByOrderNo(orderNo);
+        if (order == null) {
+            throw new BusinessException(404, "收款单不存在");
+        }
+        return refreshFromUpstream(order);
+    }
+
+    /**
+     * 节流门 + 降级,形状照 {@code LeshouyingCashierController#refreshFromUpstream}:
+     * 终态短路 → 窗口内短路 → 问一次上游(失败按本地快照返回,页面继续轮询)。
+     */
+    private HarOrderVO refreshFromUpstream(HarOrderEntity order) {
+        if (!HarOrderStatus.isPending(order.getTradeStatus())) {
+            return HarOrderVO.of(order);
+        }
+        OffsetDateTime last = order.getLastQueryTime();
+        if (last != null && last.isAfter(OffsetDateTime.now().minusSeconds(POLL_INTERVAL_SECONDS))) {
+            return HarOrderVO.of(order);
+        }
+        try {
+            return orderService.syncFromUpstream(order);
+        } catch (Exception e) {
+            // 一次查询失败不得变成操作员屏幕上的报错:按本地快照照常返回,页面继续轮询。
+            log.warn("[汇安融收银台] 查单未成功,按本地状态返回: order_no={}, err={}",
+                    order.getOrderNo(), e.getMessage());
+            return HarOrderVO.of(order);
+        }
+    }
+
+    /**
+     * 当前登录身份的租户 ID;没有归属直接 403。
+     *
+     * <p>取值与 {@code HarOrderService#getCurrentTenantId} 同形(本仓各持一份私有副本),
+     * 区别只在「为空」的出口:下单腿会在商户查询里报出一句误导的 404(「尚未配置商户编号」),
+     * 而真正的原因是这身份根本没有租户。
+     */
+    private Long requireTenantId() {
+        var auth = SecurityContextHolder.getContext().getAuthentication();
+        Long tenantId = (auth != null && auth.getPrincipal() instanceof LoginUser user)
+                ? user.getTenantId() : null;
+        if (tenantId == null) {
+            throw new BusinessException(403, "当前登录身份没有归属租户,无法执行收款操作,请联系管理员");
+        }
+        return tenantId;
+    }
+}

+ 182 - 0
java/src/main/resources/db/har-cashier-menu.sql

@@ -0,0 +1,182 @@
+-- 汇安融扫码收款(收银台)—— 菜单与权限(2026-10-06)
+--
+-- 【本脚本尚未执行】执行由用户做。执行前先跑文末「只读核对」第 1 条。
+--
+-- ── 本脚本干什么 ──────────────────────────────────────────────────────
+-- 在**已存在**的「汇安融通道」目录(`route_name='payment-har'`,`har-menu.sql` 所建)下,新增:
+--   · 1 行页面(type=2)「扫码收款」 —— 收银台路由
+--   · 1 行按钮(type=3)「发起收款」 —— 取码权限点
+-- 并把这两行授权给 role_id = 1(管理员角色)。
+--   ★ 与乐收赢那份(`lsy-cashier-menu.sql`)**故意不同**:那份把页面/按钮/目录一并授给
+--     role 2(租户角色);这份**只授 role 1**。理由见下「★ 授权给谁」,附现成的放开段。
+--
+-- ── 表结构约定(照抄踩过的坑)────────────────────────────────────────
+--   · `component_path` 是**相对 `src/views/` 的路径**:不带 `views/` 前缀、不带 `.vue` 后缀。
+--     本脚本的值与页面文件对齐:`frontend/src/views/module_payment/har/cashier/index.vue`。
+--     写错会拼成查不到的模块名 ⇒ **静默落到 error/404.vue**(不是报错)。
+--   · 目录那一行是**查**目录、不是插目录(HAR 目录已由 `har-menu.sql` 建好)。
+--   · 目录行本身也必须在 `sys_role_menus` 里(`UserService.buildMenuTree` 只会把 parent
+--     同在集合内的节点挂上树,父不在集合里的子节点被**直接丢弃**,不报错)。
+--     —— 对 role 1 而言这一行执行前就有(`har-menu.sql` 授过);本脚本仍带 NOT EXISTS 兜一道。
+--
+-- ── ★★ 锚 `route_name`(身份),不锚 `name`(标签)──────────────────────
+-- 同 `lsy-cashier-menu.sql:133-137` 的实测理由:那一侧的目录被**人手工改过名**
+-- (2026-09-23 叫「收付款」、09-28 实测已叫「银联收单」,全仓零命中)。
+-- 本目录的 `route_name` 由 `har-menu.sql:19` 写入 = `payment-har`,此后无任何脚本改过它。
+-- 若这里按名字(如「汇安融通道」)查,一旦有人改过标签,`dir` 为空 ⇒ **全脚本静默空跑**。
+--
+-- ── ★ permission 必须与 @PreAuthorize 逐字相同 ──────────────────────────
+-- 差一个字符就是 403,而且**超管测不出来**(`PermissionCheck` 对 `ROLE_SUPERUSER` 直接放行)。
+-- 本脚本两个 permission 逐字取自 **2026-10-06 从源码现取**(未抄任何文档):
+--   HarCashierController.java       @PreAuthorize("@perm.hasAny('module_payment:har:cashier:create')") —— POST /qrcode
+--   HarCashierController.java       @PreAuthorize("@perm.hasAny('module_payment:har:cashier:list')")   —— GET  /{orderNo}
+-- 类级映射 `@RequestMapping("/payment/har/cashier")`(同文件)。
+-- ★ 取值口径是「现取」,不是「抄一次就完」—— 控制器改了这里必须重取。
+-- ★ 钉子:`HarCashierControllerTest#endpoints_carryTheirExactPathsAndPermissionLiterals`
+--   把这两个字面量与注解钉在一起;控制器改了而这里没改,是「菜单给了、点进去 403」。
+--
+-- ── ★★ route_name 与前端组件名必须逐字相同 ────────────────────────────
+-- `<keep-alive :include>` 匹配的是**组件名**,而 tags-view 推进 `cachedViews` 的是**路由名**
+-- (`tags-view.store.ts`)—— 对不上时 vue 不报错,只是**静默不缓存**(乐收赢踩过,`bf09016`)。
+-- 本行 `route_name='payment-har-cashier'` ⇒ 新页面 `defineOptions({ name: "payment-har-cashier" })`,
+-- 两头都已按此写。
+--   ★ 既存的三个 HAR 页面(商户/门店/订单)**没有写 defineOptions** ⇒ 它们的 `keep_alive=true`
+--     今天静默失效。那是**既有缺陷、不在本批次范围**,未顺手改(用户令:不相关的不动)。
+--     本页只保证自己自洽。
+--
+-- ── ★★ 授权给谁:只授 role 1;放开段留给裁定 ──────────────────────────
+-- 2026-10-05 的裁定口径(当日复量):**role 2 在 HAR 子树下只有 276/279,目录 275 不在手**
+-- ⇒ 租户看不见 HAR(与「HAR 未上线」一致)。本页照此只授 role 1。
+-- 为什么不「先授 role 2 页面、等将来再说」:`buildMenuTree` 父节点缺席 ⇒ 子节点被静默丢弃,
+-- 只授页面/按钮**等于没授**(角色 2 什么都看不到);而放开视野的最小动作是**连目录 275
+-- 一起授**,那会让 17 个租户用户看到「汇安融通道」目录 —— 是否现在这么做不是本脚本能定的
+-- (har-menu.sql 对 role 2 的门店/订单授权同样要等这个裁定)。⇒ 现成放开段见脚本末尾。
+--
+-- ★ 如实写代价:本页写权限 `module_payment:har:cashier:create` 是一条**新的资金入口**
+--   (真的能产生收款的单)。只授 role 1 ⇒ 今天只有管理员与超管能用;role 2 何时放开见上。
+--
+-- ── 幂等(与 lsy-cashier-menu.sql 同形,两种相反情形都不报错)──────────
+--   1) 页面行已存在 ⇒ `page` 为空 ⇒ 页面与按钮都不插(`buttons` 是 `FROM page`)且不报错。
+--   2) 目录不存在 ⇒ `dir` 为空 ⇒ 三行都不插,且不报错。执行前先跑文末第 1 条只读核对。
+--
+-- ── "order" ──────────────────────────────────────────────────────────
+-- 用 `MAX("order") + 1` **现算**,不写死数字(HAR 目录下现有子行 1/2/3,本行应拿到 4;
+-- 有人手工调过序时不撞车。`sys_menu` 对 (parent_id, "order") 没有唯一约束 ⇒ 撞了不报错、
+-- 只是两行同序)。
+--
+-- ── ★ 已知未验证点(如实)────────────────────────────────────────────
+-- HAR 的 `pay_url` 至今**没有人真扫过**:通道四接口在测试环境打过,但主扫收款链接从未被
+-- 一部真实手机扫过。上线后需用真实小额(如 1 元)实弹验证一次「取码 → 顾客扫 → 变绿」。
+
+WITH dir AS (
+    SELECT id FROM sys_menu
+     WHERE route_name = 'payment-har' AND type = 1 AND status = '0' AND parent_id IS NULL
+),
+page AS (
+    INSERT INTO sys_menu (name, type, "order", permission, icon, route_name, route_path, component_path,
+                          redirect, hidden, keep_alive, always_show, affix, title, params,
+                          uuid, parent_id, status, created_time, updated_time)
+    SELECT '扫码收款', 2,
+           (SELECT COALESCE(MAX("order"), 0) + 1 FROM sys_menu WHERE parent_id = dir.id),
+           'module_payment:har:cashier:list', NULL,
+           'payment-har-cashier', '/payment/har/cashier',
+           'module_payment/har/cashier/index',
+           NULL, false, true, false, false, '扫码收款', NULL,
+           gen_random_uuid(), dir.id, '0', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+    FROM dir
+    WHERE NOT EXISTS (SELECT 1 FROM sys_menu
+                       WHERE name = '扫码收款' AND parent_id = dir.id AND status = '0')
+    RETURNING id
+),
+buttons AS (
+    INSERT INTO sys_menu (name, type, "order", permission, icon, route_name, route_path, component_path,
+                          redirect, hidden, keep_alive, always_show, affix, title, params,
+                          uuid, parent_id, status, created_time, updated_time)
+    SELECT v.name, 3, v.ord, v.perm, NULL, NULL, NULL, NULL,
+           NULL, false, false, false, false, v.name, NULL,
+           gen_random_uuid(), page.id, '0', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+    FROM page,
+         (VALUES
+            ('发起收款', 1, 'module_payment:har:cashier:create')
+         ) AS v(name, ord, perm)
+    RETURNING id
+)
+-- 页面行与按钮行**都要**授权:菜单树按 sys_role_menus 过滤,只授页面会让按钮权限缺席 ⇒ 403。
+-- 目录行一并带上 NOT EXISTS 兜底(role 1 执行前就持有,兜的是「被谁删过」这种情况)。
+INSERT INTO sys_role_menus (role_id, menu_id)
+SELECT r.role_id, x.id
+  FROM (SELECT id FROM page
+        UNION ALL SELECT id FROM buttons
+        UNION ALL SELECT d.id FROM dir d) AS x
+ CROSS JOIN (VALUES (1)) AS r(role_id)
+ WHERE NOT EXISTS (SELECT 1 FROM sys_role_menus rm
+                    WHERE rm.role_id = r.role_id AND rm.menu_id = x.id);
+
+
+-- ── 放开段(**不要**和上面一起跑;等「HAR 对租户开放」的裁定)──────────
+-- 裁定为「开放」时,单独跑这一段即可(与上面同形,只是把 role 2 补上;
+-- 目录 275 这一行是必须的 —— 父不在集合里,子节点会被 buildMenuTree 静默丢弃)。
+--
+-- INSERT INTO sys_role_menus (role_id, menu_id)
+-- SELECT r.role_id, x.id
+--   FROM (SELECT id FROM sys_menu
+--          WHERE (route_name = 'payment-har-cashier' AND type = 2)
+--             OR (permission = 'module_payment:har:cashier:create' AND type = 3)
+--             OR (route_name = 'payment-har' AND type = 1)) AS x
+--  CROSS JOIN (VALUES (2)) AS r(role_id)
+--  WHERE NOT EXISTS (SELECT 1 FROM sys_role_menus rm
+--                     WHERE rm.role_id = r.role_id AND rm.menu_id = x.id);
+--
+-- ★ 收回(万一放开后又决定收回):删 role 2 对这三行的授权即可 ——
+--   DELETE FROM sys_role_menus
+--    WHERE role_id = 2
+--      AND menu_id IN (SELECT id FROM sys_menu
+--                       WHERE route_name IN ('payment-har', 'payment-har-cashier')
+--                          OR permission = 'module_payment:har:cashier:create');
+--   ★ 收回**目录那一行**(route_name='payment-har')才是让整个目录从角色 2 视野消失的那一下。
+
+
+-- ── 只读核对(执行后手工跑一遍;第 1 条**执行前**也先跑)────────────────
+--
+-- 1) 目录在不在(**为空则本脚本什么都不会插,且不报错**)
+--   SELECT id, name, title, type, route_name, route_path FROM sys_menu
+--    WHERE route_name = 'payment-har' AND type = 1 AND status = '0' AND parent_id IS NULL;
+--   期望:**1 行**(2026-10-05 复量时该目录 id=275)。查的是 `route_name` 而不是 `name`(同 CTE 的理由)。
+--
+-- 2) 页面行
+--   SELECT id, name, type, "order", permission, route_name, route_path, component_path,
+--          keep_alive, uuid
+--     FROM sys_menu WHERE name = '扫码收款' AND status = '0'
+--      AND parent_id = (SELECT id FROM sys_menu
+--                        WHERE route_name = 'payment-har' AND type = 1 AND status = '0' AND parent_id IS NULL);
+--   期望:**1 行**,type=2,permission='module_payment:har:cashier:list',
+--         route_name='payment-har-cashier',route_path='/payment/har/cashier',
+--         component_path='module_payment/har/cashier/index',
+--         keep_alive=true,uuid 非空(36 字符)。
+--   ★ 若出现**2 行**:`name` 撞了乐收赢那份脚本的「扫码收款」(它在另一个父目录下,正常;
+--     本查询已按 parent 限定,若仍 2 行说明同一目录里真重复了,先查清再动)。
+--
+-- 3) 按钮行(**漏了 ⇒ 非超管点「发起收款」必 403,而超管测不出来**)
+--   SELECT m.id, m.name, m.type, m."order", m.permission
+--     FROM sys_menu m JOIN sys_menu p ON m.parent_id = p.id
+--    WHERE p.name = '扫码收款' AND m.type = 3 AND m.status = '0'
+--      AND p.route_name = 'payment-har-cashier'
+--    ORDER BY m."order";
+--   期望:**1 行**,permission 逐字为 'module_payment:har:cashier:create'。
+--
+-- 4) 授权行
+--   SELECT rm.role_id, m.id, m.name, m.type, m.permission
+--     FROM sys_role_menus rm JOIN sys_menu m ON m.id = rm.menu_id
+--    WHERE m.status = '0'
+--      AND (m.route_name IN ('payment-har-cashier', 'payment-har')
+--           OR m.permission = 'module_payment:har:cashier:create')
+--    ORDER BY rm.role_id, m.type, m.id;
+--   期望:**3 行**(role 1:目录 + 扫码收款 + 发起收款)。role 2 **一行都没有** ——
+--        那是本脚本的授权范围决定,不是漏授(理由见文件头)。
+--
+-- 5) 端到端(**唯一能证明权限串没写错的一条**):用一个**非超管**的账号登录,
+--    确认两个 `module_payment:har:cashier:*` 都出现在
+--    `MenuMapper.selectPermissionsByUserId` 的返回里。
+--    ★ 超管账号**证明不了任何事** —— 它对 ROLE_SUPERUSER 直接放行。
+--    ★ 今天 role 2 的成员看不到本页(目录未授),这一步要用**非超管但持有 role 1**的账号,
+--      或先跑放开段再用租户账号验。

+ 197 - 0
java/src/test/java/com/payment/platform/module/payment/har/controller/HarCashierControllerTest.java

@@ -0,0 +1,197 @@
+package com.payment.platform.module.payment.har.controller;
+
+import com.payment.platform.common.config.JacksonConfig;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.exception.GlobalExceptionHandler;
+import com.payment.platform.module.payment.har.dto.HarCashierCreateDTO;
+import com.payment.platform.module.payment.har.dto.HarOrderVO;
+import com.payment.platform.module.payment.har.service.HarCashierService;
+import org.hamcrest.Matchers;
+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.http.MediaType;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+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.Method;
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 收银台两个端点的**出口形态**与接线。
+ *
+ * <p>本类钉四件事:
+ * <ol>
+ *   <li><b>路径与权限串逐字</b> —— 前端 API 层与交付菜单 SQL 都按这两条对齐;
+ *       权限判定是全等匹配,差一个字符即 403。</li>
+ *   <li><b>报文的字段名是下划线式</b> —— 全局 SNAKE_CASE 策略;这条同时是
+ *       「前端按 snake_case 写」的机读契约。</li>
+ *   <li><b>金额是操作员唯一的输入</b> —— 空/0/负数必须当场 400,不带去上游。</li>
+ *   <li><b>未知字段 400 不是 500</b> —— 兜底 500 会让写错键名的调用方以为我们崩了。</li>
+ * </ol>
+ *
+ * <p>服务行为由 {@code HarCashierServiceTest} 覆盖,两者不重叠。形态照
+ * {@code LeshouyingCashierControllerTest}:standaloneSetup + 真 GlobalExceptionHandler
+ * + 应用真实 ObjectMapper(出口的 HTTP 码 / 业务码 / 字段命名由它们决定)。
+ */
+@ExtendWith(MockitoExtension.class)
+class HarCashierControllerTest {
+
+    private static final String PREFIX = "/payment/har/cashier";
+    private static final String OTN = "CS1789012345678901234";
+    private static final String PAY_URL = "https://qr.alipay.com/xyz";
+
+    @Mock private HarCashierService service;
+
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        HarCashierController controller = new HarCashierController(service);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller)
+                .setControllerAdvice(new GlobalExceptionHandler())
+                .setMessageConverters(new MappingJackson2HttpMessageConverter(
+                        new JacksonConfig().objectMapper()))
+                .build();
+    }
+
+    // ───────────────────────── 一、路径与权限串 ─────────────────────────
+
+    /**
+     * 两个端点各自的路径与权限串**逐字**钉死。
+     *
+     * <p>跨文件的那一半(菜单 SQL 的 permission 列)靠交付脚本头部的「现取」口径对齐;
+     * 这里钉的是注解本身。两条串必须互不相同 —— 一个菜单行只装一个串,
+     * 复用会让「能看状态」直接变成「能取码建单」。
+     */
+    @Test
+    void endpoints_carryTheirExactPathsAndPermissionLiterals() throws Exception {
+        assertEquals(PREFIX,
+                HarCashierController.class.getAnnotation(RequestMapping.class).value()[0],
+                "前端 API 层与菜单 SQL 都按这个前缀走");
+
+        Method createQr = HarCashierController.class.getDeclaredMethod(
+                "createQr", HarCashierCreateDTO.class);
+        assertArrayEquals(new String[]{"/qrcode"},
+                createQr.getAnnotation(PostMapping.class).value(), "取码要建单,必须是 POST");
+        PreAuthorize createPa = createQr.getAnnotation(PreAuthorize.class);
+        assertNotNull(createPa, "createQr 缺 @PreAuthorize ⇒ 该端点不受权限管控");
+        assertEquals("@perm.hasAny('module_payment:har:cashier:create')", createPa.value(),
+                "权限串要和交付菜单 SQL 逐字相同");
+
+        Method status = HarCashierController.class.getDeclaredMethod("status", String.class);
+        assertArrayEquals(new String[]{"/{orderNo}"},
+                status.getAnnotation(GetMapping.class).value());
+        PreAuthorize statusPa = status.getAnnotation(PreAuthorize.class);
+        assertNotNull(statusPa, "status 缺 @PreAuthorize ⇒ 该端点不受权限管控");
+        assertEquals("@perm.hasAny('module_payment:har:cashier:list')", statusPa.value(),
+                "权限串要和交付菜单 SQL 逐字相同");
+
+        assertNotEquals(createPa.value(), statusPa.value(),
+                "两个端点不得复用同一个权限串");
+    }
+
+    // ───────────────────────── 二、取码端点 ─────────────────────────
+
+    /** 取码:入参下划线式落到 DTO,出参 VO 下划线式回给页面。 */
+    @Test
+    void createQr_forwardsToService_returnsSnakeCaseVo() throws Exception {
+        when(service.createQr(any())).thenReturn(vo("0", PAY_URL));
+
+        mockMvc.perform(post(PREFIX + "/qrcode")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"price\":1.23,\"remark\":\"3 号桌\",\"merchant_code\":\"M001\"}"))
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data.order_no").value(OTN))
+                .andExpect(jsonPath("$.data.pay_url").value(PAY_URL))
+                .andExpect(jsonPath("$.data.trade_status").value("0"));
+
+        ArgumentCaptor<HarCashierCreateDTO> captor = ArgumentCaptor.forClass(HarCashierCreateDTO.class);
+        verify(service).createQr(captor.capture());
+        assertEquals(new BigDecimal("1.23"), captor.getValue().getPrice());
+        assertEquals("3 号桌", captor.getValue().getRemark());
+        assertEquals("M001", captor.getValue().getMerchantCode(),
+                "报文字段名是下划线式(全局 SNAKE_CASE)");
+    }
+
+    /** 金额是操作员唯一的输入 ⇒ 空/0/负数当场拒掉,一个服务调用都不发。 */
+    @Test
+    void createQr_nonPositiveOrMissingPrice_is400_withoutTouchingService() throws Exception {
+        for (String body : new String[]{"{}", "{\"price\":0}", "{\"price\":-1}"}) {
+            mockMvc.perform(post(PREFIX + "/qrcode")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(body))
+                    .andExpect(status().isBadRequest())
+                    .andExpect(jsonPath("$.code").value(400))
+                    .andExpect(jsonPath("$.msg").value(Matchers.containsString("price")));
+        }
+
+        verifyNoInteractions(service);
+    }
+
+    /**
+     * 未知字段(如服务端决定的 {@code tenant_id})⇒ 400 + 一句人话,
+     * **不是** HTTP 500「服务器内部错误」。
+     */
+    @Test
+    void createQr_unknownBodyField_is400WithHint_notServerError() throws Exception {
+        mockMvc.perform(post(PREFIX + "/qrcode")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"price\":1.23,\"tenant_id\":999}"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.msg").value(Matchers.containsString("未知字段")));
+
+        verifyNoInteractions(service);
+    }
+
+    // ───────────────────────── 三、查单端点 ─────────────────────────
+
+    @Test
+    void status_returnsServiceSnapshot() throws Exception {
+        when(service.status(OTN)).thenReturn(vo("1", PAY_URL));
+
+        mockMvc.perform(get(PREFIX + "/" + OTN))
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data.trade_status").value("1"))
+                .andExpect(jsonPath("$.data.pay_url").value(PAY_URL));
+    }
+
+    /** 查不到 ⇒ 业务码 404(HTTP 200 + body code,与全仓业务异常出口同形),不是 500。 */
+    @Test
+    void status_missingOrder_reports404BusinessCode_not500() throws Exception {
+        when(service.status(OTN)).thenThrow(new BusinessException(404, "收款单不存在"));
+
+        mockMvc.perform(get(PREFIX + "/" + OTN))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(404))
+                .andExpect(jsonPath("$.msg").value("收款单不存在"));
+    }
+
+    // ───────────────────────── helpers ─────────────────────────
+
+    private static HarOrderVO vo(String status, String payUrl) {
+        HarOrderVO vo = new HarOrderVO();
+        vo.setOrderNo(OTN);
+        vo.setTradeStatus(status);
+        vo.setPayUrl(payUrl);
+        vo.setPrice(new BigDecimal("1.23"));
+        return vo;
+    }
+}

+ 279 - 0
java/src/test/java/com/payment/platform/module/payment/har/service/HarCashierServiceTest.java

@@ -0,0 +1,279 @@
+package com.payment.platform.module.payment.har.service;
+
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.module.payment.har.dto.HarCashierCreateDTO;
+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.HarOrderEntity;
+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.context.SecurityContextHolder;
+
+import java.math.BigDecimal;
+import java.time.OffsetDateTime;
+
+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> —— 上下文里没有租户 ⇒ 403,且一个下游调用都不发。</li>
+ *   <li><b>取码装配的不变量</b> —— 主扫(authCode 空)、固定 trade_type/biz/pay_timeout、
+ *       备注进 title(上游 subject 必填);企业号这一格**报文里不存在**。</li>
+ *   <li><b>节流是真的</b> —— 窗口内不再问上游;窗口外才问一次。收银台页面每 3 秒一拉,
+ *       没有这条,`syncFromUpstream` 的退避节奏会被页面轮询打穿。</li>
+ *   <li><b>上游失败要降级</b> —— 查询抛异常不得变成操作员屏幕上的报错,按本地快照照常返回。</li>
+ * </ol>
+ *
+ * <p>【为什么 mock 服务而不是真跑】本类只管收银台自己那层装配与门控;
+ * 下单/查询腿的行为由 {@code HarOrderServiceTest} 覆盖,两者不重叠。
+ */
+@ExtendWith(MockitoExtension.class)
+class HarCashierServiceTest {
+
+    /** 认证上下文里的租户 —— 「归属只许来自这里」的那个值。 */
+    private static final long TENANT = 7L;
+
+    private static final String OTN = "CS1789012345678901234";
+    private static final String PAY_URL = "https://qr.alipay.com/xyz";
+
+    @Mock private HarOrderService orderService;
+
+    private HarCashierService service;
+
+    @BeforeEach
+    void setUp() {
+        service = new HarCashierService(orderService);
+    }
+
+    @AfterEach
+    void clearContext() {
+        SecurityContextHolder.clearContext();
+    }
+
+    // ───────────────────────── 一、归属只来自登录身份 ─────────────────────────
+
+    /** 上下文里没有租户 ⇒ 403,且**一个下游调用都不发**。 */
+    @Test
+    void createQr_withoutTenantInContext_is403_andTouchesNothingElse() {
+        SecurityContextHolder.clearContext();
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.createQr(dto(new BigDecimal("1.00"), null)));
+
+        assertEquals(403, e.getCode());
+        verifyNoInteractions(orderService);
+    }
+
+    /** 查单同样先过租户闸:没有归属就 403,不去碰任何订单查询。 */
+    @Test
+    void status_withoutTenantInContext_is403_andTouchesNothingElse() {
+        SecurityContextHolder.clearContext();
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.status(OTN));
+
+        assertEquals(403, e.getCode());
+        verifyNoInteractions(orderService);
+    }
+
+    // ───────────────────────── 二、取码装配的不变量 ─────────────────────────
+
+    /**
+     * 交给 {@code HarOrderService.create} 的那份 DTO 必须是这个形状。
+     *
+     * <p>★ {@code enterpriseId} 与 apiKey 两个位置参数都必须是 null:归属只来自认证上下文,
+     * 报文里没有企业号这一格 —— 第 ① 分支(显式 merchant_code)之外的商户寻址只能是租户级。
+     */
+    @Test
+    void createQr_assemblesMainScanOrder_originComesFromContextOnly() {
+        HarTestAuth.asTenant(TENANT, () -> {
+            when(orderService.create(any(), any(), any())).thenReturn(vo("0", PAY_URL));
+            HarCashierCreateDTO dto = dto(new BigDecimal("12.34"), "3 号桌");
+            dto.setMerchantCode("M001");
+
+            service.createQr(dto);
+
+            ArgumentCaptor<HarOrderCreateDTO> captor = ArgumentCaptor.forClass(HarOrderCreateDTO.class);
+            verify(orderService).create(captor.capture(), isNull(), isNull());
+            HarOrderCreateDTO c = captor.getValue();
+            assertNull(c.getAuthCode(), "收银台是主扫:不带付款码,走 precreate 取码");
+            assertNull(c.getEnterpriseId(), "归属只来自认证上下文:企业号这一格不存在");
+            assertEquals(new BigDecimal("12.34"), c.getPrice());
+            assertEquals("3 号桌", c.getTitle(), "备注就是上游 subject(订单标题)");
+            assertEquals("alipay_un_stage", c.getTradeType());
+            assertEquals("har_ali_un_stage", c.getBiz());
+            assertEquals(15, c.getPayTimeout().intValue(), "固定支付时限,见 PAY_TIMEOUT_MINUTES");
+            assertEquals("M001", c.getMerchantCode());
+            return null;
+        });
+    }
+
+    /** 备注留空/全空白 ⇒ title 兜底(上游 subject 必填,不能带空串下去)。 */
+    @Test
+    void createQr_blankRemark_fallsBackToDefaultTitle() {
+        HarTestAuth.asTenant(TENANT, () -> {
+            when(orderService.create(any(), any(), any())).thenReturn(vo("0", PAY_URL));
+
+            service.createQr(dto(new BigDecimal("1.00"), null));
+            service.createQr(dto(new BigDecimal("1.00"), "   "));
+
+            ArgumentCaptor<HarOrderCreateDTO> captor = ArgumentCaptor.forClass(HarOrderCreateDTO.class);
+            verify(orderService, times(2)).create(captor.capture(), any(), any());
+            for (HarOrderCreateDTO c : captor.getAllValues()) {
+                assertEquals("扫码收款", c.getTitle());
+            }
+            return null;
+        });
+    }
+
+    /**
+     * 上游受理了但拿不到收款链接 ⇒ 500 + 可重试文案。
+     *
+     * <p>主扫的全部意义就是那条链接;没有它这笔单在界面上是一张画不出来的二维码。
+     * 报成功等于让操作员盯着一张空码。
+     */
+    @Test
+    void createQr_whenPayUrlMissing_reportsRetryableFailure() {
+        HarTestAuth.asTenant(TENANT, () -> {
+            when(orderService.create(any(), any(), any())).thenReturn(vo("0", null));
+
+            BusinessException e = assertThrows(BusinessException.class,
+                    () -> service.createQr(dto(new BigDecimal("1.00"), null)));
+
+            assertEquals(500, e.getCode());
+            assertTrue(e.getMessage().contains("二维码生成失败"), "实测: " + e.getMessage());
+            return null;
+        });
+    }
+
+    // ───────────────────────── 三、查单 + 节流 + 降级 ─────────────────────────
+
+    /** 查不到(不存在或别家租户的)⇒ 404,**且不去打扰上游**。 */
+    @Test
+    void status_missingOrder_is404_andNeverAsksUpstream() {
+        HarTestAuth.asTenant(TENANT, () -> {
+            when(orderService.getByOrderNo(OTN)).thenReturn(null);
+
+            BusinessException e = assertThrows(BusinessException.class, () -> service.status(OTN));
+
+            assertEquals(404, e.getCode());
+            verify(orderService, never()).syncFromUpstream(any());
+            return null;
+        });
+    }
+
+    /** 还没查过(lastQueryTime 为空)⇒ 立即问一次上游,并把**查完之后**的快照回给页面。 */
+    @Test
+    void status_pendingAndNeverQueried_asksUpstream_andReportsSettledState() {
+        HarTestAuth.asTenant(TENANT, () -> {
+            HarOrderEntity row = row("0", null);
+            when(orderService.getByOrderNo(OTN)).thenReturn(row);
+            // 真 syncFromUpstream 的文档化副作用就是「查到什么就写什么并回快照」,桩按同一形状回。
+            when(orderService.syncFromUpstream(row)).thenReturn(vo("1", PAY_URL));
+
+            HarOrderVO got = service.status(OTN);
+
+            assertEquals("1", got.getTradeStatus(), "页面拿到的必须是查完之后的状态");
+            verify(orderService).syncFromUpstream(row);
+            return null;
+        });
+    }
+
+    /** 上一个窗口还没走完 ⇒ 这一轮不问上游(否则「每 3 秒一问」会退化成「每次轮询一问」)。 */
+    @Test
+    void status_queriedWithinWindow_skipsUpstream() {
+        HarTestAuth.asTenant(TENANT, () -> {
+            HarOrderEntity row = row("0", OffsetDateTime.now());
+            when(orderService.getByOrderNo(OTN)).thenReturn(row);
+
+            HarOrderVO got = service.status(OTN);
+
+            assertEquals("0", got.getTradeStatus());
+            verify(orderService, never()).syncFromUpstream(any());
+            return null;
+        });
+    }
+
+    /** 窗口刚过 ⇒ 下一轮照常问上游(节流不是「只问一次」)。 */
+    @Test
+    void status_justPastWindow_asksAgain() {
+        HarTestAuth.asTenant(TENANT, () -> {
+            HarOrderEntity row = row("0", OffsetDateTime.now().minusSeconds(4));
+            when(orderService.getByOrderNo(OTN)).thenReturn(row);
+            when(orderService.syncFromUpstream(row)).thenReturn(vo("0", PAY_URL));
+
+            service.status(OTN);
+
+            verify(orderService).syncFromUpstream(row);
+            return null;
+        });
+    }
+
+    /** 已经是终态 ⇒ 永不再问上游(这条是「轮询不烧上游配额」的唯一保证)。 */
+    @Test
+    void status_terminal_neverAsksUpstream() {
+        HarTestAuth.asTenant(TENANT, () -> {
+            HarOrderEntity row = row("1", OffsetDateTime.now().minusDays(1));
+            when(orderService.getByOrderNo(OTN)).thenReturn(row);
+
+            HarOrderVO got = service.status(OTN);
+
+            assertEquals("1", got.getTradeStatus());
+            verify(orderService, never()).syncFromUpstream(any());
+            return null;
+        });
+    }
+
+    /** 上游这一跳失败 ⇒ **降级**:照常回本地快照,页面继续轮询,不是一屏报错。 */
+    @Test
+    void status_upstreamFailure_degradesToLocalSnapshot() {
+        HarTestAuth.asTenant(TENANT, () -> {
+            HarOrderEntity row = row("0", OffsetDateTime.now().minusSeconds(30));
+            when(orderService.getByOrderNo(OTN)).thenReturn(row);
+            when(orderService.syncFromUpstream(row))
+                    .thenThrow(new RuntimeException("上游连接超时"));
+
+            HarOrderVO got = service.status(OTN);
+
+            assertEquals("0", got.getTradeStatus());
+            return null;
+        });
+    }
+
+    // ───────────────────────── helpers ─────────────────────────
+
+    private static HarCashierCreateDTO dto(BigDecimal price, String remark) {
+        HarCashierCreateDTO dto = new HarCashierCreateDTO();
+        dto.setPrice(price);
+        dto.setRemark(remark);
+        return dto;
+    }
+
+    private static HarOrderVO vo(String status, String payUrl) {
+        HarOrderVO vo = new HarOrderVO();
+        vo.setOrderNo(OTN);
+        vo.setTradeStatus(status);
+        vo.setPayUrl(payUrl);
+        vo.setPrice(new BigDecimal("1.23"));
+        return vo;
+    }
+
+    private static HarOrderEntity row(String status, OffsetDateTime lastQueryTime) {
+        HarOrderEntity e = new HarOrderEntity();
+        e.setOrderNo(OTN);
+        e.setTradeStatus(status);
+        e.setLastQueryTime(lastQueryTime);
+        return e;
+    }
+}