Kaynağa Gözat

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

alphaH 1 hafta önce
ebeveyn
işleme
62c4700424

+ 1847 - 0
.claude/plan/2026-08-25-alipay-batch-pay-implementation.md

@@ -0,0 +1,1847 @@
+# 批量付款到户有密 接入实现计划
+
+> **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_AUTHORIZATION`、`biz_scene=STANDARD_CREATE_FUND_ORDER`、`authorize_link_type=SHORT_URL`;批次 `product_code=BATCH_API_TO_ACC`、`biz_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):
+
+```sql
+-- 制单授权记录(批量付款到户有密)
+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**
+
+```bash
+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/Mapper`、`BatchOrderEntity/Mapper`、`BatchDetailEntity/Mapper`(任务 3-6 使用)
+
+- [ ] **Step 1: 写实体类(3 个,模式同 `account/entity/TransferEntity.java`)**
+
+```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 个)**
+
+```java
+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> {
+}
+```
+
+(`BatchOrderMapper`、`BatchDetailMapper` 同构,泛型分别换为对应实体)
+
+- [ ] **Step 3: 编译验证**
+
+Run: `cd java && mvn -q compile`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 4: Commit**
+
+```bash
+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 的 `BatchAuthorizeMapper`、`EnterpriseMapper`(现有)、`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: 写失败测试**
+
+```java
+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: 写实现**
+
+```java
+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**
+
+```bash
+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 的 `BatchOrderMapper`、`BatchDetailMapper`;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**
+
+```java
+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)**
+
+```java
+    @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/Response`、`AlipayFundTransRenderPayModel/Request/Response`、`AlipayFundBatchDetailQueryModel/Request/Response`、`AlipayFundBatchCloseModel/Request/Response`、`TransOrderDetail`、`Participant`)
+
+- [ ] **Step 3: 跑测试确认失败**
+
+Run: `cd java && mvn -q test -Dtest=AlipayBatchPayServiceTest`
+Expected: 编译失败(方法不存在)
+
+- [ ] **Step 4: 写实现(追加到 AlipayBatchPayService)**
+
+```java
+    // ==================== 批次 ====================
+
+    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()`(元素 `AccDetailModel` 含 `getOutBizNo/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**
+
+```bash
+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: 写实现**
+
+```java
+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**
+
+```bash
+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 的 `BatchOrderMapper`、`BatchDetailMapper`、`BatchAuthorizeMapper`
+- Produces: handler 自动注册进 `NotificationService.handlers`(`@Component`),处理 `alipay.fund.authorize.status.notify` 与 `alipay.fund.batch.order.changed`
+
+- [ ] **Step 1: 写失败测试**
+
+```java
+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: 写实现**
+
+```java
+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` 追加:
+
+```java
+        FUND_AUTHORIZE_STATUS_NOTIFY("alipay.fund.authorize.status.notify"),
+        FUND_BATCH_ORDER_CHANGED("alipay.fund.batch.order.changed"),
+```
+
+`NotifyContext` 追加字段与方法(模式同现有 `transferMapper`):
+
+```java
+    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()` 追加链式注入:
+
+```java
+                .setBatchAuthorizeMapper(batchAuthorizeMapper)
+                .setBatchOrderMapper(batchOrderMapper)
+```
+
+`NotificationService` 已确认用 `@RequiredArgsConstructor` + `private final` 字段(第 42-53 行),在字段区追加:
+
+```java
+    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**
+
+```bash
+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.ts` 的 `request<ApiResponse<T>>` 模式:
+
+```typescript
+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 },
+    });
+  },
+};
+```
+
+(`request` 与 `ApiResponse` 的导入路径以 `account.ts` 现有 import 为准,执行时核对)
+
+- [ ] **Step 2: 写 BatchPayAuthorize.vue(制单授权页)**
+
+功能:输入付款方支付宝 UID → 生成授权链接 → 复制链接/展示授权状态(查询按钮轮询)。UI 风格复用 `AccountOverview.vue` 的 Element Plus 表单模式(el-card + el-form + el-button)。
+
+```vue
+<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)。
+
+```vue
+<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 + 「生成支付链接」按钮(`renderPay` → `window.open(pay_url)`)+ 「关闭批次」按钮。数据源为后端 `GET /batch/detail`(Task 5 已实现,返回 `{ order, details }`)。
+
+```vue
+<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**
+
+```bash
+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 重载(实证)、`ExcelUtil` 在 `com.payment.platform.common.utils`、导出参照 `AccountService.transferExport`(298-377 行)→ Task 4 列表方法按此写;⑤ `ObjectMapper` 改静态字段 `oMapper`(同 NotificationService 55 行模式),测试构造器保持 4 参数。