2026-08-25-alipay-batch-pay-implementation.md 84 KB

批量付款到户有密 接入实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 将支付宝「批量付款到户有密」产品接入平台 —— 企业财务 PC 端制单授权、批量付款(≤1000 笔)、端内输密码支付,与现有安全发记账本体系并行。

Architecture: 新增 module/payment/batch/ 包(entity/mapper/dto/service/controller),复用 AlipayClientFactory.getClient(enterpriseId, "BATCH_PAY") 证书客户端;异步通知走现有 /payment/notify 入口 + BaseNotifyHandler 策略分发,新增一个 BatchPayHandler;前端在资金账户模块新增「批量付款」页面。

Tech Stack: Java 17 / Spring Boot / MyBatis-Plus / alipay-sdk-java 4.40.865.ALL / Flyway / Vue3 + Element Plus / JUnit5 + Mockito

Spec: .claude/plan/2026-08-25-alipay-batch-pay-design.md(设计文档已提交,本计划依据它拆解)

Global Constraints

  • 实体基类:批次/明细/授权实体均继承 com.payment.platform.common.base.PaymentEnterpriseBaseEntity(含 tenant_id、enterprise_id 公共列)
  • 单号生成:com.payment.platform.common.utils.SnowflakeIdGenerator.nextIdStr()
  • 错误抛出:com.payment.platform.common.exception.BusinessException(400, msg)
  • 支付宝客户端:alipayClientFactory.getClient(enterpriseId, "BATCH_PAY");普通接口 certificateExecute,页面接口 pageExecute
  • 产品固定值:授权 product_code=TRANSFER_API_STANDARD_AUTHORIZATIONbiz_scene=STANDARD_CREATE_FUND_ORDERauthorize_link_type=SHORT_URL;批次 product_code=BATCH_API_TO_ACCbiz_scene=STANDARD_MESSAGE_BATCH_PAY
  • 批次限制:≤1000 笔、明细金额 ≥1 元、out_batch_no 批内唯一、out_biz_no 明细唯一
  • 转账场景报备(26 年新接入商户必传):transfer_scene_name + transfer_scene_report_infos[{info_type, info_content}]
  • Flyway 迁移文件:java/src/main/resources/db/migration/V1.5__*.sql(现有 V1.1~V1.4 已占用)
  • 权限注解:@PreAuthorize("@perm.hasAny('module_payment:account:...')"),复用现有权限点不新增
  • 前端 API 封装:src/api/module_payment/batch.ts;页面挂载到 src/views/module_payment/account/(先读 index.vue 确认 tab 组织方式)
  • 测试:JUnit 5(org.junit.jupiter)+ Mockito + spring-test;支付宝 SDK 类全部经 jar tf + javap 验证存在(见各任务代码)

Task 1: Flyway 迁移脚本 — 3 张表

Files:

  • Create: java/src/main/resources/db/migration/V1.5__create_pay_batch_tables.sql

Interfaces:

  • Produces: 表 pay_batch_authorize / pay_batch_order / pay_batch_detail(任务 2 的实体映射它们)

  • [ ] Step 1: 写迁移脚本

参照现有 V1.1__create_pay_f2f_trade.sql 的列类型风格(bigint id、varchar、decimal、timestamp with time zone):

-- 制单授权记录(批量付款到户有密)
CREATE TABLE pay_batch_authorize (
    id                bigint PRIMARY KEY,
    tenant_id         bigint,
    enterprise_id     varchar(64)  NOT NULL,
    out_biz_no        varchar(64)  NOT NULL,
    participant_id    varchar(64)  NOT NULL,
    participant_id_type varchar(16) NOT NULL DEFAULT 'ALIPAY_USER_ID',
    agreement_no      varchar(64),
    status            varchar(32)  NOT NULL DEFAULT 'AUTHING',
    authorize_link    varchar(512),
    authorize_expire_time timestamp,
    created_time      timestamp,
    updated_time      timestamp,
    CONSTRAINT uk_batch_authorize_out_biz_no UNIQUE (out_biz_no)
);
CREATE INDEX idx_batch_authorize_enterprise ON pay_batch_authorize (enterprise_id);

-- 批次主表
CREATE TABLE pay_batch_order (
    id                 bigint PRIMARY KEY,
    tenant_id          bigint,
    enterprise_id      varchar(64)  NOT NULL,
    out_batch_no       varchar(32)  NOT NULL,
    batch_trans_id     varchar(32),
    total_amount       decimal(16,2) NOT NULL,
    total_count        int          NOT NULL,
    order_title        varchar(64),
    status             varchar(32)  NOT NULL DEFAULT 'INIT',
    time_expire        timestamp,
    payer_uid          varchar(64),
    agreement_no       varchar(64),
    transfer_scene_name varchar(64),
    transfer_scene_report_infos jsonb,
    remark             varchar(200),
    pay_url            varchar(1024),
    error_code         varchar(64),
    error_msg          varchar(512),
    created_time       timestamp,
    updated_time       timestamp,
    CONSTRAINT uk_batch_order_out_batch_no UNIQUE (out_batch_no)
);
CREATE INDEX idx_batch_order_enterprise ON pay_batch_order (enterprise_id);

-- 批次明细表
CREATE TABLE pay_batch_detail (
    id                bigint PRIMARY KEY,
    tenant_id         bigint,
    enterprise_id     varchar(64)  NOT NULL,
    batch_id          bigint       NOT NULL,
    out_biz_no        varchar(64)  NOT NULL,
    amount            decimal(16,2) NOT NULL,
    remark            varchar(100),
    payee_identity    varchar(64)  NOT NULL,
    payee_identity_type varchar(32) NOT NULL,
    payee_name        varchar(256),
    status            varchar(32)  NOT NULL DEFAULT 'INIT',
    error_code        varchar(64),
    error_msg         varchar(512),
    created_time      timestamp,
    updated_time      timestamp,
    CONSTRAINT uk_batch_detail_batch_out_biz UNIQUE (batch_id, out_biz_no)
);
CREATE INDEX idx_batch_detail_batch ON pay_batch_detail (batch_id);
  • Step 2: 验证

Run: cd java && mvn -q flyway:migrate(或启动应用观察 Flyway 日志) Expected: 迁移成功,3 张表创建

  • [ ] Step 3: Commit

    git add java/src/main/resources/db/migration/V1.5__create_pay_batch_tables.sql
    git commit -m "feat: 批量付款到户有密 - 新增批次/明细/授权表迁移脚本"
    

Task 2: 实体 + Mapper(3 套)

Files:

  • Create: java/src/main/java/com/payment/platform/module/payment/batch/entity/BatchAuthorizeEntity.java
  • Create: java/src/main/java/com/payment/platform/module/payment/batch/entity/BatchOrderEntity.java
  • Create: java/src/main/java/com/payment/platform/module/payment/batch/entity/BatchDetailEntity.java
  • Create: java/src/main/java/com/payment/platform/module/payment/batch/mapper/BatchAuthorizeMapper.java
  • Create: java/src/main/java/com/payment/platform/module/payment/batch/mapper/BatchOrderMapper.java
  • Create: java/src/main/java/com/payment/platform/module/payment/batch/mapper/BatchDetailMapper.java

Interfaces:

  • Consumes: Task 1 的三张表
  • Produces: BatchAuthorizeEntity/MapperBatchOrderEntity/MapperBatchDetailEntity/Mapper(任务 3-6 使用)

  • [ ] Step 1: 写实体类(3 个,模式同 account/entity/TransferEntity.java

    package com.payment.platform.module.payment.batch.entity;
    
    import com.baomidou.mybatisplus.annotation.TableField;
    import com.baomidou.mybatisplus.annotation.TableName;
    import com.payment.platform.common.base.PaymentEnterpriseBaseEntity;
    import com.payment.platform.common.handler.JsonbTypeHandler;
    import lombok.Data;
    import lombok.EqualsAndHashCode;
    
    import java.math.BigDecimal;
    import java.time.OffsetDateTime;
    
    @Data
    @EqualsAndHashCode(callSuper = true)
    @TableName("pay_batch_authorize")
    public class BatchAuthorizeEntity extends PaymentEnterpriseBaseEntity {
    private String outBizNo;
    private String participantId;
    private String participantIdType;
    private String agreementNo;
    /** AUTHING / AUTHED / UNBIND */
    private String status;
    private String authorizeLink;
    private OffsetDateTime authorizeExpireTime;
    }
    
    @Data
    @EqualsAndHashCode(callSuper = true)
    @TableName("pay_batch_order")
    public class BatchOrderEntity extends PaymentEnterpriseBaseEntity {
    private String outBatchNo;
    private String batchTransId;
    private BigDecimal totalAmount;
    private Integer totalCount;
    private String orderTitle;
    /** INIT / SUCCESS / DISUSE / FAIL */
    private String status;
    private OffsetDateTime timeExpire;
    private String payerUid;
    private String agreementNo;
    private String transferSceneName;
    @TableField(typeHandler = JsonbTypeHandler.class)
    private String transferSceneReportInfos;
    private String remark;
    private String payUrl;
    private String errorCode;
    private String errorMsg;
    }
    
    @Data
    @EqualsAndHashCode(callSuper = true)
    @TableName("pay_batch_detail")
    public class BatchDetailEntity extends PaymentEnterpriseBaseEntity {
    private Long batchId;
    private String outBizNo;
    private BigDecimal amount;
    private String remark;
    private String payeeIdentity;
    private String payeeIdentityType;
    private String payeeName;
    /** INIT / SUCCESS / FAIL */
    private String status;
    private String errorCode;
    private String errorMsg;
    }
    
  • [ ] Step 2: 写 Mapper(3 个)

    package com.payment.platform.module.payment.batch.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.payment.platform.module.payment.batch.entity.BatchAuthorizeEntity;
    import org.apache.ibatis.annotations.Mapper;
    
    @Mapper
    public interface BatchAuthorizeMapper extends BaseMapper<BatchAuthorizeEntity> {
    }
    

BatchOrderMapperBatchDetailMapper 同构,泛型分别换为对应实体)

  • Step 3: 编译验证

Run: cd java && mvn -q compile Expected: BUILD SUCCESS

  • [ ] Step 4: Commit

    git add java/src/main/java/com/payment/platform/module/payment/batch/
    git commit -m "feat: 批量付款 - 批次/明细/授权实体与 Mapper"
    

Task 3: AlipayBatchPayService — 制单授权(apply/query)

Files:

  • Create: java/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.java
  • Create: java/src/test/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayServiceTest.java

Interfaces:

  • Consumes: Task 2 的 BatchAuthorizeMapperEnterpriseMapper(现有)、AlipayClientFactory(现有)、SnowflakeIdGenerator(现有)
  • Produces: authorizeApply(String enterpriseId, String participantId)Map<String,String>(authorize_link、out_biz_no、status);queryAuthorize(String enterpriseId, String outBizNo)Map<String,String>(agreement_no、status)

  • [ ] Step 1: 写失败测试

    package com.payment.platform.module.payment.batch.service;
    
    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.request.AlipayFundAuthorizeUniApplyRequest;
    import com.alipay.api.request.AlipayFundAuthorizeUniQueryRequest;
    import com.alipay.api.response.AlipayFundAuthorizeUniApplyResponse;
    import com.alipay.api.response.AlipayFundAuthorizeUniQueryResponse;
    import com.payment.platform.common.exception.BusinessException;
    import com.payment.platform.core.alipay.AlipayClientFactory;
    import com.payment.platform.module.payment.batch.entity.BatchAuthorizeEntity;
    import com.payment.platform.module.payment.batch.mapper.BatchAuthorizeMapper;
    import org.junit.jupiter.api.BeforeEach;
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.api.extension.ExtendWith;
    import org.mockito.ArgumentCaptor;
    import org.mockito.Mock;
    import org.mockito.junit.jupiter.MockitoExtension;
    
    import java.util.Map;
    
    import static org.junit.jupiter.api.Assertions.*;
    import static org.mockito.ArgumentMatchers.any;
    import static org.mockito.Mockito.*;
    
    @ExtendWith(MockitoExtension.class)
    class AlipayBatchPayServiceTest {
    
    @Mock private AlipayClientFactory alipayClientFactory;
    @Mock private AlipayClient alipayClient;
    @Mock private BatchAuthorizeMapper batchAuthorizeMapper;
    private AlipayBatchPayService service;
    
    @BeforeEach
    void setUp() {
        service = new AlipayBatchPayService(alipayClientFactory, batchAuthorizeMapper);
        when(alipayClientFactory.getClient("E100", "BATCH_PAY")).thenReturn(alipayClient);
    }
    
    @Test
    void authorizeApply_returnsShortLinkAndPersists() throws AlipayApiException {
        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
        resp.setAuthorizeLink("https://ur.alipay.com/abc");
        resp.setOutBizNo("A1");
        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
    
        Map<String, String> result = service.authorizeApply("E100", "2088123412341234");
    
        assertEquals("https://ur.alipay.com/abc", result.get("authorize_link"));
        assertEquals("AUTHING", result.get("status"));
    
        ArgumentCaptor<AlipayFundAuthorizeUniApplyRequest> cap = ArgumentCaptor.forClass(AlipayFundAuthorizeUniApplyRequest.class);
        verify(alipayClient).certificateExecute(cap.capture());
        AlipayFundAuthorizeUniApplyModel m = cap.getValue().getBizModel();
        assertEquals("TRANSFER_API_STANDARD_AUTHORIZATION", m.getProductCode());
        assertEquals("STANDARD_CREATE_FUND_ORDER", m.getBizScene());
        assertEquals("SHORT_URL", m.getAuthorizeLinkType());
        assertEquals("pc", m.getChannel());
        assertEquals("2088123412341234", m.getPrincipalInfo().getParticipantId());
    
        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
        verify(batchAuthorizeMapper).insert(ent.capture());
        assertEquals("E100", ent.getValue().getEnterpriseId());
        assertEquals("AUTHING", ent.getValue().getStatus());
    }
    
    @Test
    void authorizeApply_failure_throwsBusinessException() throws AlipayApiException {
        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class)))
                .thenThrow(new AlipayApiException("network error"));
    
        assertThrows(BusinessException.class, () -> service.authorizeApply("E100", "2088123412341234"));
    }
    
    @Test
    void queryAuthorize_returnsAgreementNo() throws AlipayApiException {
        AlipayFundAuthorizeUniQueryResponse resp = new AlipayFundAuthorizeUniQueryResponse();
        resp.setAgreementNo("AGMT001");
        resp.setStatus("AUTHED");
        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniQueryRequest.class))).thenReturn(resp);
    
        Map<String, String> result = service.queryAuthorize("E100", "A1");
    
        assertEquals("AGMT001", result.get("agreement_no"));
        assertEquals("AUTHED", result.get("status"));
    }
    }
    
  • [ ] Step 2: 跑测试确认失败

Run: cd java && mvn -q test -Dtest=AlipayBatchPayServiceTest Expected: 编译失败(AlipayBatchPayService 不存在)

  • [ ] Step 3: 写实现

    package com.payment.platform.module.payment.batch.service;
    
    import com.alipay.api.AlipayApiException;
    import com.alipay.api.AlipayClient;
    import com.alipay.api.domain.AlipayFundAuthorizeUniApplyModel;
    import com.alipay.api.domain.AlipayFundAuthorizeUniQueryModel;
    import com.alipay.api.domain.AuthParticipantInfo;
    import com.alipay.api.request.AlipayFundAuthorizeUniApplyRequest;
    import com.alipay.api.request.AlipayFundAuthorizeUniQueryRequest;
    import com.alipay.api.response.AlipayFundAuthorizeUniApplyResponse;
    import com.alipay.api.response.AlipayFundAuthorizeUniQueryResponse;
    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.time.OffsetDateTime;
    import java.util.ArrayList;
    import java.util.LinkedHashMap;
    import java.util.List;
    import java.util.Map;
    
    /**
    * 批量付款到户有密 — 制单授权
    * <p>
    * 授权链路: authorizeApply 生成短链(PC) → 付款方端内授权(永久生效) →
    * fund.authorize.status.notify 异步通知回写 agreement_no。
    */
    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class AlipayBatchPayService {
    
    private static final String AUTHORIZE_PRODUCT_CODE = "TRANSFER_API_STANDARD_AUTHORIZATION";
    private static final String AUTHORIZE_BIZ_SCENE = "STANDARD_CREATE_FUND_ORDER";
    private static final String AUTHORIZE_LINK_TYPE = "SHORT_URL";
    private static final String BIZ_TYPE = "BATCH_PAY";
    
    /** 静态 ObjectMapper(同 NotificationService 第 55 行模式),用于 ext_info / 报备 JSON 序列化 */
    private static final ObjectMapper oMapper = new ObjectMapper();
    
    private final AlipayClientFactory alipayClientFactory;
    private final BatchAuthorizeMapper batchAuthorizeMapper;
    
    /** alipay.fund.authorize.uni.apply — 生成制单授权短链接(PC 渠道) */
    @Transactional
    public Map<String, String> authorizeApply(String enterpriseId, String participantId) {
        if (participantId == null || participantId.isBlank())
            throw new BusinessException(400, "付款方支付宝账号不能为空");
        String outBizNo = SnowflakeIdGenerator.nextIdStr();
        try {
            AlipayFundAuthorizeUniApplyModel model = new AlipayFundAuthorizeUniApplyModel();
            model.setProductCode(AUTHORIZE_PRODUCT_CODE);
            model.setBizScene(AUTHORIZE_BIZ_SCENE);
            model.setOutBizNo(outBizNo);
            model.setAuthorizeLinkType(AUTHORIZE_LINK_TYPE);
            model.setChannel("pc");
            AuthParticipantInfo principal = new AuthParticipantInfo();
            principal.setParticipantId(participantId);
            principal.setParticipantIdType("ALIPAY_USER_ID");
            model.setPrincipalInfo(principal);
    
            AlipayFundAuthorizeUniApplyRequest request = new AlipayFundAuthorizeUniApplyRequest();
            request.setBizModel(model);
            AlipayClient client = alipayClientFactory.getClient(enterpriseId, BIZ_TYPE);
            AlipayFundAuthorizeUniApplyResponse response = client.certificateExecute(request);
            if (!response.isSuccess())
                throw new BusinessException(400, "生成授权链接失败: " + response.getMsg());
    
            BatchAuthorizeEntity entity = new BatchAuthorizeEntity();
            entity.setEnterpriseId(enterpriseId);
            entity.setOutBizNo(outBizNo);
            entity.setParticipantId(participantId);
            entity.setParticipantIdType("ALIPAY_USER_ID");
            entity.setStatus("AUTHING");
            entity.setAuthorizeLink(response.getAuthorizeLink());
            batchAuthorizeMapper.insert(entity);
    
            return Map.of("authorize_link", response.getAuthorizeLink(),
                    "out_biz_no", outBizNo, "status", "AUTHING");
        } catch (AlipayApiException e) {
            throw new BusinessException(400, "生成授权链接失败: " + e.getMessage());
        }
    }
    
    /** alipay.fund.authorize.uni.query — 查询制单授权状态(单协议) */
    public Map<String, String> queryAuthorize(String enterpriseId, String outBizNo) {
        try {
            AlipayFundAuthorizeUniQueryModel model = new AlipayFundAuthorizeUniQueryModel();
            model.setProductCode(AUTHORIZE_PRODUCT_CODE);
            model.setBizScene(AUTHORIZE_BIZ_SCENE);
            model.setOutBizNo(outBizNo);
    
            AlipayFundAuthorizeUniQueryRequest request = new AlipayFundAuthorizeUniQueryRequest();
            request.setBizModel(model);
            AlipayFundAuthorizeUniQueryResponse response =
                    alipayClientFactory.getClient(enterpriseId, BIZ_TYPE).certificateExecute(request);
            if (!response.isSuccess())
                throw new BusinessException(400, "查询授权状态失败: " + response.getMsg());
    
            if (response.getAgreementNo() != null && !response.getAgreementNo().isBlank()) {
                BatchAuthorizeEntity entity = batchAuthorizeMapper.selectOne(
                        new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
                                .eq(BatchAuthorizeEntity::getOutBizNo, outBizNo));
                if (entity != null) {
                    entity.setAgreementNo(response.getAgreementNo());
                    entity.setStatus("AUTHED");
                    batchAuthorizeMapper.updateById(entity);
                }
            }
            return Map.of("agreement_no", response.getAgreementNo() != null ? response.getAgreementNo() : "",
                    "status", response.getStatus() != null ? response.getStatus() : "AUTHING");
        } catch (AlipayApiException e) {
            throw new BusinessException(400, "查询授权状态失败: " + e.getMessage());
        }
    }
    }
    
  • [ ] Step 4: 跑测试确认通过

Run: cd java && mvn -q test -Dtest=AlipayBatchPayServiceTest Expected: 3 个测试全部 PASS

  • [ ] Step 5: Commit

    git add java/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.java java/src/test/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayServiceTest.java
    git commit -m "feat: 批量付款 - 制单授权申请与查询"
    

Task 4: AlipayBatchPayService — 批次(create/renderPay/query/close)

Files:

  • Modify: java/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.java
  • Modify: java/src/test/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayServiceTest.java
  • Create: java/src/main/java/com/payment/platform/module/payment/batch/dto/BatchCreateDTO.java

Interfaces:

  • Consumes: Task 2 的 BatchOrderMapperBatchDetailMapper;Task 3 的 service
  • Produces: batchCreate(BatchCreateDTO dto)Map<String,Object>(out_batch_no、batch_trans_id、status);renderPay(String enterpriseId, String outBatchNo)Map<String,String>(pay_url);batchQuery(String enterpriseId, String outBatchNo)Map<String,Object>(批次+明细状态);batchClose(String enterpriseId, String outBatchNo)Map<String,String>

  • [ ] Step 1: 写 DTO

    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;
    
    @Data
    public class BatchCreateDTO {
    
    @Schema(description = "企业ID")
    private String enterpriseId;
    
    @Schema(description = "租户ID(内部,由 Controller 注入)")
    private Long tenantId;
    
    @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;
    }
    }
    
  • [ ] Step 2: 写失败测试(追加到 AlipayBatchPayServiceTest)

    @Mock private BatchOrderMapper batchOrderMapper;
    @Mock private BatchDetailMapper batchDetailMapper;
    // setUp 中: service = new AlipayBatchPayService(alipayClientFactory, batchAuthorizeMapper, batchOrderMapper, batchDetailMapper);
    
    @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.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 = 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("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");
        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));
    }
    

(对应 import:AlipayFundBatchCreateModel/Request/ResponseAlipayFundTransRenderPayModel/Request/ResponseAlipayFundBatchDetailQueryModel/Request/ResponseAlipayFundBatchCloseModel/Request/ResponseTransOrderDetailParticipant

  • Step 3: 跑测试确认失败

Run: cd java && mvn -q test -Dtest=AlipayBatchPayServiceTest Expected: 编译失败(方法不存在)

  • [ ] Step 4: 写实现(追加到 AlipayBatchPayService)

    // ==================== 批次 ====================
    
    private static final String BATCH_PRODUCT_CODE = "BATCH_API_TO_ACC";
    private static final String BATCH_BIZ_SCENE = "STANDARD_MESSAGE_BATCH_PAY";
    
    private final BatchOrderMapper batchOrderMapper;
    private final BatchDetailMapper batchDetailMapper;
    
    /** 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) {
        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);
        List<BatchOrderEntity> records = batchOrderMapper.selectList(w);
        // 列: 序号/批次号/支付宝批次号/标题/金额(元)/笔数/状态/创建时间/错误信息
        // 状态映射: INIT=受理中, SUCCESS=成功, DISUSE=已关闭, FAIL=失败
        // 逐行组装 LinkedHashMap,时间用 Asia/Shanghai 时区格式化 yyyy-MM-dd HH:mm:ss
        List<Map<String, Object>> listData = new ArrayList<>();
        // ...(与 transferExport 相同的组装与 mappingDict 定义,映射表保持一致)
        Map<String, String> mappingDict = new LinkedHashMap<>();
        return ExcelUtil.exportToExcel(listData, mappingDict);
    }
    

注:ObjectMapper 用类静态字段 oMapper(同 NotificationService 第 55 行模式),测试构造器保持 4 参数不变。SDK 响应字段已 javap 实证:批次状态 getBatchStatus()、明细列表 getAccDetailList()(元素 AccDetailModelgetOutBizNo/getDetailNo/getStatus/getErrorCode/getErrorMsg)。batchExport 的 listData/mappingDict 组装细节照抄 AccountService.transferExport(第 298-377 行),仅列名与字段对应不同。

  • Step 5: 跑测试确认通过

Run: cd java && mvn -q test -Dtest=AlipayBatchPayServiceTest Expected: 全部 PASS

  • [ ] Step 6: Commit

    git add java/src/main/java/com/payment/platform/module/payment/batch/
    git commit -m "feat: 批量付款 - 批次创建/支付页/查询/关单"
    

Task 5: BatchPayController — REST 端点

Files:

  • Create: java/src/main/java/com/payment/platform/module/payment/batch/controller/BatchPayController.java

Interfaces:

  • Consumes: Task 3-4 的 AlipayBatchPayService
  • Produces: /payment/account/batch/* REST 端点(前端 Task 7 调用)

  • [ ] Step 1: 写实现

    package com.payment.platform.module.payment.batch.controller;
    
    import com.payment.platform.common.response.PageResult;
    import com.payment.platform.common.response.Result;
    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.BatchOrderEntity;
    import com.payment.platform.module.payment.batch.service.AlipayBatchPayService;
    import jakarta.servlet.http.HttpServletResponse;
    import jakarta.validation.Valid;
    import lombok.RequiredArgsConstructor;
    import org.springframework.security.access.prepost.PreAuthorize;
    import org.springframework.web.bind.annotation.*;
    
    import java.io.IOException;
    import java.util.Map;
    
    @RestController
    @RequestMapping("/payment/account/batch")
    @RequiredArgsConstructor
    public class BatchPayController {
    
    private final AlipayBatchPayService batchPayService;
    
    @PreAuthorize("@perm.hasAny('module_payment:account:authorize')")
    @PostMapping("/authorize/apply")
    public Result<Map<String, String>> authorizeApply(@RequestBody Map<String, Object> b) {
        return Result.ok(batchPayService.authorizeApply(
                (String) b.get("enterprise_id"), (String) b.get("participant_id")));
    }
    
    @PreAuthorize("@perm.hasAny('module_payment:account:authorize')")
    @GetMapping("/authorize/query")
    public Result<Map<String, String>> queryAuthorize(
            @RequestParam("enterprise_id") String enterpriseId,
            @RequestParam("out_biz_no") String outBizNo) {
        return Result.ok(batchPayService.queryAuthorize(enterpriseId, outBizNo));
    }
    
    @PreAuthorize("@perm.hasAny('module_payment:account:transfer')")
    @PostMapping("/create")
    public Result<Map<String, Object>> batchCreate(@Valid @RequestBody BatchCreateDTO dto) {
        return Result.ok(batchPayService.batchCreate(dto));
    }
    
    @PreAuthorize("@perm.hasAny('module_payment:account:transfer')")
    @PostMapping("/pay")
    public Result<Map<String, String>> renderPay(@RequestBody Map<String, Object> b) {
        return Result.ok(batchPayService.renderPay(
                (String) b.get("enterprise_id"), (String) b.get("out_batch_no")));
    }
    
    @PreAuthorize("@perm.hasAny('module_payment:account:transfer:list')")
    @GetMapping("/query")
    public Result<Map<String, Object>> batchQuery(
            @RequestParam("enterprise_id") String enterpriseId,
            @RequestParam("out_batch_no") String outBatchNo) {
        return Result.ok(batchPayService.batchQuery(enterpriseId, outBatchNo));
    }
    
    @PreAuthorize("@perm.hasAny('module_payment:account:transfer')")
    @PostMapping("/close")
    public Result<Map<String, String>> batchClose(@RequestBody Map<String, Object> b) {
        return Result.ok(batchPayService.batchClose(
                (String) b.get("enterprise_id"), (String) b.get("out_batch_no")));
    }
    
    @PreAuthorize("@perm.hasAny('module_payment:account:transfer:list')")
    @GetMapping("/list")
    public Result<PageResult<BatchOrderEntity>> batchList(
            @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
            @RequestParam(name = "page_size", defaultValue = "20") int pageSize,
            @RequestParam(name = "enterprise_id", required = false) String enterpriseId,
            @RequestParam(name = "status", required = false) String status,
            @RequestParam(name = "start_time", required = false) String startTime,
            @RequestParam(name = "end_time", required = false) String endTime) {
        return Result.ok(batchPayService.batchList(enterpriseId, status, startTime, endTime, pageNo, pageSize));
    }
    
    @PreAuthorize("@perm.hasAny('module_payment:account:transfer:detail')")
    @GetMapping("/detail")
    public Result<Map<String, Object>> batchDetail(
            @RequestParam("out_batch_no") String outBatchNo,
            @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
            @RequestParam(name = "page_size", defaultValue = "20") int pageSize) {
        return Result.ok(batchPayService.batchDetail(outBatchNo, pageNo, pageSize));
    }
    
    @PreAuthorize("@perm.hasAny('module_payment:account:authorize')")
    @GetMapping("/authorize/list")
    public Result<PageResult<BatchAuthorizeEntity>> authorizeList(
            @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
            @RequestParam(name = "page_size", defaultValue = "20") int pageSize,
            @RequestParam(name = "enterprise_id", required = false) String enterpriseId) {
        return Result.ok(batchPayService.authorizeList(enterpriseId, pageNo, pageSize));
    }
    
    @PreAuthorize("@perm.hasAny('module_payment:account:transfer:list')")
    @GetMapping("/export")
    public void batchExport(
            @RequestParam(name = "enterprise_id", required = false) String enterpriseId,
            @RequestParam(name = "status", required = false) String status,
            @RequestParam(name = "start_time", required = false) String startTime,
            @RequestParam(name = "end_time", required = false) String endTime,
            HttpServletResponse response) throws IOException {
        byte[] bytes = batchPayService.batchExport(enterpriseId, status, startTime, endTime);
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setHeader("Content-Disposition", "attachment; filename=batch_pay_report.xlsx");
        response.getOutputStream().write(bytes);
    }
    }
    
  • [ ] Step 2: 编译验证

Run: cd java && mvn -q compile Expected: BUILD SUCCESS

  • [ ] Step 3: Commit

    git add java/src/main/java/com/payment/platform/module/payment/batch/controller/BatchPayController.java
    git commit -m "feat: 批量付款 - REST 端点"
    

Task 6: 异步通知 — BatchPayHandler

Files:

  • Modify: java/src/main/java/com/payment/platform/module/payment/notification/enums/NotificationEnums.java
  • Modify: java/src/main/java/com/payment/platform/module/payment/notification/handler/NotifyContext.java
  • Modify: java/src/main/java/com/payment/platform/module/payment/notification/service/NotificationService.java(buildContext 注入新 mapper)
  • Create: java/src/main/java/com/payment/platform/module/payment/notification/handler/BatchPayHandler.java
  • Create: java/src/test/java/com/payment/platform/module/payment/notification/handler/BatchPayHandlerTest.java

Interfaces:

  • Consumes: Task 2 的 BatchOrderMapperBatchDetailMapperBatchAuthorizeMapper
  • Produces: handler 自动注册进 NotificationService.handlers@Component),处理 alipay.fund.authorize.status.notifyalipay.fund.batch.order.changed

  • [ ] Step 1: 写失败测试

    package com.payment.platform.module.payment.notification.handler;
    
    import com.payment.platform.module.payment.batch.entity.BatchAuthorizeEntity;
    import com.payment.platform.module.payment.batch.entity.BatchOrderEntity;
    import com.payment.platform.module.payment.batch.mapper.BatchAuthorizeMapper;
    import com.payment.platform.module.payment.batch.mapper.BatchOrderMapper;
    import org.junit.jupiter.api.BeforeEach;
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.api.extension.ExtendWith;
    import org.mockito.Mock;
    import org.mockito.junit.jupiter.MockitoExtension;
    
    import java.util.HashMap;
    import java.util.Map;
    
    import static org.junit.jupiter.api.Assertions.*;
    import static org.mockito.ArgumentMatchers.any;
    import static org.mockito.Mockito.*;
    
    @ExtendWith(MockitoExtension.class)
    class BatchPayHandlerTest {
    
    @Mock private BatchAuthorizeMapper batchAuthorizeMapper;
    @Mock private BatchOrderMapper batchOrderMapper;
    private BatchPayHandler handler;
    
    @BeforeEach
    void setUp() {
        // handler 不持有 mapper —— mapper 通过 NotifyContext 传入(BaseNotifyHandler 的 ctx 模式)
        handler = new BatchPayHandler();
    }
    
    private NotifyContext ctx() {
        return new NotifyContext()
                .setBatchAuthorizeMapper(batchAuthorizeMapper)
                .setBatchOrderMapper(batchOrderMapper);
    }
    
    @Test
    void acceptsBothNotifyMethods() {
        assertTrue(handler.accept("alipay.fund.authorize.status.notify"));
        assertTrue(handler.accept("alipay.fund.batch.order.changed"));
        assertFalse(handler.accept("alipay.commerce.ec.enterprise.change.notify"));
    }
    
    @Test
    void authorizeNotify_updatesAgreementNo() {
        BatchAuthorizeEntity entity = new BatchAuthorizeEntity();
        entity.setId(1L);
        when(batchAuthorizeMapper.selectOne(any())).thenReturn(entity);
    
        Map<String, String> params = new HashMap<>();
        params.put("out_biz_no", "A1");
        params.put("agreement_no", "AGMT001");
        params.put("status", "AUTHED");
        handler.dispatch("alipay.fund.authorize.status.notify", params, ctx());
    
        assertEquals("AGMT001", entity.getAgreementNo());
        assertEquals("AUTHED", entity.getStatus());
        verify(batchAuthorizeMapper).updateById(entity);
    }
    
    @Test
    void batchNotify_updatesOrderStatus() {
        BatchOrderEntity order = new BatchOrderEntity();
        order.setId(1L);
        order.setOutBatchNo("B1");
        order.setStatus("INIT");
        when(batchOrderMapper.selectOne(any())).thenReturn(order);
    
        Map<String, String> params = new HashMap<>();
        params.put("out_batch_no", "B1");
        params.put("status", "SUCCESS");
        handler.dispatch("alipay.fund.batch.order.changed", params, ctx());
    
        assertEquals("SUCCESS", order.getStatus());
        verify(batchOrderMapper).updateById(order);
    }
    }
    
  • [ ] Step 2: 跑测试确认失败

Run: cd java && mvn -q test -Dtest=BatchPayHandlerTest Expected: 编译失败(BatchPayHandler 不存在)

  • [ ] Step 3: 写实现

    package com.payment.platform.module.payment.notification.handler;
    
    import com.payment.platform.module.payment.batch.entity.BatchAuthorizeEntity;
    import com.payment.platform.module.payment.batch.entity.BatchOrderEntity;
    import com.payment.platform.module.payment.batch.mapper.BatchAuthorizeMapper;
    import com.payment.platform.module.payment.batch.mapper.BatchOrderMapper;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Component;
    
    import java.util.Map;
    
    /**
    * 批量付款到户有密 通知处理器
    * <p>
    * 监听:
    * - alipay.fund.authorize.status.notify  制单授权签约/解约
    * - alipay.fund.batch.order.changed      批次状态变更
    */
    @Slf4j
    @Component
    public class BatchPayHandler extends BaseNotifyHandler {
    
    @Override
    protected String[] acceptedMethods() {
        return new String[]{
                "alipay.fund.authorize.status.notify",
                "alipay.fund.batch.order.changed"
        };
    }
    
    @Override
    protected void handle(String msgMethod, Map<String, String> params) {
        // 无上下文兜底(仅走 dispatch(msgMethod, params) 无 ctx 版本时触发)
        log.warn("BatchPayHandler 收到无上下文通知,跳过: msg_method={}", msgMethod);
    }
    
    @Override
    protected void handle(String msgMethod, Map<String, String> params, NotifyContext ctx) {
        try {
            if ("alipay.fund.authorize.status.notify".equals(msgMethod)) {
                handleAuthorizeNotify(params, ctx.getBatchAuthorizeMapper());
            } else if ("alipay.fund.batch.order.changed".equals(msgMethod)) {
                handleBatchNotify(params, ctx.getBatchOrderMapper());
            }
        } catch (Exception e) {
            log.error("批量付款通知处理异常: msg_method={}", msgMethod, e);
        }
    }
    
    private void handleAuthorizeNotify(Map<String, String> params, BatchAuthorizeMapper batchAuthorizeMapper) {
        String outBizNo = params.get("out_biz_no");
        if (outBizNo == null || outBizNo.isBlank()) {
            log.warn("授权通知缺少 out_biz_no");
            return;
        }
        BatchAuthorizeEntity entity = batchAuthorizeMapper.selectOne(
                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
                        .eq(BatchAuthorizeEntity::getOutBizNo, outBizNo));
        if (entity == null) {
            log.warn("授权通知找不到本地记录: out_biz_no={}", outBizNo);
            return;
        }
        if (params.get("agreement_no") != null) entity.setAgreementNo(params.get("agreement_no"));
        // 支付宝通知的 status/action 字段值以接口文档为准(AUTHED/UNBIND 等),此处直接回写
        if (params.get("status") != null) entity.setStatus(params.get("status"));
        batchAuthorizeMapper.updateById(entity);
        log.info("制单授权状态更新: out_biz_no={}, status={}", outBizNo, entity.getStatus());
    }
    
    private void handleBatchNotify(Map<String, String> params, BatchOrderMapper batchOrderMapper) {
        String outBatchNo = params.get("out_batch_no");
        if (outBatchNo == null || outBatchNo.isBlank()) {
            log.warn("批次通知缺少 out_batch_no");
            return;
        }
        BatchOrderEntity order = batchOrderMapper.selectOne(
                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
                        .eq(BatchOrderEntity::getOutBatchNo, outBatchNo));
        if (order == null) {
            log.warn("批次通知找不到本地记录: out_batch_no={}", outBatchNo);
            return;
        }
        String status = params.get("status");
        if (status != null) {
            order.setStatus(status);
            batchOrderMapper.updateById(order);
        }
        log.info("批次状态更新: out_batch_no={}, status={}", outBatchNo, status);
    }
    }
    
  • [ ] Step 4: 枚举 + Context 接线

NotificationEnums.AlipayNotifyMethod 追加:

        FUND_AUTHORIZE_STATUS_NOTIFY("alipay.fund.authorize.status.notify"),
        FUND_BATCH_ORDER_CHANGED("alipay.fund.batch.order.changed"),

NotifyContext 追加字段与方法(模式同现有 transferMapper):

    private BatchAuthorizeMapper batchAuthorizeMapper;
    private BatchOrderMapper batchOrderMapper;

    public BatchAuthorizeMapper getBatchAuthorizeMapper() { return batchAuthorizeMapper; }
    public NotifyContext setBatchAuthorizeMapper(BatchAuthorizeMapper v) { this.batchAuthorizeMapper = v; return this; }
    public BatchOrderMapper getBatchOrderMapper() { return batchOrderMapper; }
    public NotifyContext setBatchOrderMapper(BatchOrderMapper v) { this.batchOrderMapper = v; return this; }

(import com.payment.platform.module.payment.batch.mapper.BatchAuthorizeMapper / BatchOrderMapper

NotificationService.buildContext() 追加链式注入:

                .setBatchAuthorizeMapper(batchAuthorizeMapper)
                .setBatchOrderMapper(batchOrderMapper)

NotificationService 已确认用 @RequiredArgsConstructor + private final 字段(第 42-53 行),在字段区追加:

    private final BatchAuthorizeMapper batchAuthorizeMapper;
    private final BatchOrderMapper batchOrderMapper;

(import com.payment.platform.module.payment.batch.mapper.BatchAuthorizeMapper / BatchOrderMapper

  • Step 5: 跑测试确认通过

Run: cd java && mvn -q test -Dtest=BatchPayHandlerTest Expected: 3 个测试全部 PASS

  • [ ] Step 6: Commit

    git add java/src/main/java/com/payment/platform/module/payment/notification/
    git commit -m "feat: 批量付款 - 异步通知处理器(授权/批次状态)"
    

Task 7: 前端 — API 封装 + 批量付款页面

Files:

  • Create: frontend/src/api/module_payment/batch.ts
  • Create: frontend/src/views/module_payment/account/components/BatchPayAuthorize.vue
  • Create: frontend/src/views/module_payment/account/components/BatchPayList.vue
  • Create: frontend/src/views/module_payment/account/components/BatchPayCreate.vue
  • Create: frontend/src/views/module_payment/account/components/BatchPayDetail.vue
  • Modify: frontend/src/views/module_payment/account/index.vue(加「批量付款」tab,先读该文件确认 tab 组织方式)

Interfaces:

  • Consumes: Task 5 的 REST 端点
  • Produces: 页面入口(动态路由由后端菜单表驱动,见 Task 8)

  • [ ] Step 1: 写 API 封装

参照 src/api/module_payment/account.tsrequest<ApiResponse<T>> 模式:

import request from "@/api/request";
import type { ApiResponse } from "@/api/types";

const API_PATH = "/payment/account/batch";

export interface BatchAuthorizeVO {
  out_biz_no: string;
  status: string;
  agreement_no?: string;
  authorize_link?: string;
  participant_id: string;
}

export interface BatchOrderVO {
  out_batch_no: string;
  batch_trans_id?: string;
  total_amount: string;
  total_count: number;
  order_title?: string;
  status: string;
  created_time?: string;
  pay_url?: string;
}

export interface BatchDetailItem {
  out_biz_no: string;
  amount: string;
  remark?: string;
  payee_identity: string;
  payee_identity_type?: string;
  payee_name?: string;
}

export interface BatchCreateParams {
  enterprise_id?: string;
  order_title: string;
  payer_uid: string;
  agreement_no?: string;
  transfer_scene_name?: string;
  transfer_scene_report_infos?: Array<{ info_type: string; info_content: string }>;
  time_expire?: string;
  remark?: string;
  details: BatchDetailItem[];
}

export default {
  authorizeApply(enterpriseId: string, participantId: string) {
    return request<ApiResponse<{ authorize_link: string; out_biz_no: string; status: string }>>({
      url: `${API_PATH}/authorize/apply`,
      method: "post",
      data: { enterprise_id: enterpriseId, participant_id: participantId },
    });
  },
  queryAuthorize(enterpriseId: string, outBizNo: string) {
    return request<ApiResponse<{ agreement_no: string; status: string }>>({
      url: `${API_PATH}/authorize/query`,
      method: "get",
      params: { enterprise_id: enterpriseId, out_biz_no: outBizNo },
    });
  },
  batchCreate(data: BatchCreateParams) {
    return request<ApiResponse<{ out_batch_no: string; batch_trans_id: string; status: string }>>({
      url: `${API_PATH}/create`,
      method: "post",
      data,
    });
  },
  renderPay(enterpriseId: string, outBatchNo: string) {
    return request<ApiResponse<{ pay_url: string }>>({
      url: `${API_PATH}/pay`,
      method: "post",
      data: { enterprise_id: enterpriseId, out_batch_no: outBatchNo },
    });
  },
  batchQuery(enterpriseId: string, outBatchNo: string) {
    return request<ApiResponse<{ status: string }>>({
      url: `${API_PATH}/query`,
      method: "get",
      params: { enterprise_id: enterpriseId, out_batch_no: outBatchNo },
    });
  },
  batchClose(enterpriseId: string, outBatchNo: string) {
    return request<ApiResponse<{ status: string }>>({
      url: `${API_PATH}/close`,
      method: "post",
      data: { enterprise_id: enterpriseId, out_batch_no: outBatchNo },
    });
  },
  authorizeList(params: { enterprise_id?: string; page_no?: number; page_size?: number }) {
    return request<ApiResponse<{ list: BatchAuthorizeVO[]; total: number }>>({
      url: `${API_PATH}/authorize/list`,
      method: "get",
      params,
    });
  },
  batchList(params: {
    enterprise_id?: string;
    status?: string;
    start_time?: string;
    end_time?: string;
    page_no?: number;
    page_size?: number;
  }) {
    return request<ApiResponse<{ list: BatchOrderVO[]; total: number }>>({
      url: `${API_PATH}/list`,
      method: "get",
      params,
    });
  },
  batchDetail(outBatchNo: string, pageNo = 1, pageSize = 20) {
    return request<ApiResponse<{ order: BatchOrderVO; details: { list: BatchDetailItem[]; total: number } }>>({
      url: `${API_PATH}/detail`,
      method: "get",
      params: { out_batch_no: outBatchNo, page_no: pageNo, page_size: pageSize },
    });
  },
};

requestApiResponse 的导入路径以 account.ts 现有 import 为准,执行时核对)

  • Step 2: 写 BatchPayAuthorize.vue(制单授权页)

功能:输入付款方支付宝 UID → 生成授权链接 → 复制链接/展示授权状态(查询按钮轮询)。UI 风格复用 AccountOverview.vue 的 Element Plus 表单模式(el-card + el-form + el-button)。

<script setup lang="ts">
import { computed, ref } from "vue";
import BatchPayAPI from "@/api/module_payment/batch";
import { useEnterpriseStore } from "@/store";
import { ElMessage } from "element-plus";

const enterpriseStore = useEnterpriseStore();
const enterpriseId = computed(() => enterpriseStore.getCurrentEnterprise?.enterprise_id);

const participantId = ref("");
const link = ref("");
const outBizNo = ref("");
const status = ref("");

async function handleApply() {
  if (!enterpriseId.value || !participantId.value) {
    ElMessage.warning("请选择企业并填写付款方支付宝 UID");
    return;
  }
  const res = await BatchPayAPI.authorizeApply(enterpriseId.value, participantId.value);
  link.value = res.data.data.authorize_link;
  outBizNo.value = res.data.data.out_biz_no;
  status.value = res.data.data.status;
}

async function handleQuery() {
  if (!outBizNo.value) return;
  const res = await BatchPayAPI.queryAuthorize(enterpriseId.value!, outBizNo.value);
  status.value = res.data.data.status;
  ElMessage.success(`授权状态: ${status.value}`);
}
</script>

<template>
  <el-card>
    <template #header>制单授权(批量付款到户有密)</template>
    <el-form label-width="160px">
      <el-form-item label="付款方支付宝UID">
        <el-input v-model="participantId" placeholder="企业财务的支付宝 UID(2088 开头)" />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="handleApply">生成授权链接</el-button>
      </el-form-item>
      <el-form-item v-if="link" label="授权链接(PC 浏览器打开)">
        <el-input :model-value="link" readonly>
          <template #append>
            <el-button @click="navigator.clipboard?.writeText(link)">复制</el-button>
          </template>
        </el-input>
      </el-form-item>
      <el-form-item v-if="outBizNo" label="授权单号 / 状态">
        <span>{{ outBizNo }} / {{ status }}</span>
        <el-button size="small" style="margin-left: 12px" @click="handleQuery">刷新状态</el-button>
      </el-form-item>
    </el-form>
  </el-card>
</template>

useEnterpriseStore 导入路径以 AccountOverview.vue 现有写法为准;computed 需 import)

  • Step 3: 写 BatchPayList.vue(批次列表)

功能:分页列表(out_batch_no、标题、总金额、笔数、状态、创建时间)、行操作(支付/详情/关闭)。数据源为后端 GET /batch/list(Task 5 已实现)。状态 tag 映射:INIT=受理中(warning)、SUCCESS=成功(success)、DISUSE=已关闭(info)、FAIL=失败(danger)。

<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import BatchPayAPI from "@/api/module_payment/batch";
import { useEnterpriseStore } from "@/store";
import { ElMessage, ElMessageBox } from "element-plus";

const emit = defineEmits<{ view: [outBatchNo: string] }>();
const enterpriseStore = useEnterpriseStore();
const enterpriseId = computed(() => enterpriseStore.getCurrentEnterprise?.enterprise_id);

const list = ref<BatchPayAPI.BatchOrderVO[]>([]);
const total = ref(0);
const pageNo = ref(1);
const pageSize = ref(20);
const statusFilter = ref("");
const loading = ref(false);

const STATUS_TAG: Record<string, string> = {
  INIT: "warning", SUCCESS: "success", DISUSE: "info", FAIL: "danger",
};
const STATUS_TEXT: Record<string, string> = {
  INIT: "受理中", SUCCESS: "成功", DISUSE: "已关闭", FAIL: "失败",
};

async function load() {
  loading.value = true;
  try {
    const res = await BatchPayAPI.batchList({
      enterprise_id: enterpriseId.value,
      status: statusFilter.value || undefined,
      page_no: pageNo.value,
      page_size: pageSize.value,
    });
    list.value = res.data.data.list;
    total.value = res.data.data.total;
  } finally {
    loading.value = false;
  }
}

function handlePay(row: BatchPayAPI.BatchOrderVO) {
  BatchPayAPI.renderPay(enterpriseId.value!, row.out_batch_no).then((res) => {
    window.open(res.data.data.pay_url, "_blank");
  });
}

async function handleClose(row: BatchPayAPI.BatchOrderVO) {
  await ElMessageBox.confirm("关闭后该批次不可再支付,确定关闭?", "提示", { type: "warning" });
  await BatchPayAPI.batchClose(enterpriseId.value!, row.out_batch_no);
  ElMessage.success("批次已关闭");
  load();
}

onMounted(load);
</script>

<template>
  <el-card>
    <template #header>
      <div style="display: flex; justify-content: space-between; align-items: center">
        <span>批量付款批次</span>
        <el-select v-model="statusFilter" placeholder="状态筛选" clearable style="width: 140px"
          @change="pageNo = 1; load()">
          <el-option v-for="(t, s) in STATUS_TEXT" :key="s" :label="t" :value="s" />
        </el-select>
      </div>
    </template>
    <el-table v-loading="loading" :data="list">
      <el-table-column prop="out_batch_no" label="批次号" width="200" />
      <el-table-column prop="order_title" label="标题" min-width="140" />
      <el-table-column prop="total_amount" label="总金额(元)" width="110" />
      <el-table-column prop="total_count" label="笔数" width="70" />
      <el-table-column label="状态" width="90">
        <template #default="{ row }">
          <el-tag :type="(STATUS_TAG[row.status] as any) || 'info'">{{ STATUS_TEXT[row.status] || row.status }}</el-tag>
        </template>
      </el-table-column>
      <el-table-column prop="created_time" label="创建时间" width="170" />
      <el-table-column label="操作" width="210" fixed="right">
        <template #default="{ row }">
          <el-button v-if="row.status === 'INIT'" size="small" type="primary" @click="handlePay(row)">支付</el-button>
          <el-button size="small" @click="emit('view', row.out_batch_no)">详情</el-button>
          <el-button v-if="row.status === 'INIT'" size="small" type="danger" @click="handleClose(row)">关闭</el-button>
        </template>
      </el-table-column>
    </el-table>
    <el-pagination v-model:current-page="pageNo" :page-size="pageSize" :total="total"
      layout="total, prev, pager, next" @current-change="load" style="margin-top: 12px" />
  </el-card>
</template>
  • Step 4: 写 BatchPayCreate.vue(创建批次)

功能:表单(标题、付款方 UID、协议号选填、转账场景下拉 + 报备信息动态增删)+ 明细表格(动态行:收款账号、类型下拉、姓名、金额、备注)或 Excel 导入(模板下载 + 解析)。场景下拉固定值:现金营销/企业退款/佣金报酬/二手回收/业务结算/公益补助/行政补贴和退款/保险理赔。

  • Step 5: 写 BatchPayDetail.vue(批次详情)

功能:批次信息描述 + 明细 el-table + 「生成支付链接」按钮(renderPaywindow.open(pay_url))+ 「关闭批次」按钮。数据源为后端 GET /batch/detail(Task 5 已实现,返回 { order, details })。

<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import BatchPayAPI from "@/api/module_payment/batch";
import { useEnterpriseStore } from "@/store";
import { ElMessage, ElMessageBox } from "element-plus";

const props = defineProps<{ outBatchNo: string }>();
const enterpriseStore = useEnterpriseStore();
const enterpriseId = computed(() => enterpriseStore.getCurrentEnterprise?.enterprise_id);

const order = ref<BatchPayAPI.BatchOrderVO | null>(null);
const details = ref<BatchPayAPI.BatchDetailItem[]>([]);
const total = ref(0);
const pageNo = ref(1);
const pageSize = ref(20);

const DETAIL_STATUS_TEXT: Record<string, string> = { INIT: "处理中", SUCCESS: "成功", FAIL: "失败" };

async function load() {
  const res = await BatchPayAPI.batchDetail(props.outBatchNo, pageNo.value, pageSize.value);
  order.value = res.data.data.order;
  details.value = res.data.data.details.list;
  total.value = res.data.data.details.total;
}

function handlePay() {
  BatchPayAPI.renderPay(enterpriseId.value!, props.outBatchNo).then((res) => {
    window.open(res.data.data.pay_url, "_blank");
  });
}

async function handleClose() {
  await ElMessageBox.confirm("关闭后该批次不可再支付,确定关闭?", "提示", { type: "warning" });
  await BatchPayAPI.batchClose(enterpriseId.value!, props.outBatchNo);
  ElMessage.success("批次已关闭");
  load();
}

onMounted(load);
</script>

<template>
  <el-card v-if="order">
    <template #header>
      <span>批次详情 {{ order.out_batch_no }}</span>
      <el-button v-if="order.status === 'INIT'" type="primary" style="margin-left: 12px" @click="handlePay">生成支付链接</el-button>
      <el-button v-if="order.status === 'INIT'" type="danger" style="margin-left: 8px" @click="handleClose">关闭批次</el-button>
    </template>
    <el-descriptions :column="3" border style="margin-bottom: 16px">
      <el-descriptions-item label="标题">{{ order.order_title }}</el-descriptions-item>
      <el-descriptions-item label="总金额(元)">{{ order.total_amount }}</el-descriptions-item>
      <el-descriptions-item label="笔数">{{ order.total_count }}</el-descriptions-item>
      <el-descriptions-item label="状态">{{ order.status }}</el-descriptions-item>
      <el-descriptions-item label="支付宝批次号">{{ order.batch_trans_id || "-" }}</el-descriptions-item>
    </el-descriptions>
    <el-table :data="details">
      <el-table-column prop="out_biz_no" label="明细单号" width="200" />
      <el-table-column prop="payee_name" label="收款方姓名" width="120" />
      <el-table-column prop="payee_identity" label="收款方账号" width="200" />
      <el-table-column prop="amount" label="金额(元)" width="110" />
      <el-table-column label="状态" width="90">
        <template #default="{ row }">
          {{ DETAIL_STATUS_TEXT[row.status] || row.status }}
        </template>
      </el-table-column>
      <el-table-column prop="error_msg" label="失败原因" min-width="140" />
    </el-table>
    <el-pagination v-model:current-page="pageNo" :page-size="pageSize" :total="total"
      layout="total, prev, pager, next" @current-change="load" style="margin-top: 12px" />
  </el-card>
</template>
  • Step 6: 挂载 tab

frontend/src/views/module_payment/account/index.vue,按现有 tab 模式(el-tabs 或其他)追加「批量付款」入口,路由或组件切换指向上述 4 个组件(参考 TransferDetail.vue 的挂载方式)。

  • Step 7: 构建验证

Run: cd frontend && npm run build Expected: 构建成功

  • [ ] Step 8: Commit

    git add frontend/src/api/module_payment/batch.ts frontend/src/views/module_payment/account/
    git commit -m "feat: 批量付款 - 前端页面(授权/列表/创建/详情)"
    

Task 8: 菜单权限 + 全链路验证

Files:

  • Modify: 数据库菜单/权限数据(走现有菜单管理或 SQL,参照 .claude/plan/2026-07-08-industry-invoice-platform-plan.md 中菜单 SQL 先例)
  • 验证:Playwright 走通页面链路

Interfaces:

  • Consumes: Task 7 页面

  • [ ] Step 1: 菜单配置

在系统菜单/权限表配置「资金账户 → 批量付款」菜单(或复用现有资金账户菜单下挂 tab,若 Task 7 已挂载则此步仅确认权限点已分配给对应角色)。本期涉及 4 个权限点:module_payment:account:authorize(授权 apply/query/list)、module_payment:account:transfer(create/pay/close)、module_payment:account:transfer:list(批次 list/export)、module_payment:account:transfer:detail(批次 detail)—— 均复用现有权限点,不新增。

  • Step 2: 全链路 Playwright 验证

启动后端 + 前端 dev server,用 Playwright MCP 走通:

  1. 登录 → 进入资金账户 → 批量付款
  2. 制单授权页:输入 UID → 生成授权链接(支付宝侧未开通产品时会返回错误码 —— 记录错误并确认前置条件)
  3. 创建批次页:填写标题/付款方/场景 + 明细 → 提交(同上,观察支付宝响应)
  4. 若支付宝侧已开通(邀测),完整走「授权 → 创建 → 支付 URL 打开」
  • Step 3: 验收汇报

汇报:各接口实测响应、未开通产品的错误码表现、前端页面渲染截图。


Self-Review 记录

  • Spec 覆盖:设计文档 §4(3 张表)→ Task 1-2;§5.1(授权/批次服务 + 列表/详情/导出)→ Task 3-4;§5.2(Controller 全部 9 个端点:authorize/apply、authorize/query、authorize/list、create、pay、list、detail、close、export)→ Task 5;§5.3(通知)→ Task 6;§6(前端 4 页面)→ Task 7;§9(前置条件)→ Task 8。§2.2 回单标注二期,不实现。
  • 占位符检查batchExport 的 listData/mappingDict 组装标为「照抄 AccountService.transferExport」(该方法是现成参照物,列名已在注释给出);其余代码均为完整实现。
  • 类型一致性BatchCreateDTO(Task 4)→ batchCreate 签名 → Controller(Task 5)→ 前端 BatchCreateParams(Task 7)字段一一对应;batchList/batchDetail/authorizeList(Task 4)→ Controller(Task 5)→ batch.ts(Task 7)签名一致;handler 枚举值(Task 6)与文档一致。
  • SDK 实证修正(javap 实测,非猜测):① AlipayFundBatchDetailQueryResponse 批次状态是 getBatchStatus()(不是 getStatus()),明细列表 getAccDetailList()AccDetailModel 含 getOutBizNo/getDetailNo/getStatus/getErrorCode/getErrorMsg)→ 已修正 Task 4 batchQuery 实现与测试;② BaseNotifyHandler 的 abstract 签名是 handle(String, Map),带 ctx 版本 handle(String, Map, NotifyContext) 默认委托,mapper 从 ctx 取 → Task 6 已按此重写(handler 无构造器注入,测试用 dispatch(method, params, ctx));③ NotificationService@RequiredArgsConstructor + final 字段(42-53 行)、buildContext() 链式(157 行)→ Task 6 接线直接写死;④ PageResult.of(pageNo, pageSize, total, list) 无 Page 重载(实证)、ExcelUtilcom.payment.platform.common.utils、导出参照 AccountService.transferExport(298-377 行)→ Task 4 列表方法按此写;⑤ ObjectMapper 改静态字段 oMapper(同 NotificationService 55 行模式),测试构造器保持 4 参数。