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(设计文档已提交,本计划依据它拆解)
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,页面接口 pageExecuteproduct_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_PAYout_batch_no 批内唯一、out_biz_no 明细唯一transfer_scene_name + transfer_scene_report_infos[{info_type, info_content}]java/src/main/resources/db/migration/V1.5__*.sql(现有 V1.1~V1.4 已占用)@PreAuthorize("@perm.hasAny('module_payment:account:...')"),复用现有权限点不新增src/api/module_payment/batch.ts;页面挂载到 src/views/module_payment/account/(先读 index.vue 确认 tab 组织方式)org.junit.jupiter)+ Mockito + spring-test;支付宝 SDK 类全部经 jar tf + javap 验证存在(见各任务代码)Files:
java/src/main/resources/db/migration/V1.5__create_pay_batch_tables.sqlInterfaces:
Produces: 表 pay_batch_authorize / pay_batch_order / pay_batch_detail(任务 2 的实体映射它们)
[ ] Step 1: 写迁移脚本
参照现有 V1.1__create_pay_f2f_trade.sql 的列类型风格(bigint id、varchar、decimal、timestamp with time zone):
-- 制单授权记录(批量付款到户有密)
CREATE TABLE pay_batch_authorize (
id bigint PRIMARY KEY,
tenant_id bigint,
enterprise_id varchar(64) NOT NULL,
out_biz_no varchar(64) NOT NULL,
participant_id varchar(64) NOT NULL,
participant_id_type varchar(16) NOT NULL DEFAULT 'ALIPAY_USER_ID',
agreement_no varchar(64),
status varchar(32) NOT NULL DEFAULT 'AUTHING',
authorize_link varchar(512),
authorize_expire_time timestamp,
created_time timestamp,
updated_time timestamp,
CONSTRAINT uk_batch_authorize_out_biz_no UNIQUE (out_biz_no)
);
CREATE INDEX idx_batch_authorize_enterprise ON pay_batch_authorize (enterprise_id);
-- 批次主表
CREATE TABLE pay_batch_order (
id bigint PRIMARY KEY,
tenant_id bigint,
enterprise_id varchar(64) NOT NULL,
out_batch_no varchar(32) NOT NULL,
batch_trans_id varchar(32),
total_amount decimal(16,2) NOT NULL,
total_count int NOT NULL,
order_title varchar(64),
status varchar(32) NOT NULL DEFAULT 'INIT',
time_expire timestamp,
payer_uid varchar(64),
agreement_no varchar(64),
transfer_scene_name varchar(64),
transfer_scene_report_infos jsonb,
remark varchar(200),
pay_url varchar(1024),
error_code varchar(64),
error_msg varchar(512),
created_time timestamp,
updated_time timestamp,
CONSTRAINT uk_batch_order_out_batch_no UNIQUE (out_batch_no)
);
CREATE INDEX idx_batch_order_enterprise ON pay_batch_order (enterprise_id);
-- 批次明细表
CREATE TABLE pay_batch_detail (
id bigint PRIMARY KEY,
tenant_id bigint,
enterprise_id varchar(64) NOT NULL,
batch_id bigint NOT NULL,
out_biz_no varchar(64) NOT NULL,
amount decimal(16,2) NOT NULL,
remark varchar(100),
payee_identity varchar(64) NOT NULL,
payee_identity_type varchar(32) NOT NULL,
payee_name varchar(256),
status varchar(32) NOT NULL DEFAULT 'INIT',
error_code varchar(64),
error_msg varchar(512),
created_time timestamp,
updated_time timestamp,
CONSTRAINT uk_batch_detail_batch_out_biz UNIQUE (batch_id, out_biz_no)
);
CREATE INDEX idx_batch_detail_batch ON pay_batch_detail (batch_id);
Run: cd java && mvn -q flyway:migrate(或启动应用观察 Flyway 日志)
Expected: 迁移成功,3 张表创建
[ ] Step 3: Commit
git add java/src/main/resources/db/migration/V1.5__create_pay_batch_tables.sql
git commit -m "feat: 批量付款到户有密 - 新增批次/明细/授权表迁移脚本"
Files:
java/src/main/java/com/payment/platform/module/payment/batch/entity/BatchAuthorizeEntity.javajava/src/main/java/com/payment/platform/module/payment/batch/entity/BatchOrderEntity.javajava/src/main/java/com/payment/platform/module/payment/batch/entity/BatchDetailEntity.javajava/src/main/java/com/payment/platform/module/payment/batch/mapper/BatchAuthorizeMapper.javajava/src/main/java/com/payment/platform/module/payment/batch/mapper/BatchOrderMapper.javajava/src/main/java/com/payment/platform/module/payment/batch/mapper/BatchDetailMapper.javaInterfaces:
Produces: BatchAuthorizeEntity/Mapper、BatchOrderEntity/Mapper、BatchDetailEntity/Mapper(任务 3-6 使用)
[ ] Step 1: 写实体类(3 个,模式同 account/entity/TransferEntity.java)
package com.payment.platform.module.payment.batch.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.payment.platform.common.base.PaymentEnterpriseBaseEntity;
import com.payment.platform.common.handler.JsonbTypeHandler;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("pay_batch_authorize")
public class BatchAuthorizeEntity extends PaymentEnterpriseBaseEntity {
private String outBizNo;
private String participantId;
private String participantIdType;
private String agreementNo;
/** AUTHING / AUTHED / UNBIND */
private String status;
private String authorizeLink;
private OffsetDateTime authorizeExpireTime;
}
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("pay_batch_order")
public class BatchOrderEntity extends PaymentEnterpriseBaseEntity {
private String outBatchNo;
private String batchTransId;
private BigDecimal totalAmount;
private Integer totalCount;
private String orderTitle;
/** INIT / SUCCESS / DISUSE / FAIL */
private String status;
private OffsetDateTime timeExpire;
private String payerUid;
private String agreementNo;
private String transferSceneName;
@TableField(typeHandler = JsonbTypeHandler.class)
private String transferSceneReportInfos;
private String remark;
private String payUrl;
private String errorCode;
private String errorMsg;
}
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("pay_batch_detail")
public class BatchDetailEntity extends PaymentEnterpriseBaseEntity {
private Long batchId;
private String outBizNo;
private BigDecimal amount;
private String remark;
private String payeeIdentity;
private String payeeIdentityType;
private String payeeName;
/** INIT / SUCCESS / FAIL */
private String status;
private String errorCode;
private String errorMsg;
}
[ ] Step 2: 写 Mapper(3 个)
package com.payment.platform.module.payment.batch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.payment.platform.module.payment.batch.entity.BatchAuthorizeEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BatchAuthorizeMapper extends BaseMapper<BatchAuthorizeEntity> {
}
(BatchOrderMapper、BatchDetailMapper 同构,泛型分别换为对应实体)
Run: cd java && mvn -q compile
Expected: BUILD SUCCESS
[ ] Step 4: Commit
git add java/src/main/java/com/payment/platform/module/payment/batch/
git commit -m "feat: 批量付款 - 批次/明细/授权实体与 Mapper"
Files:
java/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.javajava/src/test/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayServiceTest.javaInterfaces:
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: 写失败测试
package com.payment.platform.module.payment.batch.service;
import com.alipay.api.AlipayClient;
import com.alipay.api.AlipayApiException;
import com.alipay.api.domain.AlipayFundAuthorizeUniApplyModel;
import com.alipay.api.domain.AlipayFundAuthorizeUniQueryModel;
import com.alipay.api.request.AlipayFundAuthorizeUniApplyRequest;
import com.alipay.api.request.AlipayFundAuthorizeUniQueryRequest;
import com.alipay.api.response.AlipayFundAuthorizeUniApplyResponse;
import com.alipay.api.response.AlipayFundAuthorizeUniQueryResponse;
import com.payment.platform.common.exception.BusinessException;
import com.payment.platform.core.alipay.AlipayClientFactory;
import com.payment.platform.module.payment.batch.entity.BatchAuthorizeEntity;
import com.payment.platform.module.payment.batch.mapper.BatchAuthorizeMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class AlipayBatchPayServiceTest {
@Mock private AlipayClientFactory alipayClientFactory;
@Mock private AlipayClient alipayClient;
@Mock private BatchAuthorizeMapper batchAuthorizeMapper;
private AlipayBatchPayService service;
@BeforeEach
void setUp() {
service = new AlipayBatchPayService(alipayClientFactory, batchAuthorizeMapper);
when(alipayClientFactory.getClient("E100", "BATCH_PAY")).thenReturn(alipayClient);
}
@Test
void authorizeApply_returnsShortLinkAndPersists() throws AlipayApiException {
AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
resp.setAuthorizeLink("https://ur.alipay.com/abc");
resp.setOutBizNo("A1");
when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
Map<String, String> result = service.authorizeApply("E100", "2088123412341234");
assertEquals("https://ur.alipay.com/abc", result.get("authorize_link"));
assertEquals("AUTHING", result.get("status"));
ArgumentCaptor<AlipayFundAuthorizeUniApplyRequest> cap = ArgumentCaptor.forClass(AlipayFundAuthorizeUniApplyRequest.class);
verify(alipayClient).certificateExecute(cap.capture());
AlipayFundAuthorizeUniApplyModel m = cap.getValue().getBizModel();
assertEquals("TRANSFER_API_STANDARD_AUTHORIZATION", m.getProductCode());
assertEquals("STANDARD_CREATE_FUND_ORDER", m.getBizScene());
assertEquals("SHORT_URL", m.getAuthorizeLinkType());
assertEquals("pc", m.getChannel());
assertEquals("2088123412341234", m.getPrincipalInfo().getParticipantId());
ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
verify(batchAuthorizeMapper).insert(ent.capture());
assertEquals("E100", ent.getValue().getEnterpriseId());
assertEquals("AUTHING", ent.getValue().getStatus());
}
@Test
void authorizeApply_failure_throwsBusinessException() throws AlipayApiException {
when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class)))
.thenThrow(new AlipayApiException("network error"));
assertThrows(BusinessException.class, () -> service.authorizeApply("E100", "2088123412341234"));
}
@Test
void queryAuthorize_returnsAgreementNo() throws AlipayApiException {
AlipayFundAuthorizeUniQueryResponse resp = new AlipayFundAuthorizeUniQueryResponse();
resp.setAgreementNo("AGMT001");
resp.setStatus("AUTHED");
when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniQueryRequest.class))).thenReturn(resp);
Map<String, String> result = service.queryAuthorize("E100", "A1");
assertEquals("AGMT001", result.get("agreement_no"));
assertEquals("AUTHED", result.get("status"));
}
}
[ ] Step 2: 跑测试确认失败
Run: cd java && mvn -q test -Dtest=AlipayBatchPayServiceTest
Expected: 编译失败(AlipayBatchPayService 不存在)
[ ] Step 3: 写实现
package com.payment.platform.module.payment.batch.service;
import com.alipay.api.AlipayApiException;
import com.alipay.api.AlipayClient;
import com.alipay.api.domain.AlipayFundAuthorizeUniApplyModel;
import com.alipay.api.domain.AlipayFundAuthorizeUniQueryModel;
import com.alipay.api.domain.AuthParticipantInfo;
import com.alipay.api.request.AlipayFundAuthorizeUniApplyRequest;
import com.alipay.api.request.AlipayFundAuthorizeUniQueryRequest;
import com.alipay.api.response.AlipayFundAuthorizeUniApplyResponse;
import com.alipay.api.response.AlipayFundAuthorizeUniQueryResponse;
import com.payment.platform.common.exception.BusinessException;
import com.payment.platform.common.response.PageResult;
import com.payment.platform.common.utils.ExcelUtil;
import com.payment.platform.common.utils.SnowflakeIdGenerator;
import com.payment.platform.core.alipay.AlipayClientFactory;
import com.payment.platform.module.payment.batch.dto.BatchCreateDTO;
import com.payment.platform.module.payment.batch.entity.BatchAuthorizeEntity;
import com.payment.platform.module.payment.batch.entity.BatchDetailEntity;
import com.payment.platform.module.payment.batch.entity.BatchOrderEntity;
import com.payment.platform.module.payment.batch.mapper.BatchAuthorizeMapper;
import com.payment.platform.module.payment.batch.mapper.BatchDetailMapper;
import com.payment.platform.module.payment.batch.mapper.BatchOrderMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 批量付款到户有密 — 制单授权
* <p>
* 授权链路: authorizeApply 生成短链(PC) → 付款方端内授权(永久生效) →
* fund.authorize.status.notify 异步通知回写 agreement_no。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AlipayBatchPayService {
private static final String AUTHORIZE_PRODUCT_CODE = "TRANSFER_API_STANDARD_AUTHORIZATION";
private static final String AUTHORIZE_BIZ_SCENE = "STANDARD_CREATE_FUND_ORDER";
private static final String AUTHORIZE_LINK_TYPE = "SHORT_URL";
private static final String BIZ_TYPE = "BATCH_PAY";
/** 静态 ObjectMapper(同 NotificationService 第 55 行模式),用于 ext_info / 报备 JSON 序列化 */
private static final ObjectMapper oMapper = new ObjectMapper();
private final AlipayClientFactory alipayClientFactory;
private final BatchAuthorizeMapper batchAuthorizeMapper;
/** alipay.fund.authorize.uni.apply — 生成制单授权短链接(PC 渠道) */
@Transactional
public Map<String, String> authorizeApply(String enterpriseId, String participantId) {
if (participantId == null || participantId.isBlank())
throw new BusinessException(400, "付款方支付宝账号不能为空");
String outBizNo = SnowflakeIdGenerator.nextIdStr();
try {
AlipayFundAuthorizeUniApplyModel model = new AlipayFundAuthorizeUniApplyModel();
model.setProductCode(AUTHORIZE_PRODUCT_CODE);
model.setBizScene(AUTHORIZE_BIZ_SCENE);
model.setOutBizNo(outBizNo);
model.setAuthorizeLinkType(AUTHORIZE_LINK_TYPE);
model.setChannel("pc");
AuthParticipantInfo principal = new AuthParticipantInfo();
principal.setParticipantId(participantId);
principal.setParticipantIdType("ALIPAY_USER_ID");
model.setPrincipalInfo(principal);
AlipayFundAuthorizeUniApplyRequest request = new AlipayFundAuthorizeUniApplyRequest();
request.setBizModel(model);
AlipayClient client = alipayClientFactory.getClient(enterpriseId, BIZ_TYPE);
AlipayFundAuthorizeUniApplyResponse response = client.certificateExecute(request);
if (!response.isSuccess())
throw new BusinessException(400, "生成授权链接失败: " + response.getMsg());
BatchAuthorizeEntity entity = new BatchAuthorizeEntity();
entity.setEnterpriseId(enterpriseId);
entity.setOutBizNo(outBizNo);
entity.setParticipantId(participantId);
entity.setParticipantIdType("ALIPAY_USER_ID");
entity.setStatus("AUTHING");
entity.setAuthorizeLink(response.getAuthorizeLink());
batchAuthorizeMapper.insert(entity);
return Map.of("authorize_link", response.getAuthorizeLink(),
"out_biz_no", outBizNo, "status", "AUTHING");
} catch (AlipayApiException e) {
throw new BusinessException(400, "生成授权链接失败: " + e.getMessage());
}
}
/** alipay.fund.authorize.uni.query — 查询制单授权状态(单协议) */
public Map<String, String> queryAuthorize(String enterpriseId, String outBizNo) {
try {
AlipayFundAuthorizeUniQueryModel model = new AlipayFundAuthorizeUniQueryModel();
model.setProductCode(AUTHORIZE_PRODUCT_CODE);
model.setBizScene(AUTHORIZE_BIZ_SCENE);
model.setOutBizNo(outBizNo);
AlipayFundAuthorizeUniQueryRequest request = new AlipayFundAuthorizeUniQueryRequest();
request.setBizModel(model);
AlipayFundAuthorizeUniQueryResponse response =
alipayClientFactory.getClient(enterpriseId, BIZ_TYPE).certificateExecute(request);
if (!response.isSuccess())
throw new BusinessException(400, "查询授权状态失败: " + response.getMsg());
if (response.getAgreementNo() != null && !response.getAgreementNo().isBlank()) {
BatchAuthorizeEntity entity = batchAuthorizeMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
.eq(BatchAuthorizeEntity::getOutBizNo, outBizNo));
if (entity != null) {
entity.setAgreementNo(response.getAgreementNo());
entity.setStatus("AUTHED");
batchAuthorizeMapper.updateById(entity);
}
}
return Map.of("agreement_no", response.getAgreementNo() != null ? response.getAgreementNo() : "",
"status", response.getStatus() != null ? response.getStatus() : "AUTHING");
} catch (AlipayApiException e) {
throw new BusinessException(400, "查询授权状态失败: " + e.getMessage());
}
}
}
[ ] Step 4: 跑测试确认通过
Run: cd java && mvn -q test -Dtest=AlipayBatchPayServiceTest
Expected: 3 个测试全部 PASS
[ ] Step 5: Commit
git add java/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.java java/src/test/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayServiceTest.java
git commit -m "feat: 批量付款 - 制单授权申请与查询"
Files:
java/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.javajava/src/test/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayServiceTest.javajava/src/main/java/com/payment/platform/module/payment/batch/dto/BatchCreateDTO.javaInterfaces:
BatchOrderMapper、BatchDetailMapper;Task 3 的 serviceProduces: batchCreate(BatchCreateDTO dto) → Map<String,Object>(out_batch_no、batch_trans_id、status);renderPay(String enterpriseId, String outBatchNo) → Map<String,String>(pay_url);batchQuery(String enterpriseId, String outBatchNo) → Map<String,Object>(批次+明细状态);batchClose(String enterpriseId, String outBatchNo) → Map<String,String>
[ ] Step 1: 写 DTO
package com.payment.platform.module.payment.batch.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
@Data
public class BatchCreateDTO {
@Schema(description = "企业ID")
private String enterpriseId;
@Schema(description = "租户ID(内部,由 Controller 注入)")
private Long tenantId;
@NotBlank(message = "批次标题不能为空")
@Schema(description = "批次标题(展示在付款方账单)")
private String orderTitle;
@NotBlank(message = "付款方支付宝UID不能为空")
@Schema(description = "付款方支付宝 UID")
private String payerUid;
@Schema(description = "制单授权协议号(payer_info.ext_info 传此值则校验指定协议;不传校验任意协议)")
private String agreementNo;
@Schema(description = "转账场景(26年新接入商户必传): 现金营销/企业退款/佣金报酬/二手回收/业务结算/公益补助/行政补贴和退款/保险理赔")
private String transferSceneName;
@Schema(description = "转账场景报备信息: [{info_type, info_content}](26年新接入商户必传)")
private List<Map<String, String>> transferSceneReportInfos;
@Schema(description = "超时时间(yyyy-MM-dd HH:mm),默认30天")
private String timeExpire;
@Schema(description = "业务备注")
private String remark;
@NotNull(message = "明细不能为空")
@Size(min = 1, max = 1000, message = "每批明细 1-1000 笔")
@Schema(description = "收款明细(≤1000笔,金额≥1元)")
private List<BatchDetailDTO> details;
@Data
public static class BatchDetailDTO {
@NotBlank(message = "明细外部单号不能为空")
private String outBizNo;
@NotNull(message = "明细金额不能为空")
private BigDecimal amount;
@Schema(description = "转账备注(展示在收款方账单)")
private String remark;
@NotBlank(message = "收款方账号不能为空")
private String payeeIdentity;
@Schema(description = "收款方类型: ALIPAY_LOGON_ID/ALIPAY_USER_ID/ALIPAY_OPEN_ID(默认 ALIPAY_LOGON_ID)")
private String payeeIdentityType;
@Schema(description = "收款方姓名(LOGON_ID 必填,校验姓名一致)")
private String payeeName;
}
}
[ ] Step 2: 写失败测试(追加到 AlipayBatchPayServiceTest)
@Mock private BatchOrderMapper batchOrderMapper;
@Mock private BatchDetailMapper batchDetailMapper;
// setUp 中: service = new AlipayBatchPayService(alipayClientFactory, batchAuthorizeMapper, batchOrderMapper, batchDetailMapper);
@Test
void batchCreate_persistsOrderAndDetails() throws AlipayApiException {
AlipayFundBatchCreateResponse resp = new AlipayFundBatchCreateResponse();
resp.setOutBatchNo("B1");
resp.setBatchTransId("BT1");
resp.setStatus("INIT");
when(alipayClient.certificateExecute(any(AlipayFundBatchCreateRequest.class))).thenReturn(resp);
BatchCreateDTO dto = new BatchCreateDTO();
dto.setEnterpriseId("E100");
dto.setOrderTitle("202608报销");
dto.setPayerUid("2088PAYER");
dto.setAgreementNo("AGMT001");
dto.setTransferSceneName("佣金报酬");
dto.setTransferSceneReportInfos(List.of(Map.of("info_type", "佣金报酬说明", "info_content", "8月家政服务报酬")));
BatchCreateDTO.BatchDetailDTO detail = new BatchCreateDTO.BatchDetailDTO();
detail.setOutBizNo("D1");
detail.setAmount(new BigDecimal("20.11"));
detail.setPayeeIdentity("test@taobao.com");
detail.setPayeeIdentityType("ALIPAY_LOGON_ID");
detail.setPayeeName("张三");
dto.setDetails(List.of(detail));
Map<String, Object> result = service.batchCreate(dto);
assertEquals("B1", result.get("out_batch_no"));
ArgumentCaptor<AlipayFundBatchCreateRequest> cap = ArgumentCaptor.forClass(AlipayFundBatchCreateRequest.class);
verify(alipayClient).certificateExecute(cap.capture());
AlipayFundBatchCreateModel m = cap.getValue().getBizModel();
assertEquals("BATCH_API_TO_ACC", m.getProductCode());
assertEquals("STANDARD_MESSAGE_BATCH_PAY", m.getBizScene());
assertEquals("佣金报酬", m.getTransferSceneName());
assertEquals("20.11", m.getTransOrderList().get(0).getTransAmount());
assertEquals("AGMT001", m.getPayerInfo().getExtInfo());
verify(batchOrderMapper).insert(any(BatchOrderEntity.class));
verify(batchDetailMapper).insert(any(BatchDetailEntity.class));
}
@Test
void batchCreate_detailAmountBelow1_throws() {
BatchCreateDTO dto = new BatchCreateDTO();
dto.setOrderTitle("t");
dto.setPayerUid("2088PAYER");
BatchCreateDTO.BatchDetailDTO detail = new BatchCreateDTO.BatchDetailDTO();
detail.setOutBizNo("D1");
detail.setAmount(new BigDecimal("0.5"));
detail.setPayeeIdentity("a@b.com");
dto.setDetails(List.of(detail));
assertThrows(BusinessException.class, () -> service.batchCreate(dto));
}
@Test
void batchCreate_duplicateOutBatchNo_throwsBusinessException() throws AlipayApiException {
when(batchOrderMapper.selectCount(any()))
.thenReturn(1L);
BatchCreateDTO dto = new BatchCreateDTO();
dto.setOrderTitle("t");
dto.setPayerUid("2088PAYER");
dto.setOutBatchNo("B1");
BatchCreateDTO.BatchDetailDTO detail = new BatchCreateDTO.BatchDetailDTO();
detail.setOutBizNo("D1");
detail.setAmount(new BigDecimal("10"));
detail.setPayeeIdentity("a@b.com");
dto.setDetails(List.of(detail));
assertThrows(BusinessException.class, () -> service.batchCreate(dto));
}
@Test
void renderPay_returnsPayUrl() throws AlipayApiException {
BatchOrderEntity order = new BatchOrderEntity();
order.setEnterpriseId("E100");
order.setOutBatchNo("B1");
order.setBatchTransId("BT1");
order.setStatus("INIT");
when(batchOrderMapper.selectOne(any())).thenReturn(order);
AlipayFundTransRenderPayResponse resp = new AlipayFundTransRenderPayResponse();
resp.setOrderId("BT1");
when(alipayClient.pageExecute(any(AlipayFundTransRenderPayRequest.class))).thenReturn(resp);
Map<String, String> result = service.renderPay("E100", "B1");
assertNotNull(result.get("pay_url"));
verify(batchOrderMapper).updateById(order);
}
@Test
void batchQuery_syncsBatchStatus() throws AlipayApiException {
BatchOrderEntity order = new BatchOrderEntity();
order.setId(1L);
order.setEnterpriseId("E100");
order.setOutBatchNo("B1");
order.setBatchTransId("BT1");
order.setStatus("INIT");
when(batchOrderMapper.selectOne(any())).thenReturn(order);
AlipayFundBatchDetailQueryResponse resp = new AlipayFundBatchDetailQueryResponse();
resp.setBatchStatus("SUCCESS");
when(alipayClient.certificateExecute(any(AlipayFundBatchDetailQueryRequest.class))).thenReturn(resp);
Map<String, Object> result = service.batchQuery("E100", "B1");
assertEquals("SUCCESS", result.get("status"));
assertEquals("SUCCESS", order.getStatus());
verify(batchOrderMapper).updateById(order);
}
@Test
void batchClose_callsCloseApi() throws AlipayApiException {
BatchOrderEntity order = new BatchOrderEntity();
order.setEnterpriseId("E100");
order.setOutBatchNo("B1");
order.setBatchTransId("BT1");
order.setStatus("INIT");
when(batchOrderMapper.selectOne(any())).thenReturn(order);
when(alipayClient.certificateExecute(any(AlipayFundBatchCloseRequest.class)))
.thenReturn(new AlipayFundBatchCloseResponse());
service.batchClose("E100", "B1");
verify(alipayClient).certificateExecute(any(AlipayFundBatchCloseRequest.class));
}
(对应 import:AlipayFundBatchCreateModel/Request/Response、AlipayFundTransRenderPayModel/Request/Response、AlipayFundBatchDetailQueryModel/Request/Response、AlipayFundBatchCloseModel/Request/Response、TransOrderDetail、Participant)
Run: cd java && mvn -q test -Dtest=AlipayBatchPayServiceTest
Expected: 编译失败(方法不存在)
[ ] Step 4: 写实现(追加到 AlipayBatchPayService)
// ==================== 批次 ====================
private static final String BATCH_PRODUCT_CODE = "BATCH_API_TO_ACC";
private static final String BATCH_BIZ_SCENE = "STANDARD_MESSAGE_BATCH_PAY";
private final BatchOrderMapper batchOrderMapper;
private final BatchDetailMapper batchDetailMapper;
/** alipay.fund.batch.create — 创建批量付款单据(幂等: out_batch_no 唯一) */
@Transactional
public Map<String, Object> batchCreate(BatchCreateDTO dto) {
if (dto.getOutBatchNo() == null || dto.getOutBatchNo().isBlank())
dto.setOutBatchNo(SnowflakeIdGenerator.nextIdStr());
// 幂等保护: 同单号已存在则拒绝,避免重复支付
if (batchOrderMapper.selectCount(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
.eq(BatchOrderEntity::getOutBatchNo, dto.getOutBatchNo())) > 0)
throw new BusinessException(400, "批次外部单号已存在,请勿重复创建");
BigDecimal total = BigDecimal.ZERO;
List<TransOrderDetail> transList = new ArrayList<>();
for (BatchCreateDTO.BatchDetailDTO d : dto.getDetails()) {
if (d.getAmount().compareTo(new BigDecimal("1")) < 0)
throw new BusinessException(400, "明细金额最低 1 元: " + d.getOutBizNo());
total = total.add(d.getAmount());
TransOrderDetail t = new TransOrderDetail();
t.setOutBizNo(d.getOutBizNo());
t.setTransAmount(d.getAmount().toPlainString());
t.setRemark(d.getRemark());
Participant payee = new Participant();
String idType = d.getPayeeIdentityType() != null ? d.getPayeeIdentityType() : "ALIPAY_LOGON_ID";
if ("ALIPAY_LOGON_ID".equals(idType) && (d.getPayeeName() == null || d.getPayeeName().isBlank()))
throw new BusinessException(400, "收款方为账号时姓名必填: " + d.getOutBizNo());
payee.setIdentity(d.getPayeeIdentity());
payee.setIdentityType(idType);
payee.setName(d.getPayeeName());
t.setPayeeInfo(payee);
transList.add(t);
}
try {
AlipayFundBatchCreateModel model = new AlipayFundBatchCreateModel();
model.setOutBatchNo(dto.getOutBatchNo());
model.setTotalTransAmount(total.toPlainString());
model.setTotalCount(String.valueOf(dto.getDetails().size()));
model.setProductCode(BATCH_PRODUCT_CODE);
model.setBizScene(BATCH_BIZ_SCENE);
model.setOrderTitle(dto.getOrderTitle());
if (dto.getTimeExpire() != null) model.setTimeExpire(dto.getTimeExpire());
if (dto.getRemark() != null) model.setRemark(dto.getRemark());
// 付款方 + 制单授权协议
Participant payer = new Participant();
payer.setIdentity(dto.getPayerUid());
payer.setIdentityType("ALIPAY_USER_ID");
if (dto.getAgreementNo() != null && !dto.getAgreementNo().isBlank()) {
Map<String, String> ext = new LinkedHashMap<>();
ext.put("agreement_no", dto.getAgreementNo());
try { payer.setExtInfo(oMapper.writeValueAsString(ext)); }
catch (Exception e) { throw new BusinessException(400, "序列化 ext_info 失败"); }
}
model.setPayerInfo(payer);
model.setTransOrderList(transList);
// 转账场景报备(26年新接入必传)
model.setTransferSceneName(dto.getTransferSceneName());
if (dto.getTransferSceneReportInfos() != null && !dto.getTransferSceneReportInfos().isEmpty()) {
List<TransferSceneReportInfo> infos = dto.getTransferSceneReportInfos().stream()
.map(m -> { TransferSceneReportInfo i = new TransferSceneReportInfo();
i.setInfoType(m.get("info_type")); i.setInfoContent(m.get("info_content")); return i; })
.collect(java.util.stream.Collectors.toList());
model.setTransferSceneReportInfos(infos);
}
AlipayFundBatchCreateRequest request = new AlipayFundBatchCreateRequest();
request.setBizModel(model);
AlipayFundBatchCreateResponse response =
alipayClientFactory.getClient(dto.getEnterpriseId(), BIZ_TYPE).certificateExecute(request);
if (!response.isSuccess()) {
// 幂等兜底: UNIQUE_VIOLATION 说明支付宝侧已受理同单号批次,查库返回已受理信息而非报错
if ("UNIQUE_VIOLATION".equals(response.getSubCode())) {
BatchOrderEntity existing = batchOrderMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
.eq(BatchOrderEntity::getOutBatchNo, dto.getOutBatchNo()));
if (existing != null)
return Map.of("out_batch_no", existing.getOutBatchNo(),
"batch_trans_id", existing.getBatchTransId() != null ? existing.getBatchTransId() : "",
"status", existing.getStatus());
}
throw new BusinessException(400, "创建批次失败: " + response.getMsg() + " (" + response.getSubCode() + ")");
}
BatchOrderEntity order = new BatchOrderEntity();
order.setEnterpriseId(dto.getEnterpriseId());
order.setTenantId(dto.getTenantId());
order.setOutBatchNo(dto.getOutBatchNo());
order.setBatchTransId(response.getBatchTransId());
order.setTotalAmount(total);
order.setTotalCount(dto.getDetails().size());
order.setOrderTitle(dto.getOrderTitle());
order.setStatus("INIT");
order.setPayerUid(dto.getPayerUid());
order.setAgreementNo(dto.getAgreementNo());
order.setTransferSceneName(dto.getTransferSceneName());
if (dto.getTransferSceneReportInfos() != null)
try { order.setTransferSceneReportInfos(oMapper.writeValueAsString(dto.getTransferSceneReportInfos())); }
catch (Exception ignored) { }
order.setRemark(dto.getRemark());
batchOrderMapper.insert(order);
for (BatchCreateDTO.BatchDetailDTO d : dto.getDetails()) {
BatchDetailEntity de = new BatchDetailEntity();
de.setEnterpriseId(dto.getEnterpriseId());
de.setTenantId(dto.getTenantId());
de.setBatchId(order.getId());
de.setOutBizNo(d.getOutBizNo());
de.setAmount(d.getAmount());
de.setRemark(d.getRemark());
de.setPayeeIdentity(d.getPayeeIdentity());
de.setPayeeIdentityType(d.getPayeeIdentityType() != null ? d.getPayeeIdentityType() : "ALIPAY_LOGON_ID");
de.setPayeeName(d.getPayeeName());
de.setStatus("INIT");
batchDetailMapper.insert(de);
}
return Map.of("out_batch_no", dto.getOutBatchNo(),
"batch_trans_id", response.getBatchTransId() != null ? response.getBatchTransId() : "",
"status", "INIT");
} catch (AlipayApiException e) {
throw new BusinessException(400, "创建批次失败: " + e.getMessage());
}
}
/** alipay.fund.trans.render.pay — 生成 PC 支付页链接 */
public Map<String, String> renderPay(String enterpriseId, String outBatchNo) {
BatchOrderEntity order = batchOrderMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
.eq(BatchOrderEntity::getOutBatchNo, outBatchNo));
if (order == null) throw new BusinessException(404, "批次不存在");
if (!"INIT".equals(order.getStatus()))
throw new BusinessException(400, "仅受理中的批次可支付,当前状态: " + order.getStatus());
try {
AlipayFundTransRenderPayModel model = new AlipayFundTransRenderPayModel();
model.setProductCode(BATCH_PRODUCT_CODE);
model.setBizScene(BATCH_BIZ_SCENE);
model.setOrderId(order.getBatchTransId());
AlipayFundTransRenderPayRequest request = new AlipayFundTransRenderPayRequest();
request.setBizModel(model);
AlipayFundTransRenderPayResponse response =
alipayClientFactory.getClient(enterpriseId, BIZ_TYPE).pageExecute(request);
if (response == null || response.getBody() == null)
throw new BusinessException(400, "生成支付页面失败: 无响应");
order.setPayUrl(response.getBody());
batchOrderMapper.updateById(order);
return Map.of("pay_url", response.getBody());
} catch (AlipayApiException e) {
throw new BusinessException(400, "生成支付页面失败: " + e.getMessage());
}
}
/** alipay.fund.batch.detail.query — 查询批次+明细状态并回写 DB */
public Map<String, Object> batchQuery(String enterpriseId, String outBatchNo) {
BatchOrderEntity order = batchOrderMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
.eq(BatchOrderEntity::getOutBatchNo, outBatchNo));
if (order == null) throw new BusinessException(404, "批次不存在");
try {
AlipayFundBatchDetailQueryModel model = new AlipayFundBatchDetailQueryModel();
model.setOutBatchNo(outBatchNo);
model.setProductCode(BATCH_PRODUCT_CODE);
model.setBizScene(BATCH_BIZ_SCENE);
AlipayFundBatchDetailQueryRequest request = new AlipayFundBatchDetailQueryRequest();
request.setBizModel(model);
AlipayFundBatchDetailQueryResponse response =
alipayClientFactory.getClient(enterpriseId, BIZ_TYPE).certificateExecute(request);
if (!response.isSuccess())
throw new BusinessException(400, "查询批次失败: " + response.getMsg());
// 批次状态回写(SDK 字段 batch_status,非 status — 已 javap 实证)
if (response.getBatchStatus() != null && !response.getBatchStatus().equals(order.getStatus())) {
order.setStatus(response.getBatchStatus());
batchOrderMapper.updateById(order);
}
// 明细状态回写(SDK 字段 acc_detail_list,按 out_biz_no 匹配本地明细)
if (response.getAccDetailList() != null) {
for (AccDetailModel m : response.getAccDetailList()) {
if (m.getOutBizNo() == null) continue;
BatchDetailEntity de = batchDetailMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchDetailEntity>()
.eq(BatchDetailEntity::getOutBizNo, m.getOutBizNo()));
if (de == null) continue;
if (m.getStatus() != null) de.setStatus(m.getStatus());
if (m.getErrorCode() != null) de.setErrorCode(m.getErrorCode());
if (m.getErrorMsg() != null) de.setErrorMsg(m.getErrorMsg());
batchDetailMapper.updateById(de);
}
}
return Map.of("out_batch_no", outBatchNo,
"status", response.getBatchStatus() != null ? response.getBatchStatus() : order.getStatus());
} catch (AlipayApiException e) {
throw new BusinessException(400, "查询批次失败: " + e.getMessage());
}
}
/** alipay.fund.batch.close — 主动关闭未支付批次 */
public Map<String, String> batchClose(String enterpriseId, String outBatchNo) {
BatchOrderEntity order = batchOrderMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
.eq(BatchOrderEntity::getOutBatchNo, outBatchNo));
if (order == null) throw new BusinessException(404, "批次不存在");
try {
AlipayFundBatchCloseModel model = new AlipayFundBatchCloseModel();
model.setBatchTransId(order.getBatchTransId());
model.setProductCode(BATCH_PRODUCT_CODE);
model.setBizScene(BATCH_BIZ_SCENE);
AlipayFundBatchCloseRequest request = new AlipayFundBatchCloseRequest();
request.setBizModel(model);
AlipayFundBatchCloseResponse response =
alipayClientFactory.getClient(enterpriseId, BIZ_TYPE).certificateExecute(request);
if (!response.isSuccess())
throw new BusinessException(400, "关闭批次失败: " + response.getMsg());
order.setStatus("DISUSE");
batchOrderMapper.updateById(order);
return Map.of("status", "DISUSE");
} catch (AlipayApiException e) {
throw new BusinessException(400, "关闭批次失败: " + e.getMessage());
}
}
// ==================== 列表 / 详情 / 导出 ====================
/** 授权列表(分页) */
public PageResult<BatchAuthorizeEntity> authorizeList(String enterpriseId, int pageNo, int pageSize) {
var w = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
.eq(enterpriseId != null && !enterpriseId.isBlank(), BatchAuthorizeEntity::getEnterpriseId, enterpriseId)
.orderByDesc(BatchAuthorizeEntity::getId);
var r = batchAuthorizeMapper.selectPage(new Page<>(pageNo, pageSize), w);
return PageResult.of(pageNo, pageSize, r.getTotal(), r.getRecords());
}
/** 批次列表(分页,状态/时间筛选)— 时间解析照抄 AccountService.parseDateTime 模式 */
public PageResult<BatchOrderEntity> batchList(String enterpriseId, String status,
String startTime, String endTime, int pageNo, int pageSize) {
var w = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
.eq(enterpriseId != null && !enterpriseId.isBlank(), BatchOrderEntity::getEnterpriseId, enterpriseId)
.eq(status != null && !status.isBlank(), BatchOrderEntity::getStatus, status)
.orderByDesc(BatchOrderEntity::getId);
if (startTime != null && !startTime.isBlank()) {
// "yyyy-MM-dd HH:mm" → "yyyy-MM-ddTHH:mm:00+08:00"(ISO 默认模式,无需自定义格式器)
w.ge(BatchOrderEntity::getCreatedTime,
OffsetDateTime.parse(startTime.replace(' ', 'T') + ":00+08:00"));
}
if (endTime != null && !endTime.isBlank()) {
w.le(BatchOrderEntity::getCreatedTime,
OffsetDateTime.parse(endTime.replace(' ', 'T') + ":59+08:00"));
}
var r = batchOrderMapper.selectPage(new Page<>(pageNo, pageSize), w);
return PageResult.of(pageNo, pageSize, r.getTotal(), r.getRecords());
}
/** 批次详情 + 明细分页 */
public Map<String, Object> batchDetail(String outBatchNo, int pageNo, int pageSize) {
BatchOrderEntity order = batchOrderMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
.eq(BatchOrderEntity::getOutBatchNo, outBatchNo));
if (order == null) throw new BusinessException(404, "批次不存在");
Page<BatchDetailEntity> detailPage = batchDetailMapper.selectPage(new Page<>(pageNo, pageSize),
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchDetailEntity>()
.eq(BatchDetailEntity::getBatchId, order.getId())
.orderByAsc(BatchDetailEntity::getId));
return Map.of("order", order,
"details", PageResult.of(pageNo, pageSize, detailPage.getTotal(), detailPage.getRecords()));
}
/** 批次导出 — 照抄 AccountService.transferExport 的组装写法(ExcelUtil.exportToExcel(listData, mappingDict)) */
public byte[] batchExport(String enterpriseId, String status, String startTime, String endTime) {
var w = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
.eq(enterpriseId != null && !enterpriseId.isBlank(), BatchOrderEntity::getEnterpriseId, enterpriseId)
.eq(status != null && !status.isBlank(), BatchOrderEntity::getStatus, status)
.orderByDesc(BatchOrderEntity::getId);
List<BatchOrderEntity> records = batchOrderMapper.selectList(w);
// 列: 序号/批次号/支付宝批次号/标题/金额(元)/笔数/状态/创建时间/错误信息
// 状态映射: INIT=受理中, SUCCESS=成功, DISUSE=已关闭, FAIL=失败
// 逐行组装 LinkedHashMap,时间用 Asia/Shanghai 时区格式化 yyyy-MM-dd HH:mm:ss
List<Map<String, Object>> listData = new ArrayList<>();
// ...(与 transferExport 相同的组装与 mappingDict 定义,映射表保持一致)
Map<String, String> mappingDict = new LinkedHashMap<>();
return ExcelUtil.exportToExcel(listData, mappingDict);
}
注:
ObjectMapper用类静态字段oMapper(同NotificationService第 55 行模式),测试构造器保持 4 参数不变。SDK 响应字段已 javap 实证:批次状态getBatchStatus()、明细列表getAccDetailList()(元素AccDetailModel含getOutBizNo/getDetailNo/getStatus/getErrorCode/getErrorMsg)。batchExport的 listData/mappingDict 组装细节照抄AccountService.transferExport(第 298-377 行),仅列名与字段对应不同。
Run: cd java && mvn -q test -Dtest=AlipayBatchPayServiceTest
Expected: 全部 PASS
[ ] Step 6: Commit
git add java/src/main/java/com/payment/platform/module/payment/batch/
git commit -m "feat: 批量付款 - 批次创建/支付页/查询/关单"
Files:
java/src/main/java/com/payment/platform/module/payment/batch/controller/BatchPayController.javaInterfaces:
AlipayBatchPayServiceProduces: /payment/account/batch/* REST 端点(前端 Task 7 调用)
[ ] Step 1: 写实现
package com.payment.platform.module.payment.batch.controller;
import com.payment.platform.common.response.PageResult;
import com.payment.platform.common.response.Result;
import com.payment.platform.module.payment.batch.dto.BatchCreateDTO;
import com.payment.platform.module.payment.batch.entity.BatchAuthorizeEntity;
import com.payment.platform.module.payment.batch.entity.BatchOrderEntity;
import com.payment.platform.module.payment.batch.service.AlipayBatchPayService;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.Map;
@RestController
@RequestMapping("/payment/account/batch")
@RequiredArgsConstructor
public class BatchPayController {
private final AlipayBatchPayService batchPayService;
@PreAuthorize("@perm.hasAny('module_payment:account:authorize')")
@PostMapping("/authorize/apply")
public Result<Map<String, String>> authorizeApply(@RequestBody Map<String, Object> b) {
return Result.ok(batchPayService.authorizeApply(
(String) b.get("enterprise_id"), (String) b.get("participant_id")));
}
@PreAuthorize("@perm.hasAny('module_payment:account:authorize')")
@GetMapping("/authorize/query")
public Result<Map<String, String>> queryAuthorize(
@RequestParam("enterprise_id") String enterpriseId,
@RequestParam("out_biz_no") String outBizNo) {
return Result.ok(batchPayService.queryAuthorize(enterpriseId, outBizNo));
}
@PreAuthorize("@perm.hasAny('module_payment:account:transfer')")
@PostMapping("/create")
public Result<Map<String, Object>> batchCreate(@Valid @RequestBody BatchCreateDTO dto) {
return Result.ok(batchPayService.batchCreate(dto));
}
@PreAuthorize("@perm.hasAny('module_payment:account:transfer')")
@PostMapping("/pay")
public Result<Map<String, String>> renderPay(@RequestBody Map<String, Object> b) {
return Result.ok(batchPayService.renderPay(
(String) b.get("enterprise_id"), (String) b.get("out_batch_no")));
}
@PreAuthorize("@perm.hasAny('module_payment:account:transfer:list')")
@GetMapping("/query")
public Result<Map<String, Object>> batchQuery(
@RequestParam("enterprise_id") String enterpriseId,
@RequestParam("out_batch_no") String outBatchNo) {
return Result.ok(batchPayService.batchQuery(enterpriseId, outBatchNo));
}
@PreAuthorize("@perm.hasAny('module_payment:account:transfer')")
@PostMapping("/close")
public Result<Map<String, String>> batchClose(@RequestBody Map<String, Object> b) {
return Result.ok(batchPayService.batchClose(
(String) b.get("enterprise_id"), (String) b.get("out_batch_no")));
}
@PreAuthorize("@perm.hasAny('module_payment:account:transfer:list')")
@GetMapping("/list")
public Result<PageResult<BatchOrderEntity>> batchList(
@RequestParam(name = "page_no", defaultValue = "1") int pageNo,
@RequestParam(name = "page_size", defaultValue = "20") int pageSize,
@RequestParam(name = "enterprise_id", required = false) String enterpriseId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "start_time", required = false) String startTime,
@RequestParam(name = "end_time", required = false) String endTime) {
return Result.ok(batchPayService.batchList(enterpriseId, status, startTime, endTime, pageNo, pageSize));
}
@PreAuthorize("@perm.hasAny('module_payment:account:transfer:detail')")
@GetMapping("/detail")
public Result<Map<String, Object>> batchDetail(
@RequestParam("out_batch_no") String outBatchNo,
@RequestParam(name = "page_no", defaultValue = "1") int pageNo,
@RequestParam(name = "page_size", defaultValue = "20") int pageSize) {
return Result.ok(batchPayService.batchDetail(outBatchNo, pageNo, pageSize));
}
@PreAuthorize("@perm.hasAny('module_payment:account:authorize')")
@GetMapping("/authorize/list")
public Result<PageResult<BatchAuthorizeEntity>> authorizeList(
@RequestParam(name = "page_no", defaultValue = "1") int pageNo,
@RequestParam(name = "page_size", defaultValue = "20") int pageSize,
@RequestParam(name = "enterprise_id", required = false) String enterpriseId) {
return Result.ok(batchPayService.authorizeList(enterpriseId, pageNo, pageSize));
}
@PreAuthorize("@perm.hasAny('module_payment:account:transfer:list')")
@GetMapping("/export")
public void batchExport(
@RequestParam(name = "enterprise_id", required = false) String enterpriseId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "start_time", required = false) String startTime,
@RequestParam(name = "end_time", required = false) String endTime,
HttpServletResponse response) throws IOException {
byte[] bytes = batchPayService.batchExport(enterpriseId, status, startTime, endTime);
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment; filename=batch_pay_report.xlsx");
response.getOutputStream().write(bytes);
}
}
[ ] Step 2: 编译验证
Run: cd java && mvn -q compile
Expected: BUILD SUCCESS
[ ] Step 3: Commit
git add java/src/main/java/com/payment/platform/module/payment/batch/controller/BatchPayController.java
git commit -m "feat: 批量付款 - REST 端点"
Files:
java/src/main/java/com/payment/platform/module/payment/notification/enums/NotificationEnums.javajava/src/main/java/com/payment/platform/module/payment/notification/handler/NotifyContext.javajava/src/main/java/com/payment/platform/module/payment/notification/service/NotificationService.java(buildContext 注入新 mapper)java/src/main/java/com/payment/platform/module/payment/notification/handler/BatchPayHandler.javajava/src/test/java/com/payment/platform/module/payment/notification/handler/BatchPayHandlerTest.javaInterfaces:
BatchOrderMapper、BatchDetailMapper、BatchAuthorizeMapperProduces: handler 自动注册进 NotificationService.handlers(@Component),处理 alipay.fund.authorize.status.notify 与 alipay.fund.batch.order.changed
[ ] Step 1: 写失败测试
package com.payment.platform.module.payment.notification.handler;
import com.payment.platform.module.payment.batch.entity.BatchAuthorizeEntity;
import com.payment.platform.module.payment.batch.entity.BatchOrderEntity;
import com.payment.platform.module.payment.batch.mapper.BatchAuthorizeMapper;
import com.payment.platform.module.payment.batch.mapper.BatchOrderMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class BatchPayHandlerTest {
@Mock private BatchAuthorizeMapper batchAuthorizeMapper;
@Mock private BatchOrderMapper batchOrderMapper;
private BatchPayHandler handler;
@BeforeEach
void setUp() {
// handler 不持有 mapper —— mapper 通过 NotifyContext 传入(BaseNotifyHandler 的 ctx 模式)
handler = new BatchPayHandler();
}
private NotifyContext ctx() {
return new NotifyContext()
.setBatchAuthorizeMapper(batchAuthorizeMapper)
.setBatchOrderMapper(batchOrderMapper);
}
@Test
void acceptsBothNotifyMethods() {
assertTrue(handler.accept("alipay.fund.authorize.status.notify"));
assertTrue(handler.accept("alipay.fund.batch.order.changed"));
assertFalse(handler.accept("alipay.commerce.ec.enterprise.change.notify"));
}
@Test
void authorizeNotify_updatesAgreementNo() {
BatchAuthorizeEntity entity = new BatchAuthorizeEntity();
entity.setId(1L);
when(batchAuthorizeMapper.selectOne(any())).thenReturn(entity);
Map<String, String> params = new HashMap<>();
params.put("out_biz_no", "A1");
params.put("agreement_no", "AGMT001");
params.put("status", "AUTHED");
handler.dispatch("alipay.fund.authorize.status.notify", params, ctx());
assertEquals("AGMT001", entity.getAgreementNo());
assertEquals("AUTHED", entity.getStatus());
verify(batchAuthorizeMapper).updateById(entity);
}
@Test
void batchNotify_updatesOrderStatus() {
BatchOrderEntity order = new BatchOrderEntity();
order.setId(1L);
order.setOutBatchNo("B1");
order.setStatus("INIT");
when(batchOrderMapper.selectOne(any())).thenReturn(order);
Map<String, String> params = new HashMap<>();
params.put("out_batch_no", "B1");
params.put("status", "SUCCESS");
handler.dispatch("alipay.fund.batch.order.changed", params, ctx());
assertEquals("SUCCESS", order.getStatus());
verify(batchOrderMapper).updateById(order);
}
}
[ ] Step 2: 跑测试确认失败
Run: cd java && mvn -q test -Dtest=BatchPayHandlerTest
Expected: 编译失败(BatchPayHandler 不存在)
[ ] Step 3: 写实现
package com.payment.platform.module.payment.notification.handler;
import com.payment.platform.module.payment.batch.entity.BatchAuthorizeEntity;
import com.payment.platform.module.payment.batch.entity.BatchOrderEntity;
import com.payment.platform.module.payment.batch.mapper.BatchAuthorizeMapper;
import com.payment.platform.module.payment.batch.mapper.BatchOrderMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* 批量付款到户有密 通知处理器
* <p>
* 监听:
* - alipay.fund.authorize.status.notify 制单授权签约/解约
* - alipay.fund.batch.order.changed 批次状态变更
*/
@Slf4j
@Component
public class BatchPayHandler extends BaseNotifyHandler {
@Override
protected String[] acceptedMethods() {
return new String[]{
"alipay.fund.authorize.status.notify",
"alipay.fund.batch.order.changed"
};
}
@Override
protected void handle(String msgMethod, Map<String, String> params) {
// 无上下文兜底(仅走 dispatch(msgMethod, params) 无 ctx 版本时触发)
log.warn("BatchPayHandler 收到无上下文通知,跳过: msg_method={}", msgMethod);
}
@Override
protected void handle(String msgMethod, Map<String, String> params, NotifyContext ctx) {
try {
if ("alipay.fund.authorize.status.notify".equals(msgMethod)) {
handleAuthorizeNotify(params, ctx.getBatchAuthorizeMapper());
} else if ("alipay.fund.batch.order.changed".equals(msgMethod)) {
handleBatchNotify(params, ctx.getBatchOrderMapper());
}
} catch (Exception e) {
log.error("批量付款通知处理异常: msg_method={}", msgMethod, e);
}
}
private void handleAuthorizeNotify(Map<String, String> params, BatchAuthorizeMapper batchAuthorizeMapper) {
String outBizNo = params.get("out_biz_no");
if (outBizNo == null || outBizNo.isBlank()) {
log.warn("授权通知缺少 out_biz_no");
return;
}
BatchAuthorizeEntity entity = batchAuthorizeMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
.eq(BatchAuthorizeEntity::getOutBizNo, outBizNo));
if (entity == null) {
log.warn("授权通知找不到本地记录: out_biz_no={}", outBizNo);
return;
}
if (params.get("agreement_no") != null) entity.setAgreementNo(params.get("agreement_no"));
// 支付宝通知的 status/action 字段值以接口文档为准(AUTHED/UNBIND 等),此处直接回写
if (params.get("status") != null) entity.setStatus(params.get("status"));
batchAuthorizeMapper.updateById(entity);
log.info("制单授权状态更新: out_biz_no={}, status={}", outBizNo, entity.getStatus());
}
private void handleBatchNotify(Map<String, String> params, BatchOrderMapper batchOrderMapper) {
String outBatchNo = params.get("out_batch_no");
if (outBatchNo == null || outBatchNo.isBlank()) {
log.warn("批次通知缺少 out_batch_no");
return;
}
BatchOrderEntity order = batchOrderMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
.eq(BatchOrderEntity::getOutBatchNo, outBatchNo));
if (order == null) {
log.warn("批次通知找不到本地记录: out_batch_no={}", outBatchNo);
return;
}
String status = params.get("status");
if (status != null) {
order.setStatus(status);
batchOrderMapper.updateById(order);
}
log.info("批次状态更新: out_batch_no={}, status={}", outBatchNo, status);
}
}
[ ] Step 4: 枚举 + Context 接线
NotificationEnums.AlipayNotifyMethod 追加:
FUND_AUTHORIZE_STATUS_NOTIFY("alipay.fund.authorize.status.notify"),
FUND_BATCH_ORDER_CHANGED("alipay.fund.batch.order.changed"),
NotifyContext 追加字段与方法(模式同现有 transferMapper):
private BatchAuthorizeMapper batchAuthorizeMapper;
private BatchOrderMapper batchOrderMapper;
public BatchAuthorizeMapper getBatchAuthorizeMapper() { return batchAuthorizeMapper; }
public NotifyContext setBatchAuthorizeMapper(BatchAuthorizeMapper v) { this.batchAuthorizeMapper = v; return this; }
public BatchOrderMapper getBatchOrderMapper() { return batchOrderMapper; }
public NotifyContext setBatchOrderMapper(BatchOrderMapper v) { this.batchOrderMapper = v; return this; }
(import com.payment.platform.module.payment.batch.mapper.BatchAuthorizeMapper / BatchOrderMapper)
NotificationService.buildContext() 追加链式注入:
.setBatchAuthorizeMapper(batchAuthorizeMapper)
.setBatchOrderMapper(batchOrderMapper)
NotificationService 已确认用 @RequiredArgsConstructor + private final 字段(第 42-53 行),在字段区追加:
private final BatchAuthorizeMapper batchAuthorizeMapper;
private final BatchOrderMapper batchOrderMapper;
(import com.payment.platform.module.payment.batch.mapper.BatchAuthorizeMapper / BatchOrderMapper)
Run: cd java && mvn -q test -Dtest=BatchPayHandlerTest
Expected: 3 个测试全部 PASS
[ ] Step 6: Commit
git add java/src/main/java/com/payment/platform/module/payment/notification/
git commit -m "feat: 批量付款 - 异步通知处理器(授权/批次状态)"
Files:
frontend/src/api/module_payment/batch.tsfrontend/src/views/module_payment/account/components/BatchPayAuthorize.vuefrontend/src/views/module_payment/account/components/BatchPayList.vuefrontend/src/views/module_payment/account/components/BatchPayCreate.vuefrontend/src/views/module_payment/account/components/BatchPayDetail.vuefrontend/src/views/module_payment/account/index.vue(加「批量付款」tab,先读该文件确认 tab 组织方式)Interfaces:
Produces: 页面入口(动态路由由后端菜单表驱动,见 Task 8)
[ ] Step 1: 写 API 封装
参照 src/api/module_payment/account.ts 的 request<ApiResponse<T>> 模式:
import request from "@/api/request";
import type { ApiResponse } from "@/api/types";
const API_PATH = "/payment/account/batch";
export interface BatchAuthorizeVO {
out_biz_no: string;
status: string;
agreement_no?: string;
authorize_link?: string;
participant_id: string;
}
export interface BatchOrderVO {
out_batch_no: string;
batch_trans_id?: string;
total_amount: string;
total_count: number;
order_title?: string;
status: string;
created_time?: string;
pay_url?: string;
}
export interface BatchDetailItem {
out_biz_no: string;
amount: string;
remark?: string;
payee_identity: string;
payee_identity_type?: string;
payee_name?: string;
}
export interface BatchCreateParams {
enterprise_id?: string;
order_title: string;
payer_uid: string;
agreement_no?: string;
transfer_scene_name?: string;
transfer_scene_report_infos?: Array<{ info_type: string; info_content: string }>;
time_expire?: string;
remark?: string;
details: BatchDetailItem[];
}
export default {
authorizeApply(enterpriseId: string, participantId: string) {
return request<ApiResponse<{ authorize_link: string; out_biz_no: string; status: string }>>({
url: `${API_PATH}/authorize/apply`,
method: "post",
data: { enterprise_id: enterpriseId, participant_id: participantId },
});
},
queryAuthorize(enterpriseId: string, outBizNo: string) {
return request<ApiResponse<{ agreement_no: string; status: string }>>({
url: `${API_PATH}/authorize/query`,
method: "get",
params: { enterprise_id: enterpriseId, out_biz_no: outBizNo },
});
},
batchCreate(data: BatchCreateParams) {
return request<ApiResponse<{ out_batch_no: string; batch_trans_id: string; status: string }>>({
url: `${API_PATH}/create`,
method: "post",
data,
});
},
renderPay(enterpriseId: string, outBatchNo: string) {
return request<ApiResponse<{ pay_url: string }>>({
url: `${API_PATH}/pay`,
method: "post",
data: { enterprise_id: enterpriseId, out_batch_no: outBatchNo },
});
},
batchQuery(enterpriseId: string, outBatchNo: string) {
return request<ApiResponse<{ status: string }>>({
url: `${API_PATH}/query`,
method: "get",
params: { enterprise_id: enterpriseId, out_batch_no: outBatchNo },
});
},
batchClose(enterpriseId: string, outBatchNo: string) {
return request<ApiResponse<{ status: string }>>({
url: `${API_PATH}/close`,
method: "post",
data: { enterprise_id: enterpriseId, out_batch_no: outBatchNo },
});
},
authorizeList(params: { enterprise_id?: string; page_no?: number; page_size?: number }) {
return request<ApiResponse<{ list: BatchAuthorizeVO[]; total: number }>>({
url: `${API_PATH}/authorize/list`,
method: "get",
params,
});
},
batchList(params: {
enterprise_id?: string;
status?: string;
start_time?: string;
end_time?: string;
page_no?: number;
page_size?: number;
}) {
return request<ApiResponse<{ list: BatchOrderVO[]; total: number }>>({
url: `${API_PATH}/list`,
method: "get",
params,
});
},
batchDetail(outBatchNo: string, pageNo = 1, pageSize = 20) {
return request<ApiResponse<{ order: BatchOrderVO; details: { list: BatchDetailItem[]; total: number } }>>({
url: `${API_PATH}/detail`,
method: "get",
params: { out_batch_no: outBatchNo, page_no: pageNo, page_size: pageSize },
});
},
};
(request 与 ApiResponse 的导入路径以 account.ts 现有 import 为准,执行时核对)
功能:输入付款方支付宝 UID → 生成授权链接 → 复制链接/展示授权状态(查询按钮轮询)。UI 风格复用 AccountOverview.vue 的 Element Plus 表单模式(el-card + el-form + el-button)。
<script setup lang="ts">
import { computed, ref } from "vue";
import BatchPayAPI from "@/api/module_payment/batch";
import { useEnterpriseStore } from "@/store";
import { ElMessage } from "element-plus";
const enterpriseStore = useEnterpriseStore();
const enterpriseId = computed(() => enterpriseStore.getCurrentEnterprise?.enterprise_id);
const participantId = ref("");
const link = ref("");
const outBizNo = ref("");
const status = ref("");
async function handleApply() {
if (!enterpriseId.value || !participantId.value) {
ElMessage.warning("请选择企业并填写付款方支付宝 UID");
return;
}
const res = await BatchPayAPI.authorizeApply(enterpriseId.value, participantId.value);
link.value = res.data.data.authorize_link;
outBizNo.value = res.data.data.out_biz_no;
status.value = res.data.data.status;
}
async function handleQuery() {
if (!outBizNo.value) return;
const res = await BatchPayAPI.queryAuthorize(enterpriseId.value!, outBizNo.value);
status.value = res.data.data.status;
ElMessage.success(`授权状态: ${status.value}`);
}
</script>
<template>
<el-card>
<template #header>制单授权(批量付款到户有密)</template>
<el-form label-width="160px">
<el-form-item label="付款方支付宝UID">
<el-input v-model="participantId" placeholder="企业财务的支付宝 UID(2088 开头)" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleApply">生成授权链接</el-button>
</el-form-item>
<el-form-item v-if="link" label="授权链接(PC 浏览器打开)">
<el-input :model-value="link" readonly>
<template #append>
<el-button @click="navigator.clipboard?.writeText(link)">复制</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item v-if="outBizNo" label="授权单号 / 状态">
<span>{{ outBizNo }} / {{ status }}</span>
<el-button size="small" style="margin-left: 12px" @click="handleQuery">刷新状态</el-button>
</el-form-item>
</el-form>
</el-card>
</template>
(useEnterpriseStore 导入路径以 AccountOverview.vue 现有写法为准;computed 需 import)
功能:分页列表(out_batch_no、标题、总金额、笔数、状态、创建时间)、行操作(支付/详情/关闭)。数据源为后端 GET /batch/list(Task 5 已实现)。状态 tag 映射:INIT=受理中(warning)、SUCCESS=成功(success)、DISUSE=已关闭(info)、FAIL=失败(danger)。
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import BatchPayAPI from "@/api/module_payment/batch";
import { useEnterpriseStore } from "@/store";
import { ElMessage, ElMessageBox } from "element-plus";
const emit = defineEmits<{ view: [outBatchNo: string] }>();
const enterpriseStore = useEnterpriseStore();
const enterpriseId = computed(() => enterpriseStore.getCurrentEnterprise?.enterprise_id);
const list = ref<BatchPayAPI.BatchOrderVO[]>([]);
const total = ref(0);
const pageNo = ref(1);
const pageSize = ref(20);
const statusFilter = ref("");
const loading = ref(false);
const STATUS_TAG: Record<string, string> = {
INIT: "warning", SUCCESS: "success", DISUSE: "info", FAIL: "danger",
};
const STATUS_TEXT: Record<string, string> = {
INIT: "受理中", SUCCESS: "成功", DISUSE: "已关闭", FAIL: "失败",
};
async function load() {
loading.value = true;
try {
const res = await BatchPayAPI.batchList({
enterprise_id: enterpriseId.value,
status: statusFilter.value || undefined,
page_no: pageNo.value,
page_size: pageSize.value,
});
list.value = res.data.data.list;
total.value = res.data.data.total;
} finally {
loading.value = false;
}
}
function handlePay(row: BatchPayAPI.BatchOrderVO) {
BatchPayAPI.renderPay(enterpriseId.value!, row.out_batch_no).then((res) => {
window.open(res.data.data.pay_url, "_blank");
});
}
async function handleClose(row: BatchPayAPI.BatchOrderVO) {
await ElMessageBox.confirm("关闭后该批次不可再支付,确定关闭?", "提示", { type: "warning" });
await BatchPayAPI.batchClose(enterpriseId.value!, row.out_batch_no);
ElMessage.success("批次已关闭");
load();
}
onMounted(load);
</script>
<template>
<el-card>
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center">
<span>批量付款批次</span>
<el-select v-model="statusFilter" placeholder="状态筛选" clearable style="width: 140px"
@change="pageNo = 1; load()">
<el-option v-for="(t, s) in STATUS_TEXT" :key="s" :label="t" :value="s" />
</el-select>
</div>
</template>
<el-table v-loading="loading" :data="list">
<el-table-column prop="out_batch_no" label="批次号" width="200" />
<el-table-column prop="order_title" label="标题" min-width="140" />
<el-table-column prop="total_amount" label="总金额(元)" width="110" />
<el-table-column prop="total_count" label="笔数" width="70" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="(STATUS_TAG[row.status] as any) || 'info'">{{ STATUS_TEXT[row.status] || row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_time" label="创建时间" width="170" />
<el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'INIT'" size="small" type="primary" @click="handlePay(row)">支付</el-button>
<el-button size="small" @click="emit('view', row.out_batch_no)">详情</el-button>
<el-button v-if="row.status === 'INIT'" size="small" type="danger" @click="handleClose(row)">关闭</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination v-model:current-page="pageNo" :page-size="pageSize" :total="total"
layout="total, prev, pager, next" @current-change="load" style="margin-top: 12px" />
</el-card>
</template>
功能:表单(标题、付款方 UID、协议号选填、转账场景下拉 + 报备信息动态增删)+ 明细表格(动态行:收款账号、类型下拉、姓名、金额、备注)或 Excel 导入(模板下载 + 解析)。场景下拉固定值:现金营销/企业退款/佣金报酬/二手回收/业务结算/公益补助/行政补贴和退款/保险理赔。
功能:批次信息描述 + 明细 el-table + 「生成支付链接」按钮(renderPay → window.open(pay_url))+ 「关闭批次」按钮。数据源为后端 GET /batch/detail(Task 5 已实现,返回 { order, details })。
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import BatchPayAPI from "@/api/module_payment/batch";
import { useEnterpriseStore } from "@/store";
import { ElMessage, ElMessageBox } from "element-plus";
const props = defineProps<{ outBatchNo: string }>();
const enterpriseStore = useEnterpriseStore();
const enterpriseId = computed(() => enterpriseStore.getCurrentEnterprise?.enterprise_id);
const order = ref<BatchPayAPI.BatchOrderVO | null>(null);
const details = ref<BatchPayAPI.BatchDetailItem[]>([]);
const total = ref(0);
const pageNo = ref(1);
const pageSize = ref(20);
const DETAIL_STATUS_TEXT: Record<string, string> = { INIT: "处理中", SUCCESS: "成功", FAIL: "失败" };
async function load() {
const res = await BatchPayAPI.batchDetail(props.outBatchNo, pageNo.value, pageSize.value);
order.value = res.data.data.order;
details.value = res.data.data.details.list;
total.value = res.data.data.details.total;
}
function handlePay() {
BatchPayAPI.renderPay(enterpriseId.value!, props.outBatchNo).then((res) => {
window.open(res.data.data.pay_url, "_blank");
});
}
async function handleClose() {
await ElMessageBox.confirm("关闭后该批次不可再支付,确定关闭?", "提示", { type: "warning" });
await BatchPayAPI.batchClose(enterpriseId.value!, props.outBatchNo);
ElMessage.success("批次已关闭");
load();
}
onMounted(load);
</script>
<template>
<el-card v-if="order">
<template #header>
<span>批次详情 {{ order.out_batch_no }}</span>
<el-button v-if="order.status === 'INIT'" type="primary" style="margin-left: 12px" @click="handlePay">生成支付链接</el-button>
<el-button v-if="order.status === 'INIT'" type="danger" style="margin-left: 8px" @click="handleClose">关闭批次</el-button>
</template>
<el-descriptions :column="3" border style="margin-bottom: 16px">
<el-descriptions-item label="标题">{{ order.order_title }}</el-descriptions-item>
<el-descriptions-item label="总金额(元)">{{ order.total_amount }}</el-descriptions-item>
<el-descriptions-item label="笔数">{{ order.total_count }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ order.status }}</el-descriptions-item>
<el-descriptions-item label="支付宝批次号">{{ order.batch_trans_id || "-" }}</el-descriptions-item>
</el-descriptions>
<el-table :data="details">
<el-table-column prop="out_biz_no" label="明细单号" width="200" />
<el-table-column prop="payee_name" label="收款方姓名" width="120" />
<el-table-column prop="payee_identity" label="收款方账号" width="200" />
<el-table-column prop="amount" label="金额(元)" width="110" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
{{ DETAIL_STATUS_TEXT[row.status] || row.status }}
</template>
</el-table-column>
<el-table-column prop="error_msg" label="失败原因" min-width="140" />
</el-table>
<el-pagination v-model:current-page="pageNo" :page-size="pageSize" :total="total"
layout="total, prev, pager, next" @current-change="load" style="margin-top: 12px" />
</el-card>
</template>
读 frontend/src/views/module_payment/account/index.vue,按现有 tab 模式(el-tabs 或其他)追加「批量付款」入口,路由或组件切换指向上述 4 个组件(参考 TransferDetail.vue 的挂载方式)。
Run: cd frontend && npm run build
Expected: 构建成功
[ ] Step 8: Commit
git add frontend/src/api/module_payment/batch.ts frontend/src/views/module_payment/account/
git commit -m "feat: 批量付款 - 前端页面(授权/列表/创建/详情)"
Files:
.claude/plan/2026-07-08-industry-invoice-platform-plan.md 中菜单 SQL 先例)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)—— 均复用现有权限点,不新增。
启动后端 + 前端 dev server,用 Playwright MCP 走通:
汇报:各接口实测响应、未开通产品的错误码表现、前端页面渲染截图。
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)与文档一致。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 参数。