Explorar o código

feat(乐收赢): 收银台在按下去之前拦住低于 1 元的金额

后端的闸(上一个提交)已经能拦住任何调用方,但收银台的操作员会先撞上它:屏幕上
回一句报文措辞 `amount: amount 不能低于 1 元(100 分)`,而柜台前正站着顾客。
这一提交把那句话提前到**按下「收款」之前**,措辞也换成操作员的话:
「收款金额最低 1 元」。

判在 `fen < MIN_FEN` 之后、发请求之前;原来的 `fen <= 0` 分支原样留着不动 ——
它管的是「没填 / 填了非数字」,与「填了但太小」不是同一件事,两者各自的提示语
也不同,新闸不能把它顶掉。

`MIN_FEN = 100` 与后端 `LsyAmountFloor.MIN` 是同一个数写了两遍:开放 API 的调用方
根本不经过这一页,所以后端那道才是真的闸,这一页拦的是体验。两处注释里都写明
**改一处必须改另一处**。

★ 前端只有这一个金额入口:`createCashierQr` 是全前端唯一带金额的写接口,
所以改这一处即覆盖。`pnpm build` **没有跑** —— `frontend/dist` 是 11:38 的既有
构建产物(未入库),跑一次会把它悄悄换掉;本次改动的证据不靠构建产物。

验证(`pnpm ts:check` 基线 289 条、命令恒 exit 2,所以只能同机差分):
变异前后各跑一次,读数**逐字节相同**(都是 289 条,同样的 289 条)。

执行级验证台:.claude/plan/2026-09-29-lsy-amount-floor-harness/(README 同目录)
  编译**真** SFC(@vue/compiler-sfc)+ **真** vue(createRenderer,哑节点)
  + 记账桩顶掉接口层,然后直接驱动 setup 返回的**真** `start()`,
  数 `createCashierQr` 被调了几次。8 个场景:

    A 0.5    拦下  提示「收款金额最低 1 元」
    B 0.99   拦下  同上(门槛内侧最后一格)
    C 1      放行  amount=100
    D 1.01   放行  amount=101
    E 0      拦下  「请先填一个大于 0 的金额」—— 旧的 fen<=0 分支没被顶掉
    F ""     拦下  同上(Number("") 是 0 不是 NaN)
    G abc    拦下  同上(非数字)
    H 0.999  放行  amount=100(四舍五入到分正好 100,读数不是缺陷)

  两向变异(logs/_mutate-fe-floor.txt):
    F-M1  MIN_FEN 100→0(把闸拆掉)⇒ SUMMARY FAIL 场景=8 不符=2,A/B 变成下单 50/99
    F-M2  只改一行注释          ⇒ SUMMARY PASS 场景=8 不符=0
  两条还原后复量 sha256,与变异前逐字节一致(e1e495e8ad5c…)。

★ 要重新部署才生效。
alphaH hai 2 semanas
pai
achega
05bc09f7f9

+ 82 - 0
.claude/plan/2026-09-29-lsy-amount-floor-harness/README.md

@@ -0,0 +1,82 @@
+# 收银台「最低 1 元」闸 —— 前端执行级验证台
+
+## 这台子回答哪一句话
+
+> 操作员在收银台输入 `0.5`,按下「收款」——**那一枪会不会发出去**?
+
+「源码里写了 `if (fen < MIN_FEN) return;`」不是答案。这页有 8 个场景,
+答案是**数出来的**:`createCashierQr` 被调了几次。
+
+## 怎么跑
+
+```bash
+cd .claude/plan/2026-09-29-lsy-amount-floor-harness
+node amount-floor-harness.mjs            # 默认拿本仓的 cashier/index.vue
+node amount-floor-harness.mjs <另一份 SFC 路径>
+```
+
+不带参数、不联网、不碰库、不写任何东西。**只认最后那行 `SUMMARY`** ——
+失败也 `exit 0`(与同目录各台一致),别拿 exit code 当判据。
+
+## 它怎么做到「真」
+
+| 环节 | 真的还是假的 |
+|---|---|
+| SFC | **真**:`@vue/compiler-sfc` 解析 + `compileScript` 编译本仓那个 `index.vue` |
+| vue | **真**:`frontend/node_modules` 里那份 `vue`(`createRenderer` 挂载) |
+| 被驱动的函数 | **真**:`start()` 是 setup 返回的那袋绑定里的那一个,本台直接调它 |
+| 金额换算 | **真**:页面的 `Math.round(Number(text) * 100)` 原样跑 |
+| DOM | 假:`createRenderer` 接一套哑节点,模板不渲染(`inlineTemplate: false`) |
+| 接口 | 假:`@/api/module_payment/collection` 换成**记账**桩(记下每次调用) |
+| element-plus / qrcode | 假:可调用、可无限取属性的 Proxy |
+
+「假」的那三样与要证的那句话无关:本台问的是**有没有发那一枪**,不是页面长什么样。
+副作用一则:模板不渲染 ⇒ `amountEl.value` 是 undefined ⇒ 页面里的
+`amountEl.value?.focus()` 是空操作。
+
+## 八个场景
+
+| # | 输入(元) | 期望 | 它挡的是什么 |
+|---|---|---|---|
+| A | `0.5` | 不下单,提示「收款金额最低 1 元」 | 门槛外侧 |
+| B | `0.99` | 不下单,同上 | 门槛内侧最后一格 |
+| C | `1` | **下单** `amount=100` | 门槛本身(放行侧正对照) |
+| D | `1.01` | **下单** `amount=101` | 门槛之上 |
+| E | `0` | 不下单,提示「请先填一个大于 0 的金额」 | 旧的 `fen <= 0` 分支没被新闸顶掉 |
+| F | `""` | 不下单,同上 | 空串(`Number("")` 是 0,不是 NaN) |
+| G | `abc` | 不下单,同上 | 非数字 |
+| H | `0.999` | **下单** `amount=100` | 四舍五入到分正好 1 元 ⇒ 放行(**读数,不是缺陷**) |
+
+**A/B/E/F/G 是拦下侧,C/D/H 是放行侧 —— 两侧都必须有。**
+只有拦下侧的话,把 `start()` 改成第一行 `return` 也能全绿。
+
+## 两向变异(读数在 `logs/_mutate-fe-floor.txt`)
+
+| 变异 | 期望 | 实读 |
+|---|---|---|
+| `MIN_FEN` 100 → 0(把闸拆掉) | 必须红 | `SUMMARY FAIL 场景=8 不符=2`,A 变成「下单 50」、B 变成「下单 99」 |
+| 只改一行注释 | 必须仍绿 | `SUMMARY PASS 场景=8 不符=0` |
+
+两条变异还原后复量 sha256,与变异前逐字节一致(`e1e495e8ad5c…`)。
+
+## 踩过的两个坑
+
+1. **台子跑完不退出。** 桩永远回 `PAYING` ⇒ `start()` 成功后 `poll()` 每 3 秒自己
+   重排一次定时器(`schedulePoll`),`startTicking` 另有一个 1 秒 interval。8 条读数
+   全对、`SUMMARY` 也打了,进程却一直挂着。修法是每个场景后 `b.stopTimers?.()`,
+   末尾再 `process.exit(0)` 兜底。
+   ★ 挂着那次是靠 `Get-CimInstance Win32_Process` 按命令行认出 `amount-floor-harness.mjs`
+   那个 PID 才**只杀掉自己**的(同机另有 codegraph / MCP / IDE 的十几个 node)。
+2. **`process.exit(0)` 不设 exitCode。** 与同目录各台一致:判据是 `SUMMARY` 那行,
+   不是退出码 —— 否则「台子崩了」与「场景不符」在 CI 里分不开。
+
+## 与后端那道闸的关系
+
+后端 `LsyAmountFloor.MIN = 100`(四个下单入口的 `@Min`)是**真的闸**:
+开放 API 的调用方根本不经过这一页。这一页拦的是**体验** ——
+不拦的话操作员收到的是一句报文措辞(`amount: amount 不能低于 1 元(100 分)`),
+而屏幕上正站着顾客。
+
+两处的数值是**同一件事写了两遍**:`frontend/.../cashier/index.vue` 的 `MIN_FEN`
+与 `java/.../util/LsyAmountFloor.java` 的 `MIN`。**改一处必须改另一处**,
+两边各有测试/台子钉着(后端 `LsyAmountFloorTest`,前端本台)。

+ 195 - 0
.claude/plan/2026-09-29-lsy-amount-floor-harness/amount-floor-harness.mjs

@@ -0,0 +1,195 @@
+/**
+ * 收银台「收款金额最低 1 元」这条前端闸的执行级验证台。
+ *
+ * 【要证的一句话】`cashier/index.vue` 的 `start()` 在金额低于 1 元时**不发那一枪**。
+ *
+ * 【为什么不能只看源码】「代码里写了 `if (fen < MIN_FEN) return;`」与「按下去真的不会
+ * 发出下单请求」是两句不同的话。前者是文本读数,后者是运行时行为。本台测**后者**:
+ * 编译**真** SFC(`@vue/compiler-sfc`)、用 `frontend/node_modules` 里那份**真** `vue`
+ * 挂起来、抓到 setup 返回的那袋绑定、直接驱动**真** `start()`,
+ * 然后数 `createCashierQr` **被调了几次**。
+ *
+ * 【两向读数一次跑完】场景表里既有「必须不下单」的,也有「必须下单」的:
+ *   A/B/E/F/G 是拦下侧(含旧的 `fen <= 0` 分支,防它被新闸顶掉)
+ *   C/D/H     是放行侧 —— 没有它们,「一律 return」也会让拦下侧全绿
+ * 只有一侧的话,把 `start()` 改成第一行 `return` 也能全绿。
+ *
+ * 【配方来源】`../2026-09-23-lsy-onboarding-B-harness/lsy-keepalive-harness.mjs`:
+ * 同一套「编译真 SFC + 打桩 import + 用 typescript 转译成 `new Function` 能跑的普通脚本」。
+ * 区别是那台数 `setup()` 跑几次,本台**驱动 setup 返回的那个 `start`**。
+ *
+ * 【不做 DOM】模板不渲染(`inlineTemplate: false`)。因此 `amountEl.value` 是 undefined
+ * —— 页面里的 `amountEl.value?.focus()` 是空操作,不影响本台要证的那件事。
+ * 【不设 process.exitCode】失败也退 0,**只认最后那行 `SUMMARY`**。
+ *
+ * 用法:node amount-floor-harness.mjs [index.vue 路径]
+ */
+import fs from "node:fs";
+import { createRequire } from "node:module";
+
+const FE = "D:/project2/payment-platform/frontend/";
+const require = createRequire(FE + "package.json");
+const vue = require("vue");
+const { parse, compileScript } = require("@vue/compiler-sfc");
+
+const SRC = process.argv[2] || FE + "src/views/module_payment/collection/cashier/index.vue";
+
+/** 把 SFC 编译成可 `new Function` 的普通脚本(配方抄自 keep-alive 台)。 */
+function compileSfc(file) {
+  const src = fs.readFileSync(file, "utf8");
+  const { descriptor, errors } = parse(src, { filename: file });
+  if (errors && errors.length) throw new Error("SFC 解析失败: " + errors.map(String).join("; "));
+  let code = compileScript(descriptor, { id: "afprobe", inlineTemplate: false }).content;
+
+  const braceImport = (spec) =>
+    new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${spec}['"];?`, "g");
+  const asStub = (slot) => (_m, n) =>
+    `const {${n.replace(/\btype\s+[\w$.]+\s*,?/g, "").replace(/\bas\b/g, ":")}} = globalThis.__U.${slot};`;
+  code = code
+    // ★ 会有**两条** vue import(`defineOptions` 带出的那一条 + 文件自己那条)⇒ 正则必须带 g。
+    .replace(braceImport("vue"), asStub("vue"))
+    .replace(braceImport("@/api/module_payment/collection"), asStub("api"))
+    .replace(braceImport("element-plus"), asStub("ep"))
+    .replace(/import\s+(\w+)\s+from\s*['"]qrcode['"];?/, (_m, n) => `const ${n} = globalThis.__U.ep;`);
+
+  // 漏换的 import 会当场炸,而不是悄悄跑出假绿。
+  if (/^\s*import\s/m.test(code)) throw new Error("还有没换掉的 import:\n" + code.slice(0, 400));
+
+  const ts = require("typescript");
+  code = ts.transpileModule(code, {
+    compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
+    fileName: "index.ts",
+  }).outputText;
+  code = code.replace(/export\s+default/, "const __sfc =");
+  if (/^\s*export\s/m.test(code)) throw new Error("还有没换掉的 export");
+  code += "\nglobalThis.__U.sfc = __sfc;";
+  return code;
+}
+
+// ---------- 桩 ----------
+/** 记下每一次接口调用 —— 本台要数的就是「下单这一枪有没有发出去」。 */
+const calls = [];
+const okBody = () => ({
+  data: { code: 0, data: { out_trade_no: "AFPROBE", status: "PAYING", total_amount: 100 } },
+});
+const apiStub = new Proxy(
+  {},
+  {
+    get: (_t, name) =>
+      name === "then"
+        ? undefined
+        : async (...args) => {
+            calls.push({ name: String(name), args });
+            return okBody();
+          },
+  }
+);
+
+/** UI 库:任何名字都可调用、可取属性(`ElMessage.error(...)` 要能点得下去)。 */
+const callable = new Proxy(async () => undefined, {
+  get: (_t, k) => (k === "then" ? undefined : callable),
+  apply: () => Promise.resolve(),
+});
+const epStub = new Proxy({}, { get: () => callable });
+
+// 无 DOM 台子的垫片:页面里用的是 `window.setTimeout`(`schedulePoll`)。
+// ★ 这是**台子**的垫片,不是页面的问题 —— 指出来免得后人把它当缺陷去改页面。
+if (typeof globalThis.window === "undefined") {
+  globalThis.window = globalThis;
+}
+
+globalThis.__U = { vue, api: apiStub, ep: epStub, sfc: null };
+new Function(compileSfc(SRC))();
+
+const Real = globalThis.__U.sfc;
+if (!Real || !Real.setup) throw new Error("编译出来的组件没有 setup —— compileScript 变了?");
+
+// ---------- 无 DOM 渲染器 ----------
+const node = () => ({ children: [] });
+const noop = () => {};
+const { createRenderer, h } = vue;
+const renderer = createRenderer({
+  createElement: node,
+  createText: node,
+  createComment: node,
+  setText: noop,
+  setElementText: noop,
+  insert: noop,
+  remove: noop,
+  parentNode: () => null,
+  nextSibling: () => null,
+  patchProp: noop,
+});
+
+/** 挂一次,把 setup 返回的那袋绑定留下来 —— `start` 与 `inputHint` 都在里面。 */
+function mountAndCapture() {
+  let bindings = null;
+  const Wrapped = {
+    ...Real,
+    render: () => null,
+    setup(props, ctx) {
+      bindings = Real.setup(props, ctx);
+      return bindings;
+    },
+  };
+  renderer.createApp({ render: () => h(Wrapped) }).mount(node());
+  if (!bindings) throw new Error("setup 没有返回绑定 —— 拿不到 start()");
+  return bindings;
+}
+
+// ---------- 场景表 ----------
+// amountText 是输入框里的**元**(字符串,页面自己 Math.round(*100) 成分)。
+const CASES = [
+  ["A", "0.5", false, "收款金额最低 1 元", "门槛外侧:0.5 元"],
+  ["B", "0.99", false, "收款金额最低 1 元", "门槛内侧最后一格"],
+  ["C", "1", true, "", "门槛本身:1 元"],
+  ["D", "1.01", true, "", "门槛之上"],
+  ["E", "0", false, "请先填一个大于 0 的金额", "旧的 `fen <= 0` 分支没被新闸顶掉"],
+  ["F", "", false, "请先填一个大于 0 的金额", "空串"],
+  ["G", "abc", false, "请先填一个大于 0 的金额", "非数字"],
+  ["H", "0.999", true, "", "四舍五入到分正好 100 ⇒ 放行(读数,不是缺陷)"],
+];
+
+console.log(`文件=${SRC}`);
+console.log("");
+console.log("  #  输入      期望下单  实读下单  下单金额  提示语");
+
+let bad = 0;
+for (const [tag, text, wantCall, wantHint, why] of CASES) {
+  const b = mountAndCapture();
+  calls.length = 0;
+  b.amountText.value = text;
+  b.inputHint.value = "";
+  try {
+    // `start` 是 async 且自身吞异常(catch 里注释说明「拦截器已经弹过 toast」),
+    // 这里再包一层是防 `nextTick`/桩抛出来打断本台的读数。
+    await b.start();
+  } catch (e) {
+    console.log(`  ${tag}  (start 抛了:${String(e).slice(0, 60)})`);
+  }
+  // ★ 必须收尾:桩永远回 PAYING ⇒ `start()` 成功后 `poll()` 每 3 秒自己重排一次
+  //   (`schedulePoll`),`startTicking` 还有一个 1 秒的 interval。不清的话 Node 的
+  //   事件循环永远有活干,**台子跑完全部场景也不退出** —— 第一版就是这样挂住的
+  //   (8 条读数全对、SUMMARY 也打了,进程却一直不退;靠 `Get-CimInstance` 按命令行
+  //   认出 PID 才只杀掉自己那一个)。
+  b.stopTimers?.();
+
+  const hit = calls.filter((c) => c.name === "createCashierQr");
+  const gotCall = hit.length > 0;
+  const gotAmount = gotCall ? hit[0].args[0]?.amount : "-";
+  const gotHint = b.inputHint.value;
+  const ok = gotCall === wantCall && (wantCall ? gotAmount > 0 : gotHint === wantHint);
+  if (!ok) bad++;
+  console.log(
+    `  ${tag}  ${JSON.stringify(text).padEnd(9)} ${String(wantCall).padEnd(9)} ` +
+      `${String(gotCall).padEnd(9)} ${String(gotAmount).padEnd(9)} ` +
+      `${JSON.stringify(gotHint)}${ok ? "" : "   <<< 不符(" + why + ")"}`
+  );
+}
+
+console.log("");
+console.log(`SUMMARY ${bad === 0 ? "PASS" : "FAIL"} 场景=${CASES.length} 不符=${bad}`);
+
+// 兜底退出:即使某个场景漏清了定时器,也不让台子吊在事件循环上。
+// 仍**不设 exitCode**(同目录各台一致)⇒ 失败也退 0,**只认最后那行 SUMMARY**。
+process.exit(0);

+ 17 - 0
frontend/src/views/module_payment/collection/cashier/index.vue

@@ -117,6 +117,16 @@ const POLL_MS = 3000;
 /** 连不上时把间隔拉长到多少为止 —— 否则一次断网会每 3 秒弹一次 toast */
 const POLL_MS_MAX = 15000;
 
+/**
+ * 收款金额下限,单位**分**。与后端 `LsyAmountFloor.MIN` 是同一个数 ——
+ * **改一处必须改另一处**。
+ *
+ * 【为什么这一页也要拦一道】后端那道是真的闸(开放 API 的调用方根本不经过这一页),
+ * 这里只是把话说在操作员按下去**之前**:不拦的话他收到的是一句报文措辞
+ * (`amount: amount 不能低于 1 元(100 分)`),而屏幕上正站着顾客。
+ */
+const MIN_FEN = 100;
+
 type Phase = "input" | "waiting" | "done";
 
 const phase = ref<Phase>("input");
@@ -226,6 +236,13 @@ async function start() {
     amountEl.value?.focus();
     return;
   }
+  // 下限与后端同一处数值来源(见 MIN_FEN 的说明)。判在**发请求之前**:
+  // 走到后端要撞一次 400,屏幕上那句话对站着等顾客的操作员没有意义。
+  if (fen < MIN_FEN) {
+    inputHint.value = `收款金额最低 ${MIN_FEN / 100} 元`;
+    amountEl.value?.focus();
+    return;
+  }
 
   creating.value = true;
   try {