2 Revize 6414982713 ... 7d7b0470d6

Autor SHA1 Zpráva Datum
  alphaH 7d7b0470d6 feat(汇安融转译): 16 前端转译为「超时关闭」,下游文档同步告知 před 4 dny
  alphaH f4fdb8a391 feat(汇安融订单): 16 定性为未收款终态,不再排期轮询 před 4 dny

+ 104 - 0
.claude/plan/2026-10-10-har-status16-harness/README.md

@@ -0,0 +1,104 @@
+# 汇安融 trade_status=16「超时关闭」前端转译 —— 执行级验证台
+
+## 这台子回答哪一句话
+
+> 上游把长时间未付的单关掉、`trade_status` 回来是 `"16"` —— 收银台、订单列表、
+> 订单详情这三块屏幕上,会不会出现一个裸的「16」?
+
+「源码里 `HAR_TRADE_STATUS_TEXT` 加了 `"16": "超时关闭"`」不是答案。这页有 16 条
+断言,答案是**数出来的**:真模板渲染出的文本 + `el-tag` / `el-option` 每次渲染
+记下的一对对读数。
+
+## 怎么跑
+
+```bash
+cd .claude/plan/2026-10-10-har-status16-harness
+node har-status16-harness.mjs
+```
+
+不带参数、不联网、不碰库、不写任何东西。**只认最后那行 `SUMMARY`** ——
+失败也 `exit 0`(与同目录各台一致),别拿 exit code 当判据。
+
+## 它怎么做到「真」
+
+| 环节 | 真的还是假的 |
+|---|---|
+| `har.ts`(映射表) | **真**:本仓那份原样转译求值,`HAR_TRADE_STATUS_TEXT/TAG` 直接从它取出 |
+| 订单页 SFC | **真**:`@vue/compiler-sfc` 解析 + `compileScript` + 单独 `compileTemplate`,**真模板**挂载渲染 |
+| 收银台 SFC | **真**:`compileScript` 编译本仓那个 `index.vue`(只驱动绑定,模板不渲染) |
+| vue | **真**:`frontend/node_modules` 里那份 `vue`(`createRenderer` 挂载) |
+| 页面函数 | **真**:`loadOrderList()` 是本台真实调用;详情靠直接改 `detail`/`detailVisible` 再渲染 |
+| DOM | 假:`createRenderer` 接一套内存节点(`patchProp`/`insertStaticContent` 都要给它) |
+| `el-table` / `el-tag` / `el-option` / `el-dialog` | 假:会**转发插槽或记账**的桩(`el-table` 按 `data` 逐行把 `row` 塞进列插槽;`el-tag` 记 `{type,text}`;`el-option` 记 `{label,value}`;`el-dialog` 只在 `modelValue` 时渲染);其余 `el-*` 直通默认插槽 |
+| 接口 | 假:`HarAPI` 换成**记账**桩,`orderList` 喂夹具四行、`orderDetail` 喂 16 那行 |
+
+「假」的那几样与要证的那句话无关:本台问的是**屏幕上写出什么字**,不是像素长什么样。
+
+## 16 条断言
+
+| # | 读数 | 它钉的是什么 |
+|---|---|---|
+| A1 | 列表 16 行渲染出「超时关闭」 | 转译发生 |
+| A2 | 全页文本不含裸 `"16"` | 负向:没有任何一处漏转译 |
+| A3 | 正控:8 行仍「交易关闭」 | 老文案没被碰坏 |
+| A4 | 正控:表外值 `99` 仍**原样显示** | 「表外不猜」的兜底没被谁顺手删掉 |
+| A5 | `el-tag` 配对 `{info, 超时关闭}` | 颜色与文字都对 |
+| A5b | `TAG` 表里 `16` 显式给了 `"info"` | ★ 见坑②:这格在渲染层是隐形的,只能直接钉表值 |
+| A6 | 正控:1 行仍 `{success, 支付成功}` | 已知状态的 tag 没动 |
+| A7 | 筛选下拉出现 `{16 → 超时关闭}` | 下拉吃的是 TEXT 表(v-for),这条路也得亮 |
+| A8 | 正控:下拉里 8 仍「交易关闭」 | 下拉没被改坏 |
+| B1 | 详情弹层里的状态 tag 同款转译 | 列表与详情是模板里两处独立的映射点,测一处不代表另一处 |
+| B2 | 打开详情后全页文本仍不含裸 `"16"` | 弹层打开后复核负向 |
+| C1 | 收银台 `result`:16 → 超时关闭 / muted / detail 含「没有收到钱」 | 不再落进「表外状态原样显示」的兜底 |
+| C2 | 正控:1 → 收款成功 + `money` | |
+| C3 | 正控:8 → 交易关闭 | |
+| C4 | 正控:表外值 99 仍原样 | 兜底没被删(收银台这一侧) |
+| C5 | `apply(16)` 停轮询进 `done` | 现状行为回归:16 不在收银台 `PENDING` 里 |
+
+**A1/A2/A5/A5b/A7/B1/B2/C1 是「必须转译」侧,A3/A4/A6/A8/C2/C3/C4 是正控。**
+只有转译侧的话,把映射表整个换成恒返回「超时关闭」也能全绿。
+
+## 两向变异(读数在 `logs/_t-status16-fe-mut*.txt` / `-green-final.txt`)
+
+| 变异 | 期望 | 实读 |
+|---|---|---|
+| M1:删掉 TEXT 表的 `"16"` 格 | 必须红 | `SUMMARY FAIL 断言=16 不符=6`:A1 A2 A5 A7 B1 B2 |
+| M2:删掉 TAG 表的 `"16"` 格 | 必须红 | `SUMMARY FAIL 断言=16 不符=1`:只 A5b(★ 见坑②) |
+| M3:删掉收银台 `RESULT` 的 `"16"` 格 | 必须红 | `SUMMARY FAIL 断言=16 不符=1`:只 C1 |
+| M4:无害改动(TEXT 表 0/1 两行调序) | 必须仍绿 | `SUMMARY PASS 断言=16 不符=0` |
+
+四次变异还原后复量 sha256,与变异前逐字节一致:
+`har.ts` = `1c62324eb7a2…`,`cashier/index.vue` = `d63c00b9d4be…`。
+实现前的红灯(`_t-status16-fe-red-1.txt`,当时台子 13 条断言)红在
+A1 A2 A5 B1 B2 C1 —— 恰好是「没转译」那一撮。
+
+## 踩过的坑
+
+1. **`bindingMetadata` 必须带。** `compileScript` 出来的 setup 结果带
+   `__isScriptSetup` 旗子,runtime 的 `hasSetupBinding` 见到它就**拒绝**走 `_ctx`
+   代理 ⇒ 模板里每个绑定都 undefined,槽函数一渲染就 `TypeError: _ctx.searchForm`。
+   把 `script.bindings` 传进 `compileTemplate` 的 `compilerOptions`,模板才编成
+   `$setup.xx` 直取。2026-10-10 实测踩过一次。
+2. **TAG 那一格在渲染层是隐形的。** 两处 `el-tag` 都写成 `TAG[st] || 'info'` ——
+   「TAG 表缺格」与「给了 info」渲染成同一个样子。实测:只加渲染断言时 M2(删掉
+   这格)**全绿**(`_t-status16-fe-mut2-tag.txt`,SUMMARY PASS 断言=13)。所以
+   台子补了 A5b 直接钉表值 + A7/A8 下拉(下拉吃 TEXT 不吃 TAG)。设计**保留**这格:
+   显式表态 + 表风格一致,A5b 钉着它。
+3. **夹具纪律:非状态字段全都避开子串 `"16"`。** 行号用 A1~A4 后缀,否则
+   `order_no: "…16…"` 会污染 A2/B2 的负向断言 —— 负向断言只有在「其余字段都干净」
+   时才是有判别力的。
+4. **`process.exit(0)` 不设 exitCode。** 判据是 `SUMMARY` 那行,不是退出码 ——
+   否则「台子崩了」与「断言不符」在 CI 里分不开。
+
+## 与另外两张表的关系
+
+「16」这个值同一天写进了三个地方,各写一遍、各钉各的:
+
+| 位置 | 形态 | 钉它的量具 |
+|---|---|---|
+| 前端映射表 | `har.ts` TEXT/TAG + 收银台 RESULT | **本台** |
+| 后端分类 | `HarOrderStatus.TIMEOUT_CLOSED`(FINAL 成员,不进 PENDING/PAY_RECORDED) | `HarOrderStatusTest`(含两向变异 J1/J2) |
+| 下游文档 | `har-openapi.md` 10.3/10.4/10.5/§11 + 对接 docx | 词普查回读 |
+
+值的传递原则:**本地不转义、逐字入库、逐字出网** —— 中文文案分别由前端转译与
+下游文档各自承担,上游回了什么就存什么、发什么。

+ 571 - 0
.claude/plan/2026-10-10-har-status16-harness/har-status16-harness.mjs

@@ -0,0 +1,571 @@
+/**
+ * 「trade_status=16 超时关闭」前端转译的执行级验证台(2026-10-10)。
+ *
+ * 【要证的三句话】
+ *   A. 订单列表页:挂**真 SFC**(真模板),喂 trade_status="16" 的订单,状态列
+ *      渲染出「超时关闭」;且整个页面文本里**不再出现裸 "16"**。
+ *   B. 订单详情弹层:驱动 detail / detailVisible 后,详情里的状态 tag 同样转译
+ *      (列表与详情是模板里两处独立的映射点,测一处不能代表另一处)。
+ *   C. 收银台 result computed:16 → 超时关闭(不再落进「表外状态原样显示」的兜底)。
+ *
+ * 【两向读数】表里既有「必须转译」的 16,也有正控:已知状态(1 / 8)行为不变;
+ * 表外的 "99" 仍**原样显示** —— 那是「表外不猜」的既定哲学,谁顺手把它删掉,A4/C4 该红。
+ *
+ * 【配方来源】../2026-09-29-lsy-amount-floor-harness/amount-floor-harness.mjs:
+ * 同一套「编译真 SFC + 打桩 import + typescript 转译 + new Function」。本台多两件:
+ *   ① 订单页要**渲染真模板**:compileScript(inlineTemplate:false) + 单独 compileTemplate
+ *      出 render(**必须带 bindingMetadata**,script setup 的 __isScriptSetup 旗子会让
+ *      runtime 拒绝走 _ctx 代理),二者拼回一个组件再挂;
+ *   ② el-table / el-tag / el-dialog / el-option 用「会转发插槽或记录读数」的桩
+ *      (el-table 按 data 逐行把 row 塞进列插槽;el-tag 记 {type,text};el-option 记
+ *      {label,value}),其余 el-* 直通默认插槽或渲染空。
+ * 模板里的 HAR_TRADE_STATUS_TEXT 等映射表来自**真 har.ts**(转译求值后取出),
+ * 只有 HarAPI 本身换成桩(免得真发请求)。
+ *
+ * 【A5b 为什么直接钉表值】两处 el-tag 都写成 `TAG[st] || 'info'` —— 兜底把
+ * 「TAG 表里 16 缺格」和「给了 info」渲染成同一个样子,变异体 M2(删掉这格)
+ * 在渲染层分辨不出(2026-10-10 实测:只加渲染断言时 M2 全绿)。所以表值单独钉。
+ *
+ * 【不设 process.exitCode】失败也退 0,只认最后那行 SUMMARY(同目录各台惯例)。
+ *
+ * 用法:node har-status16-harness.mjs
+ */
+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, compileTemplate } = require("@vue/compiler-sfc");
+const ts = require("typescript");
+
+const ORDER = FE + "src/views/module_payment/har/order/index.vue";
+const CASHIER = FE + "src/views/module_payment/har/cashier/index.vue";
+const HAR_TS = FE + "src/api/module_payment/har.ts";
+
+// 无 DOM 台子的垫片:页面里用的是 `window.setTimeout` / `window.clearTimeout`。
+if (typeof globalThis.window === "undefined") {
+  globalThis.window = globalThis;
+}
+
+// ---------- 通用:翻 SFC 里的 import 成桩 ----------
+const braceImport = (spec) =>
+  new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${spec}['"];?`, "g");
+const braceAsConst = (slot) => (m, names) => {
+  const clean = names
+    .replace(/\btype\s+[\w$.]+\s*,?/g, "")
+    .replace(/\bas\b/g, ":");
+  return `const {${clean}} = globalThis.__U.${slot};`;
+};
+const transpile = (code, fileName) =>
+  ts.transpileModule(code, {
+    compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
+    fileName,
+  }).outputText;
+
+// ---------- 真 har.ts:取映射表(HarAPI 换成桩) ----------
+const HAR_NAMES = [
+  "HAR_TRADE_STATUS_TEXT",
+  "HAR_TRADE_STATUS_TAG",
+  "HAR_TRADE_TYPE_OPTIONS",
+  "HAR_ORDER_STATUS_PAID",
+  "HAR_NOTIFY_STATUS_TEXT",
+  "HAR_BIZ_UN_STAGE",
+];
+function evalRealHarModule() {
+  let code = fs.readFileSync(HAR_TS, "utf8");
+  code = code.replace(
+    /import\s+(\w+)\s+from\s*['"]@\/utils\/request['"];?/,
+    "const $1 = globalThis.__U.request;"
+  );
+  code = code.replace(/export\s+default\s+/g, "const __har_default = ");
+  code = code.replace(/^\s*export\s+/gm, "");
+  if (/^\s*import\s/m.test(code) || /^\s*export\s/m.test(code)) {
+    throw new Error("har.ts 还有没换掉的 import/export —— 配方要更新");
+  }
+  code = transpile(code, "har.ts");
+  code +=
+    "\nglobalThis.__U.realHar = { __default: __har_default, " +
+    HAR_NAMES.map((n) => `${n}: (typeof ${n} !== "undefined" ? ${n} : undefined)`).join(", ") +
+    " };";
+  new Function(code)();
+  for (const n of HAR_NAMES) {
+    if (globalThis.__U.realHar[n] === undefined) throw new Error("har.ts 里没有导出 " + n);
+  }
+}
+
+// ---------- 桩:请求 / UI 库 / store ----------
+const apiCalls = [];
+const apiStub = new Proxy(
+  {},
+  {
+    get: (_t, name) =>
+      name === "then"
+        ? undefined
+        : async (...args) => {
+            apiCalls.push({ name: String(name), args });
+            return { data: { code: 0, data: { items: [], total: 0 } } };
+          },
+  }
+);
+const callable = new Proxy(async () => undefined, {
+  get: (_t, k) => (k === "then" ? undefined : callable),
+  apply: () => Promise.resolve(),
+});
+const epStub = new Proxy({}, { get: () => callable });
+const noop = () => {};
+const enterpriseStoreStub = () => ({
+  getEnterpriseList: [],
+  loadEnterpriseList: async () => {},
+});
+
+// ---------- 无 DOM 渲染器:垫出足够的节点操作,让真模板能挂/能更新 ----------
+const noopNode = () => {};
+const renderer = vue.createRenderer({
+  createElement: (tag) => ({ kind: "el", tag, children: [], parent: null }),
+  createText: (text) => ({ kind: "text", text, parent: null }),
+  createComment: (text) => ({ kind: "comment", text, parent: null }),
+  setText: (n, text) => {
+    n.text = text;
+  },
+  setElementText: (el, text) => {
+    el.children = [{ kind: "text", text, parent: el }];
+  },
+  insert: (child, parent, anchor) => {
+    child.parent = parent;
+    if (anchor) {
+      const i = parent.children.indexOf(anchor);
+      parent.children.splice(i < 0 ? parent.children.length : i, 0, child);
+    } else {
+      parent.children.push(child);
+    }
+  },
+  remove: (child) => {
+    const p = child.parent;
+    if (p) {
+      const i = p.children.indexOf(child);
+      if (i >= 0) p.children.splice(i, 1);
+    }
+    child.parent = null;
+  },
+  parentNode: (n) => n.parent ?? null,
+  nextSibling: (n) => {
+    const p = n.parent;
+    if (!p) return null;
+    const i = p.children.indexOf(n);
+    return i >= 0 ? p.children[i + 1] ?? null : null;
+  },
+  patchProp: noopNode, // 属性/class/style 与本台断言无关(只读文本)
+  insertStaticContent: (content, parent, anchor, ns, start, end) => {
+    if (start && end) {
+      // 缓存节点搬到新位置(同 DOM 语义)
+      const move = (n) => {
+        if (n.parent) renderer.remove ? null : null;
+        if (n.parent) {
+          const i = n.parent.children.indexOf(n);
+          if (i >= 0) n.parent.children.splice(i, 1);
+        }
+        n.parent = parent;
+        if (anchor) {
+          const i = parent.children.indexOf(anchor);
+          parent.children.splice(i < 0 ? parent.children.length : i, 0, n);
+        } else {
+          parent.children.push(n);
+        }
+      };
+      move(start);
+      if (end !== start) move(end);
+      return [start, end];
+    }
+    const n = { kind: "static", html: String(content), children: [], parent: null };
+    n.parent = parent;
+    parent.children.push(n);
+    return [n, n];
+  },
+});
+
+// ---------- 桩组件:转发插槽的 el-table / el-tag / el-dialog / el-option ----------
+const tagPairs = []; // el-tag 每次渲染记一对 {type, text} —— TAG 表就是靠它钉
+const optionPairs = []; // el-option 每次渲染记一对 {label, value} —— 筛选下拉吃 TEXT 表
+
+function collectText(nodes, out = []) {
+  for (const n of Array.isArray(nodes) ? nodes : [nodes]) {
+    if (n == null || n === false) continue;
+    if (typeof n === "string" || typeof n === "number") out.push(String(n));
+    else if (Array.isArray(n)) collectText(n, out);
+    else if (typeof n === "object" && n.children != null) collectText(n.children, out);
+  }
+  return out;
+}
+
+const ElTableStub = {
+  name: "el-table",
+  props: { data: { type: Array, default: () => [] } },
+  setup(props, { slots }) {
+    return () => {
+      const cols = slots.default ? slots.default().flat(Infinity) : [];
+      const out = [];
+      for (const row of props.data || []) {
+        for (const col of cols) {
+          const colSlots = col && col.children;
+          if (colSlots && typeof colSlots === "object" && typeof colSlots.default === "function") {
+            out.push(vue.h("div", { class: "cell" }, colSlots.default({ row })));
+          }
+        }
+      }
+      return vue.h("div", out);
+    };
+  },
+};
+const ElTagStub = {
+  name: "el-tag",
+  props: { type: { type: String, default: "" } },
+  setup(props, { slots }) {
+    return () => {
+      const kids = slots.default ? slots.default() : [];
+      tagPairs.push({ type: props.type, text: collectText(kids).join("") });
+      return vue.h("span", kids);
+    };
+  },
+};
+const ElOptionStub = {
+  name: "el-option",
+  props: { label: { type: String, default: "" }, value: { type: [String, Number], default: "" } },
+  setup(props) {
+    return () => {
+      optionPairs.push({ label: props.label, value: String(props.value) });
+      return null; // 选项的文字在真实组件里来自 label prop,槽里没有内容可渲染
+    };
+  },
+};
+const ElDialogStub = {
+  name: "el-dialog",
+  props: { modelValue: { type: Boolean, default: false } },
+  setup(props, { slots }) {
+    return () =>
+      props.modelValue
+        ? vue.h("div", [slots.default ? slots.default() : null, slots.footer ? slots.footer() : null])
+        : null;
+  },
+};
+function registerStubs(app) {
+  const passthrough = (name) => ({
+    name,
+    setup: (_p, { slots }) => () => (slots.default ? slots.default() : null),
+  });
+  for (const n of [
+    "el-alert",
+    "el-button",
+    "el-card",
+    "el-date-picker",
+    "el-descriptions",
+    "el-descriptions-item",
+    "el-empty",
+    "el-form",
+    "el-form-item",
+    "el-input",
+    "el-input-number",
+    "el-pagination",
+    "el-select",
+    "el-table-column",
+  ]) {
+    app.component(n, passthrough(n));
+  }
+  app.component("el-table", ElTableStub);
+  app.component("el-tag", ElTagStub);
+  app.component("el-option", ElOptionStub);
+  app.component("el-dialog", ElDialogStub);
+  app.directive("hasPerm", {});
+  app.directive("loading", {});
+}
+
+// ---------- 编译 + 挂载:订单页(真模板) ----------
+function compileOrder() {
+  const src = fs.readFileSync(ORDER, "utf8");
+  const { descriptor, errors } = parse(src, { filename: ORDER });
+  if (errors && errors.length) throw new Error("SFC 解析失败: " + errors.map(String).join("; "));
+
+  const script = compileScript(descriptor, { id: "s16order", inlineTemplate: false });
+  let code = script.content;
+  code = code
+    .replace(braceImport("vue"), braceAsConst("vue"))
+    .replace(braceImport("element-plus"), braceAsConst("ep"))
+    .replace(/import\s+dayjs\s+from\s*['"]dayjs['"];?/, "const dayjs = globalThis.__U.dayjs;")
+    .replace(
+      /import\s+(\w+)\s*,\s*\{([^}]*)\}\s*from\s*['"]@\/api\/module_payment\/har['"];?/g,
+      (_m, def, names) => {
+        const clean = names
+          .split(",")
+          .map((s) => s.trim())
+          .filter((s) => s && !/^type\s/.test(s));
+        return (
+          `const ${def} = globalThis.__U.api;` +
+          (clean.length ? ` const { ${clean.join(", ")} } = globalThis.__U.realHar;` : "")
+        );
+      }
+    )
+    .replace(
+      /import\s*\{\s*useEnterpriseStore\s*\}\s*from\s*['"]@\/store\/modules\/enterprise\.store['"];?/,
+      "const useEnterpriseStore = globalThis.__U.enterpriseStore;"
+    )
+    .replace(
+      /import\s*\{\s*useLiveRefetch\s*\}\s*from\s*['"]@\/composables['"];?/,
+      "const useLiveRefetch = globalThis.__U.noop;"
+    );
+  if (/^\s*import\s/m.test(code)) throw new Error("order 还有没换掉的 import:\n" + code.slice(0, 500));
+  code = transpile(code, "order.ts");
+  code = code.replace(/export\s+default/, "const __sfc =");
+  if (/^\s*export\s/m.test(code)) throw new Error("order 还有没换掉的 export");
+  code += "\nglobalThis.__U.orderSfc = __sfc;";
+  new Function(code)();
+
+  const tpl = compileTemplate({
+    source: descriptor.template.content,
+    filename: ORDER,
+    id: "s16order",
+    // bindingMetadata 必须带:script setup 的 setup 结果带 __isScriptSetup 旗子,
+    // runtime 的 hasSetupBinding 见到它就拒绝走 _ctx 代理(查不到 = undefined + 警告)。
+    // 带上它,模板才编成 $setup.xx 直取。2026-10-10 实测踩过一次。
+    compilerOptions: { bindingMetadata: script.bindings },
+  });
+  if (tpl.errors && tpl.errors.length) throw new Error("模板编译失败: " + tpl.errors.map(String).join("; "));
+  let tcode = tpl.code.replace(braceImport("vue"), braceAsConst("vue"));
+  tcode = tcode.replace(/export\s+function\s+render/, "function render");
+  if (/^\s*(import|export)\s/m.test(tcode)) {
+    throw new Error("模板码还有没换掉的 import/export:\n" + tcode.slice(0, 400));
+  }
+  tcode = transpile(tcode, "order.render.ts");
+  tcode += "\nglobalThis.__U.orderRender = render;";
+  new Function(tcode)();
+}
+
+function mountOrder() {
+  const Real = globalThis.__U.orderSfc;
+  let bindings = null;
+  const Wrapped = {
+    render: globalThis.__U.orderRender,
+    setup(props, ctx) {
+      bindings = Real.setup(props, ctx);
+      return bindings;
+    },
+  };
+  const root = { kind: "el", tag: "__root__", children: [], parent: null };
+  const app = renderer.createApp({ render: () => vue.h(Wrapped) });
+  registerStubs(app);
+  app.mount(root);
+  if (!bindings) throw new Error("setup 没有返回绑定");
+  return { bindings, root };
+}
+
+// ---------- 编译 + 挂载:收银台(只驱动绑定,不渲染模板,同金额闸台) ----------
+function compileCashier() {
+  const src = fs.readFileSync(CASHIER, "utf8");
+  const { descriptor, errors } = parse(src, { filename: CASHIER });
+  if (errors && errors.length) throw new Error("SFC 解析失败: " + errors.map(String).join("; "));
+
+  let code = compileScript(descriptor, { id: "s16cashier", inlineTemplate: false }).content;
+  code = code
+    .replace(braceImport("vue"), braceAsConst("vue"))
+    .replace(braceImport("element-plus"), braceAsConst("ep"))
+    .replace(/import\s+(\w+)\s+from\s*['"]qrcode['"];?/, "const $1 = globalThis.__U.ep;")
+    .replace(
+      /import\s+(\w+)\s*,\s*\{([^}]*)\}\s*from\s*['"]@\/api\/module_payment\/har['"];?/g,
+      (_m, def) => `const ${def} = globalThis.__U.api;`
+    );
+  if (/^\s*import\s/m.test(code)) throw new Error("cashier 还有没换掉的 import:\n" + code.slice(0, 500));
+  code = transpile(code, "cashier.ts");
+  code = code.replace(/export\s+default/, "const __sfc =");
+  if (/^\s*export\s/m.test(code)) throw new Error("cashier 还有没换掉的 export");
+  code += "\nglobalThis.__U.cashierSfc = __sfc;";
+  new Function(code)();
+}
+
+function mountCashier() {
+  const Real = globalThis.__U.cashierSfc;
+  let bindings = null;
+  const Wrapped = {
+    ...Real,
+    render: () => null, // 模板不渲染(同金额闸台):本段只驱动 result / apply
+    setup(props, ctx) {
+      bindings = Real.setup(props, ctx);
+      return bindings;
+    },
+  };
+  renderer.createApp({ render: () => vue.h(Wrapped) }).mount({ kind: "el", tag: "__root__", children: [], parent: null });
+  if (!bindings) throw new Error("setup 没有返回绑定");
+  return bindings;
+}
+
+// ---------- 读数工具 ----------
+function flattenText(node, out = []) {
+  if (!node) return out;
+  if (node.kind === "text") out.push(String(node.text));
+  else if (node.kind === "static") out.push(String(node.html).replace(/<[^>]*>/g, ""));
+  for (const c of node.children || []) flattenText(c, out);
+  return out;
+}
+const flush = async (n = 4) => {
+  for (let i = 0; i < n; i++) {
+    await new Promise((r) => setTimeout(r, 0));
+    await vue.nextTick();
+  }
+};
+
+// ---------- 夹具(所有非状态字段都不含子串 "16",负向断言才干净) ----------
+const MK = (no, status, extra = {}) => ({
+  id: "R-" + no,
+  order_no: "APTEST-A" + no,
+  out_biz_no: "BIZ-A" + no,
+  price: "1.00",
+  title: "样例单" + no,
+  trade_status: status,
+  trade_type: "alipay_un_stage",
+  merchant_code: "M0001",
+  created_time: "2026-10-10 09:00:00",
+  notify_status: "FAILED",
+  ...extra,
+});
+const ROW16 = MK("1", "16");
+const ROW8 = MK("2", "8");
+const ROW1 = MK("3", "1");
+const ROW99 = MK("4", "99");
+const ROWS = [ROW16, ROW8, ROW1, ROW99];
+
+// ---------- 跑 ----------
+globalThis.__U = {
+  vue,
+  api: apiStub,
+  ep: epStub,
+  request: noop,
+  dayjs: require("dayjs"),
+  enterpriseStore: enterpriseStoreStub,
+  noop,
+  realHar: null,
+  orderSfc: null,
+  orderRender: null,
+  cashierSfc: null,
+};
+// orderList 要喂夹具行;其余接口空列表(merchantList / enterprise 等)
+const rawApi = apiStub;
+globalThis.__U.api = new Proxy(
+  {},
+  {
+    get: (_t, name) =>
+      name === "then"
+        ? undefined
+        : async (...args) => {
+            apiCalls.push({ name: String(name), args });
+            if (name === "orderList") {
+              return { data: { code: 0, data: { items: ROWS, total: ROWS.length } } };
+            }
+            if (name === "orderDetail") {
+              return { data: { code: 0, data: ROW16 } };
+            }
+            return { data: { code: 0, data: { items: [], total: 0 } } };
+          },
+  }
+);
+
+evalRealHarModule();
+compileOrder();
+compileCashier();
+
+const results = [];
+const check = (tag, ok, detail) => {
+  results.push({ tag, ok });
+  console.log(`  ${tag.padEnd(4)} ${ok ? "PASS" : "FAIL"}  ${detail}`);
+};
+
+console.log(`har.ts      = ${HAR_TS}`);
+console.log(`order       = ${ORDER}`);
+console.log(`cashier     = ${CASHIER}`);
+console.log("");
+console.log("== A/B 订单列表页与详情弹层(真 SFC + 真模板) ==");
+
+const { bindings: bOrder, root: rootOrder } = mountOrder();
+await flush();
+
+const textsList = flattenText(rootOrder);
+const allList = textsList.join(" | ");
+check("A1", allList.includes("超时关闭"), "列表 16 行转译为「超时关闭」");
+check("A2", !textsList.some((t) => t.includes("16")), "全页文本不含裸 \"16\"");
+check("A3", allList.includes("交易关闭"), "正控:8 行「交易关闭」仍是老文案");
+check("A4", allList.includes("99"), "正控:表外值 99 仍原样显示(兜底没被删)");
+check(
+  "A5",
+  tagPairs.some((p) => p.text === "超时关闭" && p.type === "info"),
+  "tag 配对 {type:info, text:超时关闭} 存在"
+);
+check(
+  "A6",
+  tagPairs.some((p) => p.text === "支付成功" && p.type === "success"),
+  "正控:1 行仍是 {success, 支付成功}"
+);
+check(
+  "A5b",
+  globalThis.__U.realHar.HAR_TRADE_STATUS_TAG["16"] === "info",
+  "TAG 表里 16 显式给了 info(模板 `|| 'info'` 兜底让渲染分辨不出,直接钉表值)"
+);
+check(
+  "A7",
+  optionPairs.some((o) => o.value === "16" && o.label === "超时关闭"),
+  "筛选下拉(v-for 吃 TEXT 表)出现 {16 → 超时关闭}"
+);
+check(
+  "A8",
+  optionPairs.some((o) => o.value === "8" && o.label === "交易关闭"),
+  "正控:下拉里 8 仍是「交易关闭」"
+);
+
+const tagCountBefore = tagPairs.length;
+bOrder.detail.value = ROW16;
+bOrder.detailVisible.value = true;
+await flush();
+const added = tagPairs.slice(tagCountBefore);
+const textsAfterDetail = flattenText(rootOrder);
+check(
+  "B1",
+  added.some((p) => p.text === "超时关闭" && p.type === "info"),
+  "详情弹层里的状态 tag 同款转译"
+);
+check("B2", !textsAfterDetail.some((t) => t.includes("16")), "打开详情后全页文本仍不含裸 \"16\"");
+
+console.log("");
+console.log("== C 收银台 result / apply ==");
+
+const bCashier = mountCashier();
+await flush();
+
+const r16 = (() => {
+  bCashier.order.value = { trade_status: "16", price: "1.00" };
+  return bCashier.result.value;
+})();
+check(
+  "C1",
+  r16.title === "超时关闭" && r16.tone === "muted" && String(r16.detail || "").includes("没有收到钱"),
+  `16 → ${JSON.stringify(r16.title)} / ${JSON.stringify(r16.detail)}`
+);
+
+bCashier.order.value = { trade_status: "1", rcv_amount: "1.00", price: "1.00" };
+const r1 = bCashier.result.value;
+check("C2", r1.title === "收款成功" && r1.money === "1.00", `正控:1 → ${JSON.stringify(r1.title)} / money=${r1.money}`);
+
+bCashier.order.value = { trade_status: "8" };
+const r8 = bCashier.result.value;
+check("C3", r8.title === "交易关闭", `正控:8 → ${JSON.stringify(r8.title)}`);
+
+bCashier.order.value = { trade_status: "99" };
+const r99 = bCashier.result.value;
+check("C4", r99.title === "99", `正控:表外值 99 仍原样(兜底没被删)`);
+
+bCashier.phase.value = "waiting";
+bCashier.apply = bCashier.apply; // 绑定即函数本身
+bCashier.apply({ trade_status: "16", price: "1.00" });
+check("C5", bCashier.phase.value === "done", "apply(16) 停轮询进 done(现状行为回归)");
+
+console.log("");
+const bad = results.filter((r) => !r.ok).length;
+console.log(`SUMMARY ${bad === 0 ? "PASS" : "FAIL"} 断言=${results.length} 不符=${bad}`);
+
+// 兜底退出:即使有残留定时器也不吊在事件循环上;失败也退 0,只认 SUMMARY。
+process.exit(0);

binární
docs/支付宝收单开放接口对接指南.docx


+ 11 - 3
frontend/src/api/module_payment/har.ts

@@ -159,7 +159,7 @@ export interface HarOrderVO {
   extra_params?: string;
   /** 主扫下单返回的收款链接;被扫(auth_code)下单时为空 */
   pay_url?: string;
-  /** 0=等待支付 / 1=支付成功 / 2=退款成功 / 4=退款中 / 8=交易关闭 */
+  /** 0=等待支付 / 1=支付成功 / 2=退款成功 / 4=退款中 / 8=交易关闭 / 16=超时关闭 */
   trade_status: string;
   trade_time?: string;
   refund_time?: string;
@@ -251,17 +251,24 @@ export const HAR_BIZ_UN_STAGE = "har_ali_un_stage";
  * 「1 支付成功」—— 本通道**唯一**允许发起退款的起点状态。
  *
  * 有名字才敢在模板里比:写死 "1" 的判据散在三处时,改枚举的人只会改到一处。
- * 上游语义:退款只能从已支付发起,4 退款中 / 2 退款成功 / 8 交易关闭 都会被拒。
+ * 上游语义:退款只能从已支付发起,4 退款中 / 2 退款成功 / 8 交易关闭 / 16 超时关闭 都会被拒。
  */
 export const HAR_ORDER_STATUS_PAID = "1";
 
-/** 上游订单状态枚举,与后端 HarOrderStatus 逐字一致 */
+/**
+ * 上游订单状态枚举,与后端 HarOrderStatus 逐字一致。
+ *
+ * 16 超时关闭是**上游返回**的码(我方手上的两份规范 260924 / 261008 都没定义,
+ * 语义以上游为准:长时间未支付、上游自行关单)。2026-10-10 定性为未收款终态 ——
+ * 后端侧的机器可读含义见 HarOrderStatus.TIMEOUT_CLOSED 与它的三个集合成员关系。
+ */
 export const HAR_TRADE_STATUS_TEXT: Record<string, string> = {
   "0": "等待支付",
   "1": "支付成功",
   "2": "退款成功",
   "4": "退款中",
   "8": "交易关闭",
+  "16": "超时关闭",
 };
 
 export type ElTagType = "primary" | "success" | "warning" | "info" | "danger";
@@ -272,6 +279,7 @@ export const HAR_TRADE_STATUS_TAG: Record<string, ElTagType> = {
   "2": "info",
   "4": "warning",
   "8": "info",
+  "16": "info",
 };
 
 export const HAR_SHOP_STATUS_TEXT: Record<string, string> = {

+ 7 - 1
frontend/src/views/module_payment/apikey/docs/har-openapi.md

@@ -123,7 +123,7 @@ POST /payment/openapi/alipay/refund
 
 > ⚠️ **不要把 `"4"` 当成已退款,也不要凭它给用户承诺到账。** 用户看到的是钱有没有回来,不是接口返回了什么。同 10.4 的提醒:通知可能丢,**请对停在 `"4"` 的订单做主动查询兜底**。
 
-> ⚠️ **只有 `trade_status = "1"` 的订单能退款。** 其它状态(`"0"` / `"4"` / `"2"` / `"8"`)一律拒绝,`code=500`,`msg` 形如 `订单当前状态不可退款: <状态值>`。查不到订单、或不是你的订单,仍然是 10.2 那句 `code=500` 的 `订单不存在`。
+> ⚠️ **只有 `trade_status = "1"` 的订单能退款。** 其它状态(`"0"` / `"4"` / `"2"` / `"8"` / `"16"`)一律拒绝,`code=500`,`msg` 形如 `订单当前状态不可退款: <状态值>`。查不到订单、或不是你的订单,仍然是 10.2 那句 `code=500` 的 `订单不存在`。
 
 **重复调用会怎样** —— 这是最容易误解的一处:
 
@@ -200,6 +200,8 @@ Content-Type: application/x-www-form-urlencoded
 
 > ⚠️ **退款的最终结果(`"4"` → `"2"` / `"1"`)走的是同一条回调**,`content.status` 里就是新状态,`refund_time` 也在里面。所以上面的兜底对退款**同样适用,而且更要紧**:退款停在 `"4"` 时你等的是钱有没有回来,回调不响就只能自己查。**给长期停在 `"4"` 的订单也做主动查询。**
 
+> ℹ️ **回调的 `content.status` 也可能是 `"16"`(超时关闭)。** 长时间没有支付的订单会被上游关闭,订单收敛到这个状态时平台同样会推一条回调给你。收到 `"16"` 就说明这一单**不会再有支付**,不用再等,也不用再为它做主动查询(见 10.5)。
+
 > ⚠️ **回调不带签名。** 不能仅凭收到回调就认定收款成功。请以 **10.2 订单查询** 的结果为准,把回调当作「该查了」的触发信号。
 
 ---
@@ -215,10 +217,13 @@ Content-Type: application/x-www-form-urlencoded
 | `"2"` | 已退款 | 是 |
 | `"4"` | 退款中 | 否 |
 | `"8"` | 交易关闭 | 是 |
+| `"16"` | 超时关闭(长时间未支付,上游关闭) | 是 |
 
 判断「是否收到过钱」用 `"1"` / `"2"`;判断「是否还要等」用 `"0"` / `"4"`。
 判断「退款是否已经到账」只看 `"2"` —— 停在 `"4"` 表示平台还在向上游确认,**不代表已退**(见 10.3)。
 
+`"16"` 与 `"8"` 同类:这一单**不会再收到钱**,别再等,也不用再为它做主动查询。
+
 **订单 `notify_status`**(平台向你推送回调的结果):
 
 | 值 | 含义 |
@@ -236,6 +241,7 @@ Content-Type: application/x-www-form-urlencoded
 - [ ] 下单**不重发** —— 超时先查 10.2,确认没有再决定
 - [ ] 拿到了 `pay_url` 并能渲染成二维码
 - [ ] 按 `trade_status` 的**数字字符串**分支处理(`"1"` 才是钱到账)
+- [ ] `trade_status = "16"`(超时关闭)按未收款终态处理:这一单不会再有支付,不用等、不用主动查
 - [ ] 回调接口能处理表单编码,并把 `content` **再解析一次** JSON
 - [ ] 回调按 `out_biz_no` 做幂等
 - [ ] 对长期停在 `"0"` 的订单有主动查询兜底

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

@@ -159,6 +159,8 @@ const RESULT: Record<string, { title: string; tone: string; icon: string; detail
   "2": { title: "已全额退款", tone: "muted", icon: "—", detail: "这一笔后来被全额退款了" },
   "4": { title: "退款中", tone: "muted", icon: "—", detail: "退款是否到账,以订单页为准" },
   "8": { title: "交易关闭", tone: "muted", icon: "—", detail: "这一笔没有收到钱,可以重新发起" },
+  // 上游自行关掉的长时间未付单(两份规范都没定义这个码,2026-10-10 定性为未收款终态)
+  "16": { title: "超时关闭", tone: "muted", icon: "—", detail: "这一笔没有收到钱,可以重新发起" },
 };
 
 const result = computed(() => {

+ 15 - 2
java/src/main/java/com/payment/platform/module/payment/har/enums/HarOrderStatus.java

@@ -9,6 +9,7 @@ import java.util.Set;
  * 在排查问题时多一层翻译。
  *
  * 0 等待支付 / 1 支付成功 / 2 退款成功 / 4 退款中 / 8 交易关闭
+ * / 16 超时关闭(上游关单;两份规范均未定义,见 {@link #TIMEOUT_CLOSED})
  */
 public final class HarOrderStatus {
 
@@ -18,11 +19,23 @@ public final class HarOrderStatus {
     public static final String REFUNDING = "4";
     public static final String CLOSED = "8";
 
+    /**
+     * 上游对长时间未支付的单自行关单后回的状态码(2026-10-10 用户裁定接纳)。
+     *
+     * <p>我方手上的两份规范(260924 / 261008)都**没有定义 16** —— 语义以上游为准,
+     * 实测出现在长时间未支付的订单上(下单约 9 分钟后即被上游关闭,早于
+     * {@code pay_timeout},关单是上游自己的动作)。本地按**未收款终态**接纳:
+     * 不进 {@link #PENDING}(不再轮询)、不计 {@link #PAY_RECORDED}(钱没到)、
+     * 不可退款(退款腿只认 {@link #PAID})。值本身逐字入库、逐字出网,
+     * 中文文案由前端与下游文档各自转译,本地不转义。
+     */
+    public static final String TIMEOUT_CLOSED = "16";
+
     /** 非终态 —— 需要通过轮询收敛 */
     public static final Set<String> PENDING = Set.of(WAIT_PAY, REFUNDING);
 
-    /** 终态 —— 不再轮询 */
-    public static final Set<String> FINAL = Set.of(PAID, REFUNDED, CLOSED);
+    /** 终态 —— 不再轮询(16 见 {@link #TIMEOUT_CLOSED} 的定性) */
+    public static final Set<String> FINAL = Set.of(PAID, REFUNDED, CLOSED, TIMEOUT_CLOSED);
 
     /**
      * 计入收款净额的状态 —— 只有「1 支付成功」。

+ 5 - 0
java/src/main/java/com/payment/platform/module/payment/har/service/HarOrderService.java

@@ -679,6 +679,11 @@ public class HarOrderService {
      * 这条订单就再也查不到了,退款结果彻底失联。
      *
      * 遇到不认识的状态同样继续排期:上游将来加了新状态,继续查比静默停掉安全。
+     *
+     * <p>「16 超时关闭」自 2026-10-10 起**不是**不认识的状态 —— 它有名字、是终态
+     * ({@link HarOrderStatus#TIMEOUT_CLOSED}),走上面「终态清空」那条腿,不适用本段。
+     * 另注:表外值排了期也捞不回来({@link #findPendingForPoll} 只捞 PENDING),
+     * 历史遗留,本次未动。
      */
     public void reschedulePoll(HarOrderEntity order) {
         if (HarOrderStatus.isFinal(order.getTradeStatus())) {

+ 38 - 0
java/src/test/java/com/payment/platform/module/payment/har/enums/HarOrderStatusTest.java

@@ -0,0 +1,38 @@
+package com.payment.platform.module.payment.har.enums;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 「16 超时关闭」的定性钉子(2026-10-10 用户裁定)。
+ *
+ * <p>16 是上游返回、我方两份规范(260924 / 261008)都未定义的值,实测出现在长时间
+ * 未支付的订单上(上游自行关单)。定性为**未收款终态**,这份裁定的机器可读含义就是
+ * 三个集合的成员关系 —— 谁把 16 挪进另一个集合,就是在改口径(轮询 / 收款统计),
+ * 必须在这里红。
+ *
+ * <p>断言用字面量 {@code "16"} 而不是常量名:钉的是**值**本身。上游发来的是字符串
+ * "16",常量改名不该红,常量改值必须红。
+ */
+class HarOrderStatusTest {
+
+    @Test
+    void status16_isFinal() {
+        assertTrue(HarOrderStatus.FINAL.contains("16"),
+                "16 是未收款终态:reschedulePoll 对终态清空 next_query_time,不再排期");
+    }
+
+    @Test
+    void status16_isNotPending() {
+        assertFalse(HarOrderStatus.PENDING.contains("16"),
+                "16 不进轮询捞出集合:上游关单后不会再有新状态,继续查是白跑");
+    }
+
+    @Test
+    void status16_isNotPayRecorded() {
+        assertFalse(HarOrderStatus.PAY_RECORDED.contains("16"),
+                "16 不计收款:钱没到(首页「支付宝收款统计」口径)");
+    }
+}

+ 17 - 0
java/src/test/java/com/payment/platform/module/payment/har/service/HarOrderServiceTest.java

@@ -830,6 +830,23 @@ class HarOrderServiceTest {
         assertNotNull(o.getNextQueryTime());
     }
 
+    /**
+     * 「16 超时关闭」不再是表外值:2026-10-10 用户裁定按未收款终态接纳,
+     * 排期腿就地清空 next_query_time(此前的实测行会挂着一个永远不执行的
+     * 「幽灵排期」,因为它不在 PENDING 里、findPendingForPoll 捞不回)。
+     *
+     * <p>用字面量 "16" 钉值本身;同款定性成员关系见 HarOrderStatusTest。
+     */
+    @Test
+    void reschedulePoll_timeoutClosed16_clearsNextQueryTime() {
+        HarOrderEntity o = order("HAR1", "16");
+        o.setNextQueryTime(java.time.OffsetDateTime.now().plusMinutes(5));
+
+        service.reschedulePoll(o);
+
+        assertNull(o.getNextQueryTime(), "16 按未收款终态接纳,不应再排期");
+    }
+
     /** 查询失败也要推进下次查询时间,否则这条记录每轮都被捞起,打爆上游 */
     @Test
     void sync_queryFailure_stillAdvancesNextQueryTime() {