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