2026-08-26-alipay-batch-pay-account-level-implementation.md 48 KB

批量付款账号级改造实现计划

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=<TestClass>(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,模式照抄现有测试):

@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: 实现

    /**
    * 按服务商 + 业务类型获取客户端(账号级场景,无企业中间层)
    * <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) - 账号级无企业中间层直取业务凭证"
    

Task 2: 实体新增字段 + DDL(基类切换在 Task 4 与调用方同 commit)

Ruling(执行前修正):基类 PaymentEnterpriseBaseEntity → PaymentTenantBaseEntity 切换推迟到 Task 4 —— 切换后 AlipayBatchPayServicesetEnterpriseId() 全部编译失败,只有 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 不变,加字段):

    /** 主体名称(账号级表单录入,同时作为授权申请 principal_info.name) */
    private String participantName;
    /** 服务商(账号级表单下拉选择,授权申请 client 解析依据) */
    private Long serviceProviderId;

BatchOrderEntity.java(extends 不变,加字段):

    /** 付款主体冗余的服务商(制单时落库)——主体解绑后批次支付/查询/关闭仍可解析 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

-- 批量付款账号级改造(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"
    

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<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 等)迁入本类。

  • Step 1: 写失败测试

参考现有 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")

  • Step 2: 运行确认失败

Run: cd java && ./mvnw test -Dtest=BatchSubjectServiceTest Expected: 编译失败(类不存在)

  • Step 3: 实现 BatchSubjectService

迁移改造(从 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)
}
  • 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/ServiceProviderIdservice.authorizeApply("E100")service.apply(...))。AlipayBatchPayServiceTest 保留批次用例,setUp 中删除 enterpriseMapper stub 与构造参数。

  • Step 6: 确认全绿 + Commit

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/租户隔离"

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

    @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):

@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)。

  • Step 3: 运行确认失败

Run: cd java && ./mvnw test -Dtest=AlipayBatchPayServiceTest Expected: 新用例失败(selectOne 无 stub / 断言不匹配),存量用例编译失败(签名变更)

  • Step 4: 实现改造

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/batchExportenterpriseId 参数 → participantId,条件改 .eq(participantId..., BatchOrderEntity::getPayerUid, participantId)

batchDetail:删除 enterpriseId 参数与条件。

删除:requireEnterpriseId/payerIdentity/payerIdentityType/requireEnterpriseEnterpriseEntity/EnterpriseMapper import 与字段。

基类切换(Ruling 见 Task 2): 三个实体 BatchAuthorizeEntity/BatchOrderEntity/BatchDetailEntity 的 import 与 extends 从 PaymentEnterpriseBaseEntity 改为 PaymentTenantBaseEntitycom.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

    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 /createbatchCreate(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

    @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:* 权限"
    

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/batchDetailenterpriseId 首参删除

  • 新增 BatchAuthorizeVO.participant_name/service_provider_id/idBatchCreateParams.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、主体参数"
    

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.vueBatchPayList.vueBatchPayDetail.vue

Interfaces:

  • Consumes: 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-formParticipantName + 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 用法)+「刷新状态」

  • 行操作:

    • 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 行)
  • 表单顶部新增:

    <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>
    
  • authedSubjectsonMountedBatchPayAPI.authorizeList({page_size: 100}),过滤 status === 'AUTHED'(后端 list 返回全量可分页;页数不足时按需翻页拉全 —— 主体量小,page_size=100 即可,注释注明)

  • handleSubmitBatchPayAPI.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_idhandlePay/handleCloserow.out_batch_no 直接调(不再取 row.enterprise_id || currentEnterpriseId);@view emit 改 (outBatchNo)(去 enterpriseId)
  • 权限 v-hasPermmodule_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

    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

    -- 批量付款顶层菜单 + 权限点(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 正确

  • 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

    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 明确授权用例迁移与断言改造路径。