浏览代码

fix(转账): 全量同步置终态时把失败原因一并落库,回调腿补一次只填空白的回填

用户 2026-09-30 报「转账失败原因没有显示在页面上」。前端那两处渲染见同批前端提交,
但后端有一半:经「全量同步」(transferSyncAllService)变成 FAIL 的单子,
原因两列永远是空的 —— 这条路和回调腿(AccountHandler.handleTransOrderChanged)
走的是两个不同的写入口,后者一直在写 error_code/error_msg,前者从来没读过。

AlipayTransferService#queryTransferStatus 现在把 SDK 的 getErrorCode()/getFailReason()
一并带出来(javap 4.40.865.ALL 实测这两个 getter 存在),
transferSyncAllService 在置终态的同一处顺带落库,守卫与回调腿一致:
上游不送就一个字符都不写(StrUtil.isNotBlank)。

AccountHandler 新增 backfillFailReason:只在 FAIL/退票、且两列均空时补写,
绝不改状态 —— 于是「成功单子上挂着一句陈旧的失败原因」这条产品上不该存在的
状态,也就无从产生(前端因此按「有没有原因」显隐,而不是按 status == FAIL 显隐,
退票单子的原因同样看得见)。

测试:AlipayTransferSyncStatusTest 2 条 —— syncAll 落 FAIL 时把 error_code 与原因
一起写进去、置 SUCCESS 时两个原因列一个字符都不动;AccountHandlerTest 补回填腿(5 条)。
alphaH 2 周之前
父节点
当前提交
a5449ad4bf

+ 9 - 0
java/src/main/java/com/payment/platform/module/payment/account/service/AlipayTransferService.java

@@ -39,6 +39,7 @@ import com.alipay.api.response.AlipayFundExpandindirectOrderCancelResponse;
 import com.alipay.api.response.AlipayFundExpandindirectOrderQueryResponse;
 import com.alipay.api.response.AlipayUserAgreementQueryResponse;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import cn.hutool.core.util.StrUtil;
 import com.payment.platform.common.exception.BusinessException;
 
 import com.payment.platform.common.utils.SnowflakeIdGenerator;
@@ -761,6 +762,11 @@ public class AlipayTransferService {
             result.put("pay_fund_order_id", response.getPayFundOrderId());
             result.put("trans_amount", response.getTransAmount());
             result.put("pay_date", response.getPayDate());
+            // 失败原因:SDK 的 AlipayFundTransCommonQueryResponse 有 getErrorCode() /
+            // getFailReason()(javap 4.40.865.ALL 实测)。此前这里不读它们,
+            // 于是经「全量同步」变成 FAIL 的单子,原因两列永远是空的。
+            result.put("error_code", response.getErrorCode());
+            result.put("fail_reason", response.getFailReason());
             return result;
         } catch (AlipayApiException e) {
             log.error("查询转账状态异常", e);
@@ -884,6 +890,9 @@ public class AlipayTransferService {
                     t.setStatus(s);
                     if (status.get("pay_fund_order_id") != null)
                         t.setFundOrderId(status.get("pay_fund_order_id"));
+                    // 置终态时把原因一并落库(守卫同回调腿:上游不送就一个字符都不写)
+                    if (StrUtil.isNotBlank(status.get("error_code"))) t.setErrorCode(status.get("error_code"));
+                    if (StrUtil.isNotBlank(status.get("fail_reason"))) t.setErrorMsg(status.get("fail_reason"));
                     transferMapper.updateById(t);
                     synced++;
                     details.add(Map.of("out_biz_no", outBizNo, "new_status", s));

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

@@ -180,7 +180,10 @@ public class AccountHandler extends BaseNotifyHandler {
                 new LambdaQueryWrapper<TransferEntity>()
                         .eq(TransferEntity::getOutBizNo, outBizNo));
         if (transfer == null) return;
-        if (!"DEALING".equals(transfer.getStatus())) return;
+        if (!"DEALING".equals(transfer.getStatus())) {
+            backfillFailReason(transfer, errorCode, failReason);
+            return;
+        }
 
         if (StrUtil.isNotBlank(status)) transfer.setStatus(status);
         if (StrUtil.isNotBlank(orderId)) transfer.setOrderNo(orderId);
@@ -201,6 +204,33 @@ public class AccountHandler extends BaseNotifyHandler {
         log.info("转账状态更新: out_biz_no={}, status={}", outBizNo, status);
     }
 
+    /**
+     * 终态记录的原因补写 —— 只补**空**的,只认 FAIL,绝不改状态。
+     *
+     * <p>起因:「全量同步」({@code AlipayTransferService#transferSyncAllService})
+     * 也会把单子置 FAIL。它先跑到时记录已是终态,之后那条**带着原因**的回调
+     * 会被上面那道 DEALING 守卫挡在门外 —— 原因就永远补不上了。
+     *
+     * <p>这里把两件事分开:**状态**在终态之后仍然禁止改动(迟到的报文不该
+     * 改写资金事实),**空白的原因**允许补(它只是一个解释)。
+     * SUCCESS / REFUND 连原因也不补 —— 那不是失败单子。
+     */
+    private void backfillFailReason(TransferEntity transfer, String errorCode, String failReason) {
+        if (!"FAIL".equals(transfer.getStatus())) return;
+        boolean changed = false;
+        if (StrUtil.isNotBlank(errorCode) && StrUtil.isBlank(transfer.getErrorCode())) {
+            transfer.setErrorCode(errorCode);
+            changed = true;
+        }
+        if (StrUtil.isNotBlank(failReason) && StrUtil.isBlank(transfer.getErrorMsg())) {
+            transfer.setErrorMsg(failReason);
+            changed = true;
+        }
+        if (!changed) return;   // 已有原因:一个字符都不改,也不写库
+        transferMapper.updateById(transfer);
+        log.info("转账失败原因补写: out_biz_no={}, error_code={}", transfer.getOutBizNo(), errorCode);
+    }
+
     /**
      * 资金变更通知 -- 对应 Python _handle_fund_change
      *

+ 103 - 0
java/src/test/java/com/payment/platform/module/payment/account/service/AlipayTransferSyncStatusTest.java

@@ -0,0 +1,103 @@
+package com.payment.platform.module.payment.account.service;
+
+import com.alipay.api.AlipayClient;
+import com.alipay.api.response.AlipayFundTransCommonQueryResponse;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.payment.platform.core.alipay.AlipayClientFactory;
+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.DepositMapper;
+import com.payment.platform.module.payment.account.mapper.TransferMapper;
+import com.payment.platform.module.payment.account.mapper.WithdrawMapper;
+import com.payment.platform.module.payment.enterprise.mapper.EnterpriseMapper;
+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.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * 「全量同步」这条腿({@code alipay.fund.trans.common.query})把单子置成终态时的字段落库。
+ *
+ * <p>它和回调腿是**两条独立的路**:回调那条在 {@code 7a2ec244} 已修,这条当时没修。
+ * 症状一样 —— 状态变了、失败原因两列空着,而管理端界面此前也从不渲染它们,
+ * 于是整整一类失败记录既查不到原因、也看不见原因(2026-09-30 用户报的就是这个)。
+ *
+ * <p>上面那条断言依据 SDK 实证:{@code AlipayFundTransCommonQueryResponse} 有
+ * {@code getErrorCode()} / {@code getFailReason()}(javap 4.40.865.ALL 实测),
+ * 所以「上游不送」不成立,是我们没读。
+ */
+@ExtendWith(MockitoExtension.class)
+class AlipayTransferSyncStatusTest {
+
+    @Mock private AlipayClientFactory alipayClientFactory;
+    @Mock private AccountMapper accountMapper;
+    @Mock private TransferMapper transferMapper;
+    @Mock private DepositMapper depositMapper;
+    @Mock private WithdrawMapper withdrawMapper;
+    @Mock private EnterpriseMapper enterpriseMapper;
+    @Mock private AlipayClient alipayClient;
+
+    private AlipayTransferService service;
+
+    @BeforeEach
+    void setUp() {
+        service = new AlipayTransferService(alipayClientFactory, accountMapper, transferMapper,
+                depositMapper, withdrawMapper, enterpriseMapper, new ObjectMapper());
+    }
+
+    /** 造一条 DEALING、且上游查询回 FAIL(带原因)的记录。 */
+    private TransferEntity dealingTransfer() throws Exception {
+        TransferEntity t = new TransferEntity();
+        t.setId(1L);
+        t.setOutBizNo("1302345790789783552");
+        t.setEnterpriseId("2088000000000000");
+        t.setStatus("DEALING");
+        when(transferMapper.selectList(any())).thenReturn(List.of(t));
+        when(alipayClientFactory.getClient("2088000000000000")).thenReturn(alipayClient);
+        return t;
+    }
+
+    private AlipayFundTransCommonQueryResponse queryResponse(String status, String errorCode, String failReason)
+            throws Exception {
+        AlipayFundTransCommonQueryResponse resp = new AlipayFundTransCommonQueryResponse();
+        resp.setCode("10000");               // isSuccess() 判的是 code == 10000
+        resp.setStatus(status);
+        resp.setErrorCode(errorCode);
+        resp.setFailReason(failReason);
+        doReturn(resp).when(alipayClient).certificateExecute(any());
+        return resp;
+    }
+
+    @Test
+    void syncAll_fail_writesErrorCodeAndReason() throws Exception {
+        TransferEntity t = dealingTransfer();
+        queryResponse("FAIL", "Ali_Err_111036", "该银行账户已冻结,请联系银行客服");
+
+        service.transferSyncAllService();
+
+        assertEquals("FAIL", t.getStatus());
+        assertEquals("Ali_Err_111036", t.getErrorCode());
+        assertEquals("该银行账户已冻结,请联系银行客服", t.getErrorMsg());
+        verify(transferMapper).updateById(t);
+    }
+
+    /** 正对照:成功的单子上游不送这两个字段,守卫必须一个字符都不写。 */
+    @Test
+    void syncAll_success_leavesErrorColumnsUntouched() throws Exception {
+        TransferEntity t = dealingTransfer();
+        queryResponse("SUCCESS", null, null);
+
+        service.transferSyncAllService();
+
+        assertEquals("SUCCESS", t.getStatus());
+        assertNull(t.getErrorCode());
+        assertNull(t.getErrorMsg());
+    }
+}

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

@@ -95,4 +95,74 @@ class AccountHandlerTest {
         assertNull(t.getErrorCode());
         assertNull(t.getErrorMsg());
     }
+
+    /**
+     * 终态 FAIL 的记录:晚到的回调只补**空的**原因,不改状态。
+     *
+     * <p>为什么需要这一格:「全量同步」这条腿也把单子置 FAIL。若它先跑到,
+     * 记录就成了终态,而本方法原先把非 DEALING 一律 return —— 之后那条
+     * **带着原因**的回调被挡在门外,原因就永远补不上了。
+     */
+    @Test
+    void transOrderChanged_terminalFail_backfillsBlankReason() {
+        TransferEntity t = new TransferEntity();
+        t.setOutBizNo("1302345790789783552");
+        t.setOrderNo("20260929110070100006870072072224");
+        t.setStatus("FAIL");                        // 终态,但两列空着
+        when(transferMapper.selectOne(any())).thenReturn(t);
+
+        handler.dispatch(METHOD, failParams());
+
+        assertEquals("FAIL", t.getStatus());
+        assertEquals("Ali_Err_111036", t.getErrorCode());
+        assertEquals("该银行账户已冻结,请联系银行客服", t.getErrorMsg());
+        verify(transferMapper).updateById(t);
+        // 补写不是一次新的「转账结果」,不该再对外发一次回调
+        verify(openapiService, never()).notifyTransferResult(any(), any(), any());
+    }
+
+    /** 正对照:已有原因时一个字符都不改,且**根本不写库**。 */
+    @Test
+    void transOrderChanged_terminalFail_keepsExistingReason() {
+        TransferEntity t = new TransferEntity();
+        t.setOutBizNo("1302345790789783552");
+        t.setStatus("FAIL");
+        t.setErrorCode("OLD_CODE");
+        t.setErrorMsg("先前那次记下的原因");
+        when(transferMapper.selectOne(any())).thenReturn(t);
+
+        handler.dispatch(METHOD, failParams());
+
+        assertEquals("OLD_CODE", t.getErrorCode());
+        assertEquals("先前那次记下的原因", t.getErrorMsg());
+        verify(transferMapper, never()).updateById(any());
+    }
+
+    /** 终态 SUCCESS 不接受任何补写:迟到的失败报文不许改写它。 */
+    @Test
+    void transOrderChanged_terminalSuccess_isIgnored() {
+        TransferEntity t = new TransferEntity();
+        t.setOutBizNo("1302345790789783552");
+        t.setStatus("SUCCESS");
+        when(transferMapper.selectOne(any())).thenReturn(t);
+
+        handler.dispatch(METHOD, failParams());
+
+        assertEquals("SUCCESS", t.getStatus());
+        assertNull(t.getErrorCode());
+        assertNull(t.getErrorMsg());
+        verify(transferMapper, never()).updateById(any());
+    }
+
+    /** 上面三条共用的失败报文(键名同 {@link #transOrderChanged_fail_writesErrorCodeAndReason})。 */
+    private Map<String, String> failParams() {
+        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", "该银行账户已冻结,请联系银行客服");
+        return params;
+    }
 }