소스 검색

feat: 批量付款 - 异步通知处理器(授权/批次状态)

alphaH 5 일 전
부모
커밋
5d1fe32c1c

+ 3 - 1
java/src/main/java/com/payment/platform/module/payment/notification/enums/NotificationEnums.java

@@ -13,7 +13,9 @@ public final class NotificationEnums {
         FUND_CHANGE_NOTIFY("alipay.commerce.ec.fund.change.notify"),
         CONSUME_CHANGE("alipay.commerce.ec.consume.change.notify"),
         INVOICE_ORDER_CHANGE("alipay.ebpp.invoice.ecorder.order.changed"),
-        INSTITUTION_OPERATION("alipay.commerce.ec.institution.operation.notify");
+        INSTITUTION_OPERATION("alipay.commerce.ec.institution.operation.notify"),
+        FUND_AUTHORIZE_STATUS_NOTIFY("alipay.fund.authorize.status.notify"),
+        FUND_BATCH_ORDER_CHANGED("alipay.fund.batch.order.changed");
 
         private final String method;
         AlipayNotifyMethod(String method) { this.method = method; }

+ 108 - 0
java/src/main/java/com/payment/platform/module/payment/notification/handler/BatchPayHandler.java

@@ -0,0 +1,108 @@
+package com.payment.platform.module.payment.notification.handler;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.payment.platform.module.payment.batch.entity.BatchAuthorizeEntity;
+import com.payment.platform.module.payment.batch.entity.BatchOrderEntity;
+import com.payment.platform.module.payment.batch.mapper.BatchAuthorizeMapper;
+import com.payment.platform.module.payment.batch.mapper.BatchOrderMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 批量付款到户有密 通知处理器
+ * <p>
+ * 监听:
+ * - alipay.fund.authorize.status.notify  制单授权签约/解约
+ * - alipay.fund.batch.order.changed      批次状态变更
+ * <p>
+ * 幂等与日志记录由 NotificationService.verifyAndDispatch 统一处理(Ruling 7.1),
+ * 明细状态回写由 batchQuery 兜底(Ruling 7.2),此处仅回写批次/授权状态。
+ */
+@Slf4j
+@Component
+public class BatchPayHandler extends BaseNotifyHandler {
+
+    /** 批次终态集合 — 终态不可变,通知不再回写(design 5.3 / Ruling 7.3) */
+    private static final Set<String> BATCH_TERMINAL_STATUSES = Set.of("SUCCESS", "DISUSE", "FAIL");
+
+    @Override
+    protected String[] acceptedMethods() {
+        return new String[]{
+                "alipay.fund.authorize.status.notify",
+                "alipay.fund.batch.order.changed"
+        };
+    }
+
+    @Override
+    protected void handle(String msgMethod, Map<String, String> params) {
+        // 无上下文兜底(仅走 dispatch(msgMethod, params) 无 ctx 版本时触发)
+        log.warn("BatchPayHandler 收到无上下文通知,跳过: msg_method={}", msgMethod);
+    }
+
+    @Override
+    protected void handle(String msgMethod, Map<String, String> params, NotifyContext ctx) {
+        try {
+            if ("alipay.fund.authorize.status.notify".equals(msgMethod)) {
+                handleAuthorizeNotify(params, ctx.getBatchAuthorizeMapper());
+            } else if ("alipay.fund.batch.order.changed".equals(msgMethod)) {
+                handleBatchNotify(params, ctx.getBatchOrderMapper());
+            }
+        } catch (Exception e) {
+            log.error("批量付款通知处理异常: msg_method={}", msgMethod, e);
+        }
+    }
+
+    private void handleAuthorizeNotify(Map<String, String> params, BatchAuthorizeMapper batchAuthorizeMapper) {
+        String outBizNo = params.get("out_biz_no");
+        if (outBizNo == null || outBizNo.isBlank()) {
+            log.warn("授权通知缺少 out_biz_no");
+            return;
+        }
+        BatchAuthorizeEntity entity = batchAuthorizeMapper.selectOne(
+                new LambdaQueryWrapper<BatchAuthorizeEntity>()
+                        .eq(BatchAuthorizeEntity::getOutBizNo, outBizNo));
+        if (entity == null) {
+            log.warn("授权通知找不到本地记录: out_biz_no={}", outBizNo);
+            return;
+        }
+        // 终态判重: AUTHED→UNBIND 解约转换合法,UNBIND 后不接受回退(Ruling 7.3)
+        if ("UNBIND".equals(entity.getStatus())) {
+            log.info("授权已解约,跳过通知回写: out_biz_no={}", outBizNo);
+            return;
+        }
+        if (params.get("agreement_no") != null) entity.setAgreementNo(params.get("agreement_no"));
+        // 支付宝通知的 status/action 字段值以接口文档为准(AUTHED/UNBIND 等),此处直接回写
+        if (params.get("status") != null) entity.setStatus(params.get("status"));
+        batchAuthorizeMapper.updateById(entity);
+        log.info("制单授权状态更新: out_biz_no={}, status={}", outBizNo, entity.getStatus());
+    }
+
+    private void handleBatchNotify(Map<String, String> params, BatchOrderMapper batchOrderMapper) {
+        String outBatchNo = params.get("out_batch_no");
+        if (outBatchNo == null || outBatchNo.isBlank()) {
+            log.warn("批次通知缺少 out_batch_no");
+            return;
+        }
+        BatchOrderEntity order = batchOrderMapper.selectOne(
+                new LambdaQueryWrapper<BatchOrderEntity>()
+                        .eq(BatchOrderEntity::getOutBatchNo, outBatchNo));
+        if (order == null) {
+            log.warn("批次通知找不到本地记录: out_batch_no={}", outBatchNo);
+            return;
+        }
+        // 终态判重: SUCCESS/DISUSE/FAIL 不可变,通知不再回写(design 5.3 / Ruling 7.3)
+        if (BATCH_TERMINAL_STATUSES.contains(order.getStatus())) {
+            log.info("批次已终态,跳过通知回写: out_batch_no={}, current_status={}", outBatchNo, order.getStatus());
+            return;
+        }
+        String status = params.get("status");
+        if (status != null) {
+            order.setStatus(status);
+            batchOrderMapper.updateById(order);
+        }
+        log.info("批次状态更新: out_batch_no={}, status={}", outBatchNo, status);
+    }
+}

+ 10 - 0
java/src/main/java/com/payment/platform/module/payment/notification/handler/NotifyContext.java

@@ -1,6 +1,8 @@
 package com.payment.platform.module.payment.notification.handler;
 
 import com.payment.platform.module.payment.account.mapper.TransferMapper;
+import com.payment.platform.module.payment.batch.mapper.BatchAuthorizeMapper;
+import com.payment.platform.module.payment.batch.mapper.BatchOrderMapper;
 import com.payment.platform.module.payment.enterprise.mapper.EnterpriseMapper;
 import com.payment.platform.module.payment.expense.mapper.ExpenseRuleMapper;
 import com.payment.platform.module.payment.expense.mapper.QuotaMapper;
@@ -24,6 +26,8 @@ public class NotifyContext {
     private PayBillVoucherMapper payBillVoucherMapper;
     private ExpenseRuleMapper expenseRuleMapper;
     private QuotaMapper quotaMapper;
+    private BatchAuthorizeMapper batchAuthorizeMapper;
+    private BatchOrderMapper batchOrderMapper;
 
     public TransferMapper getTransferMapper() { return transferMapper; }
     public NotifyContext setTransferMapper(TransferMapper v) { this.transferMapper = v; return this; }
@@ -48,4 +52,10 @@ public class NotifyContext {
 
     public QuotaMapper getQuotaMapper() { return quotaMapper; }
     public NotifyContext setQuotaMapper(QuotaMapper v) { this.quotaMapper = v; return this; }
+
+    public BatchAuthorizeMapper getBatchAuthorizeMapper() { return batchAuthorizeMapper; }
+    public NotifyContext setBatchAuthorizeMapper(BatchAuthorizeMapper v) { this.batchAuthorizeMapper = v; return this; }
+
+    public BatchOrderMapper getBatchOrderMapper() { return batchOrderMapper; }
+    public NotifyContext setBatchOrderMapper(BatchOrderMapper v) { this.batchOrderMapper = v; return this; }
 }

+ 7 - 1
java/src/main/java/com/payment/platform/module/payment/notification/service/NotificationService.java

@@ -10,6 +10,8 @@ import com.payment.platform.module.payment.serviceprovider.mapper.ServiceProvide
 import cn.hutool.core.util.StrUtil;
 import com.payment.platform.core.alipay.AlipayConfig;
 import com.payment.platform.module.payment.account.mapper.TransferMapper;
+import com.payment.platform.module.payment.batch.mapper.BatchAuthorizeMapper;
+import com.payment.platform.module.payment.batch.mapper.BatchOrderMapper;
 import com.payment.platform.module.payment.enterprise.mapper.EnterpriseMapper;
 import com.payment.platform.module.payment.expense.mapper.ExpenseRuleMapper;
 import com.payment.platform.module.payment.expense.mapper.QuotaMapper;
@@ -47,6 +49,8 @@ public class NotificationService {
     private final EnterpriseMapper enterpriseMapper;
     private final ExpenseRuleMapper expenseRuleMapper;
     private final QuotaMapper quotaMapper;
+    private final BatchAuthorizeMapper batchAuthorizeMapper;
+    private final BatchOrderMapper batchOrderMapper;
     private final OpenapiService openapiService;
     private final AlipayConfig alipayConfig;
     private final ServiceProviderMapper providerMapper;
@@ -163,7 +167,9 @@ public class NotificationService {
                 .setPayBillOrderMapper(payBillOrderMapper)
                 .setPayBillVoucherMapper(payBillVoucherMapper)
                 .setExpenseRuleMapper(expenseRuleMapper)
-                .setQuotaMapper(quotaMapper);
+                .setQuotaMapper(quotaMapper)
+                .setBatchAuthorizeMapper(batchAuthorizeMapper)
+                .setBatchOrderMapper(batchOrderMapper);
     }
 
     private void dispatch(String msgMethod, Map<String, String> params) {

+ 136 - 0
java/src/test/java/com/payment/platform/module/payment/notification/handler/BatchPayHandlerTest.java

@@ -0,0 +1,136 @@
+package com.payment.platform.module.payment.notification.handler;
+
+import com.payment.platform.module.payment.batch.entity.BatchAuthorizeEntity;
+import com.payment.platform.module.payment.batch.entity.BatchOrderEntity;
+import com.payment.platform.module.payment.batch.mapper.BatchAuthorizeMapper;
+import com.payment.platform.module.payment.batch.mapper.BatchOrderMapper;
+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.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class BatchPayHandlerTest {
+
+    @Mock private BatchAuthorizeMapper batchAuthorizeMapper;
+    @Mock private BatchOrderMapper batchOrderMapper;
+    private BatchPayHandler handler;
+
+    @BeforeEach
+    void setUp() {
+        // handler 不持有 mapper —— mapper 通过 NotifyContext 传入(BaseNotifyHandler 的 ctx 模式)
+        handler = new BatchPayHandler();
+    }
+
+    private NotifyContext ctx() {
+        return new NotifyContext()
+                .setBatchAuthorizeMapper(batchAuthorizeMapper)
+                .setBatchOrderMapper(batchOrderMapper);
+    }
+
+    @Test
+    void acceptsBothNotifyMethods() {
+        assertTrue(handler.accept("alipay.fund.authorize.status.notify"));
+        assertTrue(handler.accept("alipay.fund.batch.order.changed"));
+        assertFalse(handler.accept("alipay.commerce.ec.enterprise.change.notify"));
+    }
+
+    @Test
+    void authorizeNotify_updatesAgreementNo() {
+        BatchAuthorizeEntity entity = new BatchAuthorizeEntity();
+        entity.setId(1L);
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(entity);
+
+        Map<String, String> params = new HashMap<>();
+        params.put("out_biz_no", "A1");
+        params.put("agreement_no", "AGMT001");
+        params.put("status", "AUTHED");
+        handler.dispatch("alipay.fund.authorize.status.notify", params, ctx());
+
+        assertEquals("AGMT001", entity.getAgreementNo());
+        assertEquals("AUTHED", entity.getStatus());
+        verify(batchAuthorizeMapper).updateById(entity);
+    }
+
+    @Test
+    void batchNotify_updatesOrderStatus() {
+        BatchOrderEntity order = new BatchOrderEntity();
+        order.setId(1L);
+        order.setOutBatchNo("B1");
+        order.setStatus("INIT");
+        when(batchOrderMapper.selectOne(any())).thenReturn(order);
+
+        Map<String, String> params = new HashMap<>();
+        params.put("out_batch_no", "B1");
+        params.put("status", "SUCCESS");
+        handler.dispatch("alipay.fund.batch.order.changed", params, ctx());
+
+        assertEquals("SUCCESS", order.getStatus());
+        verify(batchOrderMapper).updateById(order);
+    }
+
+    @Test
+    void batchNotify_skipsWhenOrderAlreadyTerminal() {
+        // Ruling 7.3: 本地状态 ∈ {SUCCESS, DISUSE, FAIL} 为终态不可变,通知不再回写
+        for (String terminal : List.of("SUCCESS", "DISUSE", "FAIL")) {
+            BatchOrderEntity order = new BatchOrderEntity();
+            order.setId(1L);
+            order.setOutBatchNo("B1");
+            order.setStatus(terminal);
+            when(batchOrderMapper.selectOne(any())).thenReturn(order);
+
+            Map<String, String> params = new HashMap<>();
+            params.put("out_batch_no", "B1");
+            params.put("status", "SUCCESS");
+            handler.dispatch("alipay.fund.batch.order.changed", params, ctx());
+
+            assertEquals(terminal, order.getStatus());
+        }
+        verify(batchOrderMapper, never()).updateById(any());
+    }
+
+    @Test
+    void authorizeNotify_skipsWhenAlreadyUnbind() {
+        // Ruling 7.3: UNBIND 后不接受回退(AUTHED/UNBIND 之后不再回写)
+        BatchAuthorizeEntity entity = new BatchAuthorizeEntity();
+        entity.setId(1L);
+        entity.setStatus("UNBIND");
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(entity);
+
+        Map<String, String> params = new HashMap<>();
+        params.put("out_biz_no", "A1");
+        params.put("agreement_no", "AGMT001");
+        params.put("status", "AUTHED");
+        handler.dispatch("alipay.fund.authorize.status.notify", params, ctx());
+
+        assertEquals("UNBIND", entity.getStatus());
+        assertNull(entity.getAgreementNo());
+        verify(batchAuthorizeMapper, never()).updateById(any());
+    }
+
+    @Test
+    void authorizeNotify_allowsUnbindTransitionFromAuthed() {
+        // Ruling 7.3: AUTHED→UNBIND 解约转换合法,不被终态判重拦截
+        BatchAuthorizeEntity entity = new BatchAuthorizeEntity();
+        entity.setId(1L);
+        entity.setStatus("AUTHED");
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(entity);
+
+        Map<String, String> params = new HashMap<>();
+        params.put("out_biz_no", "A1");
+        params.put("status", "UNBIND");
+        handler.dispatch("alipay.fund.authorize.status.notify", params, ctx());
+
+        assertEquals("UNBIND", entity.getStatus());
+        verify(batchAuthorizeMapper).updateById(entity);
+    }
+}