Explorar el Código

feat: 批量付款 - 批次创建/支付页/查询/关单

alphaH hace 5 días
padre
commit
4d4ae2b81b

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

@@ -0,0 +1,73 @@
+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.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class BatchCreateDTO {
+
+    @Schema(description = "企业ID")
+    private String enterpriseId;
+
+    @Schema(description = "租户ID(内部,由 Controller 注入)")
+    private Long tenantId;
+
+    @Schema(description = "批次外部单号(幂等键,不传自动生成)")
+    private String outBatchNo;
+
+    @NotBlank(message = "批次标题不能为空")
+    @Schema(description = "批次标题(展示在付款方账单)")
+    private String orderTitle;
+
+    @NotBlank(message = "付款方支付宝UID不能为空")
+    @Schema(description = "付款方支付宝 UID")
+    private String payerUid;
+
+    @Schema(description = "制单授权协议号(payer_info.ext_info 传此值则校验指定协议;不传校验任意协议)")
+    private String agreementNo;
+
+    @Schema(description = "转账场景(26年新接入商户必传): 现金营销/企业退款/佣金报酬/二手回收/业务结算/公益补助/行政补贴和退款/保险理赔")
+    private String transferSceneName;
+
+    @Schema(description = "转账场景报备信息: [{info_type, info_content}](26年新接入商户必传)")
+    private List<Map<String, String>> transferSceneReportInfos;
+
+    @Schema(description = "超时时间(yyyy-MM-dd HH:mm),默认30天")
+    private String timeExpire;
+
+    @Schema(description = "业务备注")
+    private String remark;
+
+    @NotNull(message = "明细不能为空")
+    @Size(min = 1, max = 1000, message = "每批明细 1-1000 笔")
+    @Schema(description = "收款明细(≤1000笔,金额≥1元)")
+    private List<BatchDetailDTO> details;
+
+    @Data
+    public static class BatchDetailDTO {
+        @NotBlank(message = "明细外部单号不能为空")
+        private String outBizNo;
+
+        @NotNull(message = "明细金额不能为空")
+        private BigDecimal amount;
+
+        @Schema(description = "转账备注(展示在收款方账单)")
+        private String remark;
+
+        @NotBlank(message = "收款方账号不能为空")
+        private String payeeIdentity;
+
+        @Schema(description = "收款方类型: ALIPAY_LOGON_ID/ALIPAY_USER_ID/ALIPAY_OPEN_ID(默认 ALIPAY_LOGON_ID)")
+        private String payeeIdentityType;
+
+        @Schema(description = "收款方姓名(LOGON_ID 必填,校验姓名一致)")
+        private String payeeName;
+    }
+}

+ 352 - 2
java/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.java

@@ -2,32 +2,52 @@ package com.payment.platform.module.payment.batch.service;
 
 import com.alipay.api.AlipayApiException;
 import com.alipay.api.AlipayClient;
+import com.alipay.api.domain.AccDetailModel;
 import com.alipay.api.domain.AlipayFundAuthorizeUniApplyModel;
 import com.alipay.api.domain.AlipayFundAuthorizeUniQueryModel;
+import com.alipay.api.domain.AlipayFundBatchCloseModel;
+import com.alipay.api.domain.AlipayFundBatchCreateModel;
+import com.alipay.api.domain.AlipayFundBatchDetailQueryModel;
+import com.alipay.api.domain.AlipayFundTransRenderPayModel;
 import com.alipay.api.domain.AuthParticipantInfo;
+import com.alipay.api.domain.Participant;
+import com.alipay.api.domain.TransOrderDetail;
+import com.alipay.api.domain.TransferSceneReportInfo;
 import com.alipay.api.request.AlipayFundAuthorizeUniApplyRequest;
 import com.alipay.api.request.AlipayFundAuthorizeUniQueryRequest;
+import com.alipay.api.request.AlipayFundBatchCloseRequest;
+import com.alipay.api.request.AlipayFundBatchCreateRequest;
+import com.alipay.api.request.AlipayFundBatchDetailQueryRequest;
+import com.alipay.api.request.AlipayFundTransRenderPayRequest;
 import com.alipay.api.response.AlipayFundAuthorizeUniApplyResponse;
 import com.alipay.api.response.AlipayFundAuthorizeUniQueryResponse;
+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.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.response.PageResult;
 import com.payment.platform.common.utils.ExcelUtil;
 import com.payment.platform.common.utils.SnowflakeIdGenerator;
 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;
 import com.payment.platform.module.payment.batch.entity.BatchDetailEntity;
 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 com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import java.math.BigDecimal;
 import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -54,6 +74,8 @@ public class AlipayBatchPayService {
 
     private final AlipayClientFactory alipayClientFactory;
     private final BatchAuthorizeMapper batchAuthorizeMapper;
+    private final BatchOrderMapper batchOrderMapper;
+    private final BatchDetailMapper batchDetailMapper;
 
     /** alipay.fund.authorize.uni.apply — 生成制单授权短链接(PC 渠道) */
     @Transactional
@@ -141,4 +163,332 @@ public class AlipayBatchPayService {
             throw new BusinessException(400, "查询授权状态失败: " + e.getMessage());
         }
     }
+
+    // ==================== 批次 ====================
+
+    private static final String BATCH_PRODUCT_CODE = "BATCH_API_TO_ACC";
+    private static final String BATCH_BIZ_SCENE = "STANDARD_MESSAGE_BATCH_PAY";
+
+    /** alipay.fund.batch.create — 创建批量付款单据(幂等: out_batch_no 唯一) */
+    @Transactional
+    public Map<String, Object> batchCreate(BatchCreateDTO dto) {
+        if (dto.getOutBatchNo() == null || dto.getOutBatchNo().isBlank())
+            dto.setOutBatchNo(SnowflakeIdGenerator.nextIdStr());
+        // 幂等保护: 同单号已存在则拒绝,避免重复支付
+        if (batchOrderMapper.selectCount(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
+                        .eq(BatchOrderEntity::getOutBatchNo, dto.getOutBatchNo())) > 0)
+            throw new BusinessException(400, "批次外部单号已存在,请勿重复创建");
+
+        BigDecimal total = BigDecimal.ZERO;
+        List<TransOrderDetail> transList = new ArrayList<>();
+        for (BatchCreateDTO.BatchDetailDTO d : dto.getDetails()) {
+            if (d.getAmount().compareTo(new BigDecimal("1")) < 0)
+                throw new BusinessException(400, "明细金额最低 1 元: " + d.getOutBizNo());
+            total = total.add(d.getAmount());
+            TransOrderDetail t = new TransOrderDetail();
+            t.setOutBizNo(d.getOutBizNo());
+            t.setTransAmount(d.getAmount().toPlainString());
+            t.setRemark(d.getRemark());
+            Participant payee = new Participant();
+            String idType = d.getPayeeIdentityType() != null ? d.getPayeeIdentityType() : "ALIPAY_LOGON_ID";
+            if ("ALIPAY_LOGON_ID".equals(idType) && (d.getPayeeName() == null || d.getPayeeName().isBlank()))
+                throw new BusinessException(400, "收款方为账号时姓名必填: " + d.getOutBizNo());
+            payee.setIdentity(d.getPayeeIdentity());
+            payee.setIdentityType(idType);
+            payee.setName(d.getPayeeName());
+            t.setPayeeInfo(payee);
+            transList.add(t);
+        }
+
+        try {
+            AlipayFundBatchCreateModel model = new AlipayFundBatchCreateModel();
+            model.setOutBatchNo(dto.getOutBatchNo());
+            model.setTotalTransAmount(total.toPlainString());
+            model.setTotalCount(String.valueOf(dto.getDetails().size()));
+            model.setProductCode(BATCH_PRODUCT_CODE);
+            model.setBizScene(BATCH_BIZ_SCENE);
+            model.setOrderTitle(dto.getOrderTitle());
+            if (dto.getTimeExpire() != null) model.setTimeExpire(dto.getTimeExpire());
+            if (dto.getRemark() != null) model.setRemark(dto.getRemark());
+            // 付款方 + 制单授权协议
+            Participant payer = new Participant();
+            payer.setIdentity(dto.getPayerUid());
+            payer.setIdentityType("ALIPAY_USER_ID");
+            if (dto.getAgreementNo() != null && !dto.getAgreementNo().isBlank()) {
+                Map<String, String> ext = new LinkedHashMap<>();
+                ext.put("agreement_no", dto.getAgreementNo());
+                try { payer.setExtInfo(oMapper.writeValueAsString(ext)); }
+                catch (Exception e) { throw new BusinessException(400, "序列化 ext_info 失败"); }
+            }
+            model.setPayerInfo(payer);
+            model.setTransOrderList(transList);
+            // 转账场景报备(26年新接入必传)
+            model.setTransferSceneName(dto.getTransferSceneName());
+            if (dto.getTransferSceneReportInfos() != null && !dto.getTransferSceneReportInfos().isEmpty()) {
+                List<TransferSceneReportInfo> infos = dto.getTransferSceneReportInfos().stream()
+                        .map(m -> { TransferSceneReportInfo i = new TransferSceneReportInfo();
+                                    i.setInfoType(m.get("info_type")); i.setInfoContent(m.get("info_content")); return i; })
+                        .collect(java.util.stream.Collectors.toList());
+                model.setTransferSceneReportInfos(infos);
+            }
+
+            AlipayFundBatchCreateRequest request = new AlipayFundBatchCreateRequest();
+            request.setBizModel(model);
+            AlipayFundBatchCreateResponse response =
+                    alipayClientFactory.getClient(dto.getEnterpriseId(), BIZ_TYPE).certificateExecute(request);
+            if (!response.isSuccess()) {
+                // 幂等兜底: UNIQUE_VIOLATION 说明支付宝侧已受理同单号批次,查库返回已受理信息而非报错
+                if ("UNIQUE_VIOLATION".equals(response.getSubCode())) {
+                    BatchOrderEntity existing = batchOrderMapper.selectOne(
+                            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());
+                }
+                throw new BusinessException(400, "创建批次失败: " + response.getMsg() + " (" + response.getSubCode() + ")");
+            }
+
+            BatchOrderEntity order = new BatchOrderEntity();
+            order.setEnterpriseId(dto.getEnterpriseId());
+            order.setTenantId(dto.getTenantId());
+            order.setOutBatchNo(dto.getOutBatchNo());
+            order.setBatchTransId(response.getBatchTransId());
+            order.setTotalAmount(total);
+            order.setTotalCount(dto.getDetails().size());
+            order.setOrderTitle(dto.getOrderTitle());
+            order.setStatus("INIT");
+            order.setPayerUid(dto.getPayerUid());
+            order.setAgreementNo(dto.getAgreementNo());
+            order.setTransferSceneName(dto.getTransferSceneName());
+            if (dto.getTransferSceneReportInfos() != null)
+                try { order.setTransferSceneReportInfos(oMapper.writeValueAsString(dto.getTransferSceneReportInfos())); }
+                catch (Exception ignored) { }
+            order.setRemark(dto.getRemark());
+            batchOrderMapper.insert(order);
+
+            for (BatchCreateDTO.BatchDetailDTO d : dto.getDetails()) {
+                BatchDetailEntity de = new BatchDetailEntity();
+                de.setEnterpriseId(dto.getEnterpriseId());
+                de.setTenantId(dto.getTenantId());
+                de.setBatchId(order.getId());
+                de.setOutBizNo(d.getOutBizNo());
+                de.setAmount(d.getAmount());
+                de.setRemark(d.getRemark());
+                de.setPayeeIdentity(d.getPayeeIdentity());
+                de.setPayeeIdentityType(d.getPayeeIdentityType() != null ? d.getPayeeIdentityType() : "ALIPAY_LOGON_ID");
+                de.setPayeeName(d.getPayeeName());
+                de.setStatus("INIT");
+                batchDetailMapper.insert(de);
+            }
+            return Map.of("out_batch_no", dto.getOutBatchNo(),
+                    "batch_trans_id", response.getBatchTransId() != null ? response.getBatchTransId() : "",
+                    "status", "INIT");
+        } catch (AlipayApiException e) {
+            throw new BusinessException(400, "创建批次失败: " + e.getMessage());
+        }
+    }
+
+    /** alipay.fund.trans.render.pay — 生成 PC 支付页链接 */
+    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));
+        if (order == null) throw new BusinessException(404, "批次不存在");
+        if (!"INIT".equals(order.getStatus()))
+            throw new BusinessException(400, "仅受理中的批次可支付,当前状态: " + order.getStatus());
+        try {
+            AlipayFundTransRenderPayModel model = new AlipayFundTransRenderPayModel();
+            model.setProductCode(BATCH_PRODUCT_CODE);
+            model.setBizScene(BATCH_BIZ_SCENE);
+            model.setOrderId(order.getBatchTransId());
+            AlipayFundTransRenderPayRequest request = new AlipayFundTransRenderPayRequest();
+            request.setBizModel(model);
+            AlipayFundTransRenderPayResponse response =
+                    alipayClientFactory.getClient(enterpriseId, BIZ_TYPE).pageExecute(request);
+            if (response == null || response.getBody() == null)
+                throw new BusinessException(400, "生成支付页面失败: 无响应");
+            order.setPayUrl(response.getBody());
+            batchOrderMapper.updateById(order);
+            return Map.of("pay_url", response.getBody());
+        } catch (AlipayApiException e) {
+            throw new BusinessException(400, "生成支付页面失败: " + e.getMessage());
+        }
+    }
+
+    /** alipay.fund.batch.detail.query — 查询批次+明细状态并回写 DB */
+    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));
+        if (order == null) throw new BusinessException(404, "批次不存在");
+        try {
+            AlipayFundBatchDetailQueryModel model = new AlipayFundBatchDetailQueryModel();
+            model.setOutBatchNo(outBatchNo);
+            model.setProductCode(BATCH_PRODUCT_CODE);
+            model.setBizScene(BATCH_BIZ_SCENE);
+            AlipayFundBatchDetailQueryRequest request = new AlipayFundBatchDetailQueryRequest();
+            request.setBizModel(model);
+            AlipayFundBatchDetailQueryResponse response =
+                    alipayClientFactory.getClient(enterpriseId, BIZ_TYPE).certificateExecute(request);
+            if (!response.isSuccess())
+                throw new BusinessException(400, "查询批次失败: " + response.getMsg());
+            // 批次状态回写(SDK 字段 batch_status,非 status — 已 javap 实证)
+            if (response.getBatchStatus() != null && !response.getBatchStatus().equals(order.getStatus())) {
+                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(
+                            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);
+                }
+            }
+            return Map.of("out_batch_no", outBatchNo,
+                    "status", response.getBatchStatus() != null ? response.getBatchStatus() : order.getStatus());
+        } catch (AlipayApiException e) {
+            throw new BusinessException(400, "查询批次失败: " + e.getMessage());
+        }
+    }
+
+    /** alipay.fund.batch.close — 主动关闭未支付批次 */
+    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));
+        if (order == null) throw new BusinessException(404, "批次不存在");
+        try {
+            AlipayFundBatchCloseModel model = new AlipayFundBatchCloseModel();
+            model.setBatchTransId(order.getBatchTransId());
+            model.setProductCode(BATCH_PRODUCT_CODE);
+            model.setBizScene(BATCH_BIZ_SCENE);
+            AlipayFundBatchCloseRequest request = new AlipayFundBatchCloseRequest();
+            request.setBizModel(model);
+            AlipayFundBatchCloseResponse response =
+                    alipayClientFactory.getClient(enterpriseId, BIZ_TYPE).certificateExecute(request);
+            if (!response.isSuccess())
+                throw new BusinessException(400, "关闭批次失败: " + response.getMsg());
+            order.setStatus("DISUSE");
+            batchOrderMapper.updateById(order);
+            return Map.of("status", "DISUSE");
+        } catch (AlipayApiException e) {
+            throw new BusinessException(400, "关闭批次失败: " + e.getMessage());
+        }
+    }
+
+    // ==================== 列表 / 详情 / 导出 ====================
+
+    /** 授权列表(分页) */
+    public PageResult<BatchAuthorizeEntity> authorizeList(String enterpriseId, int pageNo, int pageSize) {
+        var w = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
+                .eq(enterpriseId != null && !enterpriseId.isBlank(), BatchAuthorizeEntity::getEnterpriseId, enterpriseId)
+                .orderByDesc(BatchAuthorizeEntity::getId);
+        var r = batchAuthorizeMapper.selectPage(new Page<>(pageNo, pageSize), w);
+        return PageResult.of(pageNo, pageSize, r.getTotal(), r.getRecords());
+    }
+
+    /** 批次列表(分页,状态/时间筛选)— 时间解析照抄 AccountService.parseDateTime 模式 */
+    public PageResult<BatchOrderEntity> batchList(String enterpriseId, String status,
+            String startTime, String endTime, int pageNo, int pageSize) {
+        var w = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
+                .eq(enterpriseId != null && !enterpriseId.isBlank(), BatchOrderEntity::getEnterpriseId, enterpriseId)
+                .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"));
+        }
+        if (endTime != null && !endTime.isBlank()) {
+            w.le(BatchOrderEntity::getCreatedTime,
+                    OffsetDateTime.parse(endTime.replace(' ', 'T') + ":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) {
+        BatchOrderEntity order = batchOrderMapper.selectOne(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
+                        .eq(BatchOrderEntity::getOutBatchNo, outBatchNo));
+        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>()
+                        .eq(BatchDetailEntity::getBatchId, order.getId())
+                        .orderByAsc(BatchDetailEntity::getId));
+        return Map.of("order", order,
+                "details", PageResult.of(pageNo, pageSize, detailPage.getTotal(), detailPage.getRecords()));
+    }
+
+    /** 批次导出 — 照抄 AccountService.transferExport 的组装写法(ExcelUtil.exportToExcel(listData, mappingDict)) */
+    public byte[] batchExport(String enterpriseId, String status, String startTime, String endTime) {
+        try {
+            var w = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
+                    .eq(enterpriseId != null && !enterpriseId.isBlank(), BatchOrderEntity::getEnterpriseId, enterpriseId)
+                    .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"));
+            }
+            if (endTime != null && !endTime.isBlank()) {
+                w.le(BatchOrderEntity::getCreatedTime,
+                        OffsetDateTime.parse(endTime.replace(' ', 'T') + ":59+08:00"));
+            }
+            List<BatchOrderEntity> records = batchOrderMapper.selectList(w);
+            // 列: 序号/批次号/支付宝批次号/标题/金额(元)/笔数/状态/创建时间/错误信息
+            // 状态映射: INIT=受理中, SUCCESS=成功, DISUSE=已关闭, FAIL=失败
+            Map<String, String> statusMap = Map.of(
+                    "INIT", "受理中",
+                    "SUCCESS", "成功",
+                    "DISUSE", "已关闭",
+                    "FAIL", "失败");
+
+            List<Map<String, Object>> listData = new ArrayList<>();
+            DateTimeFormatter timeFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+            int seq = 1;
+            for (BatchOrderEntity r : records) {
+                Map<String, Object> row = new LinkedHashMap<>();
+                row.put("序号", seq++);
+                row.put("批次号", r.getOutBatchNo() != null ? r.getOutBatchNo() : "");
+                row.put("支付宝批次号", r.getBatchTransId() != null ? r.getBatchTransId() : "");
+                row.put("标题", r.getOrderTitle() != null ? r.getOrderTitle() : "");
+                row.put("金额(元)", r.getTotalAmount() != null ? r.getTotalAmount().toPlainString() : "0");
+                row.put("笔数", r.getTotalCount() != null ? r.getTotalCount() : 0);
+                row.put("状态", statusMap.getOrDefault(r.getStatus(), r.getStatus() != null ? r.getStatus() : ""));
+                row.put("创建时间", r.getCreatedTime() != null
+                        ? r.getCreatedTime().atZoneSameInstant(ZoneId.of("Asia/Shanghai")).format(timeFmt) : "");
+                row.put("错误信息", r.getErrorMsg() != null ? r.getErrorMsg() : "");
+                listData.add(row);
+            }
+
+            Map<String, String> mappingDict = new LinkedHashMap<>();
+            mappingDict.put("序号", "序号");
+            mappingDict.put("批次号", "批次号");
+            mappingDict.put("支付宝批次号", "支付宝批次号");
+            mappingDict.put("标题", "标题");
+            mappingDict.put("金额(元)", "金额(元)");
+            mappingDict.put("笔数", "笔数");
+            mappingDict.put("状态", "状态");
+            mappingDict.put("创建时间", "创建时间");
+            mappingDict.put("错误信息", "错误信息");
+
+            log.info("导出批次报表: {} 条", listData.size());
+            return ExcelUtil.exportToExcel(listData, mappingDict);
+        } catch (Exception e) {
+            log.error("导出批次报表失败", e);
+            throw new RuntimeException("导出批次报表失败: " + e.getMessage());
+        }
+    }
 }

+ 143 - 1
java/src/test/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayServiceTest.java

@@ -4,14 +4,28 @@ import com.alipay.api.AlipayClient;
 import com.alipay.api.AlipayApiException;
 import com.alipay.api.domain.AlipayFundAuthorizeUniApplyModel;
 import com.alipay.api.domain.AlipayFundAuthorizeUniQueryModel;
+import com.alipay.api.domain.AlipayFundBatchCreateModel;
 import com.alipay.api.request.AlipayFundAuthorizeUniApplyRequest;
 import com.alipay.api.request.AlipayFundAuthorizeUniQueryRequest;
+import com.alipay.api.request.AlipayFundBatchCloseRequest;
+import com.alipay.api.request.AlipayFundBatchCreateRequest;
+import com.alipay.api.request.AlipayFundBatchDetailQueryRequest;
+import com.alipay.api.request.AlipayFundTransRenderPayRequest;
 import com.alipay.api.response.AlipayFundAuthorizeUniApplyResponse;
 import com.alipay.api.response.AlipayFundAuthorizeUniQueryResponse;
+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.payment.platform.common.exception.BusinessException;
 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;
+import com.payment.platform.module.payment.batch.entity.BatchDetailEntity;
+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 org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -19,6 +33,8 @@ import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
+import java.math.BigDecimal;
+import java.util.List;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.*;
@@ -31,11 +47,13 @@ class AlipayBatchPayServiceTest {
     @Mock private AlipayClientFactory alipayClientFactory;
     @Mock private AlipayClient alipayClient;
     @Mock private BatchAuthorizeMapper batchAuthorizeMapper;
+    @Mock private BatchOrderMapper batchOrderMapper;
+    @Mock private BatchDetailMapper batchDetailMapper;
     private AlipayBatchPayService service;
 
     @BeforeEach
     void setUp() {
-        service = new AlipayBatchPayService(alipayClientFactory, batchAuthorizeMapper);
+        service = new AlipayBatchPayService(alipayClientFactory, batchAuthorizeMapper, batchOrderMapper, batchDetailMapper);
         // lenient: 重复授权预检测试用例在到达 getClient 前即抛异常,该 stub 不会被使用
         lenient().when(alipayClientFactory.getClient("E100", "BATCH_PAY")).thenReturn(alipayClient);
     }
@@ -128,4 +146,128 @@ class AlipayBatchPayServiceTest {
         verify(batchAuthorizeMapper, never()).selectOne(any());
         verify(batchAuthorizeMapper, never()).updateById(any());
     }
+
+    @Test
+    void batchCreate_persistsOrderAndDetails() throws AlipayApiException {
+        AlipayFundBatchCreateResponse resp = new AlipayFundBatchCreateResponse();
+        resp.setOutBatchNo("B1");
+        resp.setBatchTransId("BT1");
+        resp.setStatus("INIT");
+        when(alipayClient.certificateExecute(any(AlipayFundBatchCreateRequest.class))).thenReturn(resp);
+
+        BatchCreateDTO dto = new BatchCreateDTO();
+        dto.setEnterpriseId("E100");
+        dto.setOutBatchNo("B1");
+        dto.setOrderTitle("202608报销");
+        dto.setPayerUid("2088PAYER");
+        dto.setAgreementNo("AGMT001");
+        dto.setTransferSceneName("佣金报酬");
+        dto.setTransferSceneReportInfos(List.of(Map.of("info_type", "佣金报酬说明", "info_content", "8月家政服务报酬")));
+        BatchCreateDTO.BatchDetailDTO detail = new BatchCreateDTO.BatchDetailDTO();
+        detail.setOutBizNo("D1");
+        detail.setAmount(new BigDecimal("20.11"));
+        detail.setPayeeIdentity("test@taobao.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"));
+        ArgumentCaptor<AlipayFundBatchCreateRequest> cap = ArgumentCaptor.forClass(AlipayFundBatchCreateRequest.class);
+        verify(alipayClient).certificateExecute(cap.capture());
+        AlipayFundBatchCreateModel m = (AlipayFundBatchCreateModel) cap.getValue().getBizModel();
+        assertEquals("BATCH_API_TO_ACC", m.getProductCode());
+        assertEquals("STANDARD_MESSAGE_BATCH_PAY", m.getBizScene());
+        assertEquals("佣金报酬", m.getTransferSceneName());
+        assertEquals("20.11", m.getTransOrderList().get(0).getTransAmount());
+        assertEquals("{\"agreement_no\":\"AGMT001\"}", m.getPayerInfo().getExtInfo());
+
+        verify(batchOrderMapper).insert(any(BatchOrderEntity.class));
+        verify(batchDetailMapper).insert(any(BatchDetailEntity.class));
+    }
+
+    @Test
+    void batchCreate_detailAmountBelow1_throws() {
+        BatchCreateDTO dto = new BatchCreateDTO();
+        dto.setOrderTitle("t");
+        dto.setPayerUid("2088PAYER");
+        BatchCreateDTO.BatchDetailDTO detail = new BatchCreateDTO.BatchDetailDTO();
+        detail.setOutBizNo("D1");
+        detail.setAmount(new BigDecimal("0.5"));
+        detail.setPayeeIdentity("a@b.com");
+        dto.setDetails(List.of(detail));
+        assertThrows(BusinessException.class, () -> service.batchCreate(dto));
+    }
+
+    @Test
+    void batchCreate_duplicateOutBatchNo_throwsBusinessException() throws AlipayApiException {
+        when(batchOrderMapper.selectCount(any()))
+                .thenReturn(1L);
+        BatchCreateDTO dto = new BatchCreateDTO();
+        dto.setOrderTitle("t");
+        dto.setPayerUid("2088PAYER");
+        dto.setOutBatchNo("B1");
+        BatchCreateDTO.BatchDetailDTO detail = new BatchCreateDTO.BatchDetailDTO();
+        detail.setOutBizNo("D1");
+        detail.setAmount(new BigDecimal("10"));
+        detail.setPayeeIdentity("a@b.com");
+        dto.setDetails(List.of(detail));
+        assertThrows(BusinessException.class, () -> service.batchCreate(dto));
+    }
+
+    @Test
+    void renderPay_returnsPayUrl() throws AlipayApiException {
+        BatchOrderEntity order = new BatchOrderEntity();
+        order.setEnterpriseId("E100");
+        order.setOutBatchNo("B1");
+        order.setBatchTransId("BT1");
+        order.setStatus("INIT");
+        when(batchOrderMapper.selectOne(any())).thenReturn(order);
+        AlipayFundTransRenderPayResponse resp = new AlipayFundTransRenderPayResponse();
+        resp.setOrderId("BT1");
+        resp.setBody("https://render.alipay.com/paypage?orderId=BT1");
+        when(alipayClient.pageExecute(any(AlipayFundTransRenderPayRequest.class))).thenReturn(resp);
+
+        Map<String, String> result = service.renderPay("E100", "B1");
+
+        assertNotNull(result.get("pay_url"));
+        verify(batchOrderMapper).updateById(order);
+    }
+
+    @Test
+    void batchQuery_syncsBatchStatus() throws AlipayApiException {
+        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);
+
+        AlipayFundBatchDetailQueryResponse resp = new AlipayFundBatchDetailQueryResponse();
+        resp.setBatchStatus("SUCCESS");
+        when(alipayClient.certificateExecute(any(AlipayFundBatchDetailQueryRequest.class))).thenReturn(resp);
+
+        Map<String, Object> result = service.batchQuery("E100", "B1");
+
+        assertEquals("SUCCESS", result.get("status"));
+        assertEquals("SUCCESS", order.getStatus());
+        verify(batchOrderMapper).updateById(order);
+    }
+
+    @Test
+    void batchClose_callsCloseApi() throws AlipayApiException {
+        BatchOrderEntity order = new BatchOrderEntity();
+        order.setEnterpriseId("E100");
+        order.setOutBatchNo("B1");
+        order.setBatchTransId("BT1");
+        order.setStatus("INIT");
+        when(batchOrderMapper.selectOne(any())).thenReturn(order);
+        when(alipayClient.certificateExecute(any(AlipayFundBatchCloseRequest.class)))
+                .thenReturn(new AlipayFundBatchCloseResponse());
+
+        service.batchClose("E100", "B1");
+        verify(alipayClient).certificateExecute(any(AlipayFundBatchCloseRequest.class));
+    }
 }