# 批量付款到户有密 接入实现计划 > **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 { } ``` (`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`(authorize_link、out_biz_no、status);`queryAuthorize(String enterpriseId, String outBizNo)` → `Map`(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 result = service.authorizeApply("E100", "2088123412341234"); assertEquals("https://ur.alipay.com/abc", result.get("authorize_link")); assertEquals("AUTHING", result.get("status")); ArgumentCaptor 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 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 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; /** * 批量付款到户有密 — 制单授权 *

* 授权链路: 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 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 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() .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`(out_batch_no、batch_trans_id、status);`renderPay(String enterpriseId, String outBatchNo)` → `Map`(pay_url);`batchQuery(String enterpriseId, String outBatchNo)` → `Map`(批次+明细状态);`batchClose(String enterpriseId, String outBatchNo)` → `Map` - [ ] **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> 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 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 result = service.batchCreate(dto); assertEquals("B1", result.get("out_batch_no")); ArgumentCaptor 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 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 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 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() .eq(BatchOrderEntity::getOutBatchNo, dto.getOutBatchNo())) > 0) throw new BusinessException(400, "批次外部单号已存在,请勿重复创建"); BigDecimal total = BigDecimal.ZERO; List 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 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 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() .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 renderPay(String enterpriseId, String outBatchNo) { BatchOrderEntity order = batchOrderMapper.selectOne( new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .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 batchQuery(String enterpriseId, String outBatchNo) { BatchOrderEntity order = batchOrderMapper.selectOne( new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .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() .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 batchClose(String enterpriseId, String outBatchNo) { BatchOrderEntity order = batchOrderMapper.selectOne( new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .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 authorizeList(String enterpriseId, int pageNo, int pageSize) { var w = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .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 batchList(String enterpriseId, String status, String startTime, String endTime, int pageNo, int pageSize) { var w = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .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 batchDetail(String outBatchNo, int pageNo, int pageSize) { BatchOrderEntity order = batchOrderMapper.selectOne( new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .eq(BatchOrderEntity::getOutBatchNo, outBatchNo)); if (order == null) throw new BusinessException(404, "批次不存在"); Page detailPage = batchDetailMapper.selectPage(new Page<>(pageNo, pageSize), new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .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() .eq(enterpriseId != null && !enterpriseId.isBlank(), BatchOrderEntity::getEnterpriseId, enterpriseId) .eq(status != null && !status.isBlank(), BatchOrderEntity::getStatus, status) .orderByDesc(BatchOrderEntity::getId); List records = batchOrderMapper.selectList(w); // 列: 序号/批次号/支付宝批次号/标题/金额(元)/笔数/状态/创建时间/错误信息 // 状态映射: INIT=受理中, SUCCESS=成功, DISUSE=已关闭, FAIL=失败 // 逐行组装 LinkedHashMap,时间用 Asia/Shanghai 时区格式化 yyyy-MM-dd HH:mm:ss List> listData = new ArrayList<>(); // ...(与 transferExport 相同的组装与 mappingDict 定义,映射表保持一致) Map 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> authorizeApply(@RequestBody Map 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> 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> batchCreate(@Valid @RequestBody BatchCreateDTO dto) { return Result.ok(batchPayService.batchCreate(dto)); } @PreAuthorize("@perm.hasAny('module_payment:account:transfer')") @PostMapping("/pay") public Result> renderPay(@RequestBody Map 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> 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> batchClose(@RequestBody Map 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> 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> 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> 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 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 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; /** * 批量付款到户有密 通知处理器 *

* 监听: * - 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 params) { // 无上下文兜底(仅走 dispatch(msgMethod, params) 无 ctx 版本时触发) log.warn("BatchPayHandler 收到无上下文通知,跳过: msg_method={}", msgMethod); } @Override protected void handle(String msgMethod, Map 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 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() .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 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() .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>` 模式: ```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>({ url: `${API_PATH}/authorize/apply`, method: "post", data: { enterprise_id: enterpriseId, participant_id: participantId }, }); }, queryAuthorize(enterpriseId: string, outBizNo: string) { return request>({ url: `${API_PATH}/authorize/query`, method: "get", params: { enterprise_id: enterpriseId, out_biz_no: outBizNo }, }); }, batchCreate(data: BatchCreateParams) { return request>({ url: `${API_PATH}/create`, method: "post", data, }); }, renderPay(enterpriseId: string, outBatchNo: string) { return request>({ url: `${API_PATH}/pay`, method: "post", data: { enterprise_id: enterpriseId, out_batch_no: outBatchNo }, }); }, batchQuery(enterpriseId: string, outBatchNo: string) { return request>({ url: `${API_PATH}/query`, method: "get", params: { enterprise_id: enterpriseId, out_batch_no: outBatchNo }, }); }, batchClose(enterpriseId: string, outBatchNo: string) { return request>({ 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>({ 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>({ url: `${API_PATH}/list`, method: "get", params, }); }, batchDetail(outBatchNo: string, pageNo = 1, pageSize = 20) { return request>({ 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 ``` (`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 ``` - [ ] **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 ``` - [ ] **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 参数。