浏览代码

fix: 批量付款 - 租户隔离/批内唯一/幂等/时间解析

alphaH 1 周之前
父节点
当前提交
351f745c4f

+ 42 - 13
java/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.java

@@ -48,10 +48,13 @@ import java.math.BigDecimal;
 import java.time.OffsetDateTime;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 /**
  * 批量付款到户有密 — 制单授权
@@ -174,15 +177,25 @@ public class AlipayBatchPayService {
     public Map<String, Object> batchCreate(BatchCreateDTO dto) {
         if (dto.getOutBatchNo() == null || dto.getOutBatchNo().isBlank())
             dto.setOutBatchNo(SnowflakeIdGenerator.nextIdStr());
-        // 幂等保护: 同单号已存在则拒绝,避免重复支付
+        // 幂等统一: 本地命中已有单号时与 UNIQUE_VIOLATION 兜底一致 — 查库返回已受理批次信息,不抛 400
         if (batchOrderMapper.selectCount(
                 new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
-                        .eq(BatchOrderEntity::getOutBatchNo, dto.getOutBatchNo())) > 0)
+                        .eq(BatchOrderEntity::getOutBatchNo, dto.getOutBatchNo())) > 0) {
+            BatchOrderEntity existing = batchOrderMapper.selectOne(
+                    new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
+                            .eq(BatchOrderEntity::getOutBatchNo, dto.getOutBatchNo()));
+            if (existing != null)
+                return acceptedBatchResult(existing);
             throw new BusinessException(400, "批次外部单号已存在,请勿重复创建");
+        }
 
         BigDecimal total = BigDecimal.ZERO;
         List<TransOrderDetail> transList = new ArrayList<>();
+        Set<String> seenOutBizNos = new HashSet<>();
         for (BatchCreateDTO.BatchDetailDTO d : dto.getDetails()) {
+            // 每笔 out_biz_no 批内唯一
+            if (!seenOutBizNos.add(d.getOutBizNo()))
+                throw new BusinessException(400, "批次内明细外部单号重复:" + d.getOutBizNo());
             if (d.getAmount().compareTo(new BigDecimal("1")) < 0)
                 throw new BusinessException(400, "明细金额最低 1 元: " + d.getOutBizNo());
             total = total.add(d.getAmount());
@@ -244,9 +257,7 @@ public class AlipayBatchPayService {
                             new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
                                     .eq(BatchOrderEntity::getOutBatchNo, dto.getOutBatchNo()));
                     if (existing != null)
-                        return Map.of("out_batch_no", existing.getOutBatchNo(),
-                                "batch_trans_id", existing.getBatchTransId() != null ? existing.getBatchTransId() : "",
-                                "status", existing.getStatus());
+                        return acceptedBatchResult(existing);
                 }
                 throw new BusinessException(400, "创建批次失败: " + response.getMsg() + " (" + response.getSubCode() + ")");
             }
@@ -291,6 +302,13 @@ public class AlipayBatchPayService {
         }
     }
 
+    /** 幂等返回体: 已受理批次信息(本地 selectCount 命中与 UNIQUE_VIOLATION 兜底共用) */
+    private Map<String, Object> acceptedBatchResult(BatchOrderEntity existing) {
+        return Map.of("out_batch_no", existing.getOutBatchNo(),
+                "batch_trans_id", existing.getBatchTransId() != null ? existing.getBatchTransId() : "",
+                "status", existing.getStatus());
+    }
+
     /** alipay.fund.trans.render.pay — 生成 PC 支付页链接 */
     public Map<String, String> renderPay(String enterpriseId, String outBatchNo) {
         BatchOrderEntity order = batchOrderMapper.selectOne(
@@ -405,23 +423,31 @@ public class AlipayBatchPayService {
                 .eq(status != null && !status.isBlank(), BatchOrderEntity::getStatus, status)
                 .orderByDesc(BatchOrderEntity::getId);
         if (startTime != null && !startTime.isBlank()) {
-            // "yyyy-MM-dd HH:mm" → "yyyy-MM-ddTHH:mm:00+08:00"(ISO 默认模式,无需自定义格式器)
-            w.ge(BatchOrderEntity::getCreatedTime,
-                    OffsetDateTime.parse(startTime.replace(' ', 'T') + ":00+08:00"));
+            w.ge(BatchOrderEntity::getCreatedTime, parseTimeFilter(startTime, ":00+08:00"));
         }
         if (endTime != null && !endTime.isBlank()) {
-            w.le(BatchOrderEntity::getCreatedTime,
-                    OffsetDateTime.parse(endTime.replace(' ', 'T') + ":59+08:00"));
+            w.le(BatchOrderEntity::getCreatedTime, parseTimeFilter(endTime, ":59+08:00"));
         }
         var r = batchOrderMapper.selectPage(new Page<>(pageNo, pageSize), w);
         return PageResult.of(pageNo, pageSize, r.getTotal(), r.getRecords());
     }
 
-    /** 批次详情 + 明细分页 */
-    public Map<String, Object> batchDetail(String outBatchNo, int pageNo, int pageSize) {
+    /** 时间参数解析: "yyyy-MM-dd HH:mm" → OffsetDateTime;非法格式抛 400(而非 DateTimeParseException→500) */
+    private static OffsetDateTime parseTimeFilter(String time, String suffix) {
+        try {
+            // "yyyy-MM-dd HH:mm" → "yyyy-MM-ddTHH:mm:00+08:00"(ISO 默认模式,无需自定义格式器)
+            return OffsetDateTime.parse(time.replace(' ', 'T') + suffix);
+        } catch (DateTimeParseException e) {
+            throw new BusinessException(400, "时间参数格式错误:应形如 yyyy-MM-dd HH:mm");
+        }
+    }
+
+    /** 批次详情 + 明细分页(enterprise_id 租户隔离,参照 batchList 过滤写法) */
+    public Map<String, Object> batchDetail(String enterpriseId, String outBatchNo, int pageNo, int pageSize) {
         BatchOrderEntity order = batchOrderMapper.selectOne(
                 new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
-                        .eq(BatchOrderEntity::getOutBatchNo, outBatchNo));
+                        .eq(BatchOrderEntity::getOutBatchNo, outBatchNo)
+                        .eq(enterpriseId != null && !enterpriseId.isBlank(), BatchOrderEntity::getEnterpriseId, enterpriseId));
         if (order == null) throw new BusinessException(404, "批次不存在");
         Page<BatchDetailEntity> detailPage = batchDetailMapper.selectPage(new Page<>(pageNo, pageSize),
                 new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchDetailEntity>()
@@ -487,6 +513,9 @@ public class AlipayBatchPayService {
             log.info("导出批次报表: {} 条", listData.size());
             return ExcelUtil.exportToExcel(listData, mappingDict);
         } catch (Exception e) {
+            // 非法时间输入应抛 400,而非被兜底包装成 500
+            if (e instanceof DateTimeParseException)
+                throw new BusinessException(400, "时间参数格式错误:应形如 yyyy-MM-dd HH:mm");
             log.error("导出批次报表失败", e);
             throw new RuntimeException("导出批次报表失败: " + e.getMessage());
         }

+ 96 - 2
java/src/test/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayServiceTest.java

@@ -201,9 +201,15 @@ class AlipayBatchPayServiceTest {
     }
 
     @Test
-    void batchCreate_duplicateOutBatchNo_throwsBusinessException() throws AlipayApiException {
+    void batchCreate_duplicateOutBatchNo_returnsAcceptedOrder() throws AlipayApiException {
+        // Ruling 5-4 幂等统一: selectCount 命中已有单号时返回已受理批次信息,不抛 400
         when(batchOrderMapper.selectCount(any()))
                 .thenReturn(1L);
+        BatchOrderEntity existing = new BatchOrderEntity();
+        existing.setOutBatchNo("B1");
+        existing.setBatchTransId("BT1");
+        existing.setStatus("SUCCESS");
+        when(batchOrderMapper.selectOne(any())).thenReturn(existing);
         BatchCreateDTO dto = new BatchCreateDTO();
         dto.setOrderTitle("t");
         dto.setPayerUid("2088PAYER");
@@ -213,7 +219,75 @@ class AlipayBatchPayServiceTest {
         detail.setAmount(new BigDecimal("10"));
         detail.setPayeeIdentity("a@b.com");
         dto.setDetails(List.of(detail));
-        assertThrows(BusinessException.class, () -> service.batchCreate(dto));
+
+        Map<String, Object> result = service.batchCreate(dto);
+
+        assertEquals("B1", result.get("out_batch_no"));
+        assertEquals("BT1", result.get("batch_trans_id"));
+        assertEquals("SUCCESS", result.get("status"));
+        verify(alipayClient, never()).certificateExecute(any());
+    }
+
+    @Test
+    void batchCreate_uniqueViolation_returnsAcceptedOrder() throws AlipayApiException {
+        // Ruling 5-4 幂等统一: UNIQUE_VIOLATION 兜底路径回归锁 — 返回已受理信息不抛异常
+        AlipayFundBatchCreateResponse resp = new AlipayFundBatchCreateResponse();
+        resp.setSubCode("UNIQUE_VIOLATION");
+        when(alipayClient.certificateExecute(any(AlipayFundBatchCreateRequest.class))).thenReturn(resp);
+        BatchOrderEntity existing = new BatchOrderEntity();
+        existing.setOutBatchNo("B1");
+        existing.setBatchTransId("BT1");
+        existing.setStatus("INIT");
+        when(batchOrderMapper.selectOne(any())).thenReturn(existing);
+
+        BatchCreateDTO dto = new BatchCreateDTO();
+        dto.setEnterpriseId("E100");
+        dto.setOutBatchNo("B1");
+        dto.setOrderTitle("t");
+        dto.setPayerUid("2088PAYER");
+        BatchCreateDTO.BatchDetailDTO detail = new BatchCreateDTO.BatchDetailDTO();
+        detail.setOutBizNo("D1");
+        detail.setAmount(new BigDecimal("10"));
+        detail.setPayeeIdentity("a@b.com");
+        detail.setPayeeIdentityType("ALIPAY_LOGON_ID");
+        detail.setPayeeName("张三");
+        dto.setDetails(List.of(detail));
+
+        Map<String, Object> result = service.batchCreate(dto);
+
+        assertEquals("B1", result.get("out_batch_no"));
+        assertEquals("BT1", result.get("batch_trans_id"));
+        assertEquals("INIT", result.get("status"));
+        verify(batchOrderMapper, never()).insert(any());
+    }
+
+    @Test
+    void batchCreate_duplicateDetailOutBizNo_throwsBusinessException() throws AlipayApiException {
+        // Ruling 5-2: 每笔 out_biz_no 批内唯一
+        BatchCreateDTO dto = new BatchCreateDTO();
+        dto.setEnterpriseId("E100");
+        dto.setOutBatchNo("B1");
+        dto.setOrderTitle("t");
+        dto.setPayerUid("2088PAYER");
+        BatchCreateDTO.BatchDetailDTO d1 = new BatchCreateDTO.BatchDetailDTO();
+        d1.setOutBizNo("D1");
+        d1.setAmount(new BigDecimal("10"));
+        d1.setPayeeIdentity("a@b.com");
+        d1.setPayeeIdentityType("ALIPAY_LOGON_ID");
+        d1.setPayeeName("张三");
+        BatchCreateDTO.BatchDetailDTO d2 = new BatchCreateDTO.BatchDetailDTO();
+        d2.setOutBizNo("D1");
+        d2.setAmount(new BigDecimal("5"));
+        d2.setPayeeIdentity("c@d.com");
+        d2.setPayeeIdentityType("ALIPAY_LOGON_ID");
+        d2.setPayeeName("李四");
+        dto.setDetails(List.of(d1, d2));
+
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.batchCreate(dto));
+
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMessage().contains("D1"));
+        verify(alipayClient, never()).certificateExecute(any());
     }
 
     @Test
@@ -270,4 +344,24 @@ class AlipayBatchPayServiceTest {
         service.batchClose("E100", "B1");
         verify(alipayClient).certificateExecute(any(AlipayFundBatchCloseRequest.class));
     }
+
+    @Test
+    void batchList_invalidTime_throwsBusinessException() {
+        // Ruling 5-3: 非法时间输入应抛 400 而非 DateTimeParseException→500
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.batchList("E100", null, "2026-13-45 99:99", null, 1, 20));
+        assertEquals(400, ex.getCode());
+
+        BusinessException ex2 = assertThrows(BusinessException.class,
+                () -> service.batchList("E100", null, null, "2026-08-25 25:61", 1, 20));
+        assertEquals(400, ex2.getCode());
+    }
+
+    @Test
+    void batchExport_invalidTime_throwsBusinessException() {
+        // Ruling 5-3: 导出 catch-all 需区分 DateTimeParseException 抛 400
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.batchExport("E100", null, "bad-time", null));
+        assertEquals(400, ex.getCode());
+    }
 }