Преглед изворни кода

feat: 批量付款菜单 SQL + 页面单根与空值兜底完善

- db/batch-menu.sql: 批量付款菜单与 module_payment:batch:* 权限点, 已绑定 admin 角色
- batch/index.vue: 多根 fragment 改单根包裹(transition 子节点要求)
- 组件: participant_name 空值兜底、批次明细 WAIT_PAY 状态映射
- 补充账号级改造实现计划文档
alphaH пре 4 дана
родитељ
комит
bf89545105

+ 899 - 0
.claude/plan/2026-08-26-alipay-batch-pay-account-level-implementation.md

@@ -0,0 +1,899 @@
+# 批量付款账号级改造实现计划
+
+> **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`,模式照抄现有测试):
+
+```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
+/**
+ * 按服务商 + 业务类型获取客户端(账号级场景,无企业中间层)
+ * <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**
+
+```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<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)。关键用例:
+
+```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<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 / 设计 ②):
+
+```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<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/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<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)`;改为:
+
+```java
+            // 付款方 = 制单选择的授权主体(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` 同步移除断言。
+
+- [ ] **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
+<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` 服务商下拉):
+
+```vue
+<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 行)
+- 表单顶部新增:
+
+```vue
+<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_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 明确授权用例迁移与断言改造路径。

+ 1 - 1
frontend/src/views/module_payment/batch/components/BatchPayCreate.vue

@@ -16,7 +16,7 @@
           <el-option
             v-for="s in authedSubjects"
             :key="s.participant_id"
-            :label="`${s.participant_name}(${s.participant_id})`"
+            :label="`${s.participant_name || '未命名主体'}(${s.participant_id})`"
             :value="s.participant_id"
           />
         </el-select>

+ 2 - 0
frontend/src/views/module_payment/batch/components/BatchPayDetail.vue

@@ -126,11 +126,13 @@ const STATUS_TEXT: Record<string, string> = {
 };
 const DETAIL_STATUS_TAG: Record<string, TagType> = {
   INIT: "info",
+  WAIT_PAY: "warning",
   SUCCESS: "success",
   FAIL: "danger",
 };
 const DETAIL_STATUS_TEXT: Record<string, string> = {
   INIT: "处理中",
+  WAIT_PAY: "等待支付",
   SUCCESS: "成功",
   FAIL: "失败",
 };

+ 5 - 3
frontend/src/views/module_payment/batch/components/BatchPayList.vue

@@ -27,7 +27,7 @@
             <el-option
               v-for="s in allSubjects"
               :key="s.participant_id"
-              :label="`${s.participant_name}(${s.participant_id})`"
+              :label="`${s.participant_name || '未命名主体'}(${s.participant_id})`"
               :value="s.participant_id"
             />
           </el-select>
@@ -148,8 +148,10 @@ const payingNo = ref("");
 
 /** 全部主体(含已解绑 UNBIND —— 历史批次按 payer_uid 筛选,不要求当前可制单) */
 const allSubjects = ref<BatchAuthorizeVO[]>([]);
-const subjectName = (uid?: string) =>
-  allSubjects.value.find((s) => s.participant_id === uid)?.participant_name || uid || "-";
+const subjectName = (uid?: string) => {
+  const s = allSubjects.value.find((x) => x.participant_id === uid);
+  return s ? s.participant_name || "未命名主体" : uid || "-";
+};
 
 const searchForm = reactive({
   participant_id: undefined as string | undefined,

+ 21 - 17
frontend/src/views/module_payment/batch/index.vue

@@ -1,21 +1,25 @@
 <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 模式) -->
-  <BatchPayDetail
-    v-if="detailBatchNo"
-    :out-batch-no="detailBatchNo"
-    @close="detailBatchNo = ''"
-  />
+  <!-- 单根节点包裹(含详情弹层):AppMain 的 <transition mode="out-in"> 要求组件根为元素,
+       多根 fragment 会导致切换动画无法完成、后续路由挂载被卡死 -->
+  <div class="batch-container">
+    <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 模式) -->
+    <BatchPayDetail
+      v-if="detailBatchNo"
+      :out-batch-no="detailBatchNo"
+      @close="detailBatchNo = ''"
+    />
+  </div>
 </template>
 
 <script setup lang="ts">

+ 15 - 0
java/src/main/resources/db/batch-menu.sql

@@ -0,0 +1,15 @@
+-- 批量付款顶层菜单 + 权限点(2026-08-26)
+-- 顶层菜单 order: 取 sys_menu 顶层 type=2 最大 order + 1
+INSERT INTO sys_menu (parent_id, name, title, route_name, route_path, component_path, permission, type, "order", hidden, keep_alive, always_show, affix, status, created_time, updated_time)
+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), false, false, false, false, '0', now(), now());
+
+INSERT INTO sys_menu (parent_id, name, title, permission, type, "order", hidden, keep_alive, always_show, affix, status, created_time, updated_time)
+VALUES ((SELECT id FROM sys_menu WHERE route_path = '/payment/batch' AND title = '批量付款'),
+        '制单授权', '制单授权', 'module_payment:batch:authorize', 3, 1, false, false, false, false, '0', now(), now()),
+       ((SELECT id FROM sys_menu WHERE route_path = '/payment/batch' AND title = '批量付款'),
+        '批量制单', '批量制单', 'module_payment:batch:create', 3, 2, false, false, false, false, '0', now(), now()),
+       ((SELECT id FROM sys_menu WHERE route_path = '/payment/batch' AND title = '批量付款'),
+        '制单历史', '制单历史', 'module_payment:batch:list', 3, 3, false, false, false, false, '0', now(), now()),
+       ((SELECT id FROM sys_menu WHERE route_path = '/payment/batch' AND title = '批量付款'),
+        '批次详情', '批次详情', 'module_payment:batch:detail', 3, 4, false, false, false, false, '0', now(), now());