Sfoglia il codice sorgente

chore(进件): 另外三支验证台挪出批次工作区,交付说明同步

用户指示「把另外三支台子也挪出去」—— 即加记 93 登记并主动提出的那三支。
目标目录与 Task 9 台子同一处 `.claude/plan/2026-09-23-lsy-onboarding-B-harness/`
(git 跟踪 ⇒ `git clean -fdx` 清不掉),源已删。

  fe-update-harness.mjs   12783 B  sha256 5b91fa4b452d…
  fe-update-keycheck.mjs   6874 B  sha256 3d4033167ed8…   (源 6415 B,见下)
  fe-update-mutate.mjs     4197 B  sha256 a3e68dff6d58…

★ fe-update-keycheck.mjs 是这四支里唯一不是逐字节搬运的一支:它 :62 写死了兄弟台子
在批次工作区里的绝对路径(execFileSync 那支来倒前端键集)⇒ 只做 cp / 核 sha / 删源,
「文件在」全过,但它**跑不动** —— 正是四步里「第三步实跑」要拦的那类坏法。
改成 `fileURLToPath(new URL("./fe-update-harness.mjs", import.meta.url))` 自相对定位,
diff 全文核过仅此一处(1 行 import + 4 行说明 + 1 行常量 = 6 增 1 删)。
另两支与源逐字节相同。

读数(同一批调用里取的,不是转述):
- harness 旧位置 / 新位置(cwd 故意取 D:\ 根)/ 删源后 = pass=43 fail=0 三连
- keycheck 三连全绿;★ 决定性的一轮是**删源之后** —— 源已不存在它仍全绿,
  说明自相对定位确实落在新那份上,不是悄悄读了旧路径(定位若坏,此刻是 ENOENT)
- mutate `restore` rc=0;跑前先查过**无遗留 .mut-bak**(前端文件不在变异态),
  没跑真变异(那会临时改写 frontend/ 生产文件,本搬运不需要它)

交付说明三处连带订正(都是我此前写下的、搬完即失效的否定式状态断言):
§四 4 的「仍留在工作区里的同类台子(本次未动)」改为已搬走并补表与指纹;
三条路里「② 只留文件 ⇒ 工作区可能被清」标注该代价已消除;§七 / §十 两处提名各补新路径。
另并列了 CRLF 形态指纹(本仓 autocrlf=true、无 .gitattributes ⇒ 重新 checkout 抹成
CRLF,只给 LF 指纹会给验收者埋一个假阴性)。

未动生产码:三个 .mjs 与一份交付说明,无 Java / 前端 / SQL 改动。
alphaH 3 settimane fa
parent
commit
fe127af949

+ 274 - 0
.claude/plan/2026-09-23-lsy-onboarding-B-harness/fe-update-harness.mjs

@@ -0,0 +1,274 @@
+/**
+ * 前端 `/update` 入口的执行级验证台。
+ *
+ * 形状抄自同目录 `task-9-fix1-harness.mjs`(那里有为什么必须这么搭的两条依据):
+ * `@vue/compiler-sfc` 把页面**真**的 `<script setup>` 编译出来 → 用 `frontend/node_modules`
+ * 里那份**真** `vue` 跑 `setup()` → 驱动**真函数** → 断言落在**真状态**上。
+ *
+ * ★ 量具与被测量物:`vue` / `@vue/compiler-sfc` / `typescript` 都是 frontend 里那一份(真品);
+ *   `@/api/...` 与上传组件是桩(这一步没有后端可打);模板**不渲染**(`inlineTemplate: false`)
+ *   ⇒ 本台证明的是「这些函数的判定逻辑」,**不是**「浏览器里长什么样」。
+ *   ★ 因此 `formRef.value?.validate()` 那一步因 `formRef.value === undefined` 被 `?.` 跳过 ——
+ *     本台验不到 el-form 的校验行为,那部分只能靠 `vue-tsc` 与人工。
+ *
+ * ★ 本台**不设** `process.exitCode`(与 T9 那台一致)⇒ 失败也退 0,**只认最后那行 `SUMMARY`**。
+ *
+ * 用法:node fe-update-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];
+const LABEL = process.argv[3] || SRC;
+
+/** 把 SFC 编译成可 `new Function` 的普通脚本(import 换成对 `globalThis.__U` 的取值)。 */
+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: "uprobe", inlineTemplate: false }).content;
+
+  const stripTypes = (names) => names.replace(/\btype\s+[\w$.]+\s*,?/g, "");
+  const deAlias = (names) => names.replace(/\bas\b/g, ":");
+  const braceImport = (spec) =>
+    new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${spec}['"];?`, "g");
+  code = code
+    // ★ 会有**两条** vue import(`defineOptions` 带出的 `defineComponent` 一条、文件自己一条)
+    //   ⇒ 正则必须带 `g`,且 `as` 别名要转成解构的 `:`。
+    .replace(braceImport("vue"), (_m, n) => `const {${deAlias(n)}} = globalThis.__U.vue;`)
+    .replace(
+      braceImport("@/api/module_payment/onboarding"),
+      (_m, n) => `const {${stripTypes(n)}} = globalThis.__U.api;`
+    )
+    .replace(
+      /import\s+(\w+)\s+from\s*['"]@\/components\/Upload\/SingleImageUpload\.vue['"];?/,
+      (_m, n) => `const ${n} = globalThis.__U.SingleImageUpload;`
+    );
+
+  if (/^\s*import\s/m.test(code)) throw new Error("还有没换掉的 import:\n" + code.slice(0, 400));
+
+  // ★ `compileScript` **不**剥 TS ⇒ 自己过一遍单文件转译,否则 `interface Field {` 是 SyntaxError。
+  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 code = compileSfc(SRC);
+
+// ---------- 桩 ----------
+const ok = (d) => ({ data: { code: 0, data: d } });
+
+function makeApi(state) {
+  return {
+    MERCHANT_CATEGORY_OPTIONS: [],
+    getOnboardingAreas: async () => ok([]),
+    getOnboardingMcc: async () => ok([]),
+    uploadOnboardingImage: async () => ok(""),
+    createOnboardingMerchant: async (body) => {
+      state.created = body;
+      return state.create(body);
+    },
+    updateOnboardingMerchant: async (body) => {
+      state.updated = body;
+      return state.update(body);
+    },
+  };
+}
+
+/** 每个场景一套全新的 refs:重新求值一次编译产物即可。 */
+function mount(state) {
+  globalThis.__U = { vue, api: makeApi(state), SingleImageUpload: { name: "Stub" } };
+  new Function(code)();
+  return globalThis.__U.sfc.setup({}, { expose: () => {} });
+}
+
+const KEYS = (o) => (o ? Object.keys(o).sort() : null);
+
+// ---------- 断言 ----------
+let nPass = 0;
+const failed = [];
+function check(name, actual, expected) {
+  const pass = JSON.stringify(actual) === JSON.stringify(expected);
+  if (pass) nPass++;
+  else failed.push(name);
+  console.log(
+    `  ${pass ? "PASS" : "FAIL"}  ${name}  (actual=${JSON.stringify(actual)} want=${JSON.stringify(expected)})`
+  );
+}
+/** 断言「body 里**没有**这个键」—— 比 check(keys, [...]) 少写一坨期望值,失败时也更好读。 */
+function checkAbsent(name, body, key) {
+  check(name, body && Object.prototype.hasOwnProperty.call(body, key), false);
+}
+
+console.log(`=== ${LABEL} ===`);
+
+const fresh = () => ({ create: async () => ok(null), update: async () => ok(null) });
+
+// ---- U. 更新腿报文的键白名单(本台的主症:多一个键 = HTTP 500,少发 = 上游不改) ----
+{
+  const state = fresh();
+  const b = mount(state);
+  b.mode.value = "update";
+  b.form.mch_no = "  Y52211090004  ";
+  b.form.account_card_num = "6222000000000000";
+  // ★★ 手动把三个**绝不该发**的键塞进表单模型 —— 只有塞进去,「白名单在挡」这句话才不是空的。
+  //   前两个的来路是「用户在建号模式填过,切模式不清表单」(`switchMode` 有意不清);
+  //   后两个的来路是「有人图省事把封盖字段接进了表单」。
+  b.form.merchant_name_business = "某某公司";
+  b.form.lease_contract = "https://oss/lease.png";
+  b.form.category = "MICRO";
+  b.form.tenant_id = "2";
+  b.form.rate_code = "RATE_X";
+
+  await b.handleSubmit();
+  const body = state.updated;
+
+  check("U1 更新报文**不含** merchant_name_business(发了就是 500)", body?.merchant_name_business, undefined);
+  checkAbsent("U1b …用 hasOwnProperty 再确认一次", body, "merchant_name_business");
+  checkAbsent("U2 更新报文**不含** lease_contract(发了就是 500)", body, "lease_contract");
+  checkAbsent("U3 更新报文**不含** category(更新 DTO 上没有它 ⇒ 500)", body, "category");
+  checkAbsent("U4 更新报文**不含** tenant_id(封盖 ⇒ 400)", body, "tenant_id");
+  checkAbsent("U5 更新报文**不含** rate_code(封盖 ⇒ 400)", body, "rate_code");
+  check("U6 更新报文**含** mch_no,且已 trim", body?.mch_no, "Y52211090004");
+  check("U7 增量:填了的那一项原样发出", body?.account_card_num, "6222000000000000");
+  check("U8 增量:**没填**的项一个都不发(恰好两个键)", KEYS(body), ["account_card_num", "mch_no"]);
+  check("U9 提交态已复位", b.submitting.value, false);
+  check("U10 成功提示用的是**请求里**那个商户号(响应里没有)", b.updatedMchNo.value, "Y52211090004");
+}
+
+// ---- V. 正对照:建号腿一个都没被改坏 ----
+{
+  const state = fresh();
+  const b = mount(state);
+  b.form.category = "MICRO";
+  b.form.merchant_name = "某某";
+  b.form.merchant_name_business = "某某公司";
+  b.form.lease_contract = "https://oss/lease.png";
+  b.form.mch_no = "不该出现在建号报文里"; // 切过模式留下的
+  await b.handleSubmit();
+  const body = state.created;
+  check("V1 正对照:建号报文**仍然带** category", body?.category, "MICRO");
+  check("V2 正对照:建号报文**仍然带** merchant_name_business", body?.merchant_name_business, "某某公司");
+  check("V3 正对照:建号报文**仍然带** lease_contract", body?.lease_contract, "https://oss/lease.png");
+  checkAbsent("V4 建号报文**不含** mch_no(它不在 CREATE_KEYS 里,是服务端产出)", body, "mch_no");
+  checkAbsent("V5 建号报文不含 tenant_id", body, "tenant_id");
+  checkAbsent("V6 建号报文不含 rate_code", body, "rate_code");
+}
+
+// ---- W. 渲染投影:那两格在更新模式下根本不渲染 ----
+{
+  const b = mount(fresh());
+  const nameSec = b.SECTIONS.find((s) => s.key === "name");
+  const matSec = b.SECTIONS.find((s) => s.key === "materials");
+
+  check("W1 建号模式:name 分区 3 个字段", b.visibleFields(nameSec).length, 3);
+  check("W2 建号模式:materials 分区 5 个字段", b.visibleFields(matSec).length, 5);
+  b.mode.value = "update";
+  check("W3 更新模式:name 分区只剩 2 个(少了 merchant_name_business)", b.visibleFields(nameSec).length, 2);
+  check("W4 更新模式:materials 分区只剩 4 个(少了 lease_contract)", b.visibleFields(matSec).length, 4);
+  check(
+    "W5 更新模式:被去掉的正是那两个键",
+    b.visibleFields(nameSec).map((f) => f.key).concat(b.visibleFields(matSec).map((f) => f.key)).filter((k) => k === "merchant_name_business" || k === "lease_contract"),
+    []
+  );
+  const addr = b.SECTIONS.find((s) => s.key === "address");
+  check("W6 更新模式:address 分区不受影响", b.visibleFields(addr).map((f) => f.key), ["address_street"]);
+  const mcc = b.SECTIONS.find((s) => s.key === "mcc");
+  check("W7 更新模式:mcc 分区不受影响(merchant_mcc_code 在更新 DTO 上)", b.visibleFields(mcc).map((f) => f.key), ["merchant_mcc_code"]);
+}
+
+// ---- X. 切模式 / 跳转入口 ----
+{
+  const b = mount(fresh());
+  b.form.merchant_name = "填了一半";
+  b.form.category = "MICRO";
+  b.createdMchNo.value = "M20260923";
+  b.switchMode();
+  check("X1 切模式**不清**已填的值(误点一下不能毁掉 41 个框)", b.form.merchant_name, "填了一半");
+  check("X2 切模式清掉上一次的建号结果提示", b.createdMchNo.value, "");
+  check("X3 切模式清掉「没回商户号」告警", b.submitNoMchNo.value, false);
+}
+{
+  const b = mount(fresh());
+  b.form.merchant_name = "建号时填的";
+  b.form.lease_contract = "https://oss/lease.png";
+  b.startUpdateFor("Y52211090004");
+  check("X4 跳转入口:模式切到 update", b.mode.value, "update");
+  check("X5 跳转入口:商户号已预填", b.form.mch_no, "Y52211090004");
+  check("X6 跳转入口**不**把建号表单的值带过去(否则等于按建号值再覆盖一遍)", b.form.merchant_name, "建号时填的");
+}
+{
+  const b = mount(fresh());
+  b.mode.value = "update";
+  check("X7 更新模式只钉商户号一条必填", Object.keys(b.rules.value), ["mch_no"]);
+  b.mode.value = "create";
+  check("X8 建号模式只钉商户类别一条必填(没被本次改动动过)", Object.keys(b.rules.value), ["category"]);
+}
+
+// ---- Y. 更新腿的失败/形状 ----
+{
+  const state = { create: async () => ok(null), update: async () => ok(null) };
+  const b = mount(state);
+  b.mode.value = "update";
+  b.form.mch_no = "Y1";
+  await b.handleSubmit();
+  check("Y1 更新腿成功**不**去读响应里的 mch_no(Result<Void>)", b.submitNoMchNo.value, false);
+  check("Y2 更新腿不碰建号腿的提示位", b.createdMchNo.value, "");
+}
+{
+  const state = {
+    create: async () => ok(null),
+    update: async () => {
+      throw new Error("信封 500(上游挂了)");
+    },
+  };
+  const b = mount(state);
+  b.mode.value = "update";
+  b.form.mch_no = "Y2";
+  let escaped = false;
+  try {
+    await b.handleSubmit();
+  } catch {
+    escaped = true;
+  }
+  check("Y3 更新失败:rejection 不外逃(拦截器已弹过文案)", escaped, false);
+  check("Y4 更新失败:不留成功提示", b.updatedMchNo.value, "");
+  check("Y5 更新失败:提交态已复位", b.submitting.value, false);
+}
+
+// ---- Z. 键集:本台要把「39」这个数字**跑出来**,而不是从注释里抄 ----
+{
+  const b = mount(fresh());
+  check("Z1 建号键白名单 = 41(与页面注释里那个数字对撞)", b.CREATE_KEYS.length, 41);
+  check("Z2 更新键白名单 = 39(交付说明里写的那个数字)", b.UPDATE_KEYS.length, 39);
+  check("Z3 更新集是建号集的**真子集**(差的恰好 2)", b.CREATE_KEYS.length - b.UPDATE_KEYS.length, 2);
+  check(
+    "Z4 更新集里没有任何一个被排除的键",
+    b.UPDATE_KEYS.filter((k) => b.UPDATE_EXCLUDED_KEYS.includes(k)),
+    []
+  );
+  check(
+    "Z5 更新集里没有 category / tenant_id / rate_code(各自对应 500 / 400 / 400)",
+    b.UPDATE_KEYS.filter((k) => ["category", "tenant_id", "rate_code"].includes(k)),
+    []
+  );
+  check("Z6 建号集里也没有 tenant_id / rate_code", b.CREATE_KEYS.filter((k) => ["tenant_id", "rate_code"].includes(k)), []);
+}
+
+if (process.argv.includes("--dump-keys")) {
+  const b = mount(fresh());
+  console.log("KEYS_JSON " + JSON.stringify(b.UPDATE_KEYS));
+}
+
+console.log(`SUMMARY ${LABEL}: pass=${nPass} fail=${failed.length}${failed.length ? " -> " + failed.join(" | ") : ""}`);

+ 128 - 0
.claude/plan/2026-09-23-lsy-onboarding-B-harness/fe-update-keycheck.mjs

@@ -0,0 +1,128 @@
+/**
+ * 键集交叉核对:**前端能发出的 39 个 wire 键** vs **后端 updateMerchant 真正读的 50 个键**。
+ *
+ * 为什么要有这一件:键名拼错在前端**不会**当场报错 —— 它只会变成后端
+ * `FAIL_ON_UNKNOWN_PROPERTIES=true` 下的一个 HTTP 500,或者更糟:键名拼对了但白名单漏了,
+ * 于是「用户填了却没提交」。这两种都得靠集合运算抓,不能靠人眼扫 39 行。
+ *
+ * ★ 全程只在 **node 一个进程**里做,不走 awk/sed/sort/comm 那串管道。
+ *   上一版用管道量出来的读数是自相矛盾的(前端 39 个键「一个都不在」后端的 50 个里,
+ *   同时「后端有前端没有」= 50)⇒ 那是量具坏了,不是世界坏了。管道里每一环都可能
+ *   带进 CR 或改掉排序,而 `comm` 对 CR 是**逐字节**敏感的、对排序规则也敏感。
+ *
+ * 用法:node fe-update-keycheck.mjs
+ */
+import fs from "node:fs";
+import { createRequire } from "node:module";
+import { execFileSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+const ROOT = "D:/project2/payment-platform/";
+const SVC = ROOT + "java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingService.java";
+
+// ---------- 后端:updateMerchant 方法体里的 putIfNotBlank(p, "键") ----------
+const svcSrc = fs.readFileSync(SVC, "utf8");
+
+// 先定位方法体:从 `void updateMerchant(` 起,到下一个「顶格两空格的 `}` 收尾」为止。
+// 不写死行号 —— 行号会漂,而这条断言要在每次改动后都能重跑。
+const start = svcSrc.indexOf("void updateMerchant(");
+if (start < 0) throw new Error("找不到 updateMerchant —— 量具失效,别信任何读数");
+const bodyStart = svcSrc.indexOf("{", start);
+let depth = 0;
+let end = -1;
+for (let i = bodyStart; i < svcSrc.length; i++) {
+  if (svcSrc[i] === "{") depth++;
+  else if (svcSrc[i] === "}") {
+    depth--;
+    if (depth === 0) {
+      end = i;
+      break;
+    }
+  }
+}
+if (end < 0) throw new Error("updateMerchant 方法体没有闭合 —— 量具失效");
+const body = svcSrc.slice(bodyStart, end);
+
+// ★★ 线上真实形状是**三参**:`putIfNotBlank(p, "merchant_name", dto.getMerchantName());`
+//    —— 所以正则必须吃到第二个逗号为止,**不能**写成「字符串后紧跟 `)`」。
+//    上一版就是栽在这里:那支正则一个都匹配不上,读数 0,而**另一个坏量具**(`grep -oE` 的
+//    匹配含结尾引号 + `sed 's/.*"//'` 的 `.*` 贪婪吃掉到最后那个引号)吐出了 50 行**空串**,
+//    `wc -l` 报 50 —— 恰好等于预期的键数,于是「50」看起来是对的。见文件头 ★。
+const beKeys = [...body.matchAll(/putIfNotBlank\(\s*p\s*,\s*"([A-Za-z_][A-Za-z0-9_]*)"\s*,/g)].map((m) => m[1]);
+
+// 正对照(硬):全文件 `putIfNotBlank(` 的出现次数应当 = **调用点数 + 1 个方法定义**。
+// 这条同时证明两件事:① 这支正则不是空转;② updateMerchant 里的调用点数 == 全文件调用点数,
+// 即**没有**任何一处 putIfNotBlank 落在 updateMerchant 之外(漏掉就等于漏键)。
+const allOccur = [...svcSrc.matchAll(/putIfNotBlank\(/g)].length;
+const DEFINITION_SITES = 1;
+console.log(`[后端] updateMerchant 方法体字节数=${end - bodyStart}`);
+console.log(`[后端] 全文件 putIfNotBlank( 出现次数=${allOccur}(= 定义 1 处 + 调用 ${allOccur - DEFINITION_SITES} 处)`);
+console.log(`[后端] updateMerchant 内提取到 ${beKeys.length} 个键,去重后 ${new Set(beKeys).size} 个`);
+
+// ---------- 前端:跑台子把 UPDATE_KEYS 倒出来 ----------
+// ★ 2026-09-23 搬家后改成**相对本文件**定位。原先写死的是批次工作区的绝对路径,
+//   而工作区是 git-ignored 的 scratch(`git clean -fdx` 会毁掉它),源文件已随搬家删除
+//   ⇒ 再照旧写绝对路径就会指向一个**不存在的文件**(正是「文件在、跑不动」那类坏法)。
+//   这两支是一对,跟着彼此走,所以用自身位置定位。
+const HARN = fileURLToPath(new URL("./fe-update-harness.mjs", import.meta.url));
+const PAGE = ROOT + "frontend/src/views/module_payment/collection/onboarding/index.vue";
+const out = execFileSync(process.execPath, [HARN, PAGE, "keycheck", "--dump-keys"], { encoding: "utf8" });
+const line = out.split(/\r?\n/).find((l) => l.startsWith("KEYS_JSON "));
+if (!line) throw new Error("台子没吐出 KEYS_JSON —— 量具失效");
+const feKeys = JSON.parse(line.slice("KEYS_JSON ".length));
+console.log(`[前端] 台子实跑倒出 ${feKeys.length} 个键`);
+
+// ---------- 集合运算 ----------
+const be = new Set(beKeys);
+const fe = new Set(feKeys);
+const feNotBe = feKeys.filter((k) => !be.has(k)).sort();
+const beNotFe = [...be].filter((k) => !fe.has(k)).sort();
+
+console.log("");
+console.log("=== 前端有、后端没有(**必须为空**:非空 = 拼错的键名 ⇒ 线上 HTTP 500)===");
+console.log(feNotBe.length ? feNotBe.join("\n") : "(空)");
+console.log("");
+console.log("=== 后端有、前端没有 ===");
+console.log(beNotFe.join("\n"));
+console.log(`个数 = ${beNotFe.length}`);
+
+// ---------- 断言 ----------
+let bad = 0;
+const assert = (name, cond, extra = "") => {
+  console.log(`${cond ? "PASS" : "FAIL"}  ${name}${extra ? "  " + extra : ""}`);
+  if (!cond) bad++;
+};
+assert(
+  "A0 正控:全文件 putIfNotBlank( 出现次数 == 调用点数 + 1 个定义(证正则不空转、且无调用落在 updateMerchant 之外)",
+  allOccur - DEFINITION_SITES === beKeys.length,
+  `全文件出现=${allOccur} → 调用点=${allOccur - DEFINITION_SITES},方法体内提取=${beKeys.length}`
+);
+assert("A1 前端没有拼错的键(前端键集 ⊆ 后端键集)", feNotBe.length === 0, `多出=${JSON.stringify(feNotBe)}`);
+assert("A2 后端键集无重复(50 次调用 = 50 个不同键)", new Set(beKeys).size === beKeys.length);
+assert("A3 前端键集无重复", fe.size === feKeys.length);
+assert("A4 后端收 50 个", beKeys.length === 50, `actual=${beKeys.length}`);
+assert("A5 前端发 39 个", feKeys.length === 39, `actual=${feKeys.length}`);
+assert("A6 差恰好 11 个", beNotFe.length === 11, `actual=${beNotFe.length}`);
+assert(
+  "A7 差的 11 个 = 10 个无入口字段 + rate_code",
+  JSON.stringify(beNotFe) ===
+    JSON.stringify(
+      [
+        "account_idcard_stt",
+        "account_type",
+        "actual_addr",
+        "business_scope",
+        "check_code",
+        "fSettle_account_type",
+        "legal_cert_stt",
+        "license_stt",
+        "p_cashier",
+        "rate_code",
+        "reg_capital",
+      ].sort()
+    ),
+  `actual=${JSON.stringify(beNotFe)}`
+);
+
+console.log(`SUMMARY keycheck: ${bad === 0 ? "全绿" : bad + " 条红"}`);
+process.exitCode = bad === 0 ? 0 : 1;

+ 97 - 0
.claude/plan/2026-09-23-lsy-onboarding-B-harness/fe-update-mutate.mjs

@@ -0,0 +1,97 @@
+/**
+ * 前端 `/update` 入口的两向变异驱动。
+ *
+ * 每条形变都:① 断言锚点**恰好命中一次**(命中 0 次或多次都不动文件);② 打补丁;
+ * ③ 跑完由调用方 `restore`,`restore` 从 `.mut-bak` 原样拷回并复核 sha256。
+ * ★ 「还原」本身也要被验证 —— 所以 restore 会打印还原后的 sha256,由外面的脚本与基线对撞。
+ *
+ * 用法:node fe-update-mutate.mjs <mutationId|restore> <目标文件>
+ */
+import fs from "node:fs";
+import crypto from "node:crypto";
+
+const PAGE = "D:/project2/payment-platform/frontend/src/views/module_payment/collection/onboarding/index.vue";
+const ID = process.argv[2];
+
+const MUTATIONS = {
+  // 红向 ①:把「减去两个」拆掉 ⇒ 更新报文会带上 merchant_name_business / lease_contract
+  "M-U1": {
+    file: PAGE,
+    from: "const UPDATE_KEYS: string[] = CREATE_KEYS.filter((k) => !UPDATE_EXCLUDED_KEYS.includes(k));",
+    to: "const UPDATE_KEYS: string[] = CREATE_KEYS.slice();",
+  },
+  // 红向 ②:把白名单换成整表摊开 ⇒ category / tenant_id / rate_code 全都会发出去
+  "M-U2": {
+    file: PAGE,
+    from: `  const body: Record<string, string> = { mch_no: form.mch_no.trim() };
+  for (const key of UPDATE_KEYS) {
+    const value = (form[key] ?? "").trim();
+    if (value !== "") body[key] = value;
+  }
+  return body;`,
+    to: `  const body: Record<string, string> = { mch_no: form.mch_no.trim() };
+  for (const key of Object.keys(form)) {
+    const value = (form[key] ?? "").trim();
+    if (value !== "") body[key] = value;
+  }
+  return body;`,
+  },
+  // 红向 ③:渲染投影失效 ⇒ 更新模式下那两格会渲染出来(按下去必 500)
+  "M-U3": {
+    file: PAGE,
+    from: `  return mode.value === "create"
+    ? s.fields
+    : s.fields.filter((f) => !UPDATE_EXCLUDED_KEYS.includes(f.key));`,
+    to: `  return s.fields;`,
+  },
+  // 无害向:给更新腿的 catch 加一句注释 —— 行为**应当完全不变**(这一条是「刻意钝感」的那一侧)
+  "M-U5": {
+    file: PAGE,
+    from: `  } catch {
+    // 失败文案由拦截器统一弹(两道封盖的 400、非本租户的 404、上游失败的 502 都在 \`msg\` 里)`,
+    to: `  } catch {
+    // MUTANT-HARMLESS:只加一行注释
+    // 失败文案由拦截器统一弹(两道封盖的 400、非本租户的 404、上游失败的 502 都在 \`msg\` 里)`,
+  },
+};
+
+/**
+ * ★ 这两条是给 **`vue-tsc`** 用的,不是给执行级台子用的:`ts:check` 在本仓有一个**非零历史基线**
+ *   ⇒ 「前后输出一模一样」也可能是因为 vue-tsc **压根没看我新加的文件**。要主张「没引入新错」,
+ *   就必须先证明它**看得见**这两个文件。
+ */
+const API = "D:/project2/payment-platform/frontend/src/api/module_payment/onboarding.ts";
+MUTATIONS["M-T1"] = {
+  file: PAGE,
+  from: `const mode = ref<"create" | "update">("create");`,
+  to: `const mode = ref<"create" | "update">("create");\nconst __tsProbe: number = "故意写错的类型";`,
+};
+MUTATIONS["M-T2"] = {
+  file: API,
+  from: `export function updateOnboardingMerchant(data: Record<string, unknown>) {`,
+  to: `const __tsProbe2: number = "故意写错的类型";\nexport function updateOnboardingMerchant(data: Record<string, unknown>) {`,
+};
+
+const sha = (f) => crypto.createHash("sha256").update(fs.readFileSync(f)).digest("hex");
+
+if (ID === "restore") {
+  for (const f of new Set(Object.values(MUTATIONS).map((m) => m.file))) {
+    const bak = f + ".mut-bak";
+    if (fs.existsSync(bak)) {
+      fs.copyFileSync(bak, f);
+      fs.unlinkSync(bak);
+      console.log(`restored ${f} sha256=${sha(f)}`);
+    }
+  }
+  process.exit(0);
+}
+
+const m = MUTATIONS[ID];
+if (!m) throw new Error("未知变异:" + ID);
+const bak = m.file + ".mut-bak";
+if (!fs.existsSync(bak)) fs.copyFileSync(m.file, bak);
+const src = fs.readFileSync(m.file, "utf8");
+const hits = src.split(m.from).length - 1;
+if (hits !== 1) throw new Error(`[${ID}] 锚点命中 ${hits} 次,期望恰好 1 次 —— 文件未被修改`);
+fs.writeFileSync(m.file, src.replace(m.from, m.to));
+console.log(`[${ID}] patched ${m.file} sha256=${sha(m.file)}`);

+ 32 - 8
.claude/plan/2026-09-23-lsy-onboarding-B-交付说明.md

@@ -159,7 +159,8 @@
    CRLF 形态 **11049 B / sha256 `81271e98…362f`**。⇒ 你若核出后者,**不是文件被改过,是行尾**。
    它**钉的是行为不是文本**(主控实测两向变异:改前 `pass=16 fail=9` → 改后 `pass=25 fail=0`;重引同一缺陷仍红 4 条)。
    **三条路**:① 收编为正式测试 ⇒ `frontend/` 需引入测试入口(该目录**目前无测试框架**);
-   ② 只留文件 ⇒ 它留在批次工作区里,而工作区可能被清;③ 随工作区丢弃 ⇒ 下次同类页面要重造。
+   ② 只留文件 ⇒ 它留在批次工作区里,而工作区可能被清(★ **该代价已于 2026-09-23 消除** —— 见下);
+   ③ 随工作区丢弃 ⇒ 下次同类页面要重造。
    **局限(务必一并考虑)**:**模板不渲染** ⇒ 只证明函数判定逻辑、不证明浏览器表现;
    **exit code 恒 0** ⇒ 判成败**只能读 `SUMMARY` 行**。
    ★ ~~**注**:该台子在**工作区内**,若采纳 ② 需先把它挪到工作区之外。~~ —— **2026-09-23 已挪,见上。**
@@ -173,10 +174,31 @@
    `process.cwd` / `import.meta` / `__dirname` / 相对 import / 任何**写文件**调用 **全部零命中**
    (配了**同一支** grep 的正控:`readFileSync` 命中 1)。
    ★ ~~「工作区可能被清」那条残留~~ + ~~「挪出工作区是一条命令的事,要挪说一声」~~ **均已作废**。
-   ★ **仍留在工作区里的同类台子(本次未动)** —— 同一个「被 `git clean -fdx` 清掉」的风险,要挪一并说:
-   `fe-update-harness.mjs`(12783 B,见 §十)、`fe-update-keycheck.mjs`(6415 B,见 §七)、
-   `fe-update-mutate.mjs`(4197 B)。它们与 Task 9 台子**不是同一件东西**(那台子驱动进件页的**真 `setup()`**;
-   这三支是 R-7c 前端入口的验证),所以本次**只搬被裁定的那一台**,没顺手扩大范围。
+   ★ ~~**仍留在工作区里的同类台子(本次未动)**~~ —— **2026-09-23 已按用户指示「把另外三支台子也挪出去」
+   一并搬走,源已删**。同一套四步、同一个目标目录 `.claude/plan/2026-09-23-lsy-onboarding-B-harness/`。
+   它们与 Task 9 台子**不是同一件东西**(那台子驱动进件页的**真 `setup()`**;这三支是 R-7c 前端入口的验证,
+   详见 §七 / §十):
+
+   | 台子 | 字节 | sha256 | 搬后从新位置实跑 |
+   |---|---|---|---|
+   | `fe-update-harness.mjs` | 12783 | `5b91fa4b452d6aa6816ec523c2c057d28422a3a055d2e725c87af74b63984dad` | `SUMMARY post-delete: pass=43 fail=0` |
+   | `fe-update-keycheck.mjs` | **6874**(见下,有意改了 1 处) | `3d4033167ed8950cda71c3396d77fe4968f70aafef915f2616193a675eba1767` | `SUMMARY keycheck: 全绿` |
+   | `fe-update-mutate.mjs` | 4197 | `a3e68dff6d58fd86a6b219c2de50f98493434bc929c544d9f77bb1af9565a330` | `restore` rc=0 |
+
+   ★ **`fe-update-keycheck.mjs` 是这四支里唯一不是逐字节搬运的一支**:它 `:62` 原本**写死**了台子在批次
+   工作区里的绝对路径(`ROOT + ".superpowers/sdd/2026-09-23-lsy-onboarding-B/fe-update-harness.mjs"`)
+   —— 搬家后那是个**已被删除的文件**。改成了**相对本文件**定位
+   (`fileURLToPath(new URL("./fe-update-harness.mjs", import.meta.url))`)。
+   `diff` 全文核过:**改动仅此一处**(1 行 import + 4 行说明 + 1 行常量,**6 增 1 删**);其余三支与源**逐字节相同**。
+   ★ **这条引用是四步里的「第三步实跑」逼出来的** —— 搬家前先扫路径依赖才看见它;
+   若只做 ①②④,「文件在」会通过,但它**跑不动**。
+   ★ **决定性的复跑在删源之后**:源已不存在,`keycheck` 仍 `全绿` ⇒ 自相对定位**确实落在新那份上**,
+   不是悄悄读到了旧路径。三条读数都在同一台机器、同一批调用里取的。
+   ★ **行尾同样适用上文那条**(本仓 `core.autocrlf = true`、无 `.gitattributes` ⇒ 重新 checkout 抹成 CRLF)。
+   CRLF 形态**实测**(真转出来量的,不是加行数算的):harness **13057 B / `cb1ba3b25b80577a8ba31f006fa51a43de68cb9b9e0fc8c95eba360df3230980`**、
+   keycheck **7002 B / `2e1eb43d829ea7e4aabc330c77d4a5605c4b4672a580d21ace255fbfa26325e4`**、
+   mutate **4294 B / `fafacc0c8e41091a870054098c2de20dc46f90670890983dcd5101f9d8d65621`**。
+   ⇒ 你若核出这几个数,**不是文件被改过,是行尾**。
    ★ **若你要的其实是 ①「收编」**(那是新工作:得先给 `frontend/` 引入测试入口),说一声即可 ——
    本行按「现方案走」读作 ②,是因为 ① 会**改变仓库内容**,与「按现方案走」相反。
 
@@ -366,8 +388,9 @@
   ⇒ **前端能发出 39 个**(执行级台子实跑倒出,不是从注释抄)。差的 **11** 个 = 上面那
   **10** 个无入口的 + `rate_code`(**有意封盖**,不是漏做 —— 前端连输入框都不该有)。
   ⇒ 这中间的差是**已知边界**,不是缺陷。
-  ★ 这 39 个与后端那 50 个做过**跨端键名核对**(`fe-update-keycheck.mjs`,把两边键集放在
-  **同一个 node 进程**里做集合运算):前端键集 ⊆ 后端键集,**多出 0 个**。这条不能省 ——
+  ★ 这 39 个与后端那 50 个做过**跨端键名核对**(`fe-update-keycheck.mjs` ——
+  **该台子 2026-09-23 已挪到 `.claude/plan/2026-09-23-lsy-onboarding-B-harness/`**,见 §四 4;
+  把两边键集放在**同一个 node 进程**里做集合运算):前端键集 ⊆ 后端键集,**多出 0 个**。这条不能省 ——
   前端键名拼错**不会当场报错**,只会变成线上 HTTP 500。
   ★ **两处本轮新做的检查,以及一处对我此前说法的订正**:
   - **模板的三处字段渲染点全部接了投影函数**(`:99` 纯文本组 / `:163` 地址组 / `:242` 上传组
@@ -480,7 +503,8 @@
 5. **前端未做执行级验证**(无 dev server、无 Playwright)。
    ★ 补记(2026-09-23 晚):审评当时**确实**没做,这一行保留不动;但它指的那块空白**后来
    由另一条路补上了** —— 本轮新增 `fe-update-harness.mjs` 执行级台子(编译真 SFC、跑
-   `frontend/` 里那份真 `vue`、驱动真函数、只桩掉 api 与上传组件)**43 条全绿**,外加
+   `frontend/` 里那份真 `vue`、驱动真函数、只桩掉 api 与上传组件)**43 条全绿**(**该台子
+   2026-09-23 已挪到 `.claude/plan/2026-09-23-lsy-onboarding-B-harness/`**,见 §四 4),外加
    4 条两向变异与 2 条 `ts:check` 可见性变异。**仍未做**的是**浏览器里**的验证
    (本仓 `frontend/` 无测试框架、`ts:check` 有非零历史基线,「跑绿了」在这里不成立)。
 6. **没有连库、没有执行任何 SQL**(GC6)⇒ 真库那两行重复、列数/索引清单都是从注释与 SQL 读来的。