Browse Source

fix(进件): 级联失败不再被当成「末级」+ 空应答不再当成成功(Task 9 修复轮 1)

只改 `frontend/src/views/module_payment/collection/onboarding/index.vue`(119+/36-)。

F1(Important)—— `/mcc` 请求失败与「这一级就是末级」共用一个返回值:
`loadCascade` 的 `catch` 原本 `return []`,而末级判定读的正是「下一级是否 0 条」
⇒ 一次失败的二级请求会把**一级**的 code 写进必填、且要进上游签名报文的
`merchant_mcc_code`,页面上还显示一个看起来很正常的「已选:<一级名>」。
两个加重情节:上游的失败形态本就可能是「0 条且不报错」;点「重新加载」修不好
(旧值不清,而 `el-select` 只在值变化时才触发 `@change`)。
改法:`loadCascade` 返回 `T[] | null`(失败 = `null`);末级判定收进 `loadMccLevel`,
失败时**不**调 `setLeaf`;`reloadCascades` 先 `clearLeaf()` 再**显式**重驱动当前选中
的那一级(`redriveMcc`,每级都检查下游已选值是否还在重取后的选项里)。
`loadCascade` 的契约改了 ⇒ 6 个调用点逐个处理(行政区 4 处 `?? []` + 类目 2 处)。

F2(Minor)—— 空应答被当成成功:
上传取不到地址时不再 `return ""`(那会让 `onSuccess({file_url: ""})` 显示成功而字段为空),
改为抛异常 ⇒ 走组件既有的 `catch` → `onError`(唯一用户可见文案仍是既有的那句,
我抛的文案只进 console)。提交信封说成功却没回 `mch_no` 时落一条常驻 warning:
既不说成功(拿不到号),也不说失败(重提很可能再建一个商户 —— `createOnboardingRow`
是无条件 INSERT,而唯一索引建在服务端生成的 `mch_no` 上,拦不住第二次建号)。

F3(Minor)—— 删掉未被要求的 `inheritAttrs: false`(页面零处绑 `$attrs`,`AppMain` 也不传)。

验证:本仓无前端测试框架 ⇒ 用 `@vue/compiler-sfc` 编译真 SFC、跑真 `vue`、驱动真函数
(25 条断言 / 7 场景,脚本见 `.superpowers/sdd/2026-09-23-lsy-onboarding-B/task-9-fix1-harness.mjs`):
改前 pass=16/fail=9(红点正是三处症状),改后 25/0;两向变异 —— 用另一段代码重引同一缺陷
⇒ 21/4,无害改动(对调两行清空)⇒ 25/0。
`pnpm ts:check` 前后逐字节 IDENTICAL(51449 B / 287 error / exit 2);变异红向 +1 且点名
本文件 (577,9),绿向与基线逐字节相同。`vite build` exit 0 / `✓ built in 1m 1s`。
eslint 0 error(并顺带消掉改前就有的 `OnboardingLevel` 未使用那条 error)。

两处已在报告里标注待复裁:F3 的前提(「三个同级页面只钉 name」)经实测不成立
(三页全带 `inheritAttrs: false`,全仓 45 个文件同形);F2 的 `mch_no` 那处改用常驻
warning 而非既有错误通道(走 catch 等于说「这次没成」,会诱导重提)。
alphaH 3 weeks ago
parent
commit
da6fde56b7
1 changed files with 119 additions and 36 deletions
  1. 119 36
      frontend/src/views/module_payment/collection/onboarding/index.vue

+ 119 - 36
frontend/src/views/module_payment/collection/onboarding/index.vue

@@ -15,6 +15,16 @@
       </template>
     </el-alert>
 
+    <!-- 信封说成功、却没有商户号:既不能说成功(拿不到号),也不能说失败(可能已经建出来了) -->
+    <el-alert
+      v-if="submitNoMchNo"
+      type="warning"
+      :closable="false"
+      show-icon
+      style="margin-bottom: 12px"
+      title="提交已被受理,但响应里没有商户号 —— 请先核对是否已建号,直接重提会再建一个商户。"
+    />
+
     <el-alert
       v-if="cascadeFailed"
       type="error"
@@ -228,10 +238,7 @@
 // ★ 组件名必须与菜单 route_name 逐字相同:keep-alive 的 `include` 匹配的是**组件名**,
 //   而 tags-view 推进 `cachedViews` 的是**路由名**(`tags-view.store.ts:31-42`)。
 //   不写这一行,`<script setup>` 会按文件名推断成 "index" ⇒ keep_alive 永不生效(且不报错)。
-defineOptions({
-  name: "CollectionOnboarding",
-  inheritAttrs: false,
-});
+defineOptions({ name: "CollectionOnboarding" });
 
 import { computed, onMounted, reactive, ref } from "vue";
 import {
@@ -424,6 +431,8 @@ const formRef = ref();
 const form = reactive<Record<string, string>>(emptyForm());
 const submitting = ref(false);
 const createdMchNo = ref("");
+/** ★ 提交被受理、却**没回商户号**:不当成功(拿不到号),也不当失败(可能已经建出来了)。 */
+const submitNoMchNo = ref(false);
 const cascadeFailed = ref(false);
 
 /** 默认**全部展开**(理由见模板上的注释)。 */
@@ -476,26 +485,34 @@ const mcc3Name = computed(
 );
 
 /**
- * 拉一次级联腿。**失败不在这里弹文案** —— `utils/request.ts` 的拦截器已经弹过服务端的 `msg`
+ * 拉一次级联腿。
+ *
+ * ★★ 返回 **`T[] | null`**:`null` = **请求失败**;空数组 = **上游真的回了 0 条**。
+ *   这两件事**必须**分辨 —— 上游的失败形态恰恰是「0 条且不报错」(§四),而「0 条」在类目
+ *   被读作「这一级就是末级」⇒ 两者共用一个值,失败就会把一级/二级的 code 当末级写进
+ *   **必填、且要进上游签名报文**的 `merchant_mcc_code`,页面上「已选:<一级名>」还完全正常。
+ *
+ * **失败不在这里弹文案** —— `utils/request.ts` 的拦截器已经弹过服务端的 `msg`
  * 并 `Promise.reject` 了;这里只把失败落成页面上一条**常驻**提示(toast 会自己消失),
  * 并把控制台里的一句上下文补上(拦截器不知道这是哪一组级联)。
  */
-async function loadCascade<T>(fetcher: () => Promise<unknown>): Promise<T[]> {
+async function loadCascade<T>(fetcher: () => Promise<unknown>): Promise<T[] | null> {
   try {
     const res: any = await fetcher();
     return res.data?.data || [];
   } catch (e) {
     console.warn("级联数据加载失败:", e);
     cascadeFailed.value = true;
-    return [];
+    return null;
   }
 }
 
 async function loadProvinces() {
   // ★ `level="1"` ⇒ **字面上就不给 `parentCode` 这个键**(不是给空串、也不是给 undefined)。
-  provinceOptions.value = await loadCascade<OnboardingArea>(() =>
-    getOnboardingAreas({ level: "1" })
-  );
+  // ★ 空值兜底(`?? []`)只针对**失败**(`null`);行政区这三级的「空列表」没有「末级」语义,
+  // 不像类目那样会被读成结论,所以这里不必再区分。
+  provinceOptions.value =
+    (await loadCascade<OnboardingArea>(() => getOnboardingAreas({ level: "1" }))) ?? [];
 }
 
 async function handleProvinceChange() {
@@ -505,22 +522,25 @@ async function handleProvinceChange() {
   cityOptions.value = [];
   countyOptions.value = [];
   if (!form[AREA_FIELDS.province.key]) return;
-  cityOptions.value = await loadCascade<OnboardingArea>(() =>
-    getOnboardingAreas({ level: "2", parentCode: form[AREA_FIELDS.province.key] })
-  );
+  cityOptions.value =
+    (await loadCascade<OnboardingArea>(() =>
+      getOnboardingAreas({ level: "2", parentCode: form[AREA_FIELDS.province.key] })
+    )) ?? [];
 }
 
 async function handleCityChange() {
   form[AREA_FIELDS.county.key] = "";
   countyOptions.value = [];
   if (!form[AREA_FIELDS.city.key]) return;
-  countyOptions.value = await loadCascade<OnboardingArea>(() =>
-    getOnboardingAreas({ level: "3", parentCode: form[AREA_FIELDS.city.key] })
-  );
+  countyOptions.value =
+    (await loadCascade<OnboardingArea>(() =>
+      getOnboardingAreas({ level: "3", parentCode: form[AREA_FIELDS.city.key] })
+    )) ?? [];
 }
 
 async function loadMccLevel1() {
-  mccOptions1.value = await loadCascade<OnboardingMcc>(() => getOnboardingMcc({ level: "1" }));
+  mccOptions1.value =
+    (await loadCascade<OnboardingMcc>(() => getOnboardingMcc({ level: "1" }))) ?? [];
 }
 
 function setLeaf(code: string, name: string) {
@@ -534,10 +554,32 @@ function clearLeaf() {
 }
 
 /**
- * ★ **末级 = 「下一级返回空列表」的那一级**,不是写死的第三级。
- *   类目树的深度是联调残余项(一级就到底 / 两级到底 / 三级到底都可能),
- *   写死「一定取第三级」会让浅树永远选不出值。
+ * 取**下一级**类目选项,并按 ★「**末级 = 下一级返回空列表的那一级**」判定落点
+ *   (不是写死的第三级:类目树的深度是联调残余项 —— 一级就到底 / 两级到底 / 三级到底都可能,
+ *     写死「一定取第三级」会让浅树永远选不出值)。
+ *
+ * ★★ 返回 `false` = **加载失败** ⇒ **不判定末级**。这是本页最要紧的一处分支:
+ *   失败与「真的空」共用一个值时,一级/二级的 code 会被当成末级写进必填、且要进上游
+ *   签名报文的 `merchant_mcc_code`,而页面上「已选:<一级名>」看起来完全正常。
+ * ★ 只**取数与判定**,不碰任何选中值 ⇒ 「用户换级时的清空」留在各自的 `handleMccNChange`,
+ *   而 `redriveMcc` 能在**不丢用户已选**的前提下复用同一份判定(一处规则,一处实现)。
  */
+async function loadMccLevel(level: Exclude<OnboardingLevel, "1">): Promise<boolean> {
+  // 查 `level` 这一级时,父级 = 上一级**当前选中**的值;父级没选 ⇒ 无判定可言(不是失败)
+  const parentCode = level === "2" ? mcc1.value : mcc2.value;
+  const parentName = level === "2" ? mcc1Name.value : mcc2Name.value;
+  if (!parentCode) return true;
+
+  const options = await loadCascade<OnboardingMcc>(() =>
+    getOnboardingMcc({ level, parentId: parentCode })
+  );
+  if (options === null) return false; // ★ 失败:不判定末级
+  if (level === "2") mccOptions2.value = options;
+  else mccOptions3.value = options;
+  if (options.length === 0) setLeaf(parentCode, parentName);
+  return true;
+}
+
 async function handleMcc1Change() {
   mcc2.value = "";
   mcc3.value = "";
@@ -548,14 +590,10 @@ async function handleMcc1Change() {
 
   mccLoading.value = true;
   try {
-    mccOptions2.value = await loadCascade<OnboardingMcc>(() =>
-      getOnboardingMcc({ level: "2", parentId: mcc1.value })
-    );
+    await loadMccLevel("2");
   } finally {
     mccLoading.value = false;
   }
-  // 一级就是末级
-  if (mccOptions2.value.length === 0) setLeaf(mcc1.value, mcc1Name.value);
 }
 
 async function handleMcc2Change() {
@@ -566,14 +604,10 @@ async function handleMcc2Change() {
 
   mccLoading.value = true;
   try {
-    mccOptions3.value = await loadCascade<OnboardingMcc>(() =>
-      getOnboardingMcc({ level: "3", parentId: mcc2.value })
-    );
+    await loadMccLevel("3");
   } finally {
     mccLoading.value = false;
   }
-  // 二级就是末级
-  if (mccOptions3.value.length === 0) setLeaf(mcc2.value, mcc2Name.value);
 }
 
 function handleMcc3Change() {
@@ -584,10 +618,43 @@ function handleMcc3Change() {
   setLeaf(mcc3.value, mcc3Name.value);
 }
 
-function reloadCascades() {
+/**
+ * 重驱动类目链 —— 只给 `reloadCascades` 用:按**当前已选**逐级重取选项、重判末级,
+ * 且**不清**下游已选值(重载不该让人把三级重选一遍)。
+ *
+ * ★ 必须**显式**驱动:`el-select` 只在**值变化**时才触发 `@change` ⇒ 让用户「再点一次同一个
+ *   一级值」不会重跑 `handleMcc1Change`,旧的(可能是错的)末级就会一直留着。
+ * ★ 每往下走一级都要问「下游那个已选值在**重取后**的选项里还在吗」:不在就不能拿它当下一级的
+ *   父级 —— 那会上游回 0 条、又被判成末级,正是本轮要修的那类错。
+ */
+async function redriveMcc() {
+  if (!mcc1.value) return;
+  if (!(await loadMccLevel("2"))) return;
+  if (!mcc2.value || !mccOptions2.value.some((m) => m.mcc_code === mcc2.value)) return;
+  if (!(await loadMccLevel("3"))) return;
+  // 三级仍是重取后选项里的那一项才算数;不在 ⇒ 末级留空(显示「尚未选到最末一级」),不猜
+  if (mcc3.value && mccOptions3.value.some((m) => m.mcc_code === mcc3.value)) {
+    setLeaf(mcc3.value, mcc3Name.value);
+  }
+}
+
+async function reloadCascades() {
   cascadeFailed.value = false;
-  loadProvinces();
-  loadMccLevel1();
+  // ★ 先把**已经落到必填字段上**的末级清掉:重新加载再失败时,若留着旧值,页面就成了
+  //   「一条红色的未能加载」+ 一个上游会照收、且可能不是末级的 code。
+  clearLeaf();
+  mccOptions2.value = [];
+  mccOptions3.value = [];
+
+  await loadProvinces();
+  await loadMccLevel1();
+
+  mccLoading.value = true;
+  try {
+    await redriveMcc();
+  } finally {
+    mccLoading.value = false;
+  }
 }
 
 // ==================== 上传 ====================
@@ -598,11 +665,20 @@ function reloadCascades() {
  *   ⇒ 页面**不传** `name`(传错服务端的 `@RequestParam("file")` 就收不到)。
  * ★ 应答是**裸字符串**(`Result<String>`),不是对象 —— 同时**不再弹成功提示**:
  *   拦截器已经弹过(非 GET 成功响应自动弹)。
- * ★ 抛异常时组件既有的 `catch` 会走 `onError` ⇒ 这里**不写**错误处理。
+ * ★ 抛异常时组件既有的 `catch` 会走 `onError` ⇒ 这里**不写**错误处理(但**要**在拿不到
+ *   地址时抛,见下)。
  */
 async function uploadOnboardingFile(fd: FormData): Promise<string> {
   const res: any = await uploadOnboardingImage(fd);
-  return res.data?.data || "";
+  const url = res.data?.data;
+  // ★★ 拿不到地址时**不能**当成功返回 `""`:组件会把 `file_url: ""` 交给 `onSuccess` 并 `emit`
+  //   ⇒ 上传框看起来是成功的(拦截器的成功 toast 也弹过了),字段却是空的,
+  //   直到服务端必填校验才暴露。抛出去 ⇒ 组件既有的 `catch` 走 `onError`(就地报错)。
+  //   顺带把「形状变了」(回的压根不是字符串)也挡住 —— 同样是拿不到地址。
+  if (typeof url !== "string" || url === "") {
+    throw new Error("上传接口未返回图片地址");
+  }
+  return url;
 }
 
 // ==================== 提交 ====================
@@ -632,10 +708,16 @@ async function handleSubmit() {
 
   submitting.value = true;
   createdMchNo.value = "";
+  submitNoMchNo.value = false;
   try {
     const res: any = await createOnboardingMerchant(buildCreateBody());
     // 商户号读 `res.data.data.mch_no`(两层 `.data` —— 响应拦截器不解包)
-    createdMchNo.value = res.data?.data?.mch_no || "";
+    const mchNo = res.data?.data?.mch_no;
+    createdMchNo.value = typeof mchNo === "string" ? mchNo : "";
+    // ★★ 信封说成功(拦截器已弹过成功提示)却没回商户号 ⇒ 本页**唯一的产出**没了,不能静默。
+    //   但**不**当失败往下走:那会诱导重提,而商户很可能已经建出来了 ⇒ 落成一条常驻提示,
+    //   让人先去核对。这就是为什么它不走上面那个 `catch`(那条路的语义是「这次没成」)。
+    submitNoMchNo.value = createdMchNo.value === "";
   } catch {
     // 失败文案由拦截器统一弹(必填缺失清单就在 `msg` 里)⇒ 页面**不重复弹**,
     // 也不把它当成功往下走。这里只是不让 rejection 逃逸成未处理的 Promise 拒绝。
@@ -656,6 +738,7 @@ function handleReset() {
   mccOptions3.value = [];
   clearLeaf();
   createdMchNo.value = "";
+  submitNoMchNo.value = false;
   formRef.value?.clearValidate();
 }