소스 검색

fix(汇安融): 上游明确拒绝的单要收尾,别留在轮询里一直问

用户报的现象:未在上游创建成功(参数错误之类)的订单也落库了,导致轮询一直报
「上游找不到订单」。

根因在 create 的失败分支:insert 时就把 trade_status 置「0 等待支付」、
next_query_time 排到 30 秒后,而失败分支只改了 description ——
findPendingForPoll 按 PENDING={0,4} 捞单,于是这一行每轮都被捞起来去上游问一笔
**在上游从来没被建出来**的订单,退避封顶 900 秒也只是把「一直报」拉长成「每 15 分钟报一次」。

修法(照 rollbackRefundPreempt 的同款写法):收尾这一行 —— trade_status 置「8 交易关闭」
(8 不在 PENDING 里,捞单条件立刻失效)+ next_query_time 写回 **null**。

- ★ 必须走 update(null, UpdateWrapper.set(...)):next_query_time 要写回 null,而
  MyBatis-Plus 默认的 NOT_NULL 策略会把实体里的 null 字段整个跳过 ⇒ setNextQueryTime(null)
  + updateById 是**静默无效**的(本仓转账模块踩过同款:setNextRetryAt(null) 被吞)。
  代价是 updated_time 也得显式写(不经过实体就没有自动填充)。
- 只做 trade_status 那一半也能挡住捞单(两个条件是与),但留着一个非 null 的
  next_query_time 等于把「这单结束了」只表达了一半。
- WHERE 钉 id。**漏掉 WHERE 就是更新全表** —— 这条有独立断言,且变异实测会红。

**没删行,这不是遗漏。** 本地先落库再发上游是刻意的(见 create 上方那段注释与
「刻意不加 @Transactional」):超时那条路全靠这一行才不丢单。被上游拒绝只说明
「这笔单上游建不出来」,调用方那次请求确实发生过,description 里留着上游原话,
换个 out_biz_no 重试时还查得到上一次是怎么没的。删行要单独裁定,不顺手做。

超时那条路**一格没动**:HarApiException(超时/断连)是**未知**不是拒绝,那笔单可能
已经在上游建好了,必须留在「0 等待支付」继续轮询 —— 关掉它等于把一笔可能已经收款的单
写成「已关闭」,而付款通知到达时会因为本地已是终态被幂等跳过:钱在,账没了。
两条路各有一条用例钉住方向,互为对照(一条要求收尾、一条要求不许收尾)。

两向变异实测:
- 摘掉 next_query_time 那一格 ⇒ 红(setValueOf 报「SET 里没有这一列」)
- trade_status 改回 WAIT_PAY ⇒ 红(expected: <8> but was: <0>,正是「还在 PENDING 里」)
- 摘掉 .eq("id", ...) ⇒ 红(sqlSegment 为空 = 更新全表)
- 无害变异(四个 .set 换个顺序)⇒ 54/54 仍绿,证明断言不是靠占位符编号吃饭

测试侧的坑,写下来免得下次再踩:新加的 setValueOf 只从 SET 段取某列绑定的值,
不倒 paramNameValuePairs 全集 —— 后者 SET 与 WHERE 共用,而这一行的 id 在单测里
还是 null(mock 的 mapper 不回填主键),于是 containsValue(null) 会被 WHERE 那一格
**假绿**。全量 886 绿。
alphaH 2 주 전
부모
커밋
a5fbd760f4

+ 28 - 3
java/src/main/java/com/payment/platform/module/payment/har/service/HarOrderService.java

@@ -423,9 +423,11 @@ public class HarOrderService {
                 : harClient.precreate(buildPrecreateRequest(dto, merchant, orderNo));
 
         if (!response.isSuccess()) {
-            String reason = "上游返回 " + response.responseCode() + ": " + response.msg();
-            entity.setDescription(reason);
-            orderMapper.updateById(entity);
+            // 上游**明确拒绝**(参数错误之类)⇒ 这笔单在上游从来没被建出来。
+            // 不收尾的话本地这一行停在「0 等待支付」,而轮询按 PENDING={0,4} 捞单
+            // (findPendingForPoll),于是每个周期都去问一笔不存在的订单,
+            // 日志里一直刷「上游找不到订单」,永远问不出结果。
+            closeRejectedOrder(entity, "上游返回 " + response.responseCode() + ": " + response.msg());
             throw new BusinessException("下单失败: " + response.msg());
         }
 
@@ -439,6 +441,29 @@ public class HarOrderService {
         return HarOrderVO.of(entity);
     }
 
+    /**
+     * 上游明确拒绝时收尾本地这一行:置「8 交易关闭」并把 next_query_time 写回 null。
+     *
+     * 为什么是收尾而不是删行:本地先落库再发上游是**故意的**(见 create 里那段注释)——
+     * 超时那条路全靠这一行才不丢单。被上游拒绝只说明「这笔单上游建不出来」,
+     * 调用方那次请求确实发生过,description 里留一句上游原话,
+     * 换个 out_biz_no 重试时还查得到上一次是怎么没的。删行是另一回事,不顺手做。
+     *
+     * 用 {@code update(null, wrapper)} 而不是实体:next_query_time 要写回 **null**,
+     * 而 MyBatis-Plus 默认的 NOT_NULL 策略会把实体里的 null 字段整个跳过 ——
+     * 那样这行记录仍会被轮询永远捞起,白改。代价是 updated_time 也得显式写
+     * (不经过实体就没有自动填充)。同款见 {@link #rollbackRefundPreempt}。
+     */
+    private void closeRejectedOrder(HarOrderEntity order, String reason) {
+        UpdateWrapper<HarOrderEntity> w = new UpdateWrapper<>();
+        w.set("trade_status", HarOrderStatus.CLOSED)
+                .set("next_query_time", null)
+                .set("description", reason)
+                .set("updated_time", OffsetDateTime.now())
+                .eq("id", order.getId());
+        orderMapper.update(null, w);
+    }
+
     /**
      * 主动查上游并回写。
      *

+ 71 - 2
java/src/test/java/com/payment/platform/module/payment/har/service/HarOrderServiceTest.java

@@ -121,7 +121,16 @@ class HarOrderServiceTest {
         assertThrows(HarApiException.class, () -> service.create(dto(), "E1", "KEY1"));
 
         // 记录已落库才有机会被轮询收敛
-        verify(orderMapper).insert(any());
+        ArgumentCaptor<HarOrderEntity> inserted = ArgumentCaptor.forClass(HarOrderEntity.class);
+        verify(orderMapper).insert(inserted.capture());
+
+        // ★ 超时是**未知**,不是「上游拒绝」:那笔单可能已经建好了 ⇒ 必须留在
+        //   「0 等待支付」继续轮询。关掉它等于把一笔可能已经收款的单写成「已关闭」,
+        //   而付款通知到达时会因为本地已是终态而被幂等跳过 —— 钱在,账没了。
+        assertEquals(HarOrderStatus.WAIT_PAY, inserted.getValue().getTradeStatus());
+        assertNotNull(inserted.getValue().getNextQueryTime(),
+                "超时的单必须留在轮询里,否则永远没人去问结果");
+        verify(orderMapper, never()).update(isNull(), any());
     }
 
     /**
@@ -224,8 +233,24 @@ class HarOrderServiceTest {
         assertEquals("har_ali_un_stage", req.getValue().getBiz());
     }
 
+    /**
+     * 上游**明确拒绝**(参数错误之类)⇒ 本地这一行必须跟着收尾。
+     *
+     * 用户在联调里报的现象:这种单停在「0 等待支付」,而轮询按 PENDING={0,4} 捞单,
+     * 于是每个周期都去上游问一笔**在上游从来没被建出来**的订单,日志里一直刷
+     * 「找不到订单」,永远问不出结果。
+     *
+     * 收尾两件事:
+     * ① `trade_status` 改「8 交易关闭」—— 8 不在 PENDING 里,捞单条件立刻失效;
+     * ② `next_query_time` 写回 **null**。
+     *    ★ 这一格只能走 `update(null, UpdateWrapper.set(...))`:MP 默认的 NOT_NULL 策略
+     *      会把实体里的 null 字段整个跳过 ⇒ `setNextQueryTime(null)` + `updateById`
+     *      是**静默无效**的(本仓转账模块踩过同款:setNextRetryAt(null) 被吞)。
+     *      只做 ① 也能挡住捞单(两个条件是与),但留着一个非 null 的 next_query_time
+     *      等于把「这单结束了」只表达了一半。
+     */
     @Test
-    void create_upstreamNonSuccessCode_throws() {
+    void create_upstreamNonSuccessCode_throwsAndClosesTheLocalRow() {
         when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
         when(harClient.precreate(any())).thenReturn(response("40004", null));
 
@@ -234,6 +259,27 @@ class HarOrderServiceTest {
 
         assertTrue(e.getMessage().contains("下单失败"));
         verify(orderMapper).insert(any());
+
+        ArgumentCaptor<UpdateWrapper> w = ArgumentCaptor.forClass(UpdateWrapper.class);
+        verify(orderMapper).update(isNull(), w.capture());
+        UpdateWrapper<?> closing = w.getValue();
+
+        // 断言一律走 setValueOf(只看 SET 段):WHERE 里的 id 在这一刻也是 null
+        // (这一行是刚 insert 的,mock 的 mapper 不会回填主键)⇒ 倒 paramNameValuePairs
+        // 全集去 containsValue(null) 会被 WHERE 那一格**假绿**。
+        assertEquals(HarOrderStatus.CLOSED, setValueOf(closing, "trade_status"),
+                "必须落到「8 交易关闭」,否则它还在 PENDING 里、每轮都被捞去问上游。实测 SET: "
+                        + closing.getSqlSet());
+        assertNull(setValueOf(closing, "next_query_time"),
+                "next_query_time 要写回 null(实体的 null 会被 MP 的 NOT_NULL 策略跳过,"
+                        + "所以只能走 SET 子句)。实测 SET: " + closing.getSqlSet());
+        assertEquals("上游返回 40004: msg", setValueOf(closing, "description"),
+                "上游那句拒绝原因要留在本地,页面上才看得出为什么没收单。实测 SET: "
+                        + closing.getSqlSet());
+        // WHERE 只钉「有没有」,不钉值,理由同上(此刻 id 还是 null)。
+        // 漏掉 WHERE 就是**更新全表**,所以这一条必须留着。
+        assertTrue(closing.getSqlSegment().contains("id"),
+                "少了 WHERE 就是更新全表。实测: " + closing.getSqlSegment());
     }
 
     @Test
@@ -727,6 +773,29 @@ class HarOrderServiceTest {
                         + patch.getValue().getRefundReason());
     }
 
+    /**
+     * 取 SET 子句里某一列**绑定**的值 —— 只看 SET,不看 WHERE。
+     *
+     * 为什么不直接 `w.getParamNameValuePairs().containsValue(x)`:那张表是 SET 与 WHERE
+     * 共用的,两段混在一起时一条「contains」可能被**另一段**的绑定值满足。本仓有现成的
+     * 踩坑形状:`update(null, wrapper).eq("id", 刚insert还没主键的实体.getId())` 里 id 绑的
+     * 就是 null,于是「containsValue(null) 证明 next_query_time 被写成 null」是假绿。
+     * 占位符形如 `next_query_time=#{ew.paramNameValuePairs.MPGENVAL3}`,从 SET 串里取回
+     * 那个 key 再查值,归属就唯一了。
+     */
+    private static Object setValueOf(UpdateWrapper<?> w, String column) {
+        for (String item : w.getSqlSet().split(",")) {
+            if (!item.startsWith(column + "=")) {
+                continue;
+            }
+            Matcher m = Pattern.compile("paramNameValuePairs\\.(\\w+)").matcher(item);
+            assertTrue(m.find(), "这一列没有绑定值,形状不认识: " + item);
+            return w.getParamNameValuePairs().get(m.group(1));
+        }
+        fail("SET 子句里没有这一列: " + column + ",实测 SET: " + w.getSqlSet());
+        return null;
+    }
+
     /** 只取 WHERE 段引用到的绑定值,不倒 `paramNameValuePairs` 全集 —— 后者会把 SET 的绑定值也算进来 */
     private static Set<Object> whereBoundValues(UpdateWrapper<?> w) {
         Matcher m = Pattern.compile("paramNameValuePairs\\.([A-Za-z0-9_]+)")