Ver código fonte

feat(乐收赢): 顾客自助收银台页面(免登录 /#/checkout/:token)

这一屏面对的是**正要付钱的人**,所以只做三件事、顺序固定:
显示商户名 → 顾客输一个金额 → 发起支付并跳到上游收银台。没有第四件。

三处刻意的取舍:

1. **自己建 axios 实例,不挂任何拦截器**。`@/utils/request` 的两个拦截器都是给管理端写的,
   在顾客页会闯祸:请求拦截器自动附 `Authorization`(这台浏览器里可能存着一个管理端令牌 ——
   顾客页免登录,带上它只是徒增变量);响应拦截器用 `ElMessage` **弹窗**报错、并对非 GET 的
   成功响应弹「操作成功」。顾客页的错误必须**写在页面上**:他不会去读一个 3 秒后消失的浮层,
   而那正是他付款失败的那一刻。
2. **元→分按字符串拆,不做浮点乘法**。`Math.round(元 * 100)` 在这条式子对合法输入也成立,
   但那是碰巧:它依赖 IEEE754 的误差上界,而拆字符串顺手把「不是合法金额」在同一个函数里
   判掉(返回 null)。变异实测:改回浮点法会红 6 条 —— 但要说清它红在哪,
   红的是**拒绝类**向量与安全整数守卫(`1.2.3` / `.` / 超 17 位),
   而 ≤2 位小数的合法输入两种写法同值。不能把它说成「浮点会算错钱」。
3. **拿不到商户名/链接不可用/网络不通三态共用同一条 `invalid` 出口**。这条腿的失败里
   「链接坏了」远多于「网络坏了」,而顾客对两者的反应是同一个动作(找收款方);
   给一句「网络异常,请重试」会让他在一个永远不会成功的链接上反复重试。

免登录是**按路由名**放行的(`permission.ts` 的 `PUBLIC_ROUTE_NAMES`),判在登录态分支**之前**:
一个浏览器里存着管理端令牌的人(操作员自己、或刚给顾客演示完的那台手机)打开收款链接时,
若走进已登录分支,`handleAuthenticatedUser` 会先拉用户信息/企业列表再生成动态路由,
其中**任何一步抛异常都会把页面重定向到 /login**。顾客页的可用性不该由「这台浏览器里有没有
一个过期的管理端令牌」决定。
★ 不用 `to.path.startsWith("/checkout/")`:那是一条会随将来新增页面**变松**的规则 ——
哪天有人在 `/checkout/...` 下加一个管理端页面,它会在无人察觉的情况下对未登录用户开放。

执行级验证台(本仓无测试框架、`ts:check` 有历史非零基线):真 SFC 编译 + 真 vue +
驱动真函数,**65 条断言**,两向变异七条(M1 浮点法 / M2 下限改 1 分 / M3 免登录判定挪进
已登录分支 / M3b 挪到 try 末尾 / M4 无害改名 / M5 二维码只编码 token / M6 路由名改名),
每条都先验「变异体确实生效」再读红,还原后逐文件 sha256 复核回基线;M4 实测仍 65/65 绿。
alphaH 2 semanas atrás
pai
commit
6c663b9776

+ 29 - 1
frontend/src/plugins/permission.ts

@@ -4,8 +4,25 @@ import { Auth } from "@/utils/auth";
 import router from "@/router";
 import { usePermissionStore, useUserStore, useEnterpriseStore } from "@/store";
 
+/**
+ * 免登录放行判据 —— **按路由名**,不是按路径前缀。
+ *
+ * ★ 为什么不用 `to.path.startsWith("/checkout/")`:那是一条**会随将来新增页面变松**的规则 ——
+ *   哪天有人在 `/checkout/...` 下加一个管理端页面,它会在无人察觉的情况下对未登录用户开放。
+ *   按名字放行时,「公开」必须是一次**显式**的声明(路由表里那个 `name`)。
+ * ★ 也不要按 token 形状收紧(`/^\/checkout\/[0-9a-f]{32}$/`):那会把前端钉死在后端
+ *   `IdUtil.fastSimpleUUID()` 这一种令牌形状上,换一种生成方式就等于「所有链接都打不开」。
+ * ★ 只放**顾客页**,`/login` 仍走下面的精确清单 —— 它在已登录时要重定向到首页,
+ *   语义不同,别合并。
+ */
+const PUBLIC_ROUTE_NAMES = new Set(["Checkout"]);
+
+function isPublicPage(to: RouteLocationNormalized): boolean {
+  return to.matched.some((r) => typeof r.name === "string" && PUBLIC_ROUTE_NAMES.has(r.name));
+}
+
 export function setupPermission() {
-  // 白名单路由
+  // 白名单路由(精确匹配)
   const whiteList = ["/login"];
 
   router.beforeEach(async (to, from, next) => {
@@ -14,6 +31,17 @@ export function setupPermission() {
     try {
       const isLoggedIn = Auth.isLoggedIn();
 
+      // ★ 免登录页**先判、且先于登录态分支** —— 顺序不是风格:
+      //   一个浏览器里存着管理端令牌的人(操作员自己、或刚给顾客演示完的那台手机)打开收款
+      //   链接时,若走进已登录分支,`handleAuthenticatedUser` 会先拉用户信息 / 企业列表、
+      //   再生成动态路由,其中**任何一步抛异常都会把页面重定向到 `/login`**
+      //   (那条分支的 catch 就是干这个的)。顾客页的可用性不该由「这台浏览器里有没有
+      //   一个过期的管理端令牌」决定。
+      if (isPublicPage(to)) {
+        next();
+        return;
+      }
+
       if (isLoggedIn) {
         // 如果已登录但访问登录页,重定向到首页
         if (to.path === "/login") {

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

@@ -36,6 +36,21 @@ export const constantRoutes: RouteRecordRaw[] = [
     meta: { hidden: true, title: "401" },
     component: () => import("@/views/error/401.vue"),
   },
+  {
+    // 顾客页(收款链接 `/#/checkout/<token>` 的落点)—— **免登录**,所以必须放在
+    // `constantRoutes` 里:动态路由要先生成权限表,而打开链接的人没有登录态。
+    //
+    // ★ `name` 是**对外承诺**,不是本地标签:免登录放行判据读的就是它
+    //   (`plugins/permission.ts` 的 `PUBLIC_ROUTE_NAMES`)。改名要把两边一起改,
+    //   否则顾客页会静默变回「跳登录页」—— 页面本身没有任何报错。
+    // ★ 路径形状由后端决定:`LsyCheckoutLinkService#buildUrl` 拼的是
+    //   `<base>/#/checkout/<token>`(hash 路由,`createWebHashHistory`),
+    //   所以这里带不带前导 `/` 都一样,但**必须**是 `checkout/:token` 两段。
+    path: "/checkout/:token",
+    name: "Checkout",
+    meta: { hidden: true, title: "收款" },
+    component: () => import("@/views/checkout/index.vue"),
+  },
   {
     path: "/404",
     name: "404",

+ 485 - 0
frontend/src/views/checkout/index.vue

@@ -0,0 +1,485 @@
+<template>
+  <div class="checkout">
+    <div class="checkout__card">
+      <!-- ① 载入中:只占位,不给任何文案 —— 顾客看到的第一次「有内容」必须是真内容 -->
+      <div v-if="phase === 'loading'" class="skeleton">
+        <div class="skeleton__line skeleton__line--title"></div>
+        <div class="skeleton__line skeleton__line--box"></div>
+        <div class="skeleton__line skeleton__line--btn"></div>
+      </div>
+
+      <!-- ② 链接不可用:未知 / 空 / 已停用**共用同一句**(服务端就是这么回的,
+           不告诉打开链接的人「这条链接存在但被停用」)。这一步**没有任何操作**可做,
+           所以不放按钮 —— 放一个点了没用的按钮比不放更糟。 -->
+      <div v-else-if="phase === 'invalid'" class="state">
+        <div class="state__icon state__icon--warn">!</div>
+        <h2 class="state__title">收款链接无效或已停用</h2>
+        <p class="state__hint">请向收款方重新获取链接,或直接扫描对方出示的收款码。</p>
+      </div>
+
+      <!-- ③ 正常:商户名 + 金额 + 一个按钮。顾客页只有这一屏、只有这一个输入框。 -->
+      <template v-else>
+        <header class="head">
+          <!-- ★ 商户名可能为 null(发链接时那一行没填名字)⇒ 用兜底标题,
+               不显示一个空白标题。文案取自后端 LsyCheckoutInfoVO 的注释。 -->
+          <p class="head__label">付款给</p>
+          <h1 class="head__name">{{ merchantName || "收款" }}</h1>
+        </header>
+
+        <div class="amount" :class="{ 'amount--error': !!hint }">
+          <span class="amount__symbol">¥</span>
+          <input
+            ref="amountEl"
+            v-model="amountText"
+            class="amount__input"
+            type="text"
+            inputmode="decimal"
+            autocomplete="off"
+            placeholder="0.00"
+            :disabled="submitting || redirecting"
+            @input="onInput"
+            @keyup.enter="submit"
+          />
+        </div>
+        <p class="hint" :class="{ 'hint--error': !!hint }">
+          {{ hint || `最低 ${MIN_FEN / 100} 元` }}
+        </p>
+
+        <button class="pay" :disabled="submitting || redirecting" @click="submit">
+          {{ submitting ? "正在发起支付…" : "确认付款" }}
+        </button>
+
+        <!-- 跳转前的最后一屏:顾客在此刻还能看到「付多少」,跳走之后页面就不归我们管了 -->
+        <div v-if="redirecting" class="redirect">
+          <div class="redirect__spinner"></div>
+          <p class="redirect__text">
+            正在前往支付页面,应付
+            <strong>¥{{ fenToYuan(paidFen) }}</strong>
+          </p>
+          <p v-if="paidOutTradeNo" class="redirect__no">订单号 {{ paidOutTradeNo }}</p>
+        </div>
+      </template>
+    </div>
+
+    <p class="checkout__foot">本页由收款方通过支付平台生成</p>
+  </div>
+</template>
+
+<script setup lang="ts">
+/**
+ * 顾客页 —— 收款链接 `/#/checkout/<token>` 的落点。**免登录**。
+ *
+ * 【它做什么】三件事,顺序固定:显示商户名 → 顾客输一个金额 → 发起支付并跳到上游收银台。
+ * 没有第四件。「不含其他功能」是需求原文:这一屏面对的是正要付钱的人,
+ * 每多一个可点的东西就多一次「我该按哪个」的犹豫。
+ *
+ * 【为什么不用 `@/utils/request`】那个实例的两个拦截器都是给**管理端**写的,在顾客页会闯祸:
+ *   · 请求拦截器会自动附上 `Authorization`(这台浏览器里可能存着一个管理端令牌 ——
+ *     顾客页是免登录的,带上它没有意义,徒增一个「谁的令牌」的变量);
+ *   · 响应拦截器用 `ElMessage` **弹窗**报错,且对非 GET 的成功响应弹「操作成功」。
+ *     顾客页的错误必须**写在页面上**(顾客不会去读一个 3 秒后消失的浮层,
+ *     而那正是他付款失败的那一刻)。
+ * 所以这里自建一个实例,**不挂任何拦截器**,每个响应都自己看 `code`。
+ *
+ * 【为什么是 hash 路由】`createWebHashHistory` —— 后端拼的链接形如
+ * `<base>/#/checkout/<token>`(`LsyCheckoutLinkService#buildUrl` 的 `PATH_PREFIX`)。
+ * 拼成 `/checkout/<token>` 会命中静态路由表末尾的通配 404。
+ */
+defineOptions({
+  name: "Checkout",
+  inheritAttrs: false,
+});
+
+import { onMounted, ref } from "vue";
+import { useRoute } from "vue-router";
+import axios from "axios";
+
+/**
+ * 收款金额下限,单位**分**。与后端 `LsyAmountFloor.MIN` 是同一个数 ——
+ * **改一处必须改另一处**(同 `collection/cashier/index.vue` 的 `MIN_FEN`)。
+ *
+ * 【为什么顾客页也要拦一道】后端那道是真的闸(这条腿谁都能调),这里只是把话说在
+ * 顾客按下按钮**之前**:不拦的话他收到的是一句报文措辞
+ * (`amount 不能低于 1 元(100 分)`),而那是一位正要付钱的人。
+ */
+const MIN_FEN = 100;
+
+type Phase = "loading" | "invalid" | "ready";
+
+/** 只看 `code` 的最小信封形状。`Result.ok` 的成功码是 **0**(不是 200)。 */
+interface Envelope<T> {
+  code?: number;
+  msg?: string;
+  data?: T;
+}
+
+/**
+ * 顾客页专用实例:相对基址(与页面同源),**无拦截器**。
+ * ★ 超时给了 20 秒:这一步背后是「我们建单 + 上游下单」两次网络往返,
+ *   而顾客多半在移动网络下。
+ */
+const http = axios.create({
+  baseURL: import.meta.env.VITE_APP_BASE_API,
+  timeout: 20000,
+  headers: { "Content-Type": "application/json;charset=utf-8" },
+});
+
+const route = useRoute();
+const token = String(route.params.token ?? "");
+
+const phase = ref<Phase>("loading");
+const merchantName = ref<string | null>(null);
+const amountText = ref("");
+const hint = ref("");
+const submitting = ref(false);
+const redirecting = ref(false);
+const paidFen = ref(0);
+const paidOutTradeNo = ref("");
+const amountEl = ref<HTMLInputElement>();
+
+// ────────────────────────────── 金额 ──────────────────────────────
+
+/** 输入过滤:只留数字与一个小数点,小数位最多两位(多打的那一位直接不显示,而不是事后报错)。 */
+function onInput() {
+  const cleaned = amountText.value
+    .replace(/[^\d.]/g, "")
+    .replace(/^(\d*\.?\d{0,2}).*$/, "$1");
+  amountText.value = cleaned;
+  hint.value = "";
+}
+
+/**
+ * 元 → 分。**按字符串拆,不做浮点乘法。**
+ *
+ * ★ `Math.round(Number("19.99") * 100)` 在这里恰好也对(误差远小于 0.5),但它是**碰巧**对:
+ *   这条式子对「多少分」这个整数结果的正确性,依赖 IEEE754 那条误差上界,而
+ *   `"1.005"` 这类三位小数的结果就取决于先 round 还是先乘。拆字符串没有这个问题,
+ *   而且顺手把「不是合法金额」的输入在**同一个函数**里判掉(`null`)。
+ *
+ * @returns 分;`null` = 输入不是合法金额(空、多个小数点、超过两位小数)
+ */
+function yuanToFen(text: string): number | null {
+  // 容忍两种「打到一半」的输入,不把它们当错误:末尾一个小数点("5.")、
+  // 省略整数位的 ".5"(`Number("")` 是 0,所以拆分那一步天然处理得了)。
+  const s = text.trim().replace(/\.$/, "");
+  if (!/^(\d+(\.\d{1,2})?|\.\d{1,2})$/.test(s)) {
+    return null;
+  }
+  const [intPart, decPart = ""] = s.split(".");
+  const fen = Number(intPart) * 100 + Number((decPart + "00").slice(0, 2));
+  // 17 位数字以上已经超出安全整数 —— 这种输入只可能是误触,别拿它去建单
+  return Number.isSafeInteger(fen) ? fen : null;
+}
+
+/** 分 → 元(显示用,固定两位小数)。 */
+function fenToYuan(fen: number): string {
+  return (fen / 100).toFixed(2);
+}
+
+// ────────────────────────────── 两跳 ──────────────────────────────
+
+/** 开屏:拿商户显示名。拿不到(404 / 网络错)都进 `invalid`,**不区分** —— 见模板上的注释。 */
+async function loadInfo() {
+  try {
+    const res = await http.get<Envelope<{ merchant_name?: string | null }>>(
+      `/payment/checkout/${encodeURIComponent(token)}`
+    );
+    if (res.data?.code !== 0) {
+      phase.value = "invalid";
+      return;
+    }
+    merchantName.value = res.data.data?.merchant_name ?? null;
+    phase.value = "ready";
+    // 键盘弹不弹由浏览器决定(iOS 上非用户手势触发时会被忽略,那不是错误)
+    amountEl.value?.focus();
+  } catch {
+    // ★ 网络不通也归到 `invalid` 是**有意的取舍**:这条腿的失败里,
+    //   「链接坏了」远多于「网络坏了」,而顾客对两者的反应是同一个动作
+    //   (找收款方)。给一句「网络异常,请重试」会让他在一个**永远不会成功**的
+    //   链接上反复重试。真·网络问题在支付那一步会给出可照做的提示。
+    phase.value = "invalid";
+  }
+}
+
+/** 按下「确认付款」:建单 → 上游下单 → 把浏览器送到上游给的链接。 */
+async function submit() {
+  if (submitting.value || redirecting.value) {
+    return;
+  }
+  const fen = yuanToFen(amountText.value);
+  if (fen === null) {
+    hint.value = "请输入正确的金额,最多两位小数";
+    amountEl.value?.focus();
+    return;
+  }
+  if (fen < MIN_FEN) {
+    hint.value = `收款金额最低 ${MIN_FEN / 100} 元`;
+    amountEl.value?.focus();
+    return;
+  }
+
+  submitting.value = true;
+  hint.value = "";
+  try {
+    const res = await http.post<
+      Envelope<{ out_trade_no?: string; code_url?: string; total_amount?: number }>
+    >(`/payment/checkout/${encodeURIComponent(token)}/orders`, { amount: fen });
+
+    const codeUrl = res.data?.data?.code_url;
+    if (res.data?.code !== 0 || !codeUrl) {
+      // 服务端的话是可以照做的(例如「支付发起失败,请稍后重试」),原样显示。
+      // 拿不到 code_url 而 code 是 0 属于契约被破坏,同样当失败处理 —— 否则顾客会停在
+      // 一个「按钮点了没反应」的页面上,而控制台里什么都没有。
+      hint.value = res.data?.msg || "支付发起失败,请稍后重试";
+      return;
+    }
+
+    // ★ 跳转前把金额与单号显示出来:这是顾客核对金额的最后一眼,
+    //   单号是他「付了但页面没反应」时报给客服的凭据(见 LsyCheckoutOrderVO 的注释)。
+    paidFen.value = res.data.data?.total_amount ?? fen;
+    paidOutTradeNo.value = res.data.data?.out_trade_no ?? "";
+    redirecting.value = true;
+
+    // ★ 直接送到上游给的链接,**不包短链、不做中转页** —— 多一跳就多一处能把顾客卡住的地方。
+    //   给一帧让上面的提示先渲染出来(否则这一句谁也看不见,页面直接白了)。
+    await new Promise((r) => window.setTimeout(r, 80));
+    window.location.href = codeUrl;
+  } catch {
+    hint.value = "网络异常,请检查网络后重试";
+  } finally {
+    submitting.value = false;
+  }
+}
+
+onMounted(loadInfo);
+</script>
+
+<style lang="scss" scoped>
+/* 移动优先:这一屏绝大多数时候出现在手机上。桌面端只是把它居中,不做第二套布局。 */
+.checkout {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  box-sizing: border-box;
+  min-height: 100vh;
+  padding: 24px 20px calc(24px + env(safe-area-inset-bottom));
+  background: linear-gradient(168deg, #f5f7fb 0%, #e9eef7 100%);
+}
+
+.checkout__card {
+  box-sizing: border-box;
+  width: 100%;
+  max-width: 420px;
+  padding: 32px 24px;
+  background: #fff;
+  border-radius: 20px;
+  box-shadow: 0 14px 44px rgb(16 24 40 / 8%);
+}
+
+.checkout__foot {
+  margin: 20px 0 0;
+  font-size: 12px;
+  color: #98a2b3;
+}
+
+/* ── 载入占位 ── */
+.skeleton__line {
+  background: #eef1f6;
+  border-radius: 8px;
+  animation: shimmer 1.2s ease-in-out infinite;
+}
+
+.skeleton__line--title {
+  width: 45%;
+  height: 20px;
+  margin-bottom: 28px;
+}
+
+.skeleton__line--box {
+  height: 60px;
+  margin-bottom: 20px;
+}
+
+.skeleton__line--btn {
+  height: 50px;
+}
+
+@keyframes shimmer {
+  50% {
+    opacity: 0.45;
+  }
+}
+
+/* ── 链接不可用 ── */
+.state {
+  padding: 12px 0;
+  text-align: center;
+}
+
+.state__icon--warn {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 52px;
+  height: 52px;
+  margin: 0 auto 18px;
+  font-size: 26px;
+  font-weight: 700;
+  color: #b54708;
+  background: #fef0c7;
+  border-radius: 50%;
+}
+
+.state__title {
+  margin: 0 0 10px;
+  font-size: 17px;
+  font-weight: 600;
+  color: #1d2939;
+}
+
+.state__hint {
+  margin: 0;
+  font-size: 13px;
+  line-height: 1.7;
+  color: #667085;
+}
+
+/* ── 商户名 ── */
+.head {
+  margin-bottom: 28px;
+  text-align: center;
+}
+
+.head__label {
+  margin: 0 0 6px;
+  font-size: 13px;
+  color: #98a2b3;
+}
+
+.head__name {
+  margin: 0;
+  font-size: 21px;
+  font-weight: 600;
+  line-height: 1.4;
+  color: #1d2939;
+  word-break: break-all;
+}
+
+/* ── 金额 ── */
+.amount {
+  display: flex;
+  align-items: baseline;
+  gap: 6px;
+  padding-bottom: 10px;
+  border-bottom: 2px solid #eaecf0;
+  transition: border-color 0.2s;
+
+  &:focus-within {
+    border-color: #4080ff; /* = settings.ts 的 themeColor,与后台同一支主色 */
+  }
+}
+
+.amount--error {
+  border-color: #f04438;
+}
+
+.amount__symbol {
+  font-size: 26px;
+  font-weight: 600;
+  color: #1d2939;
+}
+
+.amount__input {
+  flex: 1;
+  min-width: 0;
+  padding: 0;
+  font-family: inherit;
+  font-size: 40px;
+  font-weight: 600;
+  color: #1d2939;
+  background: transparent;
+  border: none;
+  outline: none;
+
+  &::placeholder {
+    font-weight: 400;
+    color: #d0d5dd;
+  }
+
+  /* 数字等宽 + 右对齐的观感:金额从左往右打,光标不跳 */
+  font-variant-numeric: tabular-nums;
+}
+
+.hint {
+  min-height: 20px;
+  margin: 10px 0 22px;
+  font-size: 12px;
+  color: #98a2b3;
+}
+
+.hint--error {
+  color: #f04438;
+}
+
+/* ── 按钮 ── */
+.pay {
+  width: 100%;
+  height: 50px;
+  font-family: inherit;
+  font-size: 17px;
+  font-weight: 600;
+  color: #fff;
+  cursor: pointer;
+  background: #4080ff;
+  border: none;
+  border-radius: 12px;
+  transition:
+    background 0.2s,
+    transform 0.1s;
+
+  &:active:not(:disabled) {
+    transform: scale(0.99);
+  }
+
+  &:disabled {
+    cursor: default;
+    opacity: 0.6;
+  }
+}
+
+/* ── 跳转中 ── */
+.redirect {
+  margin-top: 20px;
+  text-align: center;
+}
+
+.redirect__spinner {
+  width: 22px;
+  height: 22px;
+  margin: 0 auto 10px;
+  border: 2px solid #d1e0ff;
+  border-top-color: #4080ff;
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+.redirect__text {
+  margin: 0;
+  font-size: 14px;
+  color: #475467;
+}
+
+.redirect__no {
+  margin: 6px 0 0;
+  font-size: 12px;
+  color: #98a2b3;
+  word-break: break-all;
+}
+</style>