Parcourir la source

fix(转账): 失败通知把 error_code / fail_reason 丢掉了 —— 两列一直空着

用户 2026-09-29 报:转账通知报文里有失败信息,但 pay_transfer 上没赋值。

根因(读代码定位,非推测):
`AccountHandler.handleTransOrderChanged`(本方法由 f5b92b61 于 2026-07-16 补上)
从 params 里只取了 5 个键 —— out_biz_no / order_id / status / pay_fund_order_id /
trans_amount,然后回写 6 列。**error_code 与 fail_reason 从头到尾没被读过一次**,
所以两列永远是空的。status=FAIL 却写进去了,这就是用户看到的形态。

同一文件里那条老腿 `handleTransChange`(:261-262 / :289-290)是读这两个字段的,
新腿补的时候漏了。而字段名还**换了**:本接口叫 `fail_reason`,
老腿那一族(alipay.commerce.ec.*)叫 `error_msg`。照老腿抄名字同样会落空。

字段确实到手了的证据(不是「也许上游没发」):
`NotificationService.flattenBizContent`(:197-215)把 biz_content 的每个键展开进
params(只跳过顶层已有的键)。本条报文的顶层是 charset/biz_content/utc_timestamp/
sign/app_id/version/sign_type/notify_id/msg_method,与这两个键不撞。
同一报文里的 order_id 已经落到 pay_transfer.order_no 上了 ⇒ 展开这一跳是通的。

修法:多读两个键,多写两列,都带 StrUtil.isNotBlank 守卫(成功报文里没有这两个键,
守卫保证一个字符都不写)。

新增 AccountHandlerTest(本类此前**没有任何测试**,所以这个缺陷只能靠生产数据发现):
- transOrderChanged_fail_writesErrorCodeAndReason —— 键名与取值逐字取自
  2026-09-29 生产库 alipay_notify_log.message,不是照文档或邻类推的
- transOrderChanged_success_leavesErrorColumnsUntouched —— 正对照,
  证明守卫不是「无论如何都写」

两向读数:
- 修复前跑 ⇒ 红:expected: <Ali_Err_111036> but was: <null>(正对照那条绿)
- 变异(把 params.get("fail_reason") 改成 params.get("error_msg"))⇒ 红
  这一变异**正对着缺陷本身**(错的就是字段名),证明用例钉的是名字不是行为
- 还原后 sha256 逐字节相同(43c96b6f…),grep MUTANT 无残留
- 全量 923 绿 / 0 失败 / 0 错误(surefire-reports 跑前已清空,避免跨运行累积虚高)

未改动、但同一条腿上还有一处**既有**缺陷,本提交不碰(无线上影响,且是别的批次的地盘):
`transfer.setNextRetryAt(null)` 被 MyBatis-Plus 的 NOT_NULL 更新策略静默吞掉,
所以 next_retry_at 清不掉(用户给的这行读数里它仍在)。今天全仓没有任何
@Scheduled 或 SQL 消费这个列,所以没有实际后果。

★ 已发生的这笔失败不会自动补上:状态已是 FAIL,而本方法开头就
`if (!"DEALING".equals(...)) return;` ⇒ 重复通知也会被挡在门外。要补历史得手写 UPDATE。
alphaH il y a 2 semaines
Parent
commit
7a2ec244fd

+ 11 - 1
java/src/main/java/com/payment/platform/module/payment/notification/handler/AccountHandler.java

@@ -157,7 +157,13 @@ public class AccountHandler extends BaseNotifyHandler {
 
     /**
      * 转账/充值结果通知 — alipay.fund.trans.order.changed
-     * 新API字段: order_id(替代order_no), pay_fund_order_id, trans_amount
+     * 新API字段: order_id(替代order_no), pay_fund_order_id, trans_amount, error_code, fail_reason
+     *
+     * <p>★ 失败原因在这个接口叫 {@code fail_reason},**不叫** {@code error_msg} ——
+     * 邻腿 {@link #handleTransChange} 读的是后者,那是另一族接口({@code alipay.commerce.ec.*})。
+     * 两个名字都来自实测报文,不是照文档或互相照抄推的。
+     * 键名与取值见 2026-09-29 生产库 {@code alipay_notify_log.message};
+     * 回归用例 {@code AccountHandlerTest#transOrderChanged_fail_writesErrorCodeAndReason}。
      */
     private void handleTransOrderChanged(Map<String, String> params) {
         String outBizNo = params.get("out_biz_no");
@@ -165,6 +171,8 @@ public class AccountHandler extends BaseNotifyHandler {
         String status = params.get("status");
         String payFundOrderId = params.get("pay_fund_order_id");
         String transAmount = params.get("trans_amount");
+        String errorCode = params.get("error_code");
+        String failReason = params.get("fail_reason");
         log.info("转账通知: out_biz_no={}, order_id={}, status={}", outBizNo, orderId, status);
 
         if (StrUtil.isBlank(outBizNo)) return;
@@ -180,6 +188,8 @@ public class AccountHandler extends BaseNotifyHandler {
         if (StrUtil.isNotBlank(transAmount)) {
             try { transfer.setAmount(new BigDecimal(transAmount)); } catch (Exception ignored) {}
         }
+        if (StrUtil.isNotBlank(errorCode)) transfer.setErrorCode(errorCode);
+        if (StrUtil.isNotBlank(failReason)) transfer.setErrorMsg(failReason);
         transfer.setNextRetryAt(null);
         transfer.setRetryCount(4);
         transferMapper.updateById(transfer);

+ 98 - 0
java/src/test/java/com/payment/platform/module/payment/notification/handler/AccountHandlerTest.java

@@ -0,0 +1,98 @@
+package com.payment.platform.module.payment.notification.handler;
+
+import com.payment.platform.module.payment.account.entity.TransferEntity;
+import com.payment.platform.module.payment.account.mapper.AccountMapper;
+import com.payment.platform.module.payment.account.mapper.TransferMapper;
+import com.payment.platform.module.payment.enterprise.mapper.EnterpriseMapper;
+import com.payment.platform.module.payment.openapi.service.OpenapiService;
+import com.payment.platform.module.payment.points.service.PointsService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * alipay.fund.trans.order.changed 这条腿的字段落库。
+ *
+ * <p>键名与取值逐字取自 2026-09-29 生产库 {@code alipay_notify_log.message} 里那条
+ * 报文(biz_content 展开后的形态)—— 不是照文档或邻类推的:
+ * {@code out_biz_no} 1302345790789783552 / {@code order_id}
+ * 20260929110070100006870072072224 / {@code status} FAIL /
+ * {@code error_code} Ali_Err_111036 /
+ * {@code fail_reason} 该银行账户已冻结,请联系银行客服。
+ *
+ * <p>本类此前**没有任何测试**,症状因此只表现为「生产库里两列是空的」。
+ */
+@ExtendWith(MockitoExtension.class)
+class AccountHandlerTest {
+
+    private static final String METHOD = "alipay.fund.trans.order.changed";
+
+    @Mock private TransferMapper transferMapper;
+    @Mock private AccountMapper accountMapper;
+    @Mock private EnterpriseMapper enterpriseMapper;
+    @Mock private PointsService pointsService;
+    @Mock private OpenapiService openapiService;
+
+    private AccountHandler handler;
+
+    @BeforeEach
+    void setUp() {
+        // 本 handler 的 mapper 走**构造器注入**(@RequiredArgsConstructor),不是 NotifyContext ——
+        // 与同包的 BatchPayHandler 相反。照 BatchPayHandler 的写法会构造不出来。
+        handler = new AccountHandler(transferMapper, accountMapper, enterpriseMapper,
+                pointsService, openapiService);
+    }
+
+    private TransferEntity dealingTransfer() {
+        TransferEntity t = new TransferEntity();
+        t.setOutBizNo("1302345790789783552");
+        t.setStatus("DEALING");
+        when(transferMapper.selectOne(any())).thenReturn(t);
+        return t;
+    }
+
+    @Test
+    void transOrderChanged_fail_writesErrorCodeAndReason() {
+        TransferEntity t = dealingTransfer();
+        Map<String, String> params = new HashMap<>();
+        params.put("out_biz_no", "1302345790789783552");
+        params.put("order_id", "20260929110070100006870072072224");
+        params.put("status", "FAIL");
+        params.put("trans_amount", "1170.00");
+        params.put("error_code", "Ali_Err_111036");
+        params.put("fail_reason", "该银行账户已冻结,请联系银行客服");
+
+        handler.dispatch(METHOD, params);
+
+        assertEquals("FAIL", t.getStatus());
+        assertEquals("Ali_Err_111036", t.getErrorCode());
+        assertEquals("该银行账户已冻结,请联系银行客服", t.getErrorMsg());
+        verify(transferMapper).updateById(t);
+    }
+
+    /** 正对照:成功报文里**没有**这两个键,守卫必须一个字符都不写。 */
+    @Test
+    void transOrderChanged_success_leavesErrorColumnsUntouched() {
+        TransferEntity t = dealingTransfer();
+        Map<String, String> params = new HashMap<>();
+        params.put("out_biz_no", "1302345790789783552");
+        params.put("order_id", "20260929110070100006870072072224");
+        params.put("status", "SUCCESS");
+        params.put("trans_amount", "1170.00");
+
+        handler.dispatch(METHOD, params);
+
+        assertEquals("SUCCESS", t.getStatus());
+        assertNull(t.getErrorCode());
+        assertNull(t.getErrorMsg());
+    }
+}