Parcourir la source

fix: 批量付款最终审查修复 - 租户隔离/明细同步/场景报备必填/凭证证书回退

alphaH il y a 5 jours
Parent
commit
9b8bb0470d

+ 16 - 3
java/src/main/java/com/payment/platform/core/alipay/AlipayClientFactory.java

@@ -202,11 +202,24 @@ public class AlipayClientFactory {
                 profile.getAppId(), profile.getAppPrivateKey(), profile.getAlipayPublicKey(),
                 profile.getServerUrl(), profile.getFormat(), profile.getCharset(), profile.getSignType());
 
-        // bizType 分支使用普通公钥模式(非资金类接口)
+        // 公钥未填写但三证书已配置时回退证书模式(实体 javadoc 意图: 公钥未填写时回退证书模式)。
+        // 资金类接口(BATCH_PAY 等)强制证书加签,公钥模式客户端调用 certificateExecute 会失败。
+        // 仅影响「公钥为空 + 证书存在」的 profile,对已有公钥模式的 bizType 无影响。
+        boolean hasCerts = profile.getAppCertContent() != null && !profile.getAppCertContent().isBlank()
+                && profile.getAlipayPublicCertContent() != null && !profile.getAlipayPublicCertContent().isBlank()
+                && profile.getRootCertContent() != null && !profile.getRootCertContent().isBlank();
+        boolean hasPublicKey = profile.getAlipayPublicKey() != null && !profile.getAlipayPublicKey().isBlank();
+        String mode = "key";
+        if (!hasPublicKey && hasCerts) {
+            config.setAppCertContent(profile.getAppCertContent());
+            config.setAlipayPublicCertContent(profile.getAlipayPublicCertContent());
+            config.setRootCertContent(profile.getRootCertContent());
+            mode = "cert";
+        }
         try {
             AlipayClient client = new DefaultAlipayClient(config);
-            log.info("服务商[{}]业务[{}]客户端创建成功, appId={}, mode=key",
-                    profile.getServiceProviderId(), profile.getBizType(), profile.getAppId());
+            log.info("服务商[{}]业务[{}]客户端创建成功, appId={}, mode={}",
+                    profile.getServiceProviderId(), profile.getBizType(), profile.getAppId(), mode);
             return client;
         } catch (AlipayApiException e) {
             log.error("服务商[{}]业务[{}]客户端创建失败", profile.getServiceProviderId(), profile.getBizType(), e);

+ 3 - 0
java/src/main/java/com/payment/platform/module/payment/batch/dto/BatchCreateDTO.java

@@ -2,6 +2,7 @@ package com.payment.platform.module.payment.batch.dto;
 
 import io.swagger.v3.oas.annotations.media.Schema;
 import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
 import jakarta.validation.constraints.NotNull;
 import jakarta.validation.constraints.Size;
 import lombok.Data;
@@ -33,9 +34,11 @@ public class BatchCreateDTO {
     @Schema(description = "制单授权协议号(payer_info.ext_info 传此值则校验指定协议;不传校验任意协议)")
     private String agreementNo;
 
+    @NotBlank(message = "转账场景不能为空(26年新接入商户必传)")
     @Schema(description = "转账场景(26年新接入商户必传): 现金营销/企业退款/佣金报酬/二手回收/业务结算/公益补助/行政补贴和退款/保险理赔")
     private String transferSceneName;
 
+    @NotEmpty(message = "转账场景报备信息不能为空(26年新接入商户必传)")
     @Schema(description = "转账场景报备信息: [{info_type, info_content}](26年新接入商户必传)")
     private List<Map<String, String>> transferSceneReportInfos;
 

+ 45 - 0
java/src/main/java/com/payment/platform/module/payment/batch/scheduler/BatchStatusPollScheduler.java

@@ -0,0 +1,45 @@
+package com.payment.platform.module.payment.batch.scheduler;
+
+import com.payment.platform.module.payment.batch.entity.BatchOrderEntity;
+import com.payment.platform.module.payment.batch.service.AlipayBatchPayService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * 批量付款批次状态轮询定时任务 — 明细状态自动更新兜底
+ * <p>
+ * 通知(alipay.fund.batch.order.changed)只回写批次状态,明细逐笔状态(SUCCESS/FAIL、error_msg)
+ * 由 batchQuery(alipay.fund.batch.detail.query)兜底回写。本任务每 1 分钟对非终态批次调 batchQuery,
+ * 仿照 F2fTradePollScheduler 结构: 单条异常 catch 记日志不中断。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class BatchStatusPollScheduler {
+
+    private final AlipayBatchPayService batchPayService;
+
+    @Scheduled(fixedDelay = 60 * 1000) // 每 1 分钟
+    public void pollPendingBatches() {
+        List<BatchOrderEntity> batches = batchPayService.getPendingBatches();
+        if (batches.isEmpty()) {
+            return;
+        }
+
+        int count = 0;
+        for (BatchOrderEntity batch : batches) {
+            try {
+                batchPayService.batchQuery(batch.getEnterpriseId(), batch.getOutBatchNo());
+                count++;
+            } catch (Exception e) {
+                log.error("[批次状态轮询] 轮询批次异常: out_batch_no={}, error={}",
+                        batch.getOutBatchNo(), e.getMessage());
+            }
+        }
+        log.info("[批次状态轮询] 定时任务完成: 处理 {}/{} 条", count, batches.size());
+    }
+}

+ 100 - 27
java/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.java

@@ -28,6 +28,7 @@ import com.alipay.api.response.AlipayFundTransRenderPayResponse;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.payment.platform.common.exception.BusinessException;
+import org.springframework.dao.DuplicateKeyException;
 import com.payment.platform.common.response.PageResult;
 import com.payment.platform.common.utils.ExcelUtil;
 import com.payment.platform.common.utils.SnowflakeIdGenerator;
@@ -54,7 +55,9 @@ import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
+import java.util.stream.Collectors;
 
 /**
  * 批量付款到户有密 — 制单授权
@@ -85,15 +88,27 @@ public class AlipayBatchPayService {
     public Map<String, String> authorizeApply(String enterpriseId, String participantId) {
         if (participantId == null || participantId.isBlank())
             throw new BusinessException(400, "付款方支付宝账号不能为空");
-        // 重复新增防护: 同付款方已有非 UNBIND 状态的授权记录时不允许重复申请(设计文档 2.4)
+        // 重复新增防护三态(设计文档 2.4/7):
+        //   AUTHED            → 拒绝(已有生效授权)
+        //   AUTHING 未过期    → 拒绝(授权链接一次有效,提示先完成授权)
+        //   AUTHING 已过期    → 旧记录置 UNBIND,换新 out_biz_no 重新申请
+        // DB 兜底: uk_batch_authorize_active partial unique 索引(enterprise_id, participant_id)WHERE status <> 'UNBIND'
         BatchAuthorizeEntity existing = batchAuthorizeMapper.selectOne(
                 new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
                         .eq(BatchAuthorizeEntity::getEnterpriseId, enterpriseId)
                         .eq(BatchAuthorizeEntity::getParticipantId, participantId)
                         .ne(BatchAuthorizeEntity::getStatus, "UNBIND"));
-        if (existing != null)
-            throw new BusinessException(400, "该付款方已存在制单授权申请,无需重复授权");
         String outBizNo = SnowflakeIdGenerator.nextIdStr();
+        if (existing != null) {
+            if ("AUTHED".equals(existing.getStatus()))
+                throw new BusinessException(400, "该付款方已存在制单授权申请,无需重复授权");
+            if (!isAuthorizeExpired(existing))
+                throw new BusinessException(400, "存在未完成的授权申请,请先完成授权或稍后重试");
+            existing.setStatus("UNBIND");
+            batchAuthorizeMapper.updateById(existing);
+            log.info("制单授权申请已过期,作废旧记录并重新申请: old_out_biz_no={}, participant_id={}",
+                    existing.getOutBizNo(), participantId);
+        }
         try {
             AlipayFundAuthorizeUniApplyModel model = new AlipayFundAuthorizeUniApplyModel();
             model.setProductCode(AUTHORIZE_PRODUCT_CODE);
@@ -120,7 +135,12 @@ public class AlipayBatchPayService {
             entity.setParticipantIdType("ALIPAY_USER_ID");
             entity.setStatus("AUTHING");
             entity.setAuthorizeLink(response.getAuthorizeLink());
-            batchAuthorizeMapper.insert(entity);
+            try {
+                batchAuthorizeMapper.insert(entity);
+            } catch (DuplicateKeyException e) {
+                // 并发双击兜底: 同企业同付款方已有非 UNBIND 授权记录,命中 uk_batch_authorize_active
+                throw new BusinessException(400, "该付款方已存在制单授权申请,请勿重复操作");
+            }
 
             return Map.of("authorize_link",
                     response.getAuthorizeLink() != null ? response.getAuthorizeLink() : "",
@@ -130,6 +150,19 @@ public class AlipayBatchPayService {
         }
     }
 
+    /**
+     * 授权申请是否已过期: authorize_expire_time 非空且 > now 为未过期;
+     * 为空时按 created_time + 24h 判定(授权链接一次有效,逾期视为过期可重新申请)
+     */
+    private boolean isAuthorizeExpired(BatchAuthorizeEntity entity) {
+        OffsetDateTime now = OffsetDateTime.now();
+        if (entity.getAuthorizeExpireTime() != null)
+            return !entity.getAuthorizeExpireTime().isAfter(now);
+        if (entity.getCreatedTime() != null)
+            return entity.getCreatedTime().plusHours(24).isBefore(now);
+        return true;
+    }
+
     /** alipay.fund.authorize.uni.query — 查询制单授权状态(单协议) */
     public Map<String, String> queryAuthorize(String enterpriseId, String outBizNo) {
         try {
@@ -322,7 +355,8 @@ public class AlipayBatchPayService {
     public Map<String, String> renderPay(String enterpriseId, String outBatchNo) {
         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, "批次不存在");
         if (!"INIT".equals(order.getStatus()))
             throw new BusinessException(400, "仅受理中的批次可支付,当前状态: " + order.getStatus());
@@ -349,7 +383,8 @@ public class AlipayBatchPayService {
     public Map<String, Object> batchQuery(String enterpriseId, String outBatchNo) {
         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, "批次不存在");
         try {
             AlipayFundBatchDetailQueryModel model = new AlipayFundBatchDetailQueryModel();
@@ -367,18 +402,30 @@ public class AlipayBatchPayService {
                 order.setStatus(response.getBatchStatus());
                 batchOrderMapper.updateById(order);
             }
-            // 明细状态回写(SDK 字段 acc_detail_list,按 out_biz_no 匹配本地明细)
-            if (response.getAccDetailList() != null) {
-                for (AccDetailModel m : response.getAccDetailList()) {
-                    if (m.getOutBizNo() == null) continue;
-                    BatchDetailEntity de = batchDetailMapper.selectOne(
+            // 明细状态回写(SDK 字段 acc_detail_list,按 out_biz_no 匹配本批次明细)
+            // 注意: out_biz_no 仅批内唯一(uk_batch_detail_batch_out_biz),必须带 batchId 条件,
+            // 且一次 selectList 批量取出避免 N+1(跨批次同号明细不会命中多行)
+            if (response.getAccDetailList() != null && !response.getAccDetailList().isEmpty()) {
+                List<String> outBizNos = response.getAccDetailList().stream()
+                        .map(AccDetailModel::getOutBizNo)
+                        .filter(Objects::nonNull)
+                        .collect(Collectors.toList());
+                if (!outBizNos.isEmpty()) {
+                    Map<String, BatchDetailEntity> byOutBizNo = batchDetailMapper.selectList(
                             new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchDetailEntity>()
-                                    .eq(BatchDetailEntity::getOutBizNo, m.getOutBizNo()));
-                    if (de == null) continue;
-                    if (m.getStatus() != null) de.setStatus(m.getStatus());
-                    if (m.getErrorCode() != null) de.setErrorCode(m.getErrorCode());
-                    if (m.getErrorMsg() != null) de.setErrorMsg(m.getErrorMsg());
-                    batchDetailMapper.updateById(de);
+                                    .eq(BatchDetailEntity::getBatchId, order.getId())
+                                    .in(BatchDetailEntity::getOutBizNo, outBizNos))
+                            .stream()
+                            .collect(Collectors.toMap(BatchDetailEntity::getOutBizNo, d -> d, (a, b) -> a));
+                    for (AccDetailModel m : response.getAccDetailList()) {
+                        if (m.getOutBizNo() == null) continue;
+                        BatchDetailEntity de = byOutBizNo.get(m.getOutBizNo());
+                        if (de == null) continue;
+                        if (m.getStatus() != null) de.setStatus(m.getStatus());
+                        if (m.getErrorCode() != null) de.setErrorCode(m.getErrorCode());
+                        if (m.getErrorMsg() != null) de.setErrorMsg(m.getErrorMsg());
+                        batchDetailMapper.updateById(de);
+                    }
                 }
             }
             return Map.of("out_batch_no", outBatchNo,
@@ -392,8 +439,12 @@ public class AlipayBatchPayService {
     public Map<String, String> batchClose(String enterpriseId, String outBatchNo) {
         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, "批次不存在");
+        // 与 renderPay 一致的前置守卫: 仅 INIT 状态可关闭(提前给友好提示而非透传支付宝原文案)
+        if (!"INIT".equals(order.getStatus()))
+            throw new BusinessException(400, "仅 INIT 状态的批次可关闭");
         try {
             AlipayFundBatchCloseModel model = new AlipayFundBatchCloseModel();
             model.setBatchTransId(order.getBatchTransId());
@@ -413,6 +464,22 @@ public class AlipayBatchPayService {
         }
     }
 
+    // ==================== 定时状态同步(batchQuery 兜底) ====================
+
+    /**
+     * 获取待同步状态的批次(供 BatchStatusPollScheduler 定时调 batchQuery 兜底):
+     * 非终态(排除 SUCCESS/DISUSE/FAIL)+ 已受理(batch_trans_id 非空),最多 100 条/轮
+     */
+    public List<BatchOrderEntity> getPendingBatches() {
+        return batchOrderMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
+                        .notIn(BatchOrderEntity::getStatus, List.of("SUCCESS", "DISUSE", "FAIL"))
+                        .isNotNull(BatchOrderEntity::getBatchTransId)
+                        .ne(BatchOrderEntity::getBatchTransId, "")
+                        .orderByAsc(BatchOrderEntity::getId)
+                        .last("LIMIT 100"));
+    }
+
     // ==================== 列表 / 详情 / 导出 ====================
 
     /** 授权列表(分页) */
@@ -441,11 +508,17 @@ public class AlipayBatchPayService {
         return PageResult.of(pageNo, pageSize, r.getTotal(), r.getRecords());
     }
 
-    /** 时间参数解析: "yyyy-MM-dd HH:mm" → OffsetDateTime;非法格式抛 400(而非 DateTimeParseException→500) */
+    /**
+     * 时间参数解析: "yyyy-MM-dd HH:mm" 或纯日期 "yyyy-MM-dd" → OffsetDateTime;
+     * 10 位纯日期补当天零点(起始)/23:59(结束,与 AccountService.parseDateTime 行为对齐);
+     * 非法格式抛 400(而非 DateTimeParseException→500)
+     */
     private static OffsetDateTime parseTimeFilter(String time, String suffix) {
         try {
+            // 10 位纯日期: 补 " 00:00",结束侧 suffix 会把秒替换为 :59 → 当天 23:59:59
+            String t = time.length() == 10 ? time + " 00:00" : time;
             // "yyyy-MM-dd HH:mm" → "yyyy-MM-ddTHH:mm:00+08:00"(ISO 默认模式,无需自定义格式器)
-            return OffsetDateTime.parse(time.replace(' ', 'T') + suffix);
+            return OffsetDateTime.parse(t.replace(' ', 'T') + suffix);
         } catch (DateTimeParseException e) {
             throw new BusinessException(400, "时间参数格式错误:应形如 yyyy-MM-dd HH:mm");
         }
@@ -474,12 +547,10 @@ public class AlipayBatchPayService {
                     .eq(status != null && !status.isBlank(), BatchOrderEntity::getStatus, status)
                     .orderByDesc(BatchOrderEntity::getId);
             if (startTime != null && !startTime.isBlank()) {
-                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"));
             }
             List<BatchOrderEntity> records = batchOrderMapper.selectList(w);
             // 列: 序号/批次号/支付宝批次号/标题/金额(元)/笔数/状态/创建时间/错误信息
@@ -521,10 +592,12 @@ public class AlipayBatchPayService {
 
             log.info("导出批次报表: {} 条", listData.size());
             return ExcelUtil.exportToExcel(listData, mappingDict);
+        } catch (BusinessException e) {
+            // parseTimeFilter 已抛 400(非法时间),不得被兜底包装成 500
+            throw e;
+        } catch (DateTimeParseException e) {
+            throw new BusinessException(400, "时间参数格式错误:应形如 yyyy-MM-dd HH:mm");
         } 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());
         }

+ 2 - 1
java/src/main/java/com/payment/platform/module/payment/notification/handler/BatchPayHandler.java

@@ -98,7 +98,8 @@ public class BatchPayHandler extends BaseNotifyHandler {
             log.info("批次已终态,跳过通知回写: out_batch_no={}, current_status={}", outBatchNo, order.getStatus());
             return;
         }
-        String status = params.get("status");
+        // 双键兼容: 查询接口 SDK 字段实证为 batch_status,通知字段文档不可达,两种键都接受(batch_status 优先)
+        String status = params.get("batch_status") != null ? params.get("batch_status") : params.get("status");
         if (status != null) {
             order.setStatus(status);
             batchOrderMapper.updateById(order);

+ 9 - 0
java/src/main/resources/db/migration/V1.7__alter_pay_batch_pay_url_text.sql

@@ -0,0 +1,9 @@
+-- 批量付款最终审查修复: pay_url 存不下 pageExecute HTML 表单体(1.5-4KB)→ 改 TEXT
+ALTER TABLE pay_batch_order ALTER COLUMN pay_url TYPE text;
+
+-- M1: 制单授权业务唯一性 DB 兜底(并发双击防重): 同企业同付款方仅一条非 UNBIND 有效记录
+-- 作废(M2 过期重申请)时 status 置 UNBIND,与索引条件兼容,可重新申请
+CREATE UNIQUE INDEX uk_batch_authorize_active ON pay_batch_authorize (enterprise_id, participant_id) WHERE status <> 'UNBIND';
+
+-- M4: batch_trans_id 设计标注唯一索引(PostgreSQL 唯一索引允许多 NULL,未受理批次不冲突)
+CREATE UNIQUE INDEX uk_batch_order_batch_trans ON pay_batch_order (batch_trans_id);

+ 144 - 0
java/src/test/java/com/payment/platform/core/alipay/AlipayClientFactoryProfileTest.java

@@ -0,0 +1,144 @@
+package com.payment.platform.core.alipay;
+
+import com.alipay.api.AlipayClient;
+import com.payment.platform.module.payment.enterprise.entity.EnterpriseEntity;
+import com.payment.platform.module.payment.enterprise.mapper.EnterpriseMapper;
+import com.payment.platform.module.payment.openapi.mapper.OpenConfMapper;
+import com.payment.platform.module.payment.serviceprovider.entity.ServiceProviderProfileEntity;
+import com.payment.platform.module.payment.serviceprovider.mapper.ServiceProviderMapper;
+import com.payment.platform.module.payment.serviceprovider.mapper.ServiceProviderProfileMapper;
+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.lang.reflect.Field;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/**
+ * I6 修复验证: BATCH_PAY profile 客户端模式
+ * <p>
+ * 资金接口(certificateExecute)强制证书加签,profile 公钥为空但证书齐全时必须回退证书模式
+ * (实体 javadoc: 公钥未填写时回退证书模式);公钥已填时保持公钥模式(对存量配置无影响)。
+ * <p>
+ * 证书模式断言依据: SDK 构造 DefaultAlipayClient(AlipayConfig) 时,appCertContent 非空会
+ * 经 AntCertificationUtil.getCertFromContent 加载到 AbstractAlipayClient.cert 字段(javap 实证),
+ * 公钥模式该字段为 null。
+ */
+@ExtendWith(MockitoExtension.class)
+class AlipayClientFactoryProfileTest {
+
+    private static final String TEST_CERT_PEM = "-----BEGIN CERTIFICATE-----\n"
+            + "MIICyDCCAbCgAwIBAgIJAJVc/vKMm+19MA0GCSqGSIb3DQEBDAUAMBIxEDAOBgNV\n"
+            + "BAMTB1Rlc3RBcHAwHhcNMjYwODI1MTIxNDE1WhcNMzYwODIyMTIxNDE1WjASMRAw\n"
+            + "DgYDVQQDEwdUZXN0QXBwMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA\n"
+            + "wUILsDTUzCn093I5mKlpv2L3NYwm9w9fmkJ8TqiZoCTt8L2AZpF73I/kpJyeim0V\n"
+            + "lQeiMYT0oCceqkq6qBsWe/l5DGH2G3++fFAYtJcfXzhx76/Hb5Fmuc5NM9P/83bx\n"
+            + "BRPbX8QwrEWYI3YrPPxnBtqHjaJFwKoKhN20r2fTyKTSQt+FzuLp7LpG0PQVDyHs\n"
+            + "a+cX7IIkR/gB+XB8nmw4R6FeVIIfxlFZzcYjnxPcxhtFcXizwOjI9YEBX57nnhKd\n"
+            + "sdPR0U2NW87aPKSF1+yZtETS56ykB5NMs7tWWa5WGWb4r/0dwNEuhNPSFqbA8u+F\n"
+            + "Da2Cq2VCmUhHDfplxfomXQIDAQABoyEwHzAdBgNVHQ4EFgQUXBZalWxJw731Rve2\n"
+            + "rFqASQxjUJcwDQYJKoZIhvcNAQEMBQADggEBAB0AGCIjPAfOztrbLZCCDQjtnZhy\n"
+            + "vpUVXaBF2Rvme4RvV+AB0Mewo/4jLlUuNcqPD01CvRPFnXpfOEJW0gRwuM98lUmE\n"
+            + "zsEFdcgIa0+x6sbX2LTa9YRT2V/fz5sPicbVZLeAlvSRkm3GqCBAVoz4gTI+aaRt\n"
+            + "u9tivFAZy7veIeZHZ3UaMS+aD1o80lxWicreNkXVxX8gBFm1ZYLmb6N4/J2hK7mj\n"
+            + "ihNPo1SqJx5EHtdcljIRAERWsMApPINTZZ8mCoEK1nT2WF8D2UVZE4nLplLlQVB+\n"
+            + "3xCfYe49bwrqOf6eQyod7URQfRA+UmxwsITWmgSqwevoZAYh80ItMQVMudY=\n"
+            + "-----END CERTIFICATE-----\n";
+
+    @Mock private OpenConfMapper openConfMapper;
+    @Mock private EnterpriseMapper enterpriseMapper;
+    @Mock private ServiceProviderMapper serviceProviderMapper;
+    @Mock private ServiceProviderProfileMapper profileMapper;
+    private AlipayClientFactory factory;
+
+    @BeforeEach
+    void setUp() {
+        factory = new AlipayClientFactory(
+                new com.payment.platform.core.alipay.AlipayConfig(),
+                openConfMapper, enterpriseMapper, serviceProviderMapper, profileMapper);
+    }
+
+    private ServiceProviderProfileEntity profile(boolean withPublicKey, boolean withCerts) {
+        ServiceProviderProfileEntity p = new ServiceProviderProfileEntity();
+        p.setServiceProviderId(1L);
+        p.setBizType("BATCH_PAY");
+        p.setAppId("app123");
+        p.setAppPrivateKey("PRIVATE_KEY");
+        if (withPublicKey) p.setAlipayPublicKey("PUBLIC_KEY");
+        if (withCerts) {
+            p.setAppCertContent(TEST_CERT_PEM);
+            p.setAlipayPublicCertContent(TEST_CERT_PEM);
+            p.setRootCertContent(TEST_CERT_PEM);
+        }
+        return p;
+    }
+
+    private void mockEnterpriseWithProvider() {
+        EnterpriseEntity ent = new EnterpriseEntity();
+        ent.setServiceProviderId(1L);
+        when(enterpriseMapper.selectByEnterpriseIdIgnoreTenant("E100")).thenReturn(ent);
+    }
+
+    /**
+     * 反射读取 AbstractAlipayClient.cert 字段(证书模式加载了应用公钥证书,公钥模式为 null)。
+     * 注意: DefaultAlipayClient 声明了同名的影子字段(永不赋值),需跳过取父类实际初始化的值。
+     */
+    private static Object readCertField(AlipayClient client) throws Exception {
+        Object firstFound = null;
+        Class<?> clazz = client.getClass();
+        while (clazz != null) {
+            try {
+                Field f = clazz.getDeclaredField("cert");
+                f.setAccessible(true);
+                Object v = f.get(client);
+                if (firstFound == null) firstFound = v;
+                if (v != null) return v;
+            } catch (NoSuchFieldException ignored) {
+            }
+            clazz = clazz.getSuperclass();
+        }
+        return firstFound;
+    }
+
+    @Test
+    void profile_withoutPublicKeyWithCerts_createsCertClient() throws Exception {
+        // BATCH_PAY profile 仅填证书不填公钥(资金接口场景)→ 证书模式,不抛异常
+        mockEnterpriseWithProvider();
+        when(profileMapper.selectOne(any())).thenReturn(profile(false, true));
+
+        AlipayClient client = factory.getClient("E100", "BATCH_PAY");
+
+        assertNotNull(client, "公钥为空+证书存在时应成功创建证书客户端");
+        assertNotNull(readCertField(client), "证书模式: cert 字段应已加载");
+    }
+
+    @Test
+    void profile_withPublicKey_staysKeyMode() throws Exception {
+        // 公钥已填(即使证书也存在)→ 保持公钥模式,存量配置行为不变
+        mockEnterpriseWithProvider();
+        when(profileMapper.selectOne(any())).thenReturn(profile(true, true));
+
+        AlipayClient client = factory.getClient("E100", "BATCH_PAY");
+
+        assertNotNull(client);
+        assertNull(readCertField(client), "公钥已填时应保持公钥模式(cert 不加载)");
+    }
+
+    @Test
+    void profile_withoutPublicKeyWithoutCerts_createsKeyClient() throws Exception {
+        // 公钥与证书都缺(异常配置)→ 退化为公钥模式客户端,不抛异常(与修复前行为一致)
+        mockEnterpriseWithProvider();
+        when(profileMapper.selectOne(any())).thenReturn(profile(false, false));
+
+        AlipayClient client = factory.getClient("E100", "BATCH_PAY");
+
+        assertNotNull(client);
+        assertNull(readCertField(client));
+    }
+}

+ 69 - 0
java/src/test/java/com/payment/platform/module/payment/batch/scheduler/BatchStatusPollSchedulerTest.java

@@ -0,0 +1,69 @@
+package com.payment.platform.module.payment.batch.scheduler;
+
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.module.payment.batch.entity.BatchOrderEntity;
+import com.payment.platform.module.payment.batch.service.AlipayBatchPayService;
+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.assertDoesNotThrow;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class BatchStatusPollSchedulerTest {
+
+    @Mock private AlipayBatchPayService batchPayService;
+    private BatchStatusPollScheduler scheduler;
+
+    @BeforeEach
+    void setUp() {
+        scheduler = new BatchStatusPollScheduler(batchPayService);
+    }
+
+    private BatchOrderEntity batch(String enterpriseId, String outBatchNo) {
+        BatchOrderEntity b = new BatchOrderEntity();
+        b.setEnterpriseId(enterpriseId);
+        b.setOutBatchNo(outBatchNo);
+        return b;
+    }
+
+    @Test
+    void pollPendingBatches_singleFailure_doesNotInterrupt() {
+        // 照抄 F2fTradePollScheduler 模式: 单条异常 catch 记日志不中断
+        BatchOrderEntity b1 = batch("E1", "B1");
+        BatchOrderEntity b2 = batch("E2", "B2");
+        when(batchPayService.getPendingBatches()).thenReturn(List.of(b1, b2));
+        doThrow(new BusinessException(400, "查询批次失败")).when(batchPayService).batchQuery(eq("E1"), eq("B1"));
+
+        assertDoesNotThrow(scheduler::pollPendingBatches);
+
+        // 第二条仍被执行
+        verify(batchPayService).batchQuery("E2", "B2");
+    }
+
+    @Test
+    void pollPendingBatches_empty_skips() {
+        when(batchPayService.getPendingBatches()).thenReturn(List.of());
+
+        scheduler.pollPendingBatches();
+
+        verify(batchPayService, never()).batchQuery(any(), any());
+    }
+
+    @Test
+    void pollPendingBatches_allSuccess_pollsEach() {
+        when(batchPayService.getPendingBatches()).thenReturn(List.of(batch("E1", "B1"), batch("E1", "B2")));
+
+        scheduler.pollPendingBatches();
+
+        verify(batchPayService).batchQuery("E1", "B1");
+        verify(batchPayService).batchQuery("E1", "B2");
+    }
+}

+ 346 - 0
java/src/test/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayServiceTest.java

@@ -2,6 +2,7 @@ package com.payment.platform.module.payment.batch.service;
 
 import com.alipay.api.AlipayClient;
 import com.alipay.api.AlipayApiException;
+import com.alipay.api.domain.AccDetailModel;
 import com.alipay.api.domain.AlipayFundAuthorizeUniApplyModel;
 import com.alipay.api.domain.AlipayFundAuthorizeUniQueryModel;
 import com.alipay.api.domain.AlipayFundBatchCreateModel;
@@ -17,7 +18,12 @@ import com.alipay.api.response.AlipayFundBatchCloseResponse;
 import com.alipay.api.response.AlipayFundBatchCreateResponse;
 import com.alipay.api.response.AlipayFundBatchDetailQueryResponse;
 import com.alipay.api.response.AlipayFundTransRenderPayResponse;
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.PageResult;
 import com.payment.platform.core.alipay.AlipayClientFactory;
 import com.payment.platform.module.payment.batch.dto.BatchCreateDTO;
 import com.payment.platform.module.payment.batch.entity.BatchAuthorizeEntity;
@@ -26,16 +32,23 @@ 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.BatchDetailMapper;
 import com.payment.platform.module.payment.batch.mapper.BatchOrderMapper;
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.Validation;
+import jakarta.validation.Validator;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DuplicateKeyException;
 
 import java.math.BigDecimal;
+import java.time.OffsetDateTime;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.any;
@@ -56,6 +69,11 @@ class AlipayBatchPayServiceTest {
         service = new AlipayBatchPayService(alipayClientFactory, batchAuthorizeMapper, batchOrderMapper, batchDetailMapper);
         // lenient: 重复授权预检测试用例在到达 getClient 前即抛异常,该 stub 不会被使用
         lenient().when(alipayClientFactory.getClient("E100", "BATCH_PAY")).thenReturn(alipayClient);
+        // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在无 Spring 上下文的单测中工作
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), BatchOrderEntity.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), BatchDetailEntity.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), BatchAuthorizeEntity.class);
     }
 
     @Test
@@ -407,4 +425,332 @@ class AlipayBatchPayServiceTest {
         assertEquals(400, ex.getCode());
         assertTrue(ex.getMessage().contains("制单授权"), "应提示先完成制单授权: " + ex.getMessage());
     }
+
+    // ==================== C2: 租户隔离(renderPay/batchQuery/batchClose 缺 enterpriseId 条件) ====================
+
+    @Test
+    void renderPay_otherEnterpriseBatch_notFound() {
+        // 他人批次: selectOne 带 enterpriseId 条件查不到 → 404
+        when(batchOrderMapper.selectOne(any())).thenReturn(null);
+
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.renderPay("E999", "B1"));
+
+        assertEquals(404, ex.getCode());
+        @SuppressWarnings({ "unchecked", "rawtypes" })
+        ArgumentCaptor<LambdaQueryWrapper<BatchOrderEntity>> cap = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(batchOrderMapper).selectOne(cap.capture());
+        assertTrue(cap.getValue().getSqlSegment().contains("enterprise_id"),
+                "renderPay 查询必须带 enterpriseId 条件: " + cap.getValue().getSqlSegment());
+    }
+
+    @Test
+    void batchQuery_otherEnterpriseBatch_notFound() {
+        when(batchOrderMapper.selectOne(any())).thenReturn(null);
+
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.batchQuery("E999", "B1"));
+
+        assertEquals(404, ex.getCode());
+        @SuppressWarnings({ "unchecked", "rawtypes" })
+        ArgumentCaptor<LambdaQueryWrapper<BatchOrderEntity>> cap = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(batchOrderMapper).selectOne(cap.capture());
+        assertTrue(cap.getValue().getSqlSegment().contains("enterprise_id"),
+                "batchQuery 查询必须带 enterpriseId 条件: " + cap.getValue().getSqlSegment());
+    }
+
+    @Test
+    void batchClose_otherEnterpriseBatch_notFound() throws AlipayApiException {
+        when(batchOrderMapper.selectOne(any())).thenReturn(null);
+
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.batchClose("E999", "B1"));
+
+        assertEquals(404, ex.getCode());
+        verify(alipayClient, never()).certificateExecute(any());
+        @SuppressWarnings({ "unchecked", "rawtypes" })
+        ArgumentCaptor<LambdaQueryWrapper<BatchOrderEntity>> cap = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(batchOrderMapper).selectOne(cap.capture());
+        assertTrue(cap.getValue().getSqlSegment().contains("enterprise_id"),
+                "batchClose 查询必须带 enterpriseId 条件: " + cap.getValue().getSqlSegment());
+    }
+
+    // ==================== I1: 定时同步兜底(getPendingBatches) ====================
+
+    @Test
+    void getPendingBatches_returnsOnlyNonTerminal() {
+        BatchOrderEntity b1 = new BatchOrderEntity();
+        b1.setId(1L);
+        b1.setOutBatchNo("B1");
+        b1.setStatus("INIT");
+        b1.setBatchTransId("BT1");
+        when(batchOrderMapper.selectList(any())).thenReturn(List.of(b1));
+
+        List<BatchOrderEntity> result = service.getPendingBatches();
+
+        assertEquals(1, result.size());
+        assertEquals("B1", result.get(0).getOutBatchNo());
+        @SuppressWarnings({ "unchecked", "rawtypes" })
+        ArgumentCaptor<LambdaQueryWrapper<BatchOrderEntity>> cap = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(batchOrderMapper).selectList(cap.capture());
+        String sql = cap.getValue().getSqlSegment();
+        assertTrue(sql.contains("NOT IN"), "应排除终态: " + sql);
+        assertTrue(sql.contains("batch_trans_id"), "应限定 batch_trans_id 非空: " + sql);
+    }
+
+    // ==================== I2: batchQuery 明细回写(batchId 条件 + 批量查询) ====================
+
+    @Test
+    void batchQuery_crossBatchSameOutBizNo_updatesOnlyOwnBatch() throws AlipayApiException {
+        // 两个批次含相同 out_biz_no(out_biz_no 仅批内唯一)→ 不得抛 TooManyResultsException,且只回写本批次明细
+        BatchOrderEntity order = new BatchOrderEntity();
+        order.setId(1L);
+        order.setEnterpriseId("E100");
+        order.setOutBatchNo("B1");
+        order.setBatchTransId("BT1");
+        order.setStatus("INIT");
+        when(batchOrderMapper.selectOne(any())).thenReturn(order);
+
+        AccDetailModel m = new AccDetailModel();
+        m.setOutBizNo("D1");
+        m.setStatus("SUCCESS");
+        m.setErrorCode("ACCOUNT_BALANCE_NOT_ENOUGH");
+        m.setErrorMsg("余额不足");
+        AlipayFundBatchDetailQueryResponse resp = new AlipayFundBatchDetailQueryResponse();
+        resp.setBatchStatus("SUCCESS");
+        resp.setAccDetailList(List.of(m));
+        when(alipayClient.certificateExecute(any(AlipayFundBatchDetailQueryRequest.class))).thenReturn(resp);
+
+        // 本批次明细(mapper 按 batchId + out_biz_no 过滤后返回一条;另一批次同号明细不在结果中)
+        BatchDetailEntity own = new BatchDetailEntity();
+        own.setId(10L);
+        own.setBatchId(1L);
+        own.setOutBizNo("D1");
+        own.setStatus("INIT");
+        when(batchDetailMapper.selectList(any())).thenReturn(List.of(own));
+
+        Map<String, Object> result = service.batchQuery("E100", "B1");
+
+        assertEquals("SUCCESS", result.get("status"));
+        assertEquals("SUCCESS", own.getStatus());
+        assertEquals("余额不足", own.getErrorMsg());
+        verify(batchDetailMapper).updateById(own);
+        // N+1 修复: 批量 selectList 而非循环 selectOne
+        verify(batchDetailMapper, never()).selectOne(any());
+        // 查询条件必须带 batchId(跨批次同号明细不串批)
+        @SuppressWarnings({ "unchecked", "rawtypes" })
+        ArgumentCaptor<LambdaQueryWrapper<BatchDetailEntity>> cap = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(batchDetailMapper).selectList(cap.capture());
+        assertTrue(cap.getValue().getSqlSegment().contains("batch_id"),
+                "明细回写查询必须带 batchId: " + cap.getValue().getSqlSegment());
+    }
+
+    // ==================== I3: 场景报备必填校验 ====================
+
+    @Test
+    void batchCreateDTO_transferSceneNameBlank_failsValidation() {
+        Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
+        BatchCreateDTO dto = new BatchCreateDTO();
+        dto.setOrderTitle("t");
+        dto.setPayerUid("2088PAYER");
+        dto.setTransferSceneName("");
+        dto.setTransferSceneReportInfos(List.of(Map.of("info_type", "佣金报酬说明", "info_content", "8月报酬")));
+        dto.setDetails(List.of(detail("D1", "10")));
+
+        Set<ConstraintViolation<BatchCreateDTO>> violations = validator.validate(dto);
+
+        assertTrue(violations.stream().anyMatch(v -> "transferSceneName".equals(v.getPropertyPath().toString())),
+                "transferSceneName 为空应触发 @NotBlank: " + violations);
+    }
+
+    @Test
+    void batchCreateDTO_transferSceneReportInfosEmpty_failsValidation() {
+        Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
+        BatchCreateDTO dto = new BatchCreateDTO();
+        dto.setOrderTitle("t");
+        dto.setPayerUid("2088PAYER");
+        dto.setTransferSceneName("佣金报酬");
+        dto.setTransferSceneReportInfos(List.of());
+        dto.setDetails(List.of(detail("D1", "10")));
+
+        Set<ConstraintViolation<BatchCreateDTO>> violations = validator.validate(dto);
+
+        assertTrue(violations.stream().anyMatch(v -> "transferSceneReportInfos".equals(v.getPropertyPath().toString())),
+                "场景报备为空应触发 @NotEmpty: " + violations);
+    }
+
+    private static BatchCreateDTO.BatchDetailDTO detail(String outBizNo, String amount) {
+        BatchCreateDTO.BatchDetailDTO d = new BatchCreateDTO.BatchDetailDTO();
+        d.setOutBizNo(outBizNo);
+        d.setAmount(new BigDecimal(amount));
+        d.setPayeeIdentity("a@b.com");
+        d.setPayeeIdentityType("ALIPAY_LOGON_ID");
+        d.setPayeeName("张三");
+        return d;
+    }
+
+    // ==================== I4: 10 位纯日期时间筛选 ====================
+
+    @Test
+    void batchList_pureDateRange_parsesToDayBoundaries() {
+        when(batchOrderMapper.selectPage(any(), any())).thenReturn(new Page<>());
+
+        PageResult<BatchOrderEntity> result = service.batchList("E100", null, "2026-08-01", "2026-08-02", 1, 20);
+
+        assertNotNull(result);
+        verify(batchOrderMapper).selectPage(any(), any());
+    }
+
+    @Test
+    void batchExport_pureDateRange_parses() {
+        when(batchOrderMapper.selectList(any())).thenReturn(List.of());
+
+        byte[] bytes = service.batchExport("E100", null, "2026-08-01", "2026-08-02");
+
+        assertNotNull(bytes);
+        assertTrue(bytes.length > 0);
+    }
+
+    // ==================== I5: pay_url 长 HTML body(>1024) ====================
+
+    @Test
+    void renderPay_longHtmlBody_persistsWithoutError() throws AlipayApiException {
+        BatchOrderEntity order = new BatchOrderEntity();
+        order.setEnterpriseId("E100");
+        order.setOutBatchNo("B1");
+        order.setBatchTransId("BT1");
+        order.setStatus("INIT");
+        when(batchOrderMapper.selectOne(any())).thenReturn(order);
+
+        // 真实 pageExecute 返回自动提交 HTML 表单(1.5-4KB),远超原 varchar(1024)
+        String html = "<html><body><form action=\"https://render.alipay.com/paypage\">" + "x".repeat(2000) + "</form></body></html>";
+        AlipayFundTransRenderPayResponse resp = new AlipayFundTransRenderPayResponse();
+        resp.setBody(html);
+        when(alipayClient.pageExecute(any(AlipayFundTransRenderPayRequest.class))).thenReturn(resp);
+
+        Map<String, String> result = service.renderPay("E100", "B1");
+
+        assertEquals(html, result.get("pay_url"));
+        verify(batchOrderMapper).updateById(order);
+    }
+
+    // ==================== M1: 授权并发 DB 兜底 ====================
+
+    @Test
+    void authorizeApply_insertDuplicateKey_throwsFriendlyMessage() throws AlipayApiException {
+        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
+        resp.setAuthorizeLink("https://ur.alipay.com/abc");
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
+        // 并发双击: 预检通过后 insert 命中 uk_batch_authorize_active 唯一索引
+        doThrow(new DuplicateKeyException("duplicate key")).when(batchAuthorizeMapper).insert(any());
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.authorizeApply("E100", "2088123412341234"));
+
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMessage().contains("请勿重复操作"), ex.getMessage());
+    }
+
+    // ==================== M2: AUTHING 过期重申请 ====================
+
+    @Test
+    void authorizeApply_authingNotExpired_throws() {
+        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
+        existing.setEnterpriseId("E100");
+        existing.setParticipantId("2088123412341234");
+        existing.setStatus("AUTHING");
+        existing.setAuthorizeExpireTime(OffsetDateTime.now().plusHours(1));
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.authorizeApply("E100", "2088123412341234"));
+
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMessage().contains("未完成"), ex.getMessage());
+        verify(batchAuthorizeMapper, never()).updateById(any());
+        verify(batchAuthorizeMapper, never()).insert(any());
+    }
+
+    @Test
+    void authorizeApply_authingFreshCreatedTime_throws() {
+        // authorize_expire_time 为空时按 created_time + 24h 判定: 刚创建的 AUTHING 未过期 → 拒绝
+        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
+        existing.setEnterpriseId("E100");
+        existing.setParticipantId("2088123412341234");
+        existing.setStatus("AUTHING");
+        existing.setCreatedTime(OffsetDateTime.now());
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
+
+        assertThrows(BusinessException.class, () -> service.authorizeApply("E100", "2088123412341234"));
+    }
+
+    @Test
+    void authorizeApply_authingExpired_rebindWithNewOutBizNo() throws AlipayApiException {
+        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
+        existing.setId(1L);
+        existing.setEnterpriseId("E100");
+        existing.setParticipantId("2088123412341234");
+        existing.setOutBizNo("OLD1");
+        existing.setStatus("AUTHING");
+        existing.setAuthorizeExpireTime(OffsetDateTime.now().minusHours(1));
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
+
+        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
+        resp.setAuthorizeLink("https://ur.alipay.com/abc");
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
+
+        Map<String, String> result = service.authorizeApply("E100", "2088123412341234");
+
+        assertEquals("AUTHING", result.get("status"));
+        // 旧记录作废置 UNBIND(与 uk_batch_authorize_active 的 WHERE status <> 'UNBIND' 协同)
+        verify(batchAuthorizeMapper).updateById(argThat(e -> "UNBIND".equals(e.getStatus())));
+        // 新记录插入 + 新 out_biz_no
+        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
+        verify(batchAuthorizeMapper).insert(ent.capture());
+        assertEquals("AUTHING", ent.getValue().getStatus());
+        assertFalse(ent.getValue().getOutBizNo().equals("OLD1"), "应换新 out_biz_no 重新申请");
+        assertEquals("E100", ent.getValue().getEnterpriseId());
+        // 支付宝侧使用新 out_biz_no
+        ArgumentCaptor<AlipayFundAuthorizeUniApplyRequest> cap = ArgumentCaptor.forClass(AlipayFundAuthorizeUniApplyRequest.class);
+        verify(alipayClient).certificateExecute(cap.capture());
+        AlipayFundAuthorizeUniApplyModel m = (AlipayFundAuthorizeUniApplyModel) cap.getValue().getBizModel();
+        assertEquals(ent.getValue().getOutBizNo(), m.getOutBizNo());
+    }
+
+    @Test
+    void authorizeApply_authingExpiredByCreatedTimeFallback_allowsReapply() throws AlipayApiException {
+        // expire 为空 + created_time 超过 24h → 视为过期,允许重新申请
+        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
+        existing.setId(1L);
+        existing.setEnterpriseId("E100");
+        existing.setParticipantId("2088123412341234");
+        existing.setOutBizNo("OLD1");
+        existing.setStatus("AUTHING");
+        existing.setCreatedTime(OffsetDateTime.now().minusDays(2));
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
+
+        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
+        resp.setAuthorizeLink("https://ur.alipay.com/abc");
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
+
+        Map<String, String> result = service.authorizeApply("E100", "2088123412341234");
+
+        assertNotNull(result.get("authorize_link"));
+        verify(batchAuthorizeMapper).updateById(argThat(e -> "UNBIND".equals(e.getStatus())));
+        verify(batchAuthorizeMapper).insert(any());
+    }
+
+    // ==================== M3: batchClose INIT 守卫 ====================
+
+    @Test
+    void batchClose_nonInitStatus_throwsBusinessException() throws AlipayApiException {
+        BatchOrderEntity order = new BatchOrderEntity();
+        order.setEnterpriseId("E100");
+        order.setOutBatchNo("B1");
+        order.setStatus("SUCCESS");
+        when(batchOrderMapper.selectOne(any())).thenReturn(order);
+
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.batchClose("E100", "B1"));
+
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMessage().contains("INIT"), ex.getMessage());
+        verify(alipayClient, never()).certificateExecute(any());
+    }
 }

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

@@ -78,6 +78,43 @@ class BatchPayHandlerTest {
         verify(batchOrderMapper).updateById(order);
     }
 
+    @Test
+    void batchNotify_batchStatusKey_updatesOrderStatus() {
+        // I7: 通知字段名双键兼容 — 查询接口 SDK 字段实证为 batch_status,通知若用 batch_status 键也要能回写
+        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("batch_status", "SUCCESS");
+        handler.dispatch("alipay.fund.batch.order.changed", params, ctx());
+
+        assertEquals("SUCCESS", order.getStatus());
+        verify(batchOrderMapper).updateById(order);
+    }
+
+    @Test
+    void batchNotify_batchStatusTakesPrecedenceOverStatus() {
+        // batch_status 与 status 同时存在时以 batch_status 为准
+        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("batch_status", "SUCCESS");
+        params.put("status", "FAIL");
+        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} 为终态不可变,通知不再回写