Explorar o código

fix(退款): 批量退款对话框如实转述上游应答 + 失败原因可见

用户 2026-10-06 报障:批量退款时对话框一直转圈,关掉对话框刷新页面后,刚退款的
订单一直停在「退款中」。附线上日志(11:15:34):两笔 /v3/refundorder 均 200 OK,
随后两条 ERROR「退款返回 PAY_FAIL,状态不落终态、留在 REFUNDING(占用在途闸门…)」,
result_code=PAY_FAIL、return_msg=业务执行失败[6666000245376951_F53933370可用余额不足]。

根因(读码+日志,后端零改动):
- 后端按 R11/R-1 有意不把同步 PAY_FAIL 落终态(防上游误报时放闸、重复退款),
  退款行停在 REFUNDING 占着在途闸门 ⇒ 订单显示「退款中」且不可再退;
- 前端把同步应答里的 upstream_result_code/upstream_result_msg 整个吞掉:
  提交时固定文案「已受理,等待上游结果」、说明列只在结果框(转圈那两分钟看不到)、
  退款列表页与订单抽屉都不展示 upstream_result_msg ⇒ 界面上零原因。

本轮只做「如实化」:
- 批量对话框表格新增「说明」列(轮询期间就能看到原因);提交与轮询消息按
  PAY_FAIL 转述 upstream_result_msg,措辞「上游应答失败:<原话>(等待对账确认,
  勿重复发起)」——**不**标 fail:按 R11 那只是同步应答,要等对账查询定案,
  状态照旧算「退款中」、轮询照走到上限;
- 轮询到 FAIL 终态时带上 upstream_result_msg;仍在途但上游已答失败的同样转述;
  超时提示追加一句「其中 N 笔上游已明确应答失败(原因见下表说明列),需人工核对」;
- 单笔退款的成功 toast 同口径:PAY_FAIL 时改 warning 转述原因,不报「已受理」;
- 退款管理列表页 + 订单抽屉的退款单表新增「上游应答」列(upstream_result_msg || "-");
- 批量对话框跑批时禁用右上角 X(X 关的是对话框、不是轮询:关掉后轮询还会把结果框
  弹出来);宽度 860→1040 —— 原表 6 列 min-width 合计 700,加说明列 200 到 900,
  860 的内容区会把唯一讲原因的列挤出可视区。

验证(量法+读数):
- pnpm build 通过;同机差分旧 dist.zip(274 文件)vs 新 dist(274 文件),chunk
  哈希归一后仅 4 个文件有实质差异:订单页 chunk index.CWnRp9I4.js(21185→22510B,
  新串全在此)、退款页 chunk index.zHt-3oO4.js(289366→289504B,唯一差异=新列
  +130B)、订单页 scoped css(12 处 scope id d889a10b→f484f420,源码变 id 就变)、
  入口 css(229905→229893B);其余 270 文件归一后逐字节相同;
- 入口 css 那 -12B(.filter,[filter=""]→.filter)根因已用 UNoCSS 探针复现:
  presetAttributify 的 elementRE 把非 HTML 文本里 <…> 包住的片段当标签扫,旧源
  某跨度内有个不带 = 的 filter,产出属性形态 token [filter=""],合并进 filter
  工具类选择器;增删行改变了跨度切分。旧/新 token 集分别生成 .filter,[filter=""]
  与 .filter,与真实构建逐条对上。运行时零影响:全仓无 filter="…" 属性
  (正对照 filterable 可搜到),类规则 .filter 两版都在;
- dist.zip 重打:size 9361525、sha256 948324afd00b7dbb90ba823e830240dd7afac2165c8845d51505733522359a7d;
  回读 zip:新串命中 CWnRp9I4,上游应答命中 CWnRp9I4+zHt-3oO4,上一轮「收款二维码」仍在。

遗留(资金方向,等用户拍板,未动):对账腿未必收敛(settleFromQuery 对
PAY_IN_PROCESS 静默、对「订单信息不存在」只 WARN),卡死的退款行需要人工出口
(A 权限化「标记退款失败」释放闸门 / B 放宽 R11 让同步 PAY_FAIL 落终态 /
C 问上游被拒退款的 queryrefund 应回什么)。
alphaH hai 1 semana
pai
achega
2a55995f3f

+ 98 - 8
frontend/src/views/module_payment/collection/order/index.vue

@@ -244,6 +244,14 @@
               </el-tag>
             </template>
           </el-table-column>
+          <!--
+            上游应答原话(后端存在退款行上)。2026-10-06 起必须有这一列:
+            状态停在「退款中」而上游其实早已答了「余额不足」时,原因只在这里看得到。
+            没有应答的行(如预插入还没拿到同步应答)显式渲染成 -,不留空白格。
+          -->
+          <el-table-column label="上游应答" min-width="180" show-overflow-tooltip>
+            <template #default="{ row }">{{ row.upstream_result_msg || "-" }}</template>
+          </el-table-column>
           <!--
             上游退款单号可能是空的:它只在**同步应答**里才学到(预插入的那一行还没拿到)。
             空串在这里会看起来像「退款单号丢了」,所以显式渲染成 -。
@@ -307,13 +315,22 @@
         而这里每笔退的是**该单的可退余额**,且金额**不由前端计算后发出** ——
         请求体里不带 refund_fee,剩余由服务端算(`LsyRefundService` 对台账与快照取 max,
         比页面上的算式更严)。页面上那一列只是**预计**,写明「以服务端为准」。
+
+      ★ 宽度 860→1040 是给「说明」列腾地方的:那张表 6 列 min-width 合计 700,
+        加说明列(200)到 900,超过 860 的内容区就会横向滚动 ——
+        而说明是**唯一**讲原因的一列,恰好会被挤出可视区(用户 2026-10-06 报障时
+        遮罩里转着圈、界面上一个字的原因都没有)。
+      ★ `:show-close` 跑批时关掉:X 关的是对话框,不是轮询 ——
+        轮询会继续在后台跑、两分钟后把「批量退款结果」框弹出来,操作员会以为见鬼了。
+        要中断就 F5(页面级动作,符合直觉)。
     -->
     <el-dialog
       v-model="batchRefundVisible"
       title="批量退款"
-      width="860px"
+      width="1040px"
       :close-on-click-modal="false"
       :close-on-press-escape="false"
+      :show-close="!batchRefundRunning"
     >
       <div class="batch-refund-container" :class="{ 'has-overlay': batchRefundRunning }">
         <el-alert type="warning" :closable="false" show-icon>
@@ -344,6 +361,14 @@
               </el-tag>
             </template>
           </el-table-column>
+          <!--
+            「说明」列在**轮询期间**就说话:此前这列只存在于结果框,而遮罩转圈的
+            那两分钟里操作员盯着的正是这张表(2026-10-06 报障:上游已答「余额不足」,
+            界面上一个字都没有)。文案由 submitMessageOf / 轮询那一侧写入。
+          -->
+          <el-table-column label="说明" min-width="200" show-overflow-tooltip>
+            <template #default="{ row }">{{ batchResultOf(row.out_trade_no)?.message || "-" }}</template>
+          </el-table-column>
         </el-table>
 
         <!-- 遮罩:形态照批量转账(pointer-events 关在容器上,防止处理中改表单) -->
@@ -393,9 +418,19 @@
         ★ 超时那条腿也必须说话:轮询到上限仍有未到终态的笔数时,这里给的是
           「还在退款中,稍后刷新查看」——**不**谎报成功,也不谎报失败。
           (谎报的代价:操作员按「失败」再退一次。)
+        ★ 其中「上游已明确应答失败」的笔数要单独再说一句:对它们,
+          「仍在处理中」只是半个真相 —— 上游的原话已经摆在说明列里了。
       -->
       <el-alert v-if="batchRefundTimedOut" type="warning" :closable="false" show-icon
         title="仍有退款未到终态,已停止等待。它们仍在处理中,请稍后刷新页面查看,不要重复发起。" />
+      <el-alert
+        v-if="batchRefundTimedOut && batchRefundUpstreamRejectedCount > 0"
+        type="error"
+        :closable="false"
+        show-icon
+        style="margin-top: 8px"
+        :title="`其中 ${batchRefundUpstreamRejectedCount} 笔上游已明确应答失败(原因见下表说明列),需人工核对后再处理。`"
+      />
       <el-table :data="batchRefundResults" border stripe>
         <el-table-column prop="out_trade_no" label="商户订单号" min-width="190" show-overflow-tooltip />
         <el-table-column label="退款金额(元)" width="120" align="right">
@@ -612,6 +647,17 @@ const batchRefundFailCount = computed(
 const batchRefundPendingCount = computed(
   () => batchRefundResults.value.filter((r) => r.status === "processing").length
 );
+/**
+ * 停在 processing、但上游同步应答已经明说失败(PAY_FAIL)的笔数。
+ * **只喂结果框里那句补充说明**,不上色、不进失败计数 —— 按 R11(资金方向)
+ * 同步 PAY_FAIL 不落终态:上游可能误报,落 FAIL 会放开在途闸门给二次退款开门,
+ * 要等对账查询给权威答案。所以它们**照旧算「退款中」**,这里只是提醒人工核对。
+ */
+const batchRefundUpstreamRejectedCount = computed(
+  () =>
+    batchRefundResults.value.filter((r) => r.status === "processing" && r.upstream_rejected)
+      .length
+);
 /**
  * 进度条 = **已到终态的笔数 / 总数**。
  * 提交阶段它停在 0% 是**如实**的:钱一笔都还没退回顾客,只是请求发出去了。
@@ -648,6 +694,33 @@ function batchRowTagType(outTradeNo: string) {
   return "warning";
 }
 
+/**
+ * 上游同步应答里「已明确失败」的判据 —— 只认 PAY_FAIL 这一个值,
+ * 与后端 `LeshouyingResponseParser.isPayFail` 同一口径(「只有显式 PAY_FAIL 才算失败」)。
+ */
+function isUpstreamRejected(created: any) {
+  return created?.upstream_result_code === "PAY_FAIL";
+}
+
+/**
+ * 提交那一刻上游同步应答的**如实**转述。
+ *
+ * ★ 退款接口的响应体里就带着上游的原始回答(后端把 `upstream_result_code/
+ *   upstream_result_msg` 存在退款行上)。2026-10-06 线上实测:余额不足时
+ *   result_code=PAY_FAIL、msg=「业务执行失败[6666000245376951_F53933370可用余额不足]」——
+ *   上游已经把原因说尽了,而旧文案对每一笔都只说「已受理,等待上游结果」,
+ *   界面上只剩一只转圈的对话框(用户当天报障的原样)。
+ * ★ 为什么**不直接标成「失败」**:这不是最终裁决。按 R11,同步 PAY_FAIL 不落终态、
+ *   也不放闸(怕上游误报时被重复退款),要等查询/回调给权威答案;
+ *   所以状态照旧算「退款中」、轮询照走到上限,这里只把**上游原话**摆出来。
+ */
+function submitMessageOf(created: any) {
+  if (isUpstreamRejected(created)) {
+    return `上游应答失败:${created.upstream_result_msg || "未给出原因"}(等待对账确认,勿重复发起)`;
+  }
+  return "已受理,等待上游结果";
+}
+
 function openBatchRefund() {
   // 勾选里可能夹着**开单之后状态变了**的行(页面是跨页保留勾选的,那一页可能已经刷新过)。
   // 这里再滤一遍 —— `:selectable` 只管「点的时候」,管不到「点完之后数据变了」。
@@ -716,7 +789,8 @@ async function handleBatchRefund() {
           refund_fee: created?.refund_fee ?? remainingFen(row),
           out_refund_no: created?.out_refund_no || "",
           status: "processing",
-          message: "已受理,等待上游结果",
+          message: submitMessageOf(created),
+          upstream_rejected: isUpstreamRejected(created),
         });
       } catch (e: any) {
         // 业务前置校验不通过(状态不可退 / 有退款在途 / 超余额)在这里是 HTTP 200 + code 400,
@@ -789,13 +863,20 @@ async function pollBatchRefundOnce() {
       }
       try {
         const res: any = await getLsyRefundDetail(r.out_refund_no);
-        const st = res.data.data?.status;
+        const d = res.data.data;
+        const st = d?.status;
         if (st === "SUCCESS") {
           r.status = "success";
           r.message = "退款成功,已到账";
         } else if (st === "FAIL") {
           r.status = "fail";
-          r.message = "退款失败,可稍后重试";
+          // 终态 FAIL 是**权威答案**(只有对账查询能写它,见 LsyRefundService 的 R-1 注释),
+          // 上游原话比「可稍后重试」更有用。
+          r.message = d?.upstream_result_msg ? `退款失败:${d.upstream_result_msg}` : "退款失败,可稍后重试";
+        } else if (isUpstreamRejected(d)) {
+          // 还没到终态,但上游同步应答已明说失败 —— 如实转述(为什么不算 fail:见 submitMessageOf)。
+          r.upstream_rejected = true;
+          r.message = `上游应答失败:${d?.upstream_result_msg || "未给出原因"}(等待对账确认,勿重复发起)`;
         } else {
           r.message = `上游处理中(${refundStatusLabel(st)})`;
         }
@@ -1053,10 +1134,19 @@ async function handleRefund(row: any) {
   try {
     const res: any = await refundLsyOrder(row.out_trade_no, fen);
     const created = res.data.data;
-    // 照实说「已受理」:这一步之后退款单停在「退款中」,钱还没回到顾客账上。
-    ElMessage.success(
-      `退款已受理(${refundStatusLabel(created?.status)}),到账以状态变为「退款成功」为准`
-    );
+    // 照实说:同步应答里若上游已明说失败(PAY_FAIL),就不能报「已受理」——
+    // 用户 2026-10-06 报的就是这类退款单在「退款中」卡住时界面一声不吭。
+    // 但也**不能**报「失败」:按 R11,PAY_FAIL 不落终态、要等对账查询定案(见 submitMessageOf)。
+    if (isUpstreamRejected(created)) {
+      ElMessage.warning(
+        `上游应答失败:${created.upstream_result_msg || "未给出原因"}(退款单仍在「退款中」,等待对账确认,勿重复发起)`
+      );
+    } else {
+      // 已受理:这一步之后退款单停在「退款中」,钱还没回到顾客账上。
+      ElMessage.success(
+        `退款已受理(${refundStatusLabel(created?.status)}),到账以状态变为「退款成功」为准`
+      );
+    }
     if (detailVisible.value && detail.value?.out_trade_no === row.out_trade_no) {
       // 抽屉正开着同一单:三块数据一起重拉,别让界面停在退款前的快照上
       await openDetail({ out_trade_no: row.out_trade_no });

+ 9 - 0
frontend/src/views/module_payment/collection/refund/index.vue

@@ -57,6 +57,15 @@
             </el-tag>
           </template>
         </el-table-column>
+        <!--
+          上游应答原话(后端存在退款行上,`upstream_result_msg`)。2026-10-06 起必须有这一列:
+          状态停在「退款中」而上游其实早已答了「余额不足」时,原因只在这里看得到;
+          「退款中」不等于上游还在处理 —— 这一列就是给那句话作证的。
+          没有应答的行显式渲染成 -,不留空白格。
+        -->
+        <el-table-column label="上游应答" min-width="200" show-overflow-tooltip>
+          <template #default="{ row }">{{ row.upstream_result_msg || "-" }}</template>
+        </el-table-column>
         <el-table-column
           prop="refund_no"
           label="退款单号"