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: 批量付款到户有密从企业级改造为账号级:新增「批量付款」菜单、租户级多授权主体(表单+服务商下拉)、制单/历史按主体筛选。
Architecture: pay_batch_authorize/order/detail 实体基类从 PaymentEnterpriseBaseEntity 改为 PaymentTenantBaseEntity(租户级隔离复用 TenantInnerInterceptor 自动过滤);授权管理拆为 BatchSubjectService;制单付款方从「企业身份」改为「表单选择的授权主体」;批次操作 client 按主体/订单冗余的服务商解析。
Tech Stack: Java 21 / Spring Boot / MyBatis-Plus / Maven(java/,测试 ./mvnw test);Vue3 + TS + Element Plus(frontend/);PostgreSQL(payment_platform_java)。
Spec: .claude/plan/2026-08-26-alipay-batch-pay-account-level-design.md(本计划唯一依据,冲突以 spec 为准)
java/ 目录执行 ./mvnw test -Dtest=<TestClass>(Windows git bash 下 ./mvnw;IDE 可直跑 JUnit)frontend/ 目录 npm run dev(端口见 vite 配置),Playwright MCP 交互验证feat: / fix: / docs: + 中文简述)TenantInnerInterceptor 自动过滤,代码不显式传 tenant_id(insert 自动填充,select 自动过滤)TRANSFER_API_STANDARD_AUTHORIZATION / STANDARD_CREATE_FUND_ORDER,无 back_url)—— 实测成功链路,spec D8localhost:5432/payment_platform_java(admin/xjz#123321)执行 Task 2 的 SQL 文件Files:
java/src/main/java/com/payment/platform/core/alipay/AlipayClientFactory.java(在 getClientByProvider(Long) 附近加方法)java/src/test/java/com/payment/platform/core/alipay/AlipayClientFactoryProfileTest.javaInterfaces:
public AlipayClient getClientByProvider(Long providerId, String bizType) —— 服务商 + 业务类型解析链:① pay_service_provider_profile 专属凭证(getProfileEntity(providerId, bizType) → createClientForProfile)→ ② 回退 getClientByProvider(providerId)(默认凭证,provider 不存在/停用时内部已回退默认客户端)→ ③ providerId 为 null → getClient()背景: 账号级无企业中间层,制单/授权需要「服务商 + BIZ_TYPE」直取 client。现有 getClient(enterpriseId, bizType) 的解析链就是「企业 → serviceProviderId → profile → 默认」,本方法去掉企业中间层(spec 3.4 / 5.3)。
在 AlipayClientFactoryProfileTest.java 追加(参考该文件现有 mock 模式;用反射或 setter 注入 mock 的 profileMapper/serviceProviderMapper,模式照抄现有测试):
@Test
void getClientByProvider_bizType_profileFirst() throws Exception {
// profile 命中 → 业务专属客户端(不落到服务商默认)
ServiceProviderProfileEntity profile = new ServiceProviderProfileEntity();
profile.setServiceProviderId(1L);
profile.setBizType("BATCH_PAY");
profile.setAppId("app-profile");
profile.setAppPrivateKey("priv");
profile.setAlipayPublicKey("pub");
when(profileMapper.selectOne(any())).thenReturn(profile);
AlipayClient client = factory.getClientByProvider(1L, "BATCH_PAY");
assertNotNull(client);
// createClientForProfile 走 buildSdkConfig(appId=app-profile...),构造 DefaultAlipayClient
// 验证走 profile 而非 provider: verify(serviceProviderMapper, never()).selectById(1L)
verify(serviceProviderMapper, never()).selectById(any());
}
@Test
void getClientByProvider_bizType_noProfile_fallsBackToProvider() throws Exception {
when(profileMapper.selectOne(any())).thenReturn(null);
ServiceProviderEntity sp = new ServiceProviderEntity();
sp.setId(1L);
sp.setProviderStatus("ACTIVE");
sp.setAppId("app-provider");
sp.setAppPrivateKey("priv");
sp.setAlipayPublicKey("pub");
sp.setAppCertContent("c1"); sp.setAlipayPublicCertContent("c2"); sp.setRootCertContent("c3");
when(serviceProviderMapper.selectById(1L)).thenReturn(sp);
AlipayClient client = factory.getClientByProvider(1L, "BATCH_PAY");
assertNotNull(client);
verify(serviceProviderMapper).selectById(1L);
}
Run: cd java && ./mvnw test -Dtest=AlipayClientFactoryProfileTest
Expected: 编译失败(方法不存在)
[ ] Step 3: 实现
/**
* 按服务商 + 业务类型获取客户端(账号级场景,无企业中间层)
* <p>
* 解析优先级(与 {@link #getClient(String, String)} 企业链路同构,仅少企业中间层):
* providerId + bizType → pay_service_provider_profile(业务专属凭证)
* providerId → pay_service_provider(默认凭证,不存在/停用时内部回退默认客户端)
* providerId 为 null → 默认客户端
*/
public AlipayClient getClientByProvider(Long providerId, String bizType) {
if (providerId == null) return getClient();
if (bizType != null) {
AlipayClient profileClient = getClientByProfile(providerId, bizType);
if (profileClient != null) return profileClient;
}
return getClientByProvider(providerId);
}
[ ] Step 4: 运行确认通过
Run: cd java && ./mvnw test -Dtest=AlipayClientFactoryProfileTest
Expected: PASS
[ ] Step 5: Commit
git add java/src/main/java/com/payment/platform/core/alipay/AlipayClientFactory.java java/src/test/java/com/payment/platform/core/alipay/AlipayClientFactoryProfileTest.java
git commit -m "feat: AlipayClientFactory 新增 getClientByProvider(providerId, bizType) - 账号级无企业中间层直取业务凭证"
Ruling(执行前修正):基类
PaymentEnterpriseBaseEntity → PaymentTenantBaseEntity切换推迟到 Task 4 —— 切换后AlipayBatchPayService的setEnterpriseId()全部编译失败,只有 Task 4 重写调用方时切换才能保持每个 commit 编译绿。本任务只加字段 + DDL。
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/resources/db/batch-account-level.sql(幂等 DDL,手工执行)Interfaces:
Produces: BatchAuthorizeEntity 新增 participantName(String)、serviceProviderId(Long);BatchOrderEntity 新增 serviceProviderId(Long)。基类暂不改(enterpriseId 字段仍在,Task 4 移除)。
[ ] Step 1: 实体加字段
BatchAuthorizeEntity.java(extends 不变,加字段):
/** 主体名称(账号级表单录入,同时作为授权申请 principal_info.name) */
private String participantName;
/** 服务商(账号级表单下拉选择,授权申请 client 解析依据) */
private Long serviceProviderId;
BatchOrderEntity.java(extends 不变,加字段):
/** 付款主体冗余的服务商(制单时落库)——主体解绑后批次支付/查询/关闭仍可解析 client */
private Long serviceProviderId;
Run: cd java && ./mvnw test-compile
Expected: 编译通过(只加字段不动基类,无任何破坏)
java/src/main/resources/db/batch-account-level.sql:
-- 批量付款账号级改造(2026-08-26)
-- 手工执行: psycopg2 连接 payment_platform_java 库
-- 幂等: 已存在列则跳过(执行前可用 \d pay_batch_authorize 检查)
-- 1. 授权主体字段
ALTER TABLE pay_batch_authorize
ADD COLUMN IF NOT EXISTS participant_name varchar(128),
ADD COLUMN IF NOT EXISTS service_provider_id bigint;
-- 2. 唯一索引改租户级(先删旧 enterprise 索引)
DROP INDEX IF EXISTS uk_batch_authorize_active;
-- 联调期存量数据若存在同租户同 participant 多行,先清理再建索引:
-- DELETE FROM pay_batch_authorize a USING pay_batch_authorize b
-- WHERE a.id < b.id AND a.tenant_id = b.tenant_id AND a.participant_id = b.participant_id
-- AND a.status <> 'UNBIND' AND b.status <> 'UNBIND';
CREATE UNIQUE INDEX uk_batch_authorize_active
ON pay_batch_authorize (tenant_id, participant_id)
WHERE status <> 'UNBIND';
-- 3. 批次冗余服务商
ALTER TABLE pay_batch_order
ADD COLUMN IF NOT EXISTS service_provider_id bigint;
[ ] Step 4: 执行 DDL 并验证
python - <<'EOF'
import psycopg2
conn = psycopg2.connect(host="localhost", dbname="payment_platform_java", user="admin", password="xjz#123321")
cur = conn.cursor()
sql = open(r"D:\project2\payment-platform\java\src\main\resources\db\batch-account-level.sql", encoding="utf-8").read()
cur.execute(sql)
conn.commit()
cur.execute("""SELECT column_name FROM information_schema.columns WHERE table_name='pay_batch_authorize'
AND column_name IN ('participant_name','service_provider_id')""")
print("authorize cols:", cur.fetchall())
cur.execute("SELECT indexname FROM pg_indexes WHERE tablename='pay_batch_authorize' AND indexname='uk_batch_authorize_active'")
print("index:", cur.fetchall())
conn.close()
EOF
Expected: 打印新列与索引名(若存量数据冲突导致索引失败,先执行 SQL 中注释的清理 DELETE 再重试)
[ ] Step 5: Commit
git add java/src/main/java/com/payment/platform/module/payment/batch/entity/ java/src/main/resources/db/batch-account-level.sql
git commit -m "feat: 批量付款实体基类改租户级 + 授权主体/服务商字段 + DDL"
Files:
java/src/main/java/com/payment/platform/module/payment/batch/service/BatchSubjectService.javajava/src/test/java/com/payment/platform/module/payment/batch/service/BatchSubjectServiceTest.javajava/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.java(删除授权方法 + 常量 + 辅助方法 + enterpriseMapper 依赖)Interfaces:
AlipayClientFactory.getClientByProvider(Long, String)(Task 1)Map<String, String> apply(String participantName, String participantId, Long serviceProviderId) —— 生成授权短链接并落库 AUTHING;返回 {authorize_link, out_biz_no, status}Map<String, String> rebind(Long id) —— 作废该主体非终态授权记录(AUTHING→UNBIND,EXISTS_STOPPED_AUTHORIZE 语义:换新 out_biz_no),重新申请Map<String, String> query(Long id, String outBizNo) —— uni.query 回写 AUTHED + agreement_no;AUTHORIZATION_NOT_EXIST → {agreement_no:"", status:"AUTHING"}PageResult<BatchAuthorizeEntity> list(String participantId, int pageNo, int pageSize) —— 按租户自动隔离,participant_id 可选筛选,orderByDesc(id)isAuthorizeExpired / isAuthorizedStatus / doApply背景: 从 AlipayBatchPayService 迁移 authorizeApply/authorizeRebind/doAuthorizeApply/queryAuthorize/authorizeList,改造为账号级(spec 5.1):主体参数来自表单(不再从企业解析);client 用 getClientByProvider;落库 participant_name/service_provider_id;租户隔离依赖拦截器(去 enterprise 条件)。授权常量(AUTHORIZE_PRODUCT_CODE=TRANSFER_API_STANDARD_AUTHORIZATION 等)迁入本类。
参考现有 AlipayBatchPayServiceTest 模式(Mockito + new BatchSubjectService(alipayClientFactory, batchAuthorizeMapper) + TableInfoHelper 初始化 BatchAuthorizeEntity 元数据 + lenient stub)。关键用例:
@ExtendWith(MockitoExtension.class)
class BatchSubjectServiceTest {
@Mock private AlipayClientFactory alipayClientFactory;
@Mock private AlipayClient alipayClient;
@Mock private BatchAuthorizeMapper batchAuthorizeMapper;
private BatchSubjectService service;
@BeforeEach
void setUp() {
service = new BatchSubjectService(alipayClientFactory, batchAuthorizeMapper);
lenient().when(alipayClientFactory.getClientByProvider(1L, "BATCH_PAY")).thenReturn(alipayClient);
MybatisConfiguration configuration = new MybatisConfiguration();
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), BatchAuthorizeEntity.class);
}
@Test
void apply_usesFormSubjectAndProvider() throws AlipayApiException {
AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
resp.setAuthorizeLink("https://ur.alipay.com/abc");
when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
Map<String, String> result = service.apply("张三公司", "2088111122223333", 1L);
assertEquals("https://ur.alipay.com/abc", result.get("authorize_link"));
ArgumentCaptor<AlipayFundAuthorizeUniApplyRequest> cap = ArgumentCaptor.forClass(AlipayFundAuthorizeUniApplyRequest.class);
verify(alipayClient).certificateExecute(cap.capture());
AlipayFundAuthorizeUniApplyModel m = (AlipayFundAuthorizeUniApplyModel) cap.getValue().getBizModel();
assertEquals("TRANSFER_API_STANDARD_AUTHORIZATION", m.getProductCode());
assertEquals("STANDARD_CREATE_FUND_ORDER", m.getBizScene());
// 账号级: 主体来自表单,非企业身份
assertEquals("2088111122223333", m.getPrincipalInfo().getParticipantId());
assertEquals("张三公司", m.getPrincipalInfo().getName());
ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
verify(batchAuthorizeMapper).insert(ent.capture());
assertEquals("张三公司", ent.getValue().getParticipantName());
assertEquals(1L, ent.getValue().getServiceProviderId());
assertEquals("AUTHING", ent.getValue().getStatus());
}
@Test
void apply_existingAuthedSubject_throwsBusinessException() throws AlipayApiException {
BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
existing.setParticipantId("2088111122223333");
existing.setStatus("AUTHED");
when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
assertThrows(BusinessException.class, () -> service.apply("张三公司", "2088111122223333", 1L));
verify(alipayClient, never()).certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class));
}
@Test
void apply_missingParticipantId_throwsBusinessException() {
assertThrows(BusinessException.class, () -> service.apply("张三公司", " ", 1L));
}
@Test
void rebind_invalidatesOldAuthingAndReapplies() throws AlipayApiException {
BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
existing.setId(9L);
existing.setParticipantId("2088111122223333");
existing.setStatus("AUTHING");
when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
resp.setAuthorizeLink("https://ur.alipay.com/new");
when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
service.rebind(9L);
// 旧记录作废
verify(batchAuthorizeMapper).updateById(argThat(e -> "UNBIND".equals(e.getStatus())));
// 新记录插入(换 out_biz_no)
ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
verify(batchAuthorizeMapper).insert(ent.capture());
assertNotEquals(existing.getOutBizNo(), ent.getValue().getOutBizNo());
}
@Test
void rebind_authedRecord_throwsBusinessException() {
BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
existing.setId(9L);
existing.setStatus("AUTHED");
when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
assertThrows(BusinessException.class, () -> service.rebind(9L));
verify(batchAuthorizeMapper, never()).insert(any());
}
}
注意:
selectOne(any())条件查询的 wrapper 由实现决定(按 id 或按 participant),测试 stub 用any()兼容。apply的重复预检查询条件为participant_id+ne(status, "UNBIND")。
Run: cd java && ./mvnw test -Dtest=BatchSubjectServiceTest
Expected: 编译失败(类不存在)
迁移改造(从 AlipayBatchPayService 原方法改造,见 spec 5.1 / 设计 ②):
package com.payment.platform.module.payment.batch.service;
// imports: 与 AlipayBatchPayService 原授权部分一致 + PageResult + Page + SnowflakeIdGenerator
// 常量: AUTHORIZE_PRODUCT_CODE / AUTHORIZE_BIZ_SCENE / AUTHORIZE_LINK_TYPE / BIZ_TYPE("BATCH_PAY") 迁入
@Slf4j
@Service
@RequiredArgsConstructor
public class BatchSubjectService {
private final AlipayClientFactory alipayClientFactory;
private final BatchAuthorizeMapper batchAuthorizeMapper;
/** 主体授权申请: 表单主体参数(名称/uid/服务商),不再从企业解析(spec D6/D8) */
@Transactional
public Map<String, String> apply(String participantName, String participantId, Long serviceProviderId) {
if (participantId == null || participantId.isBlank())
throw new BusinessException(400, "支付宝账号不能为空");
if (participantName == null || participantName.isBlank())
throw new BusinessException(400, "主体名称不能为空");
if (serviceProviderId == null)
throw new BusinessException(400, "请选择服务商");
// 重复预检: 同租户同主体非 UNBIND 记录(租户隔离由拦截器自动追加)
BatchAuthorizeEntity existing = batchAuthorizeMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
.eq(BatchAuthorizeEntity::getParticipantId, participantId)
.ne(BatchAuthorizeEntity::getStatus, "UNBIND"));
String outBizNo = SnowflakeIdGenerator.nextIdStr();
if (existing != null) {
if (isAuthorizedStatus(existing.getStatus()))
throw new BusinessException(400, "该支付宝账号已签约,请直接在制单时选择使用(USER_AUTHORIZATION_EXIST)");
if (!isAuthorizeExpired(existing))
throw new BusinessException(400, "该主体存在未完成的授权申请,请先完成授权或稍后重试");
existing.setStatus("UNBIND");
batchAuthorizeMapper.updateById(existing);
log.info("授权申请已过期,作废旧记录并重新申请: old_out_biz_no={}, participant_id={}",
existing.getOutBizNo(), participantId);
}
try {
// ... 与 doApply 相同(model 增加 principal.setName(participantName)),
// client 换 alipayClientFactory.getClientByProvider(serviceProviderId, BIZ_TYPE)
// 落库: setParticipantName(participantName) / setServiceProviderId(serviceProviderId),
// DuplicateKeyException → "该支付宝账号已存在授权申请,请勿重复操作"
}
}
@Transactional
public Map<String, String> rebind(Long id) {
if (id == null) throw new BusinessException(400, "缺少主体记录ID");
BatchAuthorizeEntity existing = batchAuthorizeMapper.selectById(id);
if (existing == null) throw new BusinessException(404, "授权记录不存在");
if (isAuthorizedStatus(existing.getStatus()))
throw new BusinessException(400, "该主体已存在生效授权,无需重新生成");
existing.setStatus("UNBIND");
batchAuthorizeMapper.updateById(existing);
return doApply(existing.getParticipantName(), existing.getParticipantId(), existing.getServiceProviderId());
}
// doApply(participantName, participantId, serviceProviderId) — 原 doAuthorizeApply 改造:
// client = alipayClientFactory.getClientByProvider(serviceProviderId, BIZ_TYPE)
// principal.setName(participantName); principal.setParticipantId(participantId); setParticipantIdType("ALIPAY_USER_ID")
// 落库含 participantName/serviceProviderId
public Map<String, String> query(Long id, String outBizNo) {
// 原 queryAuthorize 改造: client 按记录 service_provider_id;
// 回写条件 .eq(out_biz_no) 不变;无 id 记录 warn 日志文案去 enterpriseId
}
public PageResult<BatchAuthorizeEntity> list(String participantId, int pageNo, int pageSize) {
var w = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
.eq(participantId != null && !participantId.isBlank(), BatchAuthorizeEntity::getParticipantId, participantId)
.orderByDesc(BatchAuthorizeEntity::getId);
var r = batchAuthorizeMapper.selectPage(new Page<>(pageNo, pageSize), w);
return PageResult.of(pageNo, pageSize, r.getTotal(), r.getRecords());
}
// isAuthorizeExpired / isAuthorizedStatus 原样迁移(private)
}
Ruling(执行前修正):
payerIdentity/payerIdentityType/requireEnterprise仍被batchCreate引用(Task 4 才重写),本任务不删。
删除:AUTHORIZE_PRODUCT_CODE/AUTHORIZE_BIZ_SCENE/AUTHORIZE_LINK_TYPE 常量、authorizeApply/authorizeRebind/doAuthorizeApply/queryAuthorize/authorizeList 方法、isAuthorizeExpired/isAuthorizedStatus 私有方法。注意 BIZ_TYPE 被批次方法引用 —— BIZ_TYPE 保留在 AlipayBatchPayService(批次类自身声明 private static final String BIZ_TYPE = "BATCH_PAY")。enterpriseMapper 字段与 EnterpriseEntity/EnterpriseMapper import 本任务不删(Task 4 删)。parseTimeFilter 保留。
Run: cd java && ./mvnw test -Dtest=AlipayBatchPayServiceTest,BatchSubjectServiceTest,BatchPayHandlerTest
Expected: AlipayBatchPayServiceTest 中授权相关用例(authorizeApply/queryAuthorize/rebind 等约 10 个)因方法删除而编译失败 → 将这些用例迁移到 BatchSubjectServiceTest 并改造(enterprise stub → 主体参数;getClient("E100","BATCH_PAY") → getClientByProvider(1L,"BATCH_PAY");setEnterpriseId 断言 → setParticipantName/ServiceProviderId;service.authorizeApply("E100") → service.apply(...))。AlipayBatchPayServiceTest 保留批次用例,setUp 中删除 enterpriseMapper stub 与构造参数。
Run: cd java && ./mvnw test -Dtest=AlipayBatchPayServiceTest,BatchSubjectServiceTest,BatchPayHandlerTest,AlipayClientFactoryProfileTest
Expected: 全 PASS
git add java/src/main/java/com/payment/platform/module/payment/batch/service/ java/src/test/java/com/payment/platform/module/payment/batch/service/
git commit -m "feat: 授权管理拆为 BatchSubjectService - 表单主体参数/服务商client/租户隔离"
Files:
java/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.javajava/src/main/java/com/payment/platform/module/payment/batch/dto/BatchCreateDTO.javajava/src/test/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayServiceTest.javaInterfaces:
BatchSubjectService 无(批次类独立);BatchAuthorizeMapper(查主体 AUTHED 记录);AlipayClientFactory.getClientByProviderProduces:
BatchCreateDTO 新增 @NotBlank(message = "请选择付款主体") private String participantId;batchCreate(BatchCreateDTO) 付款方=主体;renderPay/batchQuery/batchClose(String outBatchNo) 去 enterpriseId 参数;batchList(String participantId, String status, String startTime, String endTime, int pageNo, int pageSize);batchDetail(String outBatchNo, int pageNo, int pageSize);batchExport(String participantId, String status, String startTime, String endTime);getPendingBatches() 不变[ ] Step 1: DTO 加字段
BatchCreateDTO.java:
@NotBlank(message = "请选择付款主体")
@Schema(description = "付款主体(授权主体支付宝uid,制单时从已授权主体选择)")
private String participantId;
删除 enterpriseId 字段(租户拦截器自动填充 tenant_id)。
在 AlipayBatchPayServiceTest 修改/新增(构造器改为 new AlipayBatchPayService(alipayClientFactory, batchAuthorizeMapper, batchOrderMapper, batchDetailMapper),enterpriseMapper stub 删除;alipayClientFactory.getClientByProvider(1L,"BATCH_PAY") lenient stub):
@Test
void batchCreate_usesSubjectAsPayer() throws AlipayApiException {
// 主体授权记录: participant + AUTHED + agreement
BatchAuthorizeEntity authed = new BatchAuthorizeEntity();
authed.setParticipantId("2088111122223333");
authed.setStatus("AUTHED");
authed.setAgreementNo("AGMT001");
when(batchAuthorizeMapper.selectOne(any())).thenReturn(authed);
AlipayFundBatchCreateResponse resp = new AlipayFundBatchCreateResponse();
resp.setBatchTransId("BT001");
when(alipayClient.certificateExecute(any(AlipayFundBatchCreateRequest.class))).thenReturn(resp);
BatchCreateDTO dto = new BatchCreateDTO();
dto.setParticipantId("2088111122223333");
dto.setOrderTitle("8月佣金");
dto.setTransferSceneName("佣金报酬");
dto.setTransferSceneReportInfos(List.of(Map.of("info_type", "业务场景", "info_content", "佣金")));
BatchCreateDTO.BatchDetailDTO detail = new BatchCreateDTO.BatchDetailDTO();
detail.setOutBizNo("D1");
detail.setAmount(new BigDecimal("10"));
detail.setPayeeIdentity("13800000000");
detail.setPayeeName("收款人");
dto.setDetails(List.of(detail));
Map<String, Object> result = service.batchCreate(dto);
ArgumentCaptor<AlipayFundBatchCreateRequest> cap = ArgumentCaptor.forClass(AlipayFundBatchCreateRequest.class);
verify(alipayClient).certificateExecute(cap.capture());
AlipayFundBatchCreateModel m = (AlipayFundBatchCreateModel) cap.getValue().getBizModel();
assertEquals("2088111122223333", m.getPayerInfo().getIdentity()); // 付款方=主体uid
assertTrue(m.getPayerInfo().getExtInfo().contains("AGMT001")); // 协议号带出
ArgumentCaptor<BatchOrderEntity> order = ArgumentCaptor.forClass(BatchOrderEntity.class);
verify(batchOrderMapper).insert(order.capture());
assertEquals("2088111122223333", order.getValue().getPayerUid());
assertEquals(1L, order.getValue().getServiceProviderId()); // 冗余主体服务商
verify(alipayClientFactory).getClientByProvider(1L, "BATCH_PAY");
}
@Test
void batchCreate_subjectNotAuthed_throwsBusinessException() {
when(batchAuthorizeMapper.selectOne(any())).thenReturn(null);
BatchCreateDTO dto = new BatchCreateDTO();
dto.setParticipantId("2088111122223333");
dto.setOrderTitle("8月佣金");
dto.setTransferSceneName("佣金报酬");
dto.setTransferSceneReportInfos(List.of(Map.of("info_type", "业务场景", "info_content", "佣金")));
BatchCreateDTO.BatchDetailDTO detail = new BatchCreateDTO.BatchDetailDTO();
detail.setOutBizNo("D1"); detail.setAmount(new BigDecimal("10"));
detail.setPayeeIdentity("13800000000"); detail.setPayeeName("收款人");
dto.setDetails(List.of(detail));
BusinessException ex = assertThrows(BusinessException.class, () -> service.batchCreate(dto));
assertTrue(ex.getMessage().contains("尚未完成制单授权"));
}
@Test
void renderPay_usesOrderServiceProvider() throws AlipayApiException {
BatchOrderEntity order = new BatchOrderEntity();
order.setOutBatchNo("B1");
order.setBatchTransId("BT001");
order.setStatus("INIT");
order.setServiceProviderId(1L);
when(batchOrderMapper.selectOne(any())).thenReturn(order);
AlipayFundTransRenderPayResponse resp = new AlipayFundTransRenderPayResponse();
resp.setInitializeCode("https://p.tb.cn/abc");
when(alipayClient.certificateExecute(any(AlipayFundTransRenderPayRequest.class))).thenReturn(resp);
Map<String, String> result = service.renderPay("B1");
assertEquals("https://p.tb.cn/abc", result.get("pay_url"));
verify(alipayClientFactory).getClientByProvider(1L, "BATCH_PAY");
}
现有 renderPay/batchClose/batchQuery 测试的签名同步改(去 enterpriseId 参数、
.eq(enterpriseId)stub 断言删除、client stub 换getClientByProvider)。
Run: cd java && ./mvnw test -Dtest=AlipayBatchPayServiceTest
Expected: 新用例失败(selectOne 无 stub / 断言不匹配),存量用例编译失败(签名变更)
batchCreate:删除 requireEnterprise(dto.getEnterpriseId()) 与 payerIdentity/payerIdentityType(ent);改为:
// 付款方 = 制单选择的授权主体(spec 5.3): 未完成授权不允许制单
BatchAuthorizeEntity authed = batchAuthorizeMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
.eq(BatchAuthorizeEntity::getParticipantId, dto.getParticipantId())
.in(BatchAuthorizeEntity::getStatus, "AUTHED", "NORMAL")
.orderByDesc(BatchAuthorizeEntity::getId)
.last("LIMIT 1"));
if (authed == null)
throw new BusinessException(400, "该主体尚未完成制单授权,请先在「制单授权」中完成授权");
String payerUid = authed.getParticipantId();
String agreementNo = authed.getAgreementNo();
// ... payer.setIdentity(payerUid); setIdentityType("ALIPAY_USER_ID"); ext_info 不变
落库:order.setEnterpriseId(...) 删除;order.setServiceProviderId(authed.getServiceProviderId());de.setEnterpriseId(...) 删除(batchDetail 同理)。client:alipayClientFactory.getClientByProvider(authed.getServiceProviderId(), BIZ_TYPE)。
renderPay/batchQuery/batchClose/syncBatchStatusFromAlipay:签名去 enterpriseId;查询条件删除 .eq(BatchOrderEntity::getEnterpriseId, enterpriseId);client getClientByProvider(order.getServiceProviderId(), BIZ_TYPE)(getServiceProviderId() 为 null 时 getClientByProvider 内部回退默认 —— 无需额外处理,Task 3 已删企业解析)。
batchList/batchExport:enterpriseId 参数 → participantId,条件改 .eq(participantId..., BatchOrderEntity::getPayerUid, participantId)。
batchDetail:删除 enterpriseId 参数与条件。
删除:requireEnterpriseId/payerIdentity/payerIdentityType/requireEnterprise、EnterpriseEntity/EnterpriseMapper import 与字段。
基类切换(Ruling 见 Task 2): 三个实体 BatchAuthorizeEntity/BatchOrderEntity/BatchDetailEntity 的 import 与 extends 从 PaymentEnterpriseBaseEntity 改为 PaymentTenantBaseEntity(com.payment.platform.common.base.PaymentTenantBaseEntity)。enterpriseId 字段随基类移除,全部 .setEnterpriseId(...)/.eq(..., EnterpriseId, ...) 引用在本任务删除。BatchPayHandlerTest 若构造实体引用 setEnterpriseId 同步移除断言。
Run: cd java && ./mvnw test -Dtest=AlipayBatchPayServiceTest
Expected: 全 PASS(含存量 50+ 用例改造后)
[ ] Step 6: Commit
git add java/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.java java/src/main/java/com/payment/platform/module/payment/batch/dto/BatchCreateDTO.java java/src/test/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayServiceTest.java
git commit -m "feat: 批次操作账号级改造 - 制单按授权主体、去企业ID、按订单服务商解析client"
Files:
java/src/main/java/com/payment/platform/module/payment/batch/controller/BatchPayController.javajava/src/main/java/com/payment/platform/module/payment/batch/dto/AuthorizeApplyDTO.javaInterfaces:
Produces:
@RequestMapping("/payment/batch")POST /authorize/apply body AuthorizeApplyDTO {participant_name, participant_id, service_provider_id} → batchSubjectService.apply(...),权限 module_payment:batch:authorizePOST /authorize/rebind body {id} → batchSubjectService.rebind(...),同上GET /authorize/query?out_biz_no= → batchSubjectService.query(...)(记录由 out_biz_no 反查),同上GET /authorize/list(participant_id/page_no/page_size)→ batchSubjectService.list(...),权限 module_payment:batch:listPOST /create → batchCreate(dto),权限 module_payment:batch:createPOST /pay body {out_batch_no} → renderPay(outBatchNo),权限 module_payment:batch:createGET /query?out_batch_no= → batchQuery(outBatchNo),权限 module_payment:batch:listPOST /close body {out_batch_no} → batchClose(outBatchNo),权限 module_payment:batch:createGET /list(participant_id/status/start_time/end_time/page_no/page_size)→ batchList(...),权限 module_payment:batch:listGET /detail?out_batch_no= → batchDetail(outBatchNo, pageNo, pageSize),权限 module_payment:batch:detailGET /export(participant_id/status/start_time/end_time)→ batchExport(...),权限 module_payment:batch:list[ ] Step 1: 写 DTO
@Data
public class AuthorizeApplyDTO {
@NotBlank(message = "主体名称不能为空")
private String participantName;
@NotBlank(message = "支付宝账号不能为空")
private String participantId;
@NotNull(message = "请选择服务商")
private Long serviceProviderId;
}
[ ] Step 2: 改 Controller
@RequestMapping("/payment/batch");注入 BatchSubjectService(@RequiredArgsConstructor 自动)
授权三个端点改造(见 Interfaces);批次端点去 enterprise_id 参数
@PreAuthorize 全部换 module_payment:batch:*(映射见 Interfaces)
[ ] Step 3: 编译验证
Run: cd java && ./mvnw test-compile
Expected: 通过(无其他调用方引用旧路径 —— 前端在 Task 6 同步)
[ ] Step 4: Commit
git add java/src/main/java/com/payment/platform/module/payment/batch/controller/ java/src/main/java/com/payment/platform/module/payment/batch/dto/AuthorizeApplyDTO.java
git commit -m "feat: 批量接口路由改 /payment/batch + module_payment:batch:* 权限"
Files:
frontend/src/api/module_payment/batch.tsInterfaces:
Produces:
API_PATH = "/payment/batch"BatchAuthorizeVO 新增 participant_name?: string; service_provider_id?: number; id?: numberBatchCreateParams 删除 enterprise_id,新增 participant_id: stringauthorizeApply(data: {participant_name, participant_id, service_provider_id});authorizeRebind(id: number);queryAuthorize(outBizNo);authorizeList(params: {participant_id?, page_no?, page_size?});batchCreate(data: BatchCreateParams);renderPay(outBatchNo);batchQuery(outBatchNo);batchClose(outBatchNo);batchList(params: {participant_id?, status?, start_time?, end_time?, page_no?, page_size?});batchDetail(outBatchNo, pageNo?, pageSize?);batchExport(params: {participant_id?, status?, start_time?, end_time?})[ ] Step 1: 改 batch.ts
全部方法去 enterprise_id(url/params/data);renderPay/batchQuery/batchClose/batchDetail 的 enterpriseId 首参删除
新增 BatchAuthorizeVO.participant_name/service_provider_id/id;BatchCreateParams.participant_id
BatchOrderVO 注释同步(INIT/WAIT_PAY/SUCCESS/DISUSE/FAIL/INVALID 状态全集)
[ ] Step 2: 类型检查
Run: cd frontend && npx vue-tsc --noEmit(或 npm run type-check,按 package.json 实际 script)
Expected: 无新增类型错误(组件引用在 Task 7 才改,本步允许 batch.ts 自身通过)
[ ] Step 3: Commit
git add frontend/src/api/module_payment/batch.ts
git commit -m "feat: 批量API层账号级 - /payment/batch 路径、去企业ID、主体参数"
Files:
frontend/src/views/module_payment/batch/index.vuefrontend/src/views/module_payment/batch/components/AuthorizeList.vuefrontend/src/views/module_payment/batch/components/BatchPayCreate.vue(从 account/components 复制改造)frontend/src/views/module_payment/batch/components/BatchPayList.vuefrontend/src/views/module_payment/batch/components/BatchPayDetail.vuefrontend/src/views/module_payment/account/index.vue(移除 batch-pay tab 及 import/handler)frontend/src/views/module_payment/account/components/BatchPayAuthorize.vue(删除,逻辑并入 AuthorizeList)frontend/src/views/module_payment/account/components/BatchPayCreate.vue、BatchPayList.vue、BatchPayDetail.vueInterfaces:
BatchPayAPI(Task 6 新签名);ProviderAPI.options()(服务商下拉,同 EnterpriseForm.vue:205)Produces: 新菜单组件路径 module_payment/batch/index
[ ] Step 1: index.vue(单页多 tab,照搬现有结构)
<template>
<el-tabs v-model="activeTab" class="batch-tabs" type="card">
<el-tab-pane label="制单授权" name="authorize">
<AuthorizeList />
</el-tab-pane>
<el-tab-pane label="批量制单" name="create">
<BatchPayCreate @created="handleBatchPayCreated" />
</el-tab-pane>
<el-tab-pane label="制单历史" name="list">
<BatchPayList :key="batchListKey" @view="handleViewBatchPay" />
</el-tab-pane>
</el-tabs>
<!-- 批次详情: 制单历史列表行触发(同 account/index.vue:419 模式) -->
<BatchPayDetail
v-if="detailBatchNo"
:out-batch-no="detailBatchNo"
@close="detailBatchNo = ''"
/>
</template>
<script setup lang="ts">
import { ref } from "vue";
import AuthorizeList from "./components/AuthorizeList.vue";
import BatchPayCreate from "./components/BatchPayCreate.vue";
import BatchPayList from "./components/BatchPayList.vue";
import BatchPayDetail from "./components/BatchPayDetail.vue";
const activeTab = ref("authorize");
const detailBatchNo = ref("");
const batchListKey = ref(0);
function handleViewBatchPay(outBatchNo: string) {
detailBatchNo.value = outBatchNo;
}
function handleBatchPayCreated() {
activeTab.value = "list";
batchListKey.value++;
}
</script>
参考 account/index.vue:400-425 现有结构。详情组件需支持
@close事件(Step 4 中 BatchPayDetail 增加 emit)。
[ ] Step 2: AuthorizeList.vue(授权列表 + 新增表单)
表格列:主体名称 / 支付宝uid / 服务商 / 状态(AUTHING=授权中/AUTHED=已授权/UNBIND=已解绑)/ 协议号 / 授权链接 / 操作
「新增授权」按钮 → dialog 表单(el-form,ParticipantName + participant_id + service_provider_id 服务商下拉):
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="主体名称" prop="participant_name">
<el-input v-model="form.participant_name" placeholder="如:张三(个人)/ 某某公司" style="max-width: 360px" />
</el-form-item>
<el-form-item label="支付宝账号" prop="participant_id">
<el-input v-model="form.participant_id" placeholder="支付宝用户ID(2088开头)" style="max-width: 360px" />
</el-form-item>
<el-form-item label="服务商" prop="service_provider_id">
<el-select v-model="form.service_provider_id" placeholder="选择服务商" filterable style="max-width: 360px">
<el-option v-for="p in providerOptions" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
</el-form>
提交:BatchPayAPI.authorizeApply({participant_name, participant_id, service_provider_id}) → 成功后在弹层展示授权链接(复制 + 二维码,复用原 BatchPayAuthorize 的 QRCode.toCanvas 用法)+「刷新状态」
行操作:
authorizeRebind(row.id) 二次确认)+「刷新状态」(queryAuthorize(row.out_biz_no))USER_AUTHORIZATION_EXIST 语义,spec 6.3)providerOptions.find(p => p.id === row.service_provider_id)?.name || "-"load():BatchPayAPI.authorizeList({page_no, page_size}),双保险解包 ?.items ?? ?.list(现有惯例)
[ ] Step 3: BatchPayCreate.vue 改造(主体下拉)
从 account/components/BatchPayCreate.vue 复制,改动:
enterpriseStore / currentEnterpriseId / handleSubmit 中的企业校验(原 563-566 行)表单顶部新增:
<el-form-item label="付款主体" prop="participant_id">
<el-select v-model="form.participant_id" placeholder="选择已授权主体" filterable style="max-width: 420px">
<el-option v-for="s in authedSubjects" :key="s.participant_id" :label="`${s.participant_name}(${s.participant_id})`" :value="s.participant_id" />
</el-select>
<div class="form-item-tip">仅展示已签约(AUTHED)主体;未签约请先到「制单授权」完成授权</div>
</el-form-item>
authedSubjects:onMounted 调 BatchPayAPI.authorizeList({page_size: 100}),过滤 status === 'AUTHED'(后端 list 返回全量可分页;页数不足时按需翻页拉全 —— 主体量小,page_size=100 即可,注释注明)
handleSubmit:BatchPayAPI.batchCreate({participant_id: form.participant_id, ...})(删除 enterprise_id),form 增加 participant_id: ""
校验规则:participant_id: [{required: true, message: "请选择付款主体", trigger: "change"}](el-form-item prop 绑定真实 form 字段,Element Plus 校验陷阱)
[ ] Step 4: BatchPayList.vue / BatchPayDetail.vue 改造
BatchPayList:
enterpriseStore/isPlatformUser/企业筛选下拉与 searchForm.enterprise_id/onMounted 中的企业预填authedSubjects 同 Step 3,含全部主体含 UNBIND —— 历史批次按 payer_uid 筛选,不要求 AUTHED):searchForm.participant_idload() 传 participant_id;handlePay/handleClose 用 row.out_batch_no 直接调(不再取 row.enterprise_id || currentEnterpriseId);@view emit 改 (outBatchNo)(去 enterpriseId)v-hasPerm 换 module_payment:batch:*(list→module_payment:batch:list,detail→module_payment:batch:detail,支付/关闭→module_payment:batch:create)useUserStore/isPlatformUser 逻辑BatchPayDetail:
{ outBatchNo: string }(去 enterpriseId);删除 enterpriseStore/currentEnterpriseId/enterpriseId computed 与空校验renderPay(outBatchNo) 等)新增 emit("close");页面内加「返回」按钮 @click="emit('close')"(原 account/index.vue 由外层控制显隐 —— 检查现有 419 行附近如何收起;新 index.vue 用 v-if="detailBatchNo",需 emit close 置空)
[ ] Step 5: account/index.vue 清理
删除:batch-pay tab 整块(400-425 行区域)、BatchPayAuthorize/BatchPayList/BatchPayCreate/BatchPayDetail import、batchListKey/batchPaySubTab 相关状态、handleViewBatchPay/handleBatchPayCreated handler、BatchPayDetail 条件渲染块
保留其余转账/充值/消费/收款功能不变
删除 account/components 下 4 个旧批量组件文件
[ ] Step 6: 构建 + 类型检查
Run: cd frontend && npm run build(或 npx vue-tsc --noEmit)
Expected: 构建通过,无未使用 import 报错
[ ] Step 7: Commit
git add frontend/src/views/module_payment/
git commit -m "feat: 批量付款新菜单页面 - 制单授权列表+表单、制单主体下拉、历史主体筛选、account 移除批量tab"
Files:
Create: java/src/main/resources/db/batch-menu.sql
[ ] Step 1: 写菜单 SQL
-- 批量付款顶层菜单 + 权限点(2026-08-26)
-- 顶层菜单 order: 取 sys_menu 顶层 type=2 最大 order + 1
INSERT INTO sys_menu (parent_id, title, route_name, route_path, component_path, permission, type, "order", status)
VALUES (NULL, '批量付款', 'payment-batch', '/payment/batch', 'module_payment/batch/index', 'module_payment:batch:list', 2,
(SELECT COALESCE(MAX("order"), 0) + 1 FROM sys_menu WHERE parent_id IS NULL), '0');
INSERT INTO sys_menu (parent_id, title, permission, type, "order", status)
VALUES ((SELECT id FROM sys_menu WHERE route_path = '/payment/batch' AND title = '批量付款'),
'制单授权', 'module_payment:batch:authorize', 3, 1, '0'),
((SELECT id FROM sys_menu WHERE route_path = '/payment/batch' AND title = '批量付款'),
'批量制单', 'module_payment:batch:create', 3, 2, '0'),
((SELECT id FROM sys_menu WHERE route_path = '/payment/batch' AND title = '批量付款'),
'制单历史', 'module_payment:batch:list', 3, 3, '0'),
((SELECT id FROM sys_menu WHERE route_path = '/payment/batch' AND title = '批量付款'),
'批次详情', 'module_payment:batch:detail', 3, 4, '0');
[ ] Step 2: 执行并验证
python - <<'EOF'
import psycopg2
conn = psycopg2.connect(host="localhost", dbname="payment_platform_java", user="admin", password="xjz#123321")
cur = conn.cursor()
cur.execute(open(r"D:\project2\payment-platform\java\src\main\resources\db\batch-menu.sql", encoding="utf-8").read())
conn.commit()
cur.execute("SELECT id, parent_id, title, route_path, permission FROM sys_menu WHERE title IN ('批量付款','制单授权','批量制单','制单历史','批次详情')")
for r in cur.fetchall(): print(r)
conn.close()
EOF
Expected: 5 行(顶层 + 4 权限点),parent_id 正确
Run: cd java && ./mvnw test
Run: cd frontend && npm run build
Expected: 全 PASS / 构建成功
npm run dev 启动前端(登录后,用现有测试账号):
[ ] Step 5: Commit
git add java/src/main/resources/db/batch-menu.sql
git commit -m "feat: 批量付款菜单与权限点 SQL"
getClientByProvider(Long, String)(Task 1)被 Task 3/4 消费;BatchCreateDTO.participant_id(Task 4)被 Task 5 controller 与 Task 6/7 前端消费;AuthorizeApplyDTO(Task 5)↔ authorizeApply({participant_name,...})(Task 6)↔ AuthorizeList 表单字段(Task 7)一致。