# 批量付款账号级改造实现计划 > **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 为准) ## Global Constraints - 测试运行:在 `java/` 目录执行 `./mvnw test -Dtest=`(Windows git bash 下 `./mvnw`;IDE 可直跑 JUnit) - 前端验证:`frontend/` 目录 `npm run dev`(端口见 vite 配置),Playwright MCP 交互验证 - TDD:每个任务先写失败测试,确认失败后再实现,实现后确认通过再 commit - 每任务独立 commit,提交信息风格与仓库一致(`feat:` / `fix:` / `docs:` + 中文简述) - 租户隔离依赖 `TenantInnerInterceptor` 自动过滤,**代码不显式传 tenant_id**(insert 自动填充,select 自动过滤) - 授权接口参数**保持现状**(`TRANSFER_API_STANDARD_AUTHORIZATION` / `STANDARD_CREATE_FUND_ORDER`,无 back_url)—— 实测成功链路,spec D8 - 不迁移存量数据;存量行(enterprise_id 非空)不被新查询命中 - 代码风格与现有文件一致(中文注释、Ruling 引用风格、空行习惯) - DDL 手工执行(无 migration 框架):用 psycopg2 连接 `localhost:5432/payment_platform_java`(admin/xjz#123321)执行 Task 2 的 SQL 文件 --- ## Task 1: AlipayClientFactory 新增 getClientByProvider(providerId, bizType) **Files:** - Modify: `java/src/main/java/com/payment/platform/core/alipay/AlipayClientFactory.java`(在 `getClientByProvider(Long)` 附近加方法) - Test: `java/src/test/java/com/payment/platform/core/alipay/AlipayClientFactoryProfileTest.java` **Interfaces:** - Produces: `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)。 - [ ] **Step 1: 写失败测试** 在 `AlipayClientFactoryProfileTest.java` 追加(参考该文件现有 mock 模式;用反射或 setter 注入 mock 的 `profileMapper`/`serviceProviderMapper`,模式照抄现有测试): ```java @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); } ``` - [ ] **Step 2: 运行确认失败** Run: `cd java && ./mvnw test -Dtest=AlipayClientFactoryProfileTest` Expected: 编译失败(方法不存在) - [ ] **Step 3: 实现** ```java /** * 按服务商 + 业务类型获取客户端(账号级场景,无企业中间层) *

* 解析优先级(与 {@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** ```bash 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) - 账号级无企业中间层直取业务凭证" ``` --- ## Task 2: 实体新增字段 + DDL(基类切换在 Task 4 与调用方同 commit) > **Ruling(执行前修正)**:基类 `PaymentEnterpriseBaseEntity → PaymentTenantBaseEntity` 切换推迟到 Task 4 —— 切换后 `AlipayBatchPayService` 的 `setEnterpriseId()` 全部编译失败,只有 Task 4 重写调用方时切换才能保持每个 commit 编译绿。本任务只加字段 + DDL。 **Files:** - Modify: `java/src/main/java/com/payment/platform/module/payment/batch/entity/BatchAuthorizeEntity.java` - Modify: `java/src/main/java/com/payment/platform/module/payment/batch/entity/BatchOrderEntity.java` - Create: `java/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 不变,加字段): ```java /** 主体名称(账号级表单录入,同时作为授权申请 principal_info.name) */ private String participantName; /** 服务商(账号级表单下拉选择,授权申请 client 解析依据) */ private Long serviceProviderId; ``` `BatchOrderEntity.java`(extends 不变,加字段): ```java /** 付款主体冗余的服务商(制单时落库)——主体解绑后批次支付/查询/关闭仍可解析 client */ private Long serviceProviderId; ``` - [ ] **Step 2: 编译验证** Run: `cd java && ./mvnw test-compile` Expected: 编译通过(只加字段不动基类,无任何破坏) - [ ] **Step 3: 写 DDL 文件** `java/src/main/resources/db/batch-account-level.sql`: ```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 并验证** ```bash 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** ```bash 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" ``` --- ## Task 3: BatchSubjectService 新建 — 授权管理迁移改造 **Files:** - Create: `java/src/main/java/com/payment/platform/module/payment/batch/service/BatchSubjectService.java` - Create: `java/src/test/java/com/payment/platform/module/payment/batch/service/BatchSubjectServiceTest.java` - Modify: `java/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.java`(删除授权方法 + 常量 + 辅助方法 + enterpriseMapper 依赖) **Interfaces:** - Consumes: `AlipayClientFactory.getClientByProvider(Long, String)`(Task 1) - Produces: - `Map apply(String participantName, String participantId, Long serviceProviderId)` —— 生成授权短链接并落库 AUTHING;返回 `{authorize_link, out_biz_no, status}` - `Map rebind(Long id)` —— 作废该主体非终态授权记录(AUTHING→UNBIND,`EXISTS_STOPPED_AUTHORIZE` 语义:换新 out_biz_no),重新申请 - `Map query(Long id, String outBizNo)` —— uni.query 回写 AUTHED + agreement_no;`AUTHORIZATION_NOT_EXIST` → `{agreement_no:"", status:"AUTHING"}` - `PageResult 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` 等)迁入本类。 - [ ] **Step 1: 写失败测试** 参考现有 `AlipayBatchPayServiceTest` 模式(Mockito + `new BatchSubjectService(alipayClientFactory, batchAuthorizeMapper)` + TableInfoHelper 初始化 `BatchAuthorizeEntity` 元数据 + lenient stub)。关键用例: ```java @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 result = service.apply("张三公司", "2088111122223333", 1L); assertEquals("https://ur.alipay.com/abc", result.get("authorize_link")); ArgumentCaptor 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 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 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")`。 - [ ] **Step 2: 运行确认失败** Run: `cd java && ./mvnw test -Dtest=BatchSubjectServiceTest` Expected: 编译失败(类不存在) - [ ] **Step 3: 实现 BatchSubjectService** 迁移改造(从 `AlipayBatchPayService` 原方法改造,见 spec 5.1 / 设计 ②): ```java 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 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() .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 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 query(Long id, String outBizNo) { // 原 queryAuthorize 改造: client 按记录 service_provider_id; // 回写条件 .eq(out_biz_no) 不变;无 id 记录 warn 日志文案去 enterpriseId } public PageResult list(String participantId, int pageNo, int pageSize) { var w = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .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) } ``` - [ ] **Step 4: 从 AlipayBatchPayService 删除授权逻辑** > **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` 保留。 - [ ] **Step 5: 跑全部批次相关测试,修断言** 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 与构造参数。 - [ ] **Step 6: 确认全绿 + Commit** Run: `cd java && ./mvnw test -Dtest=AlipayBatchPayServiceTest,BatchSubjectServiceTest,BatchPayHandlerTest,AlipayClientFactoryProfileTest` Expected: 全 PASS ```bash 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/租户隔离" ``` --- ## Task 4: AlipayBatchPayService 批次方法账号级改造 **Files:** - Modify: `java/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.java` - Modify: `java/src/main/java/com/payment/platform/module/payment/batch/dto/BatchCreateDTO.java` - Modify: `java/src/test/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayServiceTest.java` **Interfaces:** - Consumes: `BatchSubjectService` 无(批次类独立);`BatchAuthorizeMapper`(查主体 AUTHED 记录);`AlipayClientFactory.getClientByProvider` - Produces: - `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`: ```java @NotBlank(message = "请选择付款主体") @Schema(description = "付款主体(授权主体支付宝uid,制单时从已授权主体选择)") private String participantId; ``` 删除 `enterpriseId` 字段(租户拦截器自动填充 tenant_id)。 - [ ] **Step 2: 写失败测试(批次方法改造)** 在 `AlipayBatchPayServiceTest` 修改/新增(构造器改为 `new AlipayBatchPayService(alipayClientFactory, batchAuthorizeMapper, batchOrderMapper, batchDetailMapper)`,`enterpriseMapper` stub 删除;`alipayClientFactory.getClientByProvider(1L,"BATCH_PAY")` lenient stub): ```java @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 result = service.batchCreate(dto); ArgumentCaptor 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 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 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`)。 - [ ] **Step 3: 运行确认失败** Run: `cd java && ./mvnw test -Dtest=AlipayBatchPayServiceTest` Expected: 新用例失败(`selectOne` 无 stub / 断言不匹配),存量用例编译失败(签名变更) - [ ] **Step 4: 实现改造** `batchCreate`:删除 `requireEnterprise(dto.getEnterpriseId())` 与 `payerIdentity/payerIdentityType(ent)`;改为: ```java // 付款方 = 制单选择的授权主体(spec 5.3): 未完成授权不允许制单 BatchAuthorizeEntity authed = batchAuthorizeMapper.selectOne( new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .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` 同步移除断言。 - [ ] **Step 5: 全量跑批次测试** Run: `cd java && ./mvnw test -Dtest=AlipayBatchPayServiceTest` Expected: 全 PASS(含存量 50+ 用例改造后) - [ ] **Step 6: Commit** ```bash 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" ``` --- ## Task 5: BatchPayController 路由与权限 **Files:** - Modify: `java/src/main/java/com/payment/platform/module/payment/batch/controller/BatchPayController.java` - Modify(新增): `java/src/main/java/com/payment/platform/module/payment/batch/dto/AuthorizeApplyDTO.java` **Interfaces:** - Produces: - `@RequestMapping("/payment/batch")` - `POST /authorize/apply` body `AuthorizeApplyDTO {participant_name, participant_id, service_provider_id}` → `batchSubjectService.apply(...)`,权限 `module_payment:batch:authorize` - `POST /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:list` - `POST /create` → `batchCreate(dto)`,权限 `module_payment:batch:create` - `POST /pay` body `{out_batch_no}` → `renderPay(outBatchNo)`,权限 `module_payment:batch:create` - `GET /query?out_batch_no=` → `batchQuery(outBatchNo)`,权限 `module_payment:batch:list` - `POST /close` body `{out_batch_no}` → `batchClose(outBatchNo)`,权限 `module_payment:batch:create` - `GET /list`(participant_id/status/start_time/end_time/page_no/page_size)→ `batchList(...)`,权限 `module_payment:batch:list` - `GET /detail?out_batch_no=` → `batchDetail(outBatchNo, pageNo, pageSize)`,权限 `module_payment:batch:detail` - `GET /export`(participant_id/status/start_time/end_time)→ `batchExport(...)`,权限 `module_payment:batch:list` - [ ] **Step 1: 写 DTO** ```java @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** ```bash 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:* 权限" ``` --- ## Task 6: 前端 API 层 batch.ts **Files:** - Modify: `frontend/src/api/module_payment/batch.ts` **Interfaces:** - Produces: - `API_PATH = "/payment/batch"` - `BatchAuthorizeVO` 新增 `participant_name?: string; service_provider_id?: number; id?: number` - `BatchCreateParams` 删除 `enterprise_id`,新增 `participant_id: string` - 方法签名: `authorizeApply(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** ```bash git add frontend/src/api/module_payment/batch.ts git commit -m "feat: 批量API层账号级 - /payment/batch 路径、去企业ID、主体参数" ``` --- ## Task 7: 前端新菜单页面 + 组件迁移改造 **Files:** - Create: `frontend/src/views/module_payment/batch/index.vue` - Create: `frontend/src/views/module_payment/batch/components/AuthorizeList.vue` - Modify(迁移+改造): `frontend/src/views/module_payment/batch/components/BatchPayCreate.vue`(从 account/components 复制改造) - Modify(迁移+改造): `frontend/src/views/module_payment/batch/components/BatchPayList.vue` - Modify(迁移+改造): `frontend/src/views/module_payment/batch/components/BatchPayDetail.vue` - Modify: `frontend/src/views/module_payment/account/index.vue`(移除 batch-pay tab 及 import/handler) - Modify: `frontend/src/views/module_payment/account/components/BatchPayAuthorize.vue`(删除,逻辑并入 AuthorizeList) - Modify(删除): `frontend/src/views/module_payment/account/components/BatchPayCreate.vue`、`BatchPayList.vue`、`BatchPayDetail.vue` **Interfaces:** - Consumes: `BatchPayAPI`(Task 6 新签名);`ProviderAPI.options()`(服务商下拉,同 EnterpriseForm.vue:205) - Produces: 新菜单组件路径 `module_payment/batch/index` - [ ] **Step 1: index.vue(单页多 tab,照搬现有结构)** ```vue ``` > 参考 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` 服务商下拉): ```vue ``` - 提交:`BatchPayAPI.authorizeApply({participant_name, participant_id, service_provider_id})` → 成功后在弹层展示授权链接(复制 + 二维码,复用原 BatchPayAuthorize 的 `QRCode.toCanvas` 用法)+「刷新状态」 - 行操作: - AUTHING 行:「重新生成链接」(`authorizeRebind(row.id)` 二次确认)+「刷新状态」(`queryAuthorize(row.out_biz_no)`) - AUTHED 行:展示协议号,无生成按钮(`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 行) - 表单顶部新增: ```vue

仅展示已签约(AUTHED)主体;未签约请先到「制单授权」完成授权
``` - `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_id` - `load()` 传 `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`: - props 改 `{ outBatchNo: string }`(去 enterpriseId);删除 `enterpriseStore`/`currentEnterpriseId`/`enterpriseId` computed 与空校验 - API 调用去 enterpriseId 首参(`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** ```bash git add frontend/src/views/module_payment/ git commit -m "feat: 批量付款新菜单页面 - 制单授权列表+表单、制单主体下拉、历史主体筛选、account 移除批量tab" ``` --- ## Task 8: sys_menu SQL + 端到端验证 **Files:** - Create: `java/src/main/resources/db/batch-menu.sql` - [ ] **Step 1: 写菜单 SQL** ```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: 执行并验证** ```bash 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 正确 - [ ] **Step 3: 全量测试 + 前端构建** Run: `cd java && ./mvnw test` Run: `cd frontend && npm run build` Expected: 全 PASS / 构建成功 - [ ] **Step 4: Playwright 端到端验证关键路径** `npm run dev` 启动前端(登录后,用现有测试账号): 1. 菜单栏出现「批量付款」,进入后 3 个 tab 可见 2. 制单授权 tab:「新增授权」→ 表单(名称/uid/服务商下拉可选)→ 提交成功展示授权链接 3. 批量制单 tab:付款主体下拉仅列 AUTHED 主体;无主体时提示引导 4. 制单历史 tab:主体筛选下拉可用;批次列表渲染(存量租户数据可见) 5. 资金专户转账页不再含「批量付款」tab 6. 有 AUTHED 主体时:制单 → 列表 → 详情 → 生成支付链接 → 关闭批次(真实支付宝操作到不了,验证前端流程与后端错误提示) - [ ] **Step 5: Commit** ```bash git add java/src/main/resources/db/batch-menu.sql git commit -m "feat: 批量付款菜单与权限点 SQL" ``` --- ## 自检记录(plan 写完时的自我检查) - Spec 覆盖:①新菜单 → Task 7/8;②多授权+表单 → Task 3/7;③主体筛选 → Task 4/6/7;④服务商下拉 → Task 3/7。全部有任务。 - 类型一致性:`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)一致。 - 无占位符:所有任务给出实际代码/命令/断言。 - 存量测试:Task 3/4 明确授权用例迁移与断言改造路径。