15 İşlemeler c5431230e3 ... 8c6e6f487d

Yazar SHA1 Mesaj Tarih
  alphaH 8c6e6f487d feat: 批量付款LOGON_ID主体扫码授权制单 - user.info.share取2088 userId(应用未开通open_id实证改用user_id)+ 授权回调复用白名单aplipay/auth按参数分流 5 gün önce
  alphaH d5da56d84f Revert "feat: 授权查询暴露 authUserInfo + openId" 5 gün önce
  alphaH c556b25a6d feat: 授权查询暴露 authUserInfo + openId 5 gün önce
  alphaH a98227bcca fix: 批量付款三个 tab 联动问题 5 gün önce
  alphaH 95ca793a6a feat: 授权申请主体类型可选 + 雪花ID序列化修复 5 gün önce
  alphaH bf89545105 feat: 批量付款菜单 SQL + 页面单根与空值兜底完善 6 gün önce
  alphaH 529f5566a7 fix: AppMain transition 移除 out-in 模式修复路由切换白屏 6 gün önce
  alphaH aa1d7515a3 feat: 批量付款新菜单页面 - 制单授权列表+表单、制单主体下拉、历史主体筛选、account 移除批量tab 6 gün önce
  alphaH 356e7c80cd feat: 批量API层账号级 - /payment/batch 路径、去企业ID、主体参数 6 gün önce
  alphaH 828188f496 feat: 批量接口路由改 /payment/batch + module_payment:batch:* 权限 6 gün önce
  alphaH 4e415a986c feat: 批次操作账号级改造 - 制单按授权主体、去企业ID、按订单服务商解析client 6 gün önce
  alphaH 653b494e75 feat: 授权管理拆为 BatchSubjectService - 表单主体参数/服务商client/租户隔离 6 gün önce
  alphaH bc965fcf9d feat: 批量付款实体新增字段 - authorize 加主体名称/服务商、order 冗余服务商 + DDL 6 gün önce
  alphaH 1fcc3e924d feat: AlipayClientFactory 新增 getClientByProvider(providerId, bizType) - 账号级无企业中间层直取业务凭证 6 gün önce
  alphaH f56f3a0a8d docs: 批量付款账号级改造设计 - 新增批量付款菜单、多授权主体、主体筛选、服务商下拉 6 gün önce
33 değiştirilmiş dosya ile 3422 ekleme ve 1182 silme
  1. 257 0
      .claude/plan/2026-08-26-alipay-batch-pay-account-level-design.md
  2. 899 0
      .claude/plan/2026-08-26-alipay-batch-pay-account-level-implementation.md
  3. 51 26
      frontend/src/api/module_payment/batch.ts
  4. 1 1
      frontend/src/layouts/components/AppMain/index.vue
  5. 0 210
      frontend/src/views/module_payment/account/components/BatchPayAuthorize.vue
  6. 0 49
      frontend/src/views/module_payment/account/index.vue
  7. 474 0
      frontend/src/views/module_payment/batch/components/AuthorizeList.vue
  8. 61 13
      frontend/src/views/module_payment/batch/components/BatchPayCreate.vue
  9. 17 28
      frontend/src/views/module_payment/batch/components/BatchPayDetail.vue
  10. 41 42
      frontend/src/views/module_payment/batch/components/BatchPayList.vue
  11. 50 0
      frontend/src/views/module_payment/batch/index.vue
  12. 41 0
      java/src/main/java/com/payment/platform/core/alipay/AlipayClientFactory.java
  13. 13 0
      java/src/main/java/com/payment/platform/core/alipay/AlipayConfig.java
  14. 2 0
      java/src/main/java/com/payment/platform/core/security/SecurityConfig.java
  15. 5 1
      java/src/main/java/com/payment/platform/core/tenant/TenantInnerInterceptor.java
  16. 45 32
      java/src/main/java/com/payment/platform/module/payment/batch/controller/BatchPayController.java
  17. 26 0
      java/src/main/java/com/payment/platform/module/payment/batch/dto/AuthorizeApplyDTO.java
  18. 3 2
      java/src/main/java/com/payment/platform/module/payment/batch/dto/BatchCreateDTO.java
  19. 21 2
      java/src/main/java/com/payment/platform/module/payment/batch/entity/BatchAuthorizeEntity.java
  20. 2 2
      java/src/main/java/com/payment/platform/module/payment/batch/entity/BatchDetailEntity.java
  21. 4 2
      java/src/main/java/com/payment/platform/module/payment/batch/entity/BatchOrderEntity.java
  22. 1 1
      java/src/main/java/com/payment/platform/module/payment/batch/scheduler/BatchStatusPollScheduler.java
  23. 58 255
      java/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.java
  24. 340 0
      java/src/main/java/com/payment/platform/module/payment/batch/service/BatchSubjectService.java
  25. 40 7
      java/src/main/java/com/payment/platform/module/payment/facetoface/controller/AlipayAuthController.java
  26. 4 0
      java/src/main/resources/application.yml
  27. 27 0
      java/src/main/resources/db/batch-account-level.sql
  28. 15 0
      java/src/main/resources/db/batch-menu.sql
  29. 44 0
      java/src/test/java/com/payment/platform/core/alipay/AlipayClientFactoryProfileTest.java
  30. 9 10
      java/src/test/java/com/payment/platform/module/payment/batch/scheduler/BatchStatusPollSchedulerTest.java
  31. 187 499
      java/src/test/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayServiceTest.java
  32. 602 0
      java/src/test/java/com/payment/platform/module/payment/batch/service/BatchSubjectServiceTest.java
  33. 82 0
      java/src/test/java/com/payment/platform/module/payment/facetoface/controller/AlipayAuthControllerTest.java

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

@@ -0,0 +1,257 @@
+# 批量付款到户有密 — 账号级改造设计(2026-08-26)
+
+## 1. 背景与目标
+
+批量有密产品原为**企业级**架构:授权记录绑定企业(`pay_batch_authorize.enterprise_id`,一企业一协议,`uk_batch_authorize_active(enterprise_id, participant_id)` 唯一索引),制单付款方恒为企业自身身份,功能挂在「资金专户转账页」菜单的 tab 内。
+
+现调整为**账号级**架构,与企业管理功能隔离:
+
+1. 新增「批量付款」菜单,将原有批量支付功能迁移到新菜单
+2. 一个租户账号可维护多个授权主体(个人/企业支付宝账号均可签约,不限制签约次数,可一直签约新主体),需要表单
+3. 制单、制单历史可选择主体筛选
+4. 授权时可能获取不到 `pay_service_provider`,表单给出服务商下拉框选择(与新增企业的服务商下拉框一致)
+
+## 2. 授权接口实证(alipay.fund.authorize.uni.apply)
+
+来源:Playwright 抓取 https://opendocs.alipay.com/pre-open/fc872015_alipay.fund.authorize.uni.apply (2026-08-26,邀测站点「批量付款到户有密」产品)
+
+### 2.1 业务请求参数
+
+| 参数 | 必选 | 说明 |
+|---|---|---|
+| `product_code` | 必选 | 固定 `FUND_AUTHORIZATION` |
+| `biz_scene` | 必选 | 固定 `AUTHORIZE_FLOW` |
+| `out_biz_no` | 必选 | 授权申请外部业务号,幂等控制 |
+| `authorize_link_type` | 必选 | `SHORT_URL`(短链接)/ `TOKEN`(组件token) |
+| `back_url` | 必选 | 回跳地址(示例 `alipays://xxxxxxx`) |
+| `principal_info` | 必选 | AuthParticipantInfo 授权主体:`participant_id_type` + `participant_id` + `name`(示例 `ALIPAY_USER_ID` / `2088...` / 名称) |
+| `channel` | 可选 | `pc` / `tinyapp` |
+| `sub_biz_scene` | 可选 | 示例 `COMMON` |
+| `apply_expire_time` | 可选 | 跳转链接有效期,默认 7 天 |
+| `auth_expire_time` | 可选 | 授权失效时间,默认 2115-01-01 |
+| `back_url_type` | 可选 | `BACK_ALIPAY`(默认)/ `BACK_OUT_ALIPAY` |
+| `partner_info` / `third_party_info` | 可选 | AuthParticipantInfo |
+| `auth_biz_param` / `authorize_check_rule` | 可选 | 本次不使用 |
+
+**关键结论**:`principal_info` 主体可任意指定(个人/企业支付宝账号),`participant_id` 为 uid(`ALIPAY_USER_ID`)。支持多主体、多协议,即账号级多授权的基础。
+
+### 2.2 业务错误码(新增,影响授权逻辑)
+
+| 错误码 | 含义 | 处理 |
+|---|---|---|
+| `USER_AUTHORIZATION_EXIST` | 已存在授权协议,无需再次授权,请通过查询接口查询授权信息 | 同租户同主体已签约 → 前端提示「该账号已签约」,不重复发起(配合唯一索引预检) |
+| `EXISTS_STOPPED_AUTHORIZE` | 存在已失效的授权,请更换 outBizNo 后重试 | 主体解绑后重新授权必须换新 `out_biz_no`(现有 rebind 逻辑复用) |
+| `USER_NOT_EXIST` | 用户不存在,请检查申请账号合法性 | 表单 uid 非法提示 |
+| `UN_SUPPORT_AUTHORIZE_TERMINAL_TYPE` | 不支持的授权端 | channel 校验(本产品固定 pc) |
+
+## 3. 现状(代码实证)
+
+### 3.1 数据模型(java)
+
+- `BatchAuthorizeEntity extends PaymentEnterpriseBaseEntity`(表 `pay_batch_authorize`):`out_biz_no / participant_id / participant_id_type / agreement_no / status(AUTHING/AUTHED/UNBIND) / authorize_link / authorize_expire_time` + 基类 `tenant_id / enterprise_id / id / created_time`
+- `BatchOrderEntity extends PaymentEnterpriseBaseEntity`(表 `pay_batch_order`):`out_batch_no / batch_trans_id / total_amount / total_count / order_title / status(INIT/WAIT_PAY/SUCCESS/DISUSE/FAIL/INVALID) / payer_uid / agreement_no / transfer_scene_name / transfer_scene_report_infos / time_expire / remark / pay_url / error_code / error_msg`
+- `BatchDetailEntity extends PaymentEnterpriseBaseEntity`(表 `pay_batch_detail`):`batch_id / out_biz_no / amount / remark / payee_identity / payee_identity_type / payee_name / status / error_code / error_msg`
+- 唯一索引:`uk_batch_authorize_active(enterprise_id, participant_id)`
+
+### 3.2 租户隔离机制(无需改造,直接复用)
+
+`TenantInnerInterceptor`(MyBatis-Plus `TenantLineInnerInterceptor`)自动为 `pay_*` 表追加 `tenant_id` 条件:
+
+- 已登录非超管 → 自动按 `LoginUser.tenant_id` 过滤
+- 超管(`isSuperuser`)→ 跳过租户隔离,可看全部
+- 无认证(支付宝通知回调 / 定时任务)→ 跳过
+- insert 时自动填充 `tenant_id`
+
+`pay_batch_authorize / pay_batch_order / pay_batch_detail` 均不在 IGNORE_TABLES / CONDITIONAL_TABLES 名单 → 已登录即自动租户级隔离。**接口无需传租户参数。**
+
+### 3.3 制单与批次操作(AlipayBatchPayService)
+
+- `batchCreate(BatchCreateDTO)`:付款方从企业解析(`payerIdentity(ent)`:identity 优先回退 enterprise_id),协议号从该企业+付款方最新 AUTHED 授权记录自动带出,client 用 `getClient(enterpriseId, BIZ_TYPE)`
+- `renderPay / batchQuery / batchClose`:显式 `.eq(enterpriseId)` 条件;client 用 `getClient(enterpriseId, BIZ_TYPE)`
+- `batchList / batchDetail / batchExport / authorizeList`:enterprise_id 显式筛选
+- `getPendingBatches`:全表非终态(无认证,租户拦截器跳过)—— 不改
+- `BatchPayHandler`(通知):按 `out_biz_no` / `out_batch_no` 匹配,无 enterprise 依赖 —— 不改
+
+### 3.4 AlipayClientFactory 客户端解析链
+
+- `getClient(enterpriseId, bizType)`:企业 → `serviceProviderId` → ① profile(`pay_service_provider_profile` 业务专属凭证)→ ② 服务商默认凭证 → ③ yml fallback
+- `getClientByProvider(Long providerId)`:直接按服务商(默认凭证)
+- `getClientByProfile(spId, bizType)`:**private**,服务商+业务专属凭证
+
+改造需要新增 public 入口:`getClientByProvider(providerId, bizType)`(先 profile 后默认,与现有企业链路同构,仅少企业中间层)。
+
+### 3.5 前端(vue3 + Element Plus)
+
+- 菜单为动态菜单(`sys_menu` 表下发,`permission.store.ts` 动态路由),顶层菜单示例:`/account` → `module_payment/account/index`
+- 批量支付功能位于「资金专户转账页」`account/index.vue` 的 `batch-pay` tab:二级 tabs(制单授权 `BatchPayAuthorize` / 批次列表 `BatchPayList` / 创建批次 `BatchPayCreate`),详情 `BatchPayDetail` 条件渲染
+- API 层 `batch.ts`:`/payment/account/batch` 前缀,全部接口带 `enterprise_id`
+- 服务商下拉:`ProviderAPI.options()`(`EnterpriseForm.vue` 同款,返回 `{ id, scope_label }`)
+- 权限点:`module_payment:account:authorize / transfer / transfer:list / transfer:detail`
+
+## 4. 数据模型改造
+
+### 4.1 `pay_batch_authorize`(授权记录即主体,租户级)
+
+实体基类:`PaymentEnterpriseBaseEntity` → `PaymentTenantBaseEntity`(移除 `enterprise_id` 绑定)。
+
+| 字段 | 改动 |
+|---|---|
+| `participant_id` / `participant_id_type` | 保留(表单录入的支付宝 uid,`ALIPAY_USER_ID`) |
+| `participant_name` | **新增** —— 主体名称,表单必填,同时作为 `principal_info.name` |
+| `service_provider_id` | **新增** —— 表单服务商下拉 |
+| `out_biz_no / agreement_no / authorize_link / authorize_expire_time / status` | 保留,每条主体记录独立维护 |
+| 唯一索引 | `uk_batch_authorize_active(enterprise_id, participant_id)` → `(tenant_id, participant_id)`,防同租户重复签约(对应 `USER_AUTHORIZATION_EXIST`) |
+
+### 4.2 `pay_batch_order` / `pay_batch_detail`(租户级)
+
+实体基类同上改为 `PaymentTenantBaseEntity`。`pay_batch_order` 新增:
+
+| 字段 | 说明 |
+|---|---|
+| `service_provider_id` | 制单时冗余主体服务商 —— 主体解绑后批次仍可支付/查询/关闭(client 不依赖主体记录存活) |
+
+`payer_uid`(= 主体 uid)作为主体标识,制单历史按主体筛选直接用 `payer_uid`。
+
+### 4.3 存量数据
+
+不迁移。存量行保留(`enterprise_id` 非空旧行不再被新查询命中)。索引改为租户+主体唯一,避免与存量冲突。
+
+## 5. 后端设计
+
+### 5.1 服务拆分
+
+- **新建 `BatchSubjectService`**(主体/授权管理):`apply / rebind / query / list`
+  - `apply(participant_name, participant_id, service_provider_id)` → `principal_info.name` = 主体名称(新传字段)、`participant_id` = 表单 uid;client 用 `getClientByProvider(service_provider_id)`
+  - `USER_AUTHORIZATION_EXIST` → 提示「该账号已签约,请直接选择使用」(配合唯一索引预检)
+  - `EXISTS_STOPPED_AUTHORIZE` → rebind 换 `out_biz_no`(现有逻辑复用,每条主体记录独立维护)
+- **`AlipayBatchPayService` 保留批次操作并改造**:`create / renderPay / batchQuery / batchClose / list / detail / export / getPendingBatches`
+  - 文件已 600+ 行,授权逻辑(多主体表单、rebind、错误处理)拆出,避免继续膨胀
+
+### 5.2 租户隔离
+
+所有接口移除 `enterprise_id` 参数,依赖 `TenantInnerInterceptor` 自动隔离。`batchCreate` 中 `dto.getTenantId()` 恒空、由拦截器 insert 自动填充的模式不变。
+
+### 5.3 制单改造(`batchCreate`)
+
+- `BatchCreateDTO` 新增 `participant_id`(制单时从「已授权主体」列表选主体,付款方)
+- 付款方 = 主体 uid(不再解析企业身份);协议号 = 该主体授权记录 `agreement_no`(自动带出,规则不变)
+- client:`alipayClientFactory.getClient(providerId, BIZ_TYPE)`(新增 public 方法,见 3.4)
+- 落库:`order.service_provider_id` = 主体服务商;`order.payer_uid` = 主体 uid;`order.enterprise_id` 不再设置
+- 未完成授权的主体不可制单(本地预检:该主体需有 AUTHED 授权记录;`AUTH_INFO_NOT_EXISTS` 兜底保留)
+
+### 5.4 批次操作改造
+
+- `renderPay / batchQuery / batchClose`:显式 `.eq(enterpriseId)` 条件删除(依赖租户拦截器);client 用 `order.service_provider_id` + BIZ_TYPE
+- `list / detail / export`:enterprise_id 筛选删除;`list` 新增 `participant_id` 筛选(= `payer_uid`)
+- `authorizeList`:迁至 `BatchSubjectService`,`enterprise_id` 筛选删除,新增按租户自动隔离
+
+### 5.5 通知 / 定时
+
+- `BatchPayHandler`:不改(按 `out_biz_no` / `out_batch_no` 匹配)
+- `getPendingBatches`:不改(全表非终态)
+
+### 5.6 权限与路由
+
+- controller `@RequestMapping`:`/payment/account/batch` → `/payment/batch`
+- `@PreAuthorize` 换新权限:`module_payment:batch:authorize / create / list / detail`
+
+## 6. 前端设计
+
+### 6.1 新菜单
+
+`sys_menu` 顶层菜单「批量付款」:`route_path=/payment/batch`,`component_path=module_payment/batch/index`,权限点 `module_payment:batch:list`(及子权限点)。配套 SQL 见第 7 节。
+
+### 6.2 新页面 `module_payment/batch/index.vue`
+
+结构照搬现有「批量付款」tab:一级 tabs「制单授权 / 批量制单 / 制单历史」,详情条件渲染在底部。`account/index.vue` 移除 `batch-pay` tab 及其 import / handler(`BatchPayAuthorize/BatchPayList/BatchPayCreate/BatchPayDetail` 相关),其余转账功能不动。
+
+### 6.3 制单授权 tab(重写 BatchPayAuthorize → 列表+表单形态)
+
+- 授权列表表格:主体名称 / 支付宝uid / 服务商 / 状态 / 协议号 / 授权链接 / 操作
+- 「新增授权」按钮 → dialog 表单:**主体名称 + 支付宝uid + 服务商下拉**(`ProviderAPI.options()` 同新增企业页)
+- 行操作:生成/重新生成授权链接(二维码+复制)、刷新状态;AUTHED 行展示协议号、不提供重复生成
+
+### 6.4 批量制单 tab(改造 BatchPayCreate)
+
+顶部加「付款主体」下拉(已授权主体:`名称(uid)`,未授权不可选),明细录入不变。
+
+### 6.5 制单历史 tab(改造 BatchPayList)
+
+筛选区「企业」下拉 →「主体」下拉(按付款方 uid 筛选)+ 状态/时间筛选不变;平台超管可筛选任意主体。
+
+### 6.6 详情(BatchPayDetail)
+
+移除 `enterpriseId` prop 与相关请求参数,租户自动隔离。
+
+### 6.7 API 层 `batch.ts`
+
+- 路径:`/payment/account/batch` → `/payment/batch`
+- 全部方法移除 `enterprise_id` 参数
+- `authorizeApply / authorizeRebind` 参数改为 `participant_name / participant_id / service_provider_id`
+- `authorizeList` 支持主体筛选;`batchList` 新增 `participant_id` 筛选
+- `v-hasPerm` 同步换 `module_payment:batch:*`
+
+## 7. SQL 清单
+
+```sql
+-- 1. pay_batch_authorize 主体字段
+ALTER TABLE pay_batch_authorize
+    ADD COLUMN participant_name varchar(128),
+    ADD COLUMN service_provider_id bigint;
+
+-- 2. 唯一索引改租户级(先删旧)
+DROP INDEX IF EXISTS uk_batch_authorize_active;
+CREATE UNIQUE INDEX uk_batch_authorize_active
+    ON pay_batch_authorize (tenant_id, participant_id)
+    WHERE status <> 'UNBIND';  -- 解绑后可重新签约
+
+-- 3. pay_batch_order 冗余服务商
+ALTER TABLE pay_batch_order ADD COLUMN service_provider_id bigint;
+
+-- 4. 新菜单 + 权限点(sys_menu,parent_id 按实际顶层菜单排序取)
+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, <order>, '0');
+INSERT INTO sys_menu (parent_id, title, permission, type, "order", status)
+VALUES (<batchMenuId>, '制单授权', 'module_payment:batch:authorize', 3, 1, '0'),
+       (<batchMenuId>, '批量制单', 'module_payment:batch:create', 3, 2, '0'),
+       (<batchMenuId>, '制单历史', 'module_payment:batch:list', 3, 3, '0'),
+       (<batchMenuId>, '批次详情', 'module_payment:batch:detail', 3, 4, '0');
+
+-- 注:UNBIND 态部分唯一索引需先清存量重复行(联调期数据),执行前确认
+```
+
+> 说明:唯一索引过滤条件 `WHERE status <> 'UNBIND'` 为建议形态,实现时以存量数据核对为准(联调期数据可直接清理)。
+
+## 8. 测试策略
+
+后端(JUnit + Mockito,沿用 `AlipayBatchPayServiceTest` 模式):
+
+- `BatchSubjectService`:apply 传主体参数(principal_info.name/uid 断言);`USER_AUTHORIZATION_EXIST` 映射;rebind 换 out_biz_no;list 租户隔离
+- `batchCreate`:按主体制单(payer=主体 uid、agreement 带出、`order.service_provider_id` 落库);client 解析走 `getClientByProvider`;未授权主体预检拦截
+- `renderPay / batchQuery / batchClose`:不再依赖 enterprise 条件;client 用 `order.service_provider_id`
+- `AlipayClientFactory`:新增 `getClientByProvider(providerId, bizType)` 解析链(profile → 默认)
+- 存量 59 个测试的 enterprise 断言同步更新
+
+前端:
+
+- Playwright MCP 验证关键路径:新菜单可见 → 新增授权(表单+服务商下拉)→ 生成授权链接 → 制单选主体 → 创建批次 → 历史按主体筛选 → 详情 → 支付/关闭
+- `account/index.vue` 不再出现批量付款 tab
+
+## 9. 非目标
+
+- 不迁移存量企业级数据
+- 不改通知(`BatchPayHandler`)与定时同步(`getPendingBatches`)
+- 不开放 openapi(`TenantApiKeyAuthFilter` 场景的批量接口)—— 批量接口保持管理端认证
+- 不做主体解绑主动操作(支付宝侧解绑,本地被动同步 UNBIND)
+
+## 10. 决策记录
+
+| # | 决策 | 依据 |
+|---|---|---|
+| D1 | 授权记录即主体(扩展 pay_batch_authorize,不建主体表) | 用户选择;现有表复用,改动最小 |
+| D2 | 新菜单单页多 tab(制单授权/批量制单/制单历史) | 用户选择;与 account/index.vue 现有模式一致 |
+| D3 | 存量数据不迁移 | 用户选择;联调期测试数据 |
+| D4 | 租户隔离复用 TenantInnerInterceptor,接口去 enterprise_id | 代码实证:pay_batch_* 三表不在拦截器豁免名单 |
+| D5 | order 冗余 service_provider_id | 主体解绑后批次操作仍可用 client |
+| D6 | 服务拆分:BatchSubjectService(授权)+ AlipayBatchPayService(批次) | AlipayBatchPayService 已 600+ 行 |
+| D7 | 制单历史主体筛选用 payer_uid | order 已有该字段(= 主体 uid) |

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

+ 51 - 26
frontend/src/api/module_payment/batch.ts

@@ -1,15 +1,26 @@
 import request from "@/utils/request";
 
-const API_PATH = "/payment/account/batch";
+const API_PATH = "/payment/batch";
 
 /** 制单授权记录(后端 BatchAuthorizeEntity,全局 SNAKE_CASE 序列化) */
 export interface BatchAuthorizeVO {
+  /** 雪花ID序列化为字符串(后端 @JsonSerialize(ToStringSerializer)),防止 JS 精度丢失 */
+  id?: string;
   out_biz_no: string;
   status: string;
   agreement_no?: string;
   authorize_link?: string;
   participant_id: string;
   participant_id_type?: string;
+  /**
+   * 个人账号(ALIPAY_LOGON_ID)主体扫码授权获取的支付宝账号ID(2088 开头)— 制单时付款方 identity 用(ALIPAY_USER_ID)。
+   * 注: 本应用未开通支付宝 open_id 能力(user.info.share 响应实证),故不用 open_id 体系
+   */
+  alipay_user_id?: string;
+  /** 主体名称(表单录入,同时作为授权申请 principal_info.name) */
+  participant_name?: string;
+  /** 服务商(表单下拉选择,授权申请 client 解析依据) */
+  service_provider_id?: number;
   authorize_expire_time?: string;
   created_time?: string;
 }
@@ -34,11 +45,10 @@ export interface BatchOrderVO {
   total_amount: string;
   total_count: number;
   order_title?: string;
-  /** INIT / SUCCESS / DISUSE / FAIL */
+  /** INIT=受理中 / WAIT_PAY=等待支付 / SUCCESS=成功 / DISUSE=已关闭 / FAIL=失败 / INVALID=无效 */
   status: string;
   created_time?: string;
   pay_url?: string;
-  enterprise_id?: string;
   payer_uid?: string;
   agreement_no?: string;
   transfer_scene_name?: string;
@@ -62,9 +72,10 @@ export interface BatchDetailItem {
   error_msg?: string;
 }
 
-/** 创建批次请求体(BatchCreateDTO,snake_case;付款方恒为企业自身 UID、协议号自动带出,均不接受客户端指定) */
+/** 创建批次请求体(BatchCreateDTO,snake_case;付款方恒为已授权主体、协议号自动带出,均不接受客户端指定) */
 export interface BatchCreateParams {
-  enterprise_id?: string;
+  /** 付款主体(授权主体支付宝账号/uid/openid,制单时从已授权主体选择;后端按主体类型映射制单 identity) */
+  participant_id: string;
   order_title: string;
   transfer_scene_name?: string;
   transfer_scene_report_infos?: Array<{ info_type: string; info_content: string }>;
@@ -74,35 +85,50 @@ export interface BatchCreateParams {
 }
 
 export const BatchPayAPI = {
-  /** 生成制单授权链接(PC 渠道,付款方恒为企业自身 UID,后端不接受指定) */
-  authorizeApply(enterpriseId: string) {
+  /** 生成制单授权链接(账号级: 主体信息来自表单,服务商下拉选择) */
+  authorizeApply(data: {
+    participant_name: string;
+    participant_id: string;
+    /** 主体类型: ALIPAY_USER_ID 支付宝账号ID(默认) / ALIPAY_LOGON_ID 登录号 / ALIPAY_OPEN_ID OpenID */
+    participant_id_type?: string;
+    service_provider_id: number;
+  }) {
     return request<ApiResponse<{ authorize_link: string; out_biz_no: string; status: string }>>({
       url: `${API_PATH}/authorize/apply`,
       method: "post",
-      data: { enterprise_id: enterpriseId },
+      data,
     });
   },
 
   /** 制单授权重新生成:作废当前 AUTHING 申请(旧链接失效),换新 out_biz_no 重新申请 */
-  authorizeRebind(enterpriseId: string) {
+  authorizeRebind(id: string) {
     return request<ApiResponse<{ authorize_link: string; out_biz_no: string; status: string }>>({
       url: `${API_PATH}/authorize/rebind`,
       method: "post",
-      data: { enterprise_id: enterpriseId },
+      data: { id },
     });
   },
 
   /** 查询制单授权状态(AUTHED 时回写协议号) */
-  queryAuthorize(enterpriseId: string, outBizNo: string) {
+  queryAuthorize(outBizNo: string) {
     return request<ApiResponse<{ agreement_no: string; status: string }>>({
       url: `${API_PATH}/authorize/query`,
       method: "get",
-      params: { enterprise_id: enterpriseId, out_biz_no: outBizNo },
+      params: { out_biz_no: outBizNo },
+    });
+  },
+
+  /** 生成 user.info.share OAuth 授权链接(LOGON_ID 主体扫码获取支付宝ID 制单用)— 前端新窗口打开 */
+  openidAuthorizeUrl(id: string) {
+    return request<ApiResponse<string>>({
+      url: `${API_PATH}/authorize/openid/url`,
+      method: "get",
+      params: { id },
     });
   },
 
-  /** 制单授权记录(分页) */
-  authorizeList(params: { enterprise_id?: string; page_no?: number; page_size?: number }) {
+  /** 制单授权记录(分页,可按主体筛选) */
+  authorizeList(params: { participant_id?: string; page_no?: number; page_size?: number }) {
     return request<ApiResponse<BatchPageResult<BatchAuthorizeVO[]>>>({
       url: `${API_PATH}/authorize/list`,
       method: "get",
@@ -120,35 +146,35 @@ export const BatchPayAPI = {
   },
 
   /** 生成 PC 支付页面 — pageExecute 返回 HTML 表单(与充值/签约同款处理) */
-  renderPay(enterpriseId: string, outBatchNo: string) {
+  renderPay(outBatchNo: string) {
     return request<ApiResponse<{ pay_url: string }>>({
       url: `${API_PATH}/pay`,
       method: "post",
-      data: { enterprise_id: enterpriseId, out_batch_no: outBatchNo },
+      data: { out_batch_no: outBatchNo },
     });
   },
 
   /** 查询批次状态(同步支付宝并回写 DB) */
-  batchQuery(enterpriseId: string, outBatchNo: string) {
+  batchQuery(outBatchNo: string) {
     return request<ApiResponse<{ out_batch_no: string; status: string }>>({
       url: `${API_PATH}/query`,
       method: "get",
-      params: { enterprise_id: enterpriseId, out_batch_no: outBatchNo },
+      params: { out_batch_no: outBatchNo },
     });
   },
 
   /** 关闭未支付批次 */
-  batchClose(enterpriseId: string, outBatchNo: string) {
+  batchClose(outBatchNo: string) {
     return request<ApiResponse<{ status: string }>>({
       url: `${API_PATH}/close`,
       method: "post",
-      data: { enterprise_id: enterpriseId, out_batch_no: outBatchNo },
+      data: { out_batch_no: outBatchNo },
     });
   },
 
-  /** 批次列表(分页,状态/时间筛选) */
+  /** 批次列表(分页,主体/状态/时间筛选) */
   batchList(params: {
-    enterprise_id?: string;
+    participant_id?: string;
     status?: string;
     start_time?: string;
     end_time?: string;
@@ -162,13 +188,12 @@ export const BatchPayAPI = {
     });
   },
 
-  /** 批次详情 + 明细分页(后端要求 enterprise_id 租户隔离) */
-  batchDetail(enterpriseId: string, outBatchNo: string, pageNo = 1, pageSize = 20) {
+  /** 批次详情 + 明细分页(租户隔离由后端拦截器自动追加) */
+  batchDetail(outBatchNo: string, pageNo = 1, pageSize = 20) {
     return request<ApiResponse<{ order: BatchOrderVO; details: BatchPageResult<BatchDetailItem[]> }>>({
       url: `${API_PATH}/detail`,
       method: "get",
       params: {
-        enterprise_id: enterpriseId,
         out_batch_no: outBatchNo,
         page_no: pageNo,
         page_size: pageSize,
@@ -178,7 +203,7 @@ export const BatchPayAPI = {
 
   /** 批次报表导出(xlsx 下载,接口直出字节流非 Result 包装) */
   batchExport(params: {
-    enterprise_id?: string;
+    participant_id?: string;
     status?: string;
     start_time?: string;
     end_time?: string;

+ 1 - 1
frontend/src/layouts/components/AppMain/index.vue

@@ -2,7 +2,7 @@
   <section class="app-main" :style="{ height: appMainHeight }">
     <router-view>
       <template #default="{ Component, route }">
-        <transition :name="transitionName" mode="out-in">
+        <transition :name="transitionName">
           <keep-alive :include="cachedViews">
             <component :is="Component" :key="route.path" />
           </keep-alive>

+ 0 - 210
frontend/src/views/module_payment/account/components/BatchPayAuthorize.vue

@@ -1,210 +0,0 @@
-<template>
-  <el-card>
-    <template #header>
-      <div class="card-header">
-        <span>制单授权(批量付款到户有密)</span>
-      </div>
-    </template>
-    <el-form label-width="160px">
-      <el-form-item>
-        <el-button
-          v-if="!isAuthed"
-          v-hasPerm="['module_payment:account:authorize']"
-          type="primary"
-          :loading="applying"
-          @click="handleApply"
-        >
-          {{ existingAuthing ? "重新生成授权链接" : "生成授权链接" }}
-        </el-button>
-        <el-button
-          v-if="outBizNo"
-          v-hasPerm="['module_payment:account:authorize']"
-          size="small"
-          style="margin-left: 12px"
-          :loading="querying"
-          @click="handleQuery"
-        >
-          刷新状态
-        </el-button>
-      </el-form-item>
-      <el-form-item v-if="link" label="授权链接(PC 浏览器打开)">
-        <div style="display: flex; align-items: flex-start; gap: 12px">
-          <el-input :model-value="link" readonly style="width: 560px">
-            <template #append>
-              <el-button @click="handleCopyLink">复制</el-button>
-            </template>
-          </el-input>
-          <div class="qrcode-wrapper">
-            <canvas ref="qrcodeCanvas" class="qrcode-canvas"></canvas>
-            <div class="form-item-tip">手机支付宝扫码打开</div>
-          </div>
-        </div>
-      </el-form-item>
-      <el-form-item v-if="outBizNo" label="授权单号 / 状态">
-        <span>{{ outBizNo }} / {{ statusText }}</span>
-      </el-form-item>
-    </el-form>
-  </el-card>
-</template>
-
-<script setup lang="ts">
-import { computed, nextTick, onMounted, ref } from "vue";
-import BatchPayAPI from "@/api/module_payment/batch";
-import { useEnterpriseStore } from "@/store";
-import { ElMessage, ElMessageBox } from "element-plus";
-import QRCode from "qrcode";
-
-const enterpriseStore = useEnterpriseStore();
-const enterpriseId = computed(() => enterpriseStore.getCurrentEnterprise?.enterprise_id);
-
-const link = ref("");
-const outBizNo = ref("");
-const agreementNo = ref("");
-const status = ref("");
-/** 最新记录已 AUTHED(永久生效授权)→ 不再提供生成入口,仅展示状态 */
-const isAuthed = ref(false);
-const applying = ref(false);
-const querying = ref(false);
-const qrcodeCanvas = ref<HTMLCanvasElement>();
-
-const AUTH_STATUS_TEXT: Record<string, string> = {
-  AUTHING: "授权中",
-  AUTHED: "已授权",
-  // 存量兼容: 通知归一前落库的 NORMAL(支付宝生效状态)视同已授权
-  NORMAL: "已授权",
-  UNBIND: "已解绑",
-};
-
-const statusText = computed(() => {
-  const s = AUTH_STATUS_TEXT[status.value] || status.value || "-";
-  return agreementNo.value ? `${s}(协议号 ${agreementNo.value})` : s;
-});
-
-const existingAuthing = computed(() => status.value === "AUTHING");
-
-/** 进入页面时拉取该企业最新授权记录:AUTHING → 回溯展示链接,AUTHED → 展示已授权,UNBIND/无记录 → 初始空态 */
-async function loadLatest() {
-  if (!enterpriseId.value) return;
-  try {
-    const res = await BatchPayAPI.authorizeList({
-      enterprise_id: enterpriseId.value,
-      page_no: 1,
-      page_size: 1,
-    });
-    const rows = (res.data.data?.items ?? res.data.data?.list) || [];
-    const latest = rows[0];
-    if (!latest) return;
-    if (latest.status === "AUTHING") {
-      link.value = latest.authorize_link || "";
-      outBizNo.value = latest.out_biz_no;
-      status.value = "AUTHING";
-      agreementNo.value = "";
-      if (link.value) nextTick(() => drawQRCode());
-    } else if (latest.status === "AUTHED" || latest.status === "NORMAL") {
-      // NORMAL 为通知归一前落库的支付宝生效状态,视同已授权
-      outBizNo.value = latest.out_biz_no;
-      status.value = latest.status;
-      agreementNo.value = latest.agreement_no || "";
-      isAuthed.value = true;
-    }
-    // UNBIND → 保持初始空态(可正常生成新链接)
-  } catch (err) {
-    console.error("加载授权记录失败:", err);
-  }
-}
-
-onMounted(loadLatest);
-
-async function handleApply() {
-  if (!enterpriseId.value) {
-    ElMessage.warning("请选择企业");
-    return;
-  }
-  // 已有未完成授权申请:确认后作废旧链接重新生成(后端 /authorize/rebind)
-  if (existingAuthing.value) {
-    try {
-      await ElMessageBox.confirm(
-        "当前存在未完成的授权申请,重新生成将作废旧授权链接。是否继续?",
-        "重新生成授权链接",
-        { type: "warning", confirmButtonText: "重新生成", cancelButtonText: "取消" }
-      );
-    } catch {
-      return;
-    }
-  }
-  applying.value = true;
-  try {
-    const res = existingAuthing.value
-      ? await BatchPayAPI.authorizeRebind(enterpriseId.value)
-      : await BatchPayAPI.authorizeApply(enterpriseId.value);
-    link.value = res.data.data.authorize_link;
-    outBizNo.value = res.data.data.out_biz_no;
-    status.value = res.data.data.status;
-    agreementNo.value = "";
-    isAuthed.value = false;
-    nextTick(() => drawQRCode());
-    ElMessage.success("授权链接已生成,请尽快完成授权");
-  } finally {
-    applying.value = false;
-  }
-}
-
-/** 授权链接二维码(复用 InviteDialog 的 qrcode 库用法) */
-async function drawQRCode() {
-  if (!qrcodeCanvas.value || !link.value) return;
-  try {
-    await QRCode.toCanvas(qrcodeCanvas.value, link.value, { width: 160, margin: 1 });
-  } catch (err) {
-    console.error("授权链接二维码生成失败:", err);
-  }
-}
-
-async function handleQuery() {
-  if (!enterpriseId.value || !outBizNo.value) return;
-  querying.value = true;
-  try {
-    const res = await BatchPayAPI.queryAuthorize(enterpriseId.value, outBizNo.value);
-    status.value = res.data.data.status;
-    agreementNo.value = res.data.data.agreement_no || "";
-    if (status.value === "AUTHED" || status.value === "NORMAL") isAuthed.value = true;
-  } finally {
-    querying.value = false;
-  }
-}
-
-async function handleCopyLink() {
-  try {
-    await navigator.clipboard.writeText(link.value);
-    ElMessage.success("授权链接已复制");
-  } catch {
-    ElMessage.warning("复制失败,请手动复制");
-  }
-}
-</script>
-
-<style lang="scss" scoped>
-.card-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-}
-
-.qrcode-wrapper {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  gap: 4px;
-}
-
-.qrcode-canvas {
-  width: 160px;
-  height: 160px;
-  border: 1px solid #e4e7ed;
-  border-radius: 4px;
-}
-
-.form-item-tip {
-  font-size: 12px;
-  color: #909399;
-}
-</style>

+ 0 - 49
frontend/src/views/module_payment/account/index.vue

@@ -397,33 +397,6 @@
         </div>
       </el-tab-pane>
 
-      <el-tab-pane v-if="hasTransferPermission" label="批量付款" name="batch-pay">
-        <div class="tab-content">
-          <template v-if="!currentBatchOutNo">
-            <el-tabs v-model="batchPaySubTab">
-              <el-tab-pane label="制单授权" name="authorize">
-                <BatchPayAuthorize />
-              </el-tab-pane>
-              <el-tab-pane label="批次列表" name="list">
-                <BatchPayList :key="batchListKey" @view="handleViewBatchPay" />
-              </el-tab-pane>
-              <el-tab-pane label="创建批次" name="create">
-                <BatchPayCreate @created="handleBatchPayCreated" />
-              </el-tab-pane>
-            </el-tabs>
-          </template>
-          <template v-else>
-            <div style="margin-bottom: 12px">
-              <el-button @click="currentBatchOutNo = ''">返回批次列表</el-button>
-            </div>
-            <BatchPayDetail
-              :out-batch-no="currentBatchOutNo"
-              :enterprise-id="currentBatchEnterpriseId"
-            />
-          </template>
-        </div>
-      </el-tab-pane>
-
       <el-tab-pane label="消费记录" name="consume-record">
         <div class="tab-content">
           <el-card>
@@ -785,10 +758,6 @@ import AccountOverview from "./components/AccountOverview.vue";
 import TransferDetail from "./components/TransferDetail.vue";
 import ConsumeDetail from "./components/ConsumeDetail.vue";
 import F2fTradeRecord from "./components/F2fTradeRecord.vue";
-import BatchPayAuthorize from "./components/BatchPayAuthorize.vue";
-import BatchPayList from "./components/BatchPayList.vue";
-import BatchPayCreate from "./components/BatchPayCreate.vue";
-import BatchPayDetail from "./components/BatchPayDetail.vue";
 import TenantAPI, { TenantTable } from "@/api/module_system/tenant";
 import { ref, reactive, computed, onMounted, watch } from "vue";
 import { Refresh, Loading, Plus, QuestionFilled } from "@element-plus/icons-vue";
@@ -1172,12 +1141,6 @@ const allTenantData = ref<TenantTable[]>([]);
 const transferDetailVisible = ref(false);
 const currentTransferOutBizNo = ref("");
 
-// 批量付款到户有密
-const batchPaySubTab = ref("list");
-const batchListKey = ref(0);
-const currentBatchOutNo = ref("");
-const currentBatchEnterpriseId = ref<string | undefined>(undefined);
-
 // 批量转账相关
 const batchTransferVisible = ref(false);
 const batchTransferResultVisible = ref(false);
@@ -1904,18 +1867,6 @@ function handleViewTransferDetail(outBizNo: string) {
   transferDetailVisible.value = true;
 }
 
-function handleViewBatchPay(outBatchNo: string, enterpriseId?: string) {
-  currentBatchOutNo.value = outBatchNo;
-  currentBatchEnterpriseId.value = enterpriseId;
-}
-
-function handleBatchPayCreated() {
-  // 创建成功后回到批次列表并强制重新加载
-  currentBatchOutNo.value = "";
-  batchListKey.value += 1;
-  batchPaySubTab.value = "list";
-}
-
 function downloadFile(url: string) {
   window.open(url, "_blank");
 }

+ 474 - 0
frontend/src/views/module_payment/batch/components/AuthorizeList.vue

@@ -0,0 +1,474 @@
+<template>
+  <el-card>
+    <template #header>
+      <div class="card-header">
+        <span>制单授权(账号级:一个租户可维护多个授权主体)</span>
+        <el-button
+          v-hasPerm="['module_payment:batch:authorize']"
+          type="primary"
+          :loading="applying"
+          @click="openApplyDialog"
+        >
+          新增授权
+        </el-button>
+      </div>
+    </template>
+
+    <el-table v-loading="loading" :data="list" border stripe>
+      <template #empty>
+        <el-empty description="暂无授权主体,点击右上角「新增授权」生成授权链接" />
+      </template>
+      <el-table-column prop="participant_name" label="主体名称" min-width="140">
+        <template #default="{ row }">{{ row.participant_name || "-" }}</template>
+      </el-table-column>
+      <el-table-column label="主体账号" min-width="170">
+        <template #default="{ row }">
+          <div>{{ row.participant_id || "-" }}</div>
+          <div v-if="row.participant_id_type" class="sub-text">
+            {{ participantTypeText(row.participant_id_type) }}
+          </div>
+          <div v-if="row.alipay_user_id" class="sub-text">支付宝ID: {{ row.alipay_user_id }}</div>
+        </template>
+      </el-table-column>
+      <el-table-column label="服务商" min-width="140">
+        <template #default="{ row }">
+          {{ providerName(row.service_provider_id) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" width="90">
+        <template #default="{ row }">
+          <el-tag :type="STATUS_TAG[row.status] || 'info'">
+            {{ STATUS_TEXT[row.status] || row.status }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="agreement_no" label="协议号" min-width="170">
+        <template #default="{ row }">{{ row.agreement_no || "-" }}</template>
+      </el-table-column>
+      <el-table-column label="授权链接" min-width="120">
+        <template #default="{ row }">
+          <el-button v-if="row.authorize_link" size="small" link type="primary" @click="showLink(row)">
+            查看
+          </el-button>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="220" fixed="right">
+        <template #default="{ row }">
+          <template v-if="row.status === 'AUTHING'">
+            <el-button
+              v-hasPerm="['module_payment:batch:authorize']"
+              size="small"
+              @click="handleRebind(row)"
+            >
+              重新生成链接
+            </el-button>
+            <el-button
+              v-hasPerm="['module_payment:batch:authorize']"
+              size="small"
+              :loading="queryingNo === row.out_biz_no"
+              @click="handleRefresh(row)"
+            >
+              刷新状态
+            </el-button>
+          </template>
+          <span v-else-if="row.status === 'AUTHED' || row.status === 'NORMAL'" style="display: flex; align-items: center; gap: 8px">
+            <!-- LOGON_ID 主体扫码授权获取支付宝ID:授权成功(alipay_user_id 落库)后按钮隐藏,无需再次获取 -->
+            <el-button
+              v-if="row.participant_id_type === 'ALIPAY_LOGON_ID' && !row.alipay_user_id"
+              v-hasPerm="['module_payment:batch:authorize']"
+              size="small"
+              type="primary"
+              link
+              @click="handleGetOpenId(row)"
+            >
+              授权
+            </el-button>
+            <span class="authed-tip">已生效(可直接制单)</span>
+          </span>
+        </template>
+      </el-table-column>
+    </el-table>
+    <div class="mt-4 flex justify-end">
+      <el-pagination
+        v-model:current-page="pageNo"
+        v-model:page-size="pageSize"
+        :total="total"
+        :page-sizes="[10, 20, 50, 100]"
+        layout="total, sizes, prev, pager, next, jumper"
+        @size-change="load"
+        @current-change="load"
+      />
+    </div>
+
+    <!-- 新增授权表单 -->
+    <el-dialog v-model="applyVisible" title="新增制单授权" width="520px" :close-on-click-modal="false">
+      <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"
+          />
+          <div class="form-item-tip">须与账号实名一致(个人填真实姓名,企业填注册名称)</div>
+        </el-form-item>
+        <el-form-item label="主体类型" prop="participant_id_type">
+          <el-select v-model="form.participant_id_type" style="max-width: 360px">
+            <el-option
+              v-for="o in PARTICIPANT_TYPE_OPTIONS"
+              :key="o.value"
+              :label="o.label"
+              :value="o.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="支付宝账号" prop="participant_id">
+          <el-input
+            v-model="form.participant_id"
+            :placeholder="PARTICIPANT_ID_PLACEHOLDER[form.participant_id_type]"
+            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>
+      <template #footer>
+        <el-button @click="applyVisible = false">取消</el-button>
+        <el-button type="primary" :loading="applying" @click="handleApply">生成授权链接</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 授权链接展示(复制 + 二维码 + 刷新状态) -->
+    <el-dialog v-model="linkVisible" title="授权链接(PC 浏览器打开 / 手机支付宝扫码)" width="640px">
+      <div style="display: flex; align-items: flex-start; gap: 16px">
+        <el-input :model-value="currentLink" readonly style="flex: 1">
+          <template #append>
+            <el-button @click="handleCopyLink">复制</el-button>
+          </template>
+        </el-input>
+        <div class="qrcode-wrapper">
+          <canvas ref="qrcodeCanvas" class="qrcode-canvas"></canvas>
+          <div class="form-item-tip">手机支付宝扫码打开</div>
+        </div>
+      </div>
+      <div v-if="currentOutBizNo" style="margin-top: 12px">
+        授权单号 / 状态:{{ currentOutBizNo }} / {{ currentStatusText }}
+      </div>
+      <template #footer>
+        <el-button
+          v-if="currentStatus === 'AUTHING'"
+          :loading="querying"
+          @click="handleQueryStatus"
+        >
+          刷新状态
+        </el-button>
+        <el-button type="primary" @click="linkVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
+  </el-card>
+</template>
+
+<script setup lang="ts">
+import { computed, nextTick, onMounted, reactive, ref } from "vue";
+import BatchPayAPI, { type BatchAuthorizeVO } from "@/api/module_payment/batch";
+import ProviderAPI, { type ServiceProviderOption } from "@/api/module_system/service_provider";
+import { ElMessage, ElMessageBox } from "element-plus";
+import type { FormInstance, FormRules } from "element-plus";
+import QRCode from "qrcode";
+
+const list = ref<BatchAuthorizeVO[]>([]);
+const total = ref(0);
+const pageNo = ref(1);
+const pageSize = ref(20);
+const loading = ref(false);
+const applying = ref(false);
+const querying = ref(false);
+const queryingNo = ref("");
+
+const providerOptions = ref<ServiceProviderOption[]>([]);
+const providerName = (id?: number) =>
+  (providerOptions.value.find((p) => p.id === id)?.name) || "-";
+
+const STATUS_TAG: Record<string, "primary" | "success" | "warning" | "info" | "danger"> = {
+  AUTHING: "warning",
+  AUTHED: "success",
+  NORMAL: "success",
+  UNBIND: "info",
+};
+const STATUS_TEXT: Record<string, string> = {
+  AUTHING: "授权中",
+  AUTHED: "已授权",
+  // 存量兼容: 通知归一前落库的 NORMAL(支付宝生效状态)视同已授权
+  NORMAL: "已授权",
+  UNBIND: "已解绑",
+};
+
+async function load() {
+  loading.value = true;
+  try {
+    const res = await BatchPayAPI.authorizeList({ page_no: pageNo.value, page_size: pageSize.value });
+    // 双保险解包: 后端 PageResult 序列化字段是 list(全局类型声明 items 与实际不符,与存量一致)
+    list.value = res.data.data?.items || res.data.data?.list || [];
+    total.value = res.data.data?.total || 0;
+  } finally {
+    loading.value = false;
+  }
+}
+
+onMounted(async () => {
+  handleOpenIdCallbackResult();
+  load();
+  try {
+    const res = await ProviderAPI.options();
+    providerOptions.value = res.data.data || [];
+  } catch { /* 服务商下拉加载失败不阻塞列表 */ }
+});
+
+/**
+ * user.info.share OAuth 回调完成(后端 302 回跳 #/payment/batch?openid=success|fail)→
+ * 提示结果并清掉 query,避免刷新页面重复提示
+ */
+function handleOpenIdCallbackResult() {
+  const hash = location.hash || "";
+  const qIndex = hash.indexOf("?");
+  if (qIndex < 0) return;
+  const hashQuery = new URLSearchParams(hash.slice(qIndex + 1));
+  const result = hashQuery.get("openid");
+  if (result === "success") {
+    ElMessage.success("支付宝ID获取成功,可直接制单");
+  } else if (result === "fail") {
+    ElMessage.error(`支付宝ID获取失败:${decodeURIComponent(hashQuery.get("msg") || "未知原因")}`);
+  } else {
+    return;
+  }
+  history.replaceState(null, "", location.pathname + location.search + hash.slice(0, qIndex));
+}
+
+// ==================== 新增授权 ====================
+
+const applyVisible = ref(false);
+const formRef = ref<FormInstance>();
+/** 授权主体类型(AuthParticipantInfo.participantIdType 文档枚举) */
+const PARTICIPANT_TYPE_OPTIONS = [
+  { value: "ALIPAY_USER_ID", label: "支付宝账号ID(2088开头)" },
+  { value: "ALIPAY_LOGON_ID", label: "支付宝账号(手机号或邮箱)" },
+  { value: "ALIPAY_OPEN_ID", label: "应用OpenID" },
+];
+const PARTICIPANT_ID_PLACEHOLDER: Record<string, string> = {
+  ALIPAY_USER_ID: "支付宝账号ID(2088开头)",
+  ALIPAY_LOGON_ID: "手机号或邮箱",
+  ALIPAY_OPEN_ID: "应用OpenID",
+};
+const participantTypeText = (type?: string) =>
+  PARTICIPANT_TYPE_OPTIONS.find((o) => o.value === type)?.label || type || "-";
+const form = reactive({
+  participant_name: "",
+  participant_id: "",
+  participant_id_type: "ALIPAY_USER_ID",
+  service_provider_id: null as number | null,
+});
+const rules: FormRules = {
+  participant_name: [{ required: true, message: "请输入主体名称", trigger: "blur" }],
+  participant_id: [{ required: true, message: "请输入支付宝账号", trigger: "blur" }],
+  service_provider_id: [{ required: true, message: "请选择服务商", trigger: "change" }],
+};
+
+function openApplyDialog() {
+  formRef.value?.resetFields();
+  applyVisible.value = true;
+}
+
+async function handleApply() {
+  const valid = await formRef.value?.validate().catch(() => false);
+  if (!valid) return;
+  applying.value = true;
+  try {
+    const res = await BatchPayAPI.authorizeApply({
+      participant_name: form.participant_name.trim(),
+      participant_id: form.participant_id.trim(),
+      participant_id_type: form.participant_id_type,
+      service_provider_id: form.service_provider_id!,
+    });
+    applyVisible.value = false;
+    // 提交成功 → 展示授权链接弹层
+    currentLink.value = res.data.data.authorize_link;
+    currentOutBizNo.value = res.data.data.out_biz_no;
+    currentStatus.value = res.data.data.status;
+    linkVisible.value = true;
+    nextTick(() => drawQRCode());
+    ElMessage.success("授权链接已生成,请尽快完成授权");
+    load();
+  } finally {
+    applying.value = false;
+  }
+}
+
+// ==================== 授权链接展示 ====================
+
+const linkVisible = ref(false);
+const currentLink = ref("");
+const currentOutBizNo = ref("");
+const currentStatus = ref("");
+const qrcodeCanvas = ref<HTMLCanvasElement>();
+
+const currentStatusText = computed(() => STATUS_TEXT[currentStatus.value] || currentStatus.value || "-");
+
+function showLink(row: BatchAuthorizeVO) {
+  currentLink.value = row.authorize_link || "";
+  currentOutBizNo.value = row.out_biz_no;
+  currentStatus.value = row.status;
+  linkVisible.value = true;
+  nextTick(() => drawQRCode());
+}
+
+/** 授权链接二维码(复用原 BatchPayAuthorize 的 qrcode 库用法) */
+async function drawQRCode() {
+  if (!qrcodeCanvas.value || !currentLink.value) return;
+  try {
+    await QRCode.toCanvas(qrcodeCanvas.value, currentLink.value, { width: 160, margin: 1 });
+  } catch (err) {
+    console.error("授权链接二维码生成失败:", err);
+  }
+}
+
+async function handleCopyLink() {
+  try {
+    await navigator.clipboard.writeText(currentLink.value);
+    ElMessage.success("授权链接已复制");
+  } catch {
+    ElMessage.warning("复制失败,请手动复制");
+  }
+}
+
+/** 链接弹层内的状态刷新:AUTHED 后回写协议号并提示完成 */
+async function handleQueryStatus() {
+  if (!currentOutBizNo.value) return;
+  querying.value = true;
+  try {
+    const res = await BatchPayAPI.queryAuthorize(currentOutBizNo.value);
+    currentStatus.value = res.data.data.status;
+    if (res.data.data.status === "AUTHED" || res.data.data.status === "NORMAL") {
+      ElMessage.success("授权已完成,可直接制单");
+      linkVisible.value = false;
+    }
+    load();
+  } finally {
+    querying.value = false;
+  }
+}
+
+// ==================== 行操作 ====================
+
+/** AUTHING 行: 重新生成(作废旧链接换新 out_biz_no) */
+async function handleRebind(row: BatchAuthorizeVO) {
+  if (row.id == null) {
+    ElMessage.warning("记录缺少 id,无法重新生成");
+    return;
+  }
+  try {
+    await ElMessageBox.confirm(
+      "重新生成将作废当前授权链接并换新单号,是否继续?",
+      "重新生成授权链接",
+      { type: "warning", confirmButtonText: "重新生成", cancelButtonText: "取消" }
+    );
+  } catch {
+    return;
+  }
+  applying.value = true;
+  try {
+    const res = await BatchPayAPI.authorizeRebind(row.id);
+    currentLink.value = res.data.data.authorize_link;
+    currentOutBizNo.value = res.data.data.out_biz_no;
+    currentStatus.value = res.data.data.status;
+    linkVisible.value = true;
+    nextTick(() => drawQRCode());
+    ElMessage.success("已重新生成授权链接");
+    load();
+  } finally {
+    applying.value = false;
+  }
+}
+
+/**
+ * AUTHED + LOGON_ID 行: 当前窗口直接跳转 user.info.share OAuth 授权页(主体本人支付宝扫码授权)—
+ * 授权完成 → 支付宝回跳后端 callback 换支付宝ID(2088)落库 → 302 回本页(openid=success 提示)。
+ * 授权页跳回平台由 redirect_uri 保证,无需新窗口(避免多开窗口)
+ */
+async function handleGetOpenId(row: BatchAuthorizeVO) {
+  if (row.id == null) {
+    ElMessage.warning("记录缺少 id,无法生成授权链接");
+    return;
+  }
+  const res = await BatchPayAPI.openidAuthorizeUrl(row.id);
+  const url = res.data.data;
+  if (!url) {
+    ElMessage.warning("未返回授权链接");
+    return;
+  }
+  // 当前窗口跳转: 授权完成后支付宝自动回跳平台(登录态存 localStorage,同域跳回不丢失)
+  window.location.href = url;
+}
+
+/** AUTHING 行: 刷新状态(AUTHED 后协议号回写本地) */
+async function handleRefresh(row: BatchAuthorizeVO) {
+  queryingNo.value = row.out_biz_no;
+  try {
+    const res = await BatchPayAPI.queryAuthorize(row.out_biz_no);
+    if (res.data.data.status === "AUTHED" || res.data.data.status === "NORMAL") {
+      ElMessage.success("授权已完成,可直接制单");
+    } else {
+      ElMessage.info(`当前状态:${STATUS_TEXT[res.data.data.status] || res.data.data.status}`);
+    }
+    load();
+  } finally {
+    queryingNo.value = "";
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.authed-tip {
+  font-size: 12px;
+  color: #909399;
+}
+
+.sub-text {
+  font-size: 12px;
+  color: #909399;
+  line-height: 1.4;
+}
+
+.qrcode-wrapper {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+}
+
+.qrcode-canvas {
+  width: 160px;
+  height: 160px;
+  border: 1px solid #e4e7ed;
+  border-radius: 4px;
+}
+
+.form-item-tip {
+  width: 100%;
+  font-size: 12px;
+  color: #909399;
+}
+</style>

+ 61 - 13
frontend/src/views/module_payment/account/components/BatchPayCreate.vue → frontend/src/views/module_payment/batch/components/BatchPayCreate.vue

@@ -6,6 +6,25 @@
       </div>
     </template>
     <el-form ref="formRef" :model="form" :rules="rules" label-width="140px">
+      <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="subjectOptionLabel(s)"
+            :value="s.participant_id"
+            :disabled="needOpenId(s)"
+          />
+        </el-select>
+        <div class="form-item-tip">
+          仅展示已签约(AUTHED)主体;账号类型(手机号/邮箱)主体需先在「制单授权」中扫码获取支付宝ID
+        </div>
+      </el-form-item>
       <el-form-item label="批次标题" prop="order_title">
         <el-input
           v-model="form.order_title"
@@ -175,7 +194,7 @@
 
     <div style="margin-top: 20px">
       <el-button
-        v-hasPerm="['module_payment:account:transfer']"
+        v-hasPerm="['module_payment:batch:create']"
         type="primary"
         :loading="submitting"
         @click="handleSubmit"
@@ -199,17 +218,13 @@
 
 <script setup lang="ts">
 import { computed, onMounted, reactive, ref, watch } from "vue";
-import BatchPayAPI from "@/api/module_payment/batch";
-import { useEnterpriseStore } from "@/store";
+import BatchPayAPI, { type BatchAuthorizeVO } from "@/api/module_payment/batch";
 import { ElMessage } from "element-plus";
 import type { FormInstance, FormRules } from "element-plus";
 import * as ExcelJS from "exceljs";
 
 const emit = defineEmits<{ created: [] }>();
 
-const enterpriseStore = useEnterpriseStore();
-const enterpriseId = computed(() => enterpriseStore.getCurrentEnterprise?.enterprise_id);
-
 const formRef = ref<FormInstance>();
 const submitting = ref(false);
 const createdResult = ref<{
@@ -219,6 +234,7 @@ const createdResult = ref<{
 } | null>(null);
 
 const form = reactive({
+  participant_id: "",
   order_title: "",
   transfer_scene_name: "",
   time_expire: "",
@@ -226,11 +242,49 @@ const form = reactive({
 });
 
 const rules: FormRules = {
+  participant_id: [{ required: true, message: "请选择付款主体", trigger: "change" }],
   order_title: [{ required: true, message: "请输入批次标题", trigger: "blur" }],
   // I3: 26 年新接入商户必传转账场景(后端 DTO 同步 @NotBlank 校验)
   transfer_scene_name: [{ required: true, message: "请选择转账场景", trigger: "change" }],
 };
 
+/** 已签约(AUTHED)主体 — 制单付款方候选 */
+const authedSubjects = ref<BatchAuthorizeVO[]>([]);
+
+/** LOGON_ID 主体且未扫码获取支付宝ID → 制单不可选(后端制单也会拦截,前端先行禁用提示) */
+const needOpenId = (s: BatchAuthorizeVO) =>
+  s.participant_id_type === "ALIPAY_LOGON_ID" && !s.alipay_user_id;
+
+/** 主体下拉文案: 账号类型未获取支付宝ID 时追加提示,避免静默禁用 */
+const subjectOptionLabel = (s: BatchAuthorizeVO) =>
+  `${s.participant_name || "未命名主体"}(${s.participant_id})${needOpenId(s) ? "(未获取支付宝ID,禁选)" : ""}`;
+
+/** 重新拉取已授权主体(每次切换到本 tab 时调用,保证授权完成后立即可选) */
+async function reloadSubjects() {
+  // 主体量小,page_size=100 一次拉全(后端 list 按 id 倒序返回)
+  try {
+    const res = await BatchPayAPI.authorizeList({ page_no: 1, page_size: 100 });
+    const rows = res.data.data?.items || res.data.data?.list || [];
+    authedSubjects.value = rows.filter((r) => r.status === "AUTHED" || r.status === "NORMAL");
+  } catch {
+    // 主体加载失败不阻塞制单(后端制单预检仍会拦截未授权主体)
+  }
+}
+
+onMounted(async () => {
+  addDetailRow();
+  reloadSubjects();
+});
+
+const props = defineProps<{ active?: boolean }>();
+// el-tabs 非 lazy: 组件随页面挂载,切 tab 不重建 → 切到本 tab 时重拉主体
+watch(
+  () => props.active,
+  (v) => {
+    if (v) reloadSubjects();
+  }
+);
+
 /** 26 年新接入商户可选转账场景(与后端 BatchCreateDTO 注释一致) */
 const SCENE_OPTIONS = [
   "现金营销",
@@ -561,10 +615,6 @@ async function handleImportFileChange(file: any) {
 }
 
 async function handleSubmit() {
-  if (!enterpriseId.value) {
-    ElMessage.warning("请先选择企业");
-    return;
-  }
   const valid = await formRef.value?.validate().catch(() => false);
   if (!valid) return;
   // 场景报备信息必填(后端 DTO @NotEmpty 同步校验):每行完整 + 该场景全部信息类型齐全
@@ -613,7 +663,7 @@ async function handleSubmit() {
   submitting.value = true;
   try {
     const res = await BatchPayAPI.batchCreate({
-      enterprise_id: enterpriseId.value,
+      participant_id: form.participant_id,
       order_title: form.order_title,
       transfer_scene_name: form.transfer_scene_name || undefined,
       transfer_scene_report_infos: sceneReportInfos.length ? sceneReportInfos : undefined,
@@ -650,8 +700,6 @@ function handleReset() {
   clearForm();
   createdResult.value = null;
 }
-
-onMounted(addDetailRow);
 </script>
 
 <style lang="scss" scoped>

+ 17 - 28
frontend/src/views/module_payment/account/components/BatchPayDetail.vue → frontend/src/views/module_payment/batch/components/BatchPayDetail.vue

@@ -6,7 +6,7 @@
         <div>
           <el-button
             v-if="order.status === 'INIT' || order.status === 'WAIT_PAY'"
-            v-hasPerm="['module_payment:account:transfer']"
+            v-hasPerm="['module_payment:batch:create']"
             type="primary"
             :loading="payLoading"
             @click="handlePay"
@@ -15,13 +15,14 @@
           </el-button>
           <el-button
             v-if="order.status === 'INIT' || order.status === 'WAIT_PAY'"
-            v-hasPerm="['module_payment:account:transfer']"
+            v-hasPerm="['module_payment:batch:create']"
             type="danger"
             @click="handleClose"
           >
             关闭批次
           </el-button>
           <el-button icon="Refresh" :loading="refreshing" @click="refresh">刷新</el-button>
+          <el-button @click="emit('close')">返回</el-button>
         </div>
       </div>
     </template>
@@ -89,17 +90,13 @@
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted, ref } from "vue";
+import { onMounted, ref } from "vue";
 import BatchPayAPI, { type BatchDetailItem, type BatchOrderVO } from "@/api/module_payment/batch";
-import { useEnterpriseStore } from "@/store";
 import { ElMessage, ElMessageBox } from "element-plus";
 import dayjs from "dayjs";
 
-const props = defineProps<{ outBatchNo: string; enterpriseId?: string }>();
-
-const enterpriseStore = useEnterpriseStore();
-const currentEnterpriseId = computed(() => enterpriseStore.getCurrentEnterprise?.enterprise_id);
-const enterpriseId = computed(() => props.enterpriseId || currentEnterpriseId.value);
+const props = defineProps<{ outBatchNo: string }>();
+const emit = defineEmits<{ close: []; refresh: [] }>();
 
 const order = ref<BatchOrderVO | null>(null);
 const details = ref<BatchDetailItem[]>([]);
@@ -129,28 +126,21 @@ 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: "失败",
 };
 
 async function load() {
-  if (!enterpriseId.value) {
-    ElMessage.warning("未获取到企业信息");
-    return;
-  }
   loading.value = true;
   try {
-    const res = await BatchPayAPI.batchDetail(
-      enterpriseId.value,
-      props.outBatchNo,
-      pageNo.value,
-      pageSize.value
-    );
+    const res = await BatchPayAPI.batchDetail(props.outBatchNo, pageNo.value, pageSize.value);
     order.value = res.data.data?.order || null;
     // 双保险解包: 后端 PageResult 序列化字段是 list(与存量 index.vue 一致)
     details.value = res.data.data?.details?.items || res.data.data?.details?.list || [];
@@ -162,26 +152,24 @@ async function load() {
 
 /** 刷新: 先调 batchQuery 同步支付宝侧批次+明细状态,再拉取本地详情展示(I1 手动兜底) */
 async function refresh() {
-  if (!enterpriseId.value) {
-    ElMessage.warning("未获取到企业信息");
-    return;
-  }
   refreshing.value = true;
   try {
-    await BatchPayAPI.batchQuery(enterpriseId.value, props.outBatchNo);
+    await BatchPayAPI.batchQuery(props.outBatchNo);
   } catch {
     // 同步失败不阻塞本地展示(batchQuery 失败原因已由 request.ts 提示)
   } finally {
     refreshing.value = false;
   }
   await load();
+  // 批次/明细状态可能已变化 → 通知列表页重载
+  emit("refresh");
 }
 
 async function handlePay() {
-  if (!order.value || !enterpriseId.value) return;
+  if (!order.value) return;
   payLoading.value = true;
   try {
-    const res = await BatchPayAPI.renderPay(enterpriseId.value, order.value.out_batch_no);
+    const res = await BatchPayAPI.renderPay(order.value.out_batch_no);
     // render.pay 返回支付宝收银台短链接 initialize_code(后端实证: 成功响应 {"code":"10000","initialize_code":"https://p.tb.cn/..."}),
     // 直接新窗口打开,无需 document.write
     const payUrl = res.data.data?.pay_url || "";
@@ -199,15 +187,16 @@ async function handlePay() {
 }
 
 async function handleClose() {
-  if (!order.value || !enterpriseId.value) return;
+  if (!order.value) return;
   try {
     await ElMessageBox.confirm("关闭后该批次不可再支付,确定关闭?", "提示", { type: "warning" });
   } catch {
     return;
   }
-  await BatchPayAPI.batchClose(enterpriseId.value, order.value.out_batch_no);
+  await BatchPayAPI.batchClose(order.value.out_batch_no);
   ElMessage.success("批次已关闭");
   load();
+  emit("refresh");
 }
 
 onMounted(load);

+ 41 - 42
frontend/src/views/module_payment/account/components/BatchPayList.vue → frontend/src/views/module_payment/batch/components/BatchPayList.vue

@@ -4,7 +4,7 @@
       <div class="card-header">
         <span>批量付款批次</span>
         <el-button
-          v-hasPerm="['module_payment:account:transfer:list']"
+          v-hasPerm="['module_payment:batch:list']"
           type="primary"
           icon="Download"
           :loading="exportLoading"
@@ -16,19 +16,19 @@
     </template>
     <div class="mb-4">
       <el-form :inline="true" :model="searchForm">
-        <el-form-item v-if="isPlatformUser" label="企业">
+        <el-form-item label="付款主体">
           <el-select
-            v-model="searchForm.enterprise_id"
-            placeholder="选择企业"
+            v-model="searchForm.participant_id"
+            placeholder="全部主体"
             clearable
             filterable
-            style="width: 180px"
+            style="width: 220px"
           >
             <el-option
-              v-for="e in enterpriseStore.getEnterpriseList"
-              :key="e.enterprise_id"
-              :label="e.name"
-              :value="e.enterprise_id"
+              v-for="s in allSubjects"
+              :key="s.participant_id"
+              :label="`${s.participant_name || '未命名主体'}(${s.participant_id})`"
+              :value="s.participant_id"
             />
           </el-select>
         </el-form-item>
@@ -64,6 +64,11 @@
       <el-table-column prop="order_title" label="标题" min-width="140">
         <template #default="{ row }">{{ row.order_title || "-" }}</template>
       </el-table-column>
+      <el-table-column label="付款主体" min-width="150">
+        <template #default="{ row }">
+          {{ subjectName(row.payer_uid) }}
+        </template>
+      </el-table-column>
       <el-table-column prop="total_amount" label="总金额(元)" width="110">
         <template #default="{ row }">¥{{ row.total_amount }}</template>
       </el-table-column>
@@ -84,7 +89,7 @@
         <template #default="{ row }">
           <el-button
             v-if="row.status === 'INIT' || row.status === 'WAIT_PAY'"
-            v-hasPerm="['module_payment:account:transfer']"
+            v-hasPerm="['module_payment:batch:create']"
             size="small"
             type="primary"
             :loading="payingNo === row.out_batch_no"
@@ -93,15 +98,15 @@
             支付
           </el-button>
           <el-button
-            v-hasPerm="['module_payment:account:transfer:detail']"
+            v-hasPerm="['module_payment:batch:detail']"
             size="small"
-            @click="emit('view', row.out_batch_no, row.enterprise_id)"
+            @click="emit('view', row.out_batch_no)"
           >
             详情
           </el-button>
           <el-button
             v-if="row.status === 'INIT' || row.status === 'WAIT_PAY'"
-            v-hasPerm="['module_payment:account:transfer']"
+            v-hasPerm="['module_payment:batch:create']"
             size="small"
             type="danger"
             @click="handleClose(row)"
@@ -126,18 +131,12 @@
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted, reactive, ref } from "vue";
-import BatchPayAPI, { type BatchOrderVO } from "@/api/module_payment/batch";
-import { useEnterpriseStore, useUserStore } from "@/store";
+import { onMounted, reactive, ref } from "vue";
+import BatchPayAPI, { type BatchAuthorizeVO, type BatchOrderVO } from "@/api/module_payment/batch";
 import { ElMessage, ElMessageBox } from "element-plus";
 import dayjs from "dayjs";
 
-const emit = defineEmits<{ view: [outBatchNo: string, enterpriseId?: string] }>();
-
-const enterpriseStore = useEnterpriseStore();
-const userStore = useUserStore();
-const isPlatformUser = computed(() => userStore.is_platform_user);
-const currentEnterpriseId = computed(() => enterpriseStore.getCurrentEnterprise?.enterprise_id);
+const emit = defineEmits<{ view: [outBatchNo: string] }>();
 
 const list = ref<BatchOrderVO[]>([]);
 const total = ref(0);
@@ -147,8 +146,15 @@ const loading = ref(false);
 const exportLoading = ref(false);
 const payingNo = ref("");
 
+/** 全部主体(含已解绑 UNBIND —— 历史批次按 payer_uid 筛选,不要求当前可制单) */
+const allSubjects = ref<BatchAuthorizeVO[]>([]);
+const subjectName = (uid?: string) => {
+  const s = allSubjects.value.find((x) => x.participant_id === uid);
+  return s ? s.participant_name || "未命名主体" : uid || "-";
+};
+
 const searchForm = reactive({
-  enterprise_id: undefined as string | undefined,
+  participant_id: undefined as string | undefined,
   status: "",
   dateRange: null as string[] | null,
   start_time: undefined as string | undefined,
@@ -177,7 +183,7 @@ async function load() {
   loading.value = true;
   try {
     const res = await BatchPayAPI.batchList({
-      enterprise_id: searchForm.enterprise_id || undefined,
+      participant_id: searchForm.participant_id || undefined,
       status: searchForm.status || undefined,
       start_time: searchForm.start_time || undefined,
       end_time: searchForm.end_time || undefined,
@@ -208,7 +214,7 @@ function handleDateChange() {
 }
 
 function handleSearchReset() {
-  searchForm.enterprise_id = undefined;
+  searchForm.participant_id = undefined;
   searchForm.status = "";
   searchForm.dateRange = null;
   searchForm.start_time = undefined;
@@ -217,14 +223,9 @@ function handleSearchReset() {
 }
 
 async function handlePay(row: BatchOrderVO) {
-  const enterpriseId = row.enterprise_id || currentEnterpriseId.value;
-  if (!enterpriseId) {
-    ElMessage.warning("未获取到企业信息,无法生成支付页面");
-    return;
-  }
   payingNo.value = row.out_batch_no;
   try {
-    const res = await BatchPayAPI.renderPay(enterpriseId, row.out_batch_no);
+    const res = await BatchPayAPI.renderPay(row.out_batch_no);
     // render.pay 返回支付宝收银台短链接 initialize_code(后端实证: 成功响应 {"code":"10000","initialize_code":"https://p.tb.cn/..."}),
     // 直接新窗口打开,无需 document.write
     const payUrl = res.data.data?.pay_url || "";
@@ -242,17 +243,12 @@ async function handlePay(row: BatchOrderVO) {
 }
 
 async function handleClose(row: BatchOrderVO) {
-  const enterpriseId = row.enterprise_id || currentEnterpriseId.value;
-  if (!enterpriseId) {
-    ElMessage.warning("未获取到企业信息,无法关闭批次");
-    return;
-  }
   try {
     await ElMessageBox.confirm("关闭后该批次不可再支付,确定关闭?", "提示", { type: "warning" });
   } catch {
     return;
   }
-  await BatchPayAPI.batchClose(enterpriseId, row.out_batch_no);
+  await BatchPayAPI.batchClose(row.out_batch_no);
   ElMessage.success("批次已关闭");
   load();
 }
@@ -261,7 +257,7 @@ async function handleExport() {
   exportLoading.value = true;
   try {
     const res = await BatchPayAPI.batchExport({
-      enterprise_id: searchForm.enterprise_id || undefined,
+      participant_id: searchForm.participant_id || undefined,
       status: searchForm.status || undefined,
       start_time: searchForm.start_time || undefined,
       end_time: searchForm.end_time || undefined,
@@ -285,11 +281,14 @@ async function handleExport() {
   }
 }
 
-onMounted(() => {
-  if (!isPlatformUser.value) {
-    searchForm.enterprise_id = currentEnterpriseId.value;
-  }
+onMounted(async () => {
   load();
+  try {
+    const res = await BatchPayAPI.authorizeList({ page_no: 1, page_size: 100 });
+    allSubjects.value = res.data.data?.items || res.data.data?.list || [];
+  } catch {
+    // 主体加载失败不阻塞列表(筛选下拉仅展示为空)
+  }
 });
 </script>
 

+ 50 - 0
frontend/src/views/module_payment/batch/index.vue

@@ -0,0 +1,50 @@
+<template>
+  <!-- 单根节点包裹(含详情弹层):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
+          :active="activeTab === 'create'"
+          @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 = ''"
+      @refresh="batchListKey++"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch } 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++;
+}
+// 切换 tab 时关闭批次详情(详情挂在 tabs 外部,避免残留展示)
+watch(activeTab, () => {
+  detailBatchNo.value = "";
+});
+</script>

+ 41 - 0
java/src/main/java/com/payment/platform/core/alipay/AlipayClientFactory.java

@@ -105,6 +105,25 @@ public class AlipayClientFactory {
         return providerClients.computeIfAbsent(providerId, this::createClientForProvider);
     }
 
+    /**
+     * 按服务商 + 业务类型获取客户端(账号级场景,无企业中间层)
+     * <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) {
+            // 1. 尝试业务专属凭证
+            AlipayClient profileClient = getClientByProfile(providerId, bizType);
+            if (profileClient != null) return profileClient;
+        }
+        // 2. 回退到服务商默认凭证
+        return getClientByProvider(providerId);
+    }
+
     /**
      * 强制刷新指定服务商客户端(配置修改后调用)
      */
@@ -148,6 +167,28 @@ public class AlipayClientFactory {
         return paymentAlipayConfig;
     }
 
+    /**
+     * 按服务商 + 业务类型解析 app_id(与 {@link #getClientByProvider} 同一条解析链,无客户端时也有值)
+     * <p>
+     * 用于 OAuth 授权链接(openauth.alipay.com)等需要 app_id 但不需要完整客户端的场景:
+     *   profile(appId) → 服务商默认(appId) → yml 配置
+     */
+    public String getAppIdByProvider(Long providerId, String bizType) {
+        if (providerId != null) {
+            if (bizType != null) {
+                ServiceProviderProfileEntity profile = getProfileEntity(providerId, bizType);
+                if (profile != null && profile.getAppId() != null) {
+                    return profile.getAppId();
+                }
+            }
+            ServiceProviderEntity sp = serviceProviderMapper.selectById(providerId);
+            if (sp != null && sp.getAppId() != null) {
+                return sp.getAppId();
+            }
+        }
+        return paymentAlipayConfig.getAppId();
+    }
+
     /**
      * 按企业 + 业务类型解析 app_id(与 {@link #getClient} 同一条解析链)
      */

+ 13 - 0
java/src/main/java/com/payment/platform/core/alipay/AlipayConfig.java

@@ -53,6 +53,19 @@ public class AlipayConfig {
     /** 同步跳转地址 */
     private String returnUrl;
 
+    /**
+     * 前端页面地址(如 http://localhost:5180)— OAuth 授权回调完成后 302 跳回的前端入口,
+     * openid 授权 URL 生成时校验非空(callback 无登录态,无法反向解析前端地址)
+     */
+    private String oauthFrontUrl;
+
+    /**
+     * OAuth 授权回调地址(完整 URL)— 支付宝开放平台「授权回调地址」白名单只能配一个,
+     * 与当面付 app_auth_code 授权共用(aplipay/auth,按参数分流),须与白名单配置完全一致;
+     * openid 授权 URL 生成时校验非空(redirect_uri 固定配置,避免反代下动态 Host 与白名单不一致)
+     */
+    private String oauthRedirectUri;
+
     /** 最大重试次数 */
     private int maxRetries = 3;
 

+ 2 - 0
java/src/main/java/com/payment/platform/core/security/SecurityConfig.java

@@ -57,6 +57,8 @@ public class SecurityConfig {
             "/system/notice/available",
             "/payment/notify/health",
             "/payment/notify/alipay",
+            // 支付宝授权回调白名单地址(当面付 app_auth_code + openid auth_code 共用,按参数分流;
+            // 无登录态,租户隔离由 TenantInnerInterceptor 条件表放行)
             "/payment/aplipay/auth",
             "/v3/api-docs/**",
             "/swagger-ui/**",

+ 5 - 1
java/src/main/java/com/payment/platform/core/tenant/TenantInnerInterceptor.java

@@ -68,7 +68,11 @@ public class TenantInnerInterceptor extends TenantLineInnerInterceptor {
             "sys_tenant_api_key",    // 开放API Key(TenantApiKeyAuthFilter 认证阶段无租户上下文,
                                      //   API Key 全局唯一,须跨租户查询;管理端登录后仍按租户隔离)
             "open_transfer",         // 开放转账映射(支付宝通知回调 notifyTransferResult 无认证上下文)
-            "open_conf"              // 开放配置(回调通知 resolveReturnUrl 无认证上下文)
+            "open_conf",             // 开放配置(回调通知 resolveReturnUrl 无认证上下文)
+            "pay_batch_authorize"    // 制单授权(授权签约通知 + OAuth openid 回调均无认证上下文;
+                                     //   无认证时按 out_biz_no(雪花ID全局唯一)反查,无跨租户风险;
+                                     //   此前不在条件表导致通知回写查询恒带 tenant_id=0 而失效,
+                                     //   实际靠前端 queryAuthorize 兜底 — 加入后通知回写真正生效)
     );
 
     public TenantInnerInterceptor() {

+ 45 - 32
java/src/main/java/com/payment/platform/module/payment/batch/controller/BatchPayController.java

@@ -2,10 +2,13 @@ package com.payment.platform.module.payment.batch.controller;
 
 import com.payment.platform.common.response.PageResult;
 import com.payment.platform.common.response.Result;
+import com.payment.platform.core.alipay.AlipayConfig;
+import com.payment.platform.module.payment.batch.dto.AuthorizeApplyDTO;
 import com.payment.platform.module.payment.batch.dto.BatchCreateDTO;
 import com.payment.platform.module.payment.batch.entity.BatchAuthorizeEntity;
 import com.payment.platform.module.payment.batch.entity.BatchOrderEntity;
 import com.payment.platform.module.payment.batch.service.AlipayBatchPayService;
+import com.payment.platform.module.payment.batch.service.BatchSubjectService;
 import jakarta.servlet.http.HttpServletResponse;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
@@ -16,101 +19,111 @@ import java.io.IOException;
 import java.util.Map;
 
 @RestController
-@RequestMapping("/payment/account/batch")
+@RequestMapping("/payment/batch")
 @RequiredArgsConstructor
 public class BatchPayController {
 
     private final AlipayBatchPayService batchPayService;
+    private final BatchSubjectService batchSubjectService;
+    private final AlipayConfig alipayConfig;
 
-    @PreAuthorize("@perm.hasAny('module_payment:account:authorize')")
+    @PreAuthorize("@perm.hasAny('module_payment:batch:authorize')")
     @PostMapping("/authorize/apply")
-    public Result<Map<String, String>> authorizeApply(@RequestBody Map<String, Object> b) {
-        // 付款方恒为企业自身 UID(enterprise_id),不接受客户端指定 participant_id
-        return Result.ok(batchPayService.authorizeApply((String) b.get("enterprise_id")));
+    public Result<Map<String, String>> authorizeApply(@Valid @RequestBody AuthorizeApplyDTO dto) {
+        // 账号级: 主体信息来自表单(名称/uid/服务商),不再从企业解析
+        return Result.ok(batchSubjectService.apply(
+                dto.getParticipantName(), dto.getParticipantId(), dto.getParticipantIdType(), dto.getServiceProviderId()));
     }
 
-    @PreAuthorize("@perm.hasAny('module_payment:account:authorize')")
+    @PreAuthorize("@perm.hasAny('module_payment:batch:authorize')")
     @PostMapping("/authorize/rebind")
     public Result<Map<String, String>> authorizeRebind(@RequestBody Map<String, Object> b) {
-        return Result.ok(batchPayService.authorizeRebind((String) b.get("enterprise_id")));
+        return Result.ok(batchSubjectService.rebind(
+                b.get("id") != null ? Long.valueOf(b.get("id").toString()) : null));
     }
 
-    @PreAuthorize("@perm.hasAny('module_payment:account:authorize')")
+    @PreAuthorize("@perm.hasAny('module_payment:batch:authorize')")
     @GetMapping("/authorize/query")
     public Result<Map<String, String>> queryAuthorize(
-            @RequestParam("enterprise_id") String enterpriseId,
             @RequestParam("out_biz_no") String outBizNo) {
-        return Result.ok(batchPayService.queryAuthorize(enterpriseId, outBizNo));
+        return Result.ok(batchSubjectService.query(outBizNo));
     }
 
-    @PreAuthorize("@perm.hasAny('module_payment:account:transfer')")
+    /**
+     * 生成 user.info.share 授权链接(LOGON_ID 主体获取 open_id 制单用)— 前端新窗口打开。
+     * redirect_uri 为支付宝白名单固定地址(alipay.oauth-redirect-uri,与当面付 aplipay/auth 共用,按参数分流)
+     */
+    @PreAuthorize("@perm.hasAny('module_payment:batch:authorize')")
+    @GetMapping("/authorize/openid/url")
+    public Result<String> openIdAuthorizeUrl(@RequestParam("id") Long id) {
+        return Result.ok(batchSubjectService.openIdAuthorizeUrl(
+                id, alipayConfig.getOauthRedirectUri(), alipayConfig.getOauthFrontUrl()));
+    }
+
+    @PreAuthorize("@perm.hasAny('module_payment:batch:create')")
     @PostMapping("/create")
     public Result<Map<String, Object>> batchCreate(@Valid @RequestBody BatchCreateDTO dto) {
         return Result.ok(batchPayService.batchCreate(dto));
     }
 
-    @PreAuthorize("@perm.hasAny('module_payment:account:transfer')")
+    @PreAuthorize("@perm.hasAny('module_payment:batch:create')")
     @PostMapping("/pay")
     public Result<Map<String, String>> renderPay(@RequestBody Map<String, Object> b) {
-        return Result.ok(batchPayService.renderPay(
-                (String) b.get("enterprise_id"), (String) b.get("out_batch_no")));
+        return Result.ok(batchPayService.renderPay((String) b.get("out_batch_no")));
     }
 
-    @PreAuthorize("@perm.hasAny('module_payment:account:transfer:list')")
+    @PreAuthorize("@perm.hasAny('module_payment:batch:list')")
     @GetMapping("/query")
     public Result<Map<String, Object>> batchQuery(
-            @RequestParam("enterprise_id") String enterpriseId,
             @RequestParam("out_batch_no") String outBatchNo) {
-        return Result.ok(batchPayService.batchQuery(enterpriseId, outBatchNo));
+        return Result.ok(batchPayService.batchQuery(outBatchNo));
     }
 
-    @PreAuthorize("@perm.hasAny('module_payment:account:transfer')")
+    @PreAuthorize("@perm.hasAny('module_payment:batch:create')")
     @PostMapping("/close")
     public Result<Map<String, String>> batchClose(@RequestBody Map<String, Object> b) {
-        return Result.ok(batchPayService.batchClose(
-                (String) b.get("enterprise_id"), (String) b.get("out_batch_no")));
+        return Result.ok(batchPayService.batchClose((String) b.get("out_batch_no")));
     }
 
-    @PreAuthorize("@perm.hasAny('module_payment:account:transfer:list')")
+    @PreAuthorize("@perm.hasAny('module_payment:batch:list')")
     @GetMapping("/list")
     public Result<PageResult<BatchOrderEntity>> batchList(
             @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
             @RequestParam(name = "page_size", defaultValue = "20") int pageSize,
-            @RequestParam(name = "enterprise_id", required = false) String enterpriseId,
+            @RequestParam(name = "participant_id", required = false) String participantId,
             @RequestParam(name = "status", required = false) String status,
             @RequestParam(name = "start_time", required = false) String startTime,
             @RequestParam(name = "end_time", required = false) String endTime) {
-        return Result.ok(batchPayService.batchList(enterpriseId, status, startTime, endTime, pageNo, pageSize));
+        return Result.ok(batchPayService.batchList(participantId, status, startTime, endTime, pageNo, pageSize));
     }
 
-    @PreAuthorize("@perm.hasAny('module_payment:account:transfer:detail')")
+    @PreAuthorize("@perm.hasAny('module_payment:batch:detail')")
     @GetMapping("/detail")
     public Result<Map<String, Object>> batchDetail(
-            @RequestParam("enterprise_id") String enterpriseId,
             @RequestParam("out_batch_no") String outBatchNo,
             @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
             @RequestParam(name = "page_size", defaultValue = "20") int pageSize) {
-        return Result.ok(batchPayService.batchDetail(enterpriseId, outBatchNo, pageNo, pageSize));
+        return Result.ok(batchPayService.batchDetail(outBatchNo, pageNo, pageSize));
     }
 
-    @PreAuthorize("@perm.hasAny('module_payment:account:transfer:list')")
+    @PreAuthorize("@perm.hasAny('module_payment:batch:list')")
     @GetMapping("/authorize/list")
     public Result<PageResult<BatchAuthorizeEntity>> authorizeList(
             @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
             @RequestParam(name = "page_size", defaultValue = "20") int pageSize,
-            @RequestParam(name = "enterprise_id", required = false) String enterpriseId) {
-        return Result.ok(batchPayService.authorizeList(enterpriseId, pageNo, pageSize));
+            @RequestParam(name = "participant_id", required = false) String participantId) {
+        return Result.ok(batchSubjectService.list(participantId, pageNo, pageSize));
     }
 
-    @PreAuthorize("@perm.hasAny('module_payment:account:transfer:list')")
+    @PreAuthorize("@perm.hasAny('module_payment:batch:list')")
     @GetMapping("/export")
     public void batchExport(
-            @RequestParam(name = "enterprise_id", required = false) String enterpriseId,
+            @RequestParam(name = "participant_id", required = false) String participantId,
             @RequestParam(name = "status", required = false) String status,
             @RequestParam(name = "start_time", required = false) String startTime,
             @RequestParam(name = "end_time", required = false) String endTime,
             HttpServletResponse response) throws IOException {
-        byte[] bytes = batchPayService.batchExport(enterpriseId, status, startTime, endTime);
+        byte[] bytes = batchPayService.batchExport(participantId, status, startTime, endTime);
         response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
         response.setHeader("Content-Disposition", "attachment; filename=batch_pay_report.xlsx");
         response.getOutputStream().write(bytes);

+ 26 - 0
java/src/main/java/com/payment/platform/module/payment/batch/dto/AuthorizeApplyDTO.java

@@ -0,0 +1,26 @@
+package com.payment.platform.module.payment.batch.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+/** 账号级制单授权申请 — 主体信息由表单录入(不再从企业解析) */
+@Data
+public class AuthorizeApplyDTO {
+
+    @NotBlank(message = "主体名称不能为空")
+    @Schema(description = "主体名称(个人姓名/企业名称,展示在授权页面)")
+    private String participantName;
+
+    @NotBlank(message = "支付宝账号不能为空")
+    @Schema(description = "授权主体账号(按主体类型:UID/登录号/OpenID)")
+    private String participantId;
+
+    @Schema(description = "主体类型: ALIPAY_USER_ID 支付宝账号(默认) / ALIPAY_LOGON_ID 登录号 / ALIPAY_OPEN_ID OpenID")
+    private String participantIdType;
+
+    @NotNull(message = "请选择服务商")
+    @Schema(description = "服务商(授权申请 client 解析依据)")
+    private Long serviceProviderId;
+}

+ 3 - 2
java/src/main/java/com/payment/platform/module/payment/batch/dto/BatchCreateDTO.java

@@ -14,8 +14,9 @@ import java.util.Map;
 @Data
 public class BatchCreateDTO {
 
-    @Schema(description = "企业ID")
-    private String enterpriseId;
+    @NotBlank(message = "请选择付款主体")
+    @Schema(description = "付款主体(授权主体支付宝uid,制单时从已授权主体选择)")
+    private String participantId;
 
     @Schema(description = "租户ID(内部,由 Controller 注入)")
     private Long tenantId;

+ 21 - 2
java/src/main/java/com/payment/platform/module/payment/batch/entity/BatchAuthorizeEntity.java

@@ -2,7 +2,9 @@ package com.payment.platform.module.payment.batch.entity;
 
 import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableName;
-import com.payment.platform.common.base.PaymentEnterpriseBaseEntity;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import com.payment.platform.common.base.PaymentTenantBaseEntity;
 import com.payment.platform.common.handler.JsonbTypeHandler;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
@@ -13,11 +15,28 @@ import java.time.OffsetDateTime;
 @Data
 @EqualsAndHashCode(callSuper = true)
 @TableName("pay_batch_authorize")
-public class BatchAuthorizeEntity extends PaymentEnterpriseBaseEntity {
+public class BatchAuthorizeEntity extends PaymentTenantBaseEntity {
+    /** 雪花ID(19位)超 JS 安全整数,序列化为字符串防前端精度丢失(rebind 以 id 为参数) */
+    @Override
+    @JsonSerialize(using = ToStringSerializer.class)
+    public Long getId() {
+        return super.getId();
+    }
+
     private String outBizNo;
     private String participantId;
     private String participantIdType;
+    /** 主体名称(账号级表单录入,同时作为授权申请 principal_info.name) */
+    private String participantName;
+    /** 服务商(账号级表单下拉选择,授权申请 client 解析依据) */
+    private Long serviceProviderId;
     private String agreementNo;
+    /**
+     * 个人账号(ALIPAY_LOGON_ID 主体)扫码授权后获取的支付宝账号 ID(2088 开头,user.info.share 返回)—
+     * 制单时付款方 identity 用(ALIPAY_USER_ID)。
+     * 注: 本应用未开通支付宝 open_id 能力(user.info.share 响应无 open_id 字段,实证),故直接使用 user_id
+     */
+    private String alipayUserId;
     /** AUTHING / AUTHED / UNBIND */
     private String status;
     private String authorizeLink;

+ 2 - 2
java/src/main/java/com/payment/platform/module/payment/batch/entity/BatchDetailEntity.java

@@ -2,7 +2,7 @@ package com.payment.platform.module.payment.batch.entity;
 
 import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableName;
-import com.payment.platform.common.base.PaymentEnterpriseBaseEntity;
+import com.payment.platform.common.base.PaymentTenantBaseEntity;
 import com.payment.platform.common.handler.JsonbTypeHandler;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
@@ -13,7 +13,7 @@ import java.time.OffsetDateTime;
 @Data
 @EqualsAndHashCode(callSuper = true)
 @TableName("pay_batch_detail")
-public class BatchDetailEntity extends PaymentEnterpriseBaseEntity {
+public class BatchDetailEntity extends PaymentTenantBaseEntity {
     private Long batchId;
     private String outBizNo;
     private BigDecimal amount;

+ 4 - 2
java/src/main/java/com/payment/platform/module/payment/batch/entity/BatchOrderEntity.java

@@ -2,7 +2,7 @@ package com.payment.platform.module.payment.batch.entity;
 
 import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableName;
-import com.payment.platform.common.base.PaymentEnterpriseBaseEntity;
+import com.payment.platform.common.base.PaymentTenantBaseEntity;
 import com.payment.platform.common.handler.JsonbTypeHandler;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
@@ -13,7 +13,7 @@ import java.time.OffsetDateTime;
 @Data
 @EqualsAndHashCode(callSuper = true)
 @TableName("pay_batch_order")
-public class BatchOrderEntity extends PaymentEnterpriseBaseEntity {
+public class BatchOrderEntity extends PaymentTenantBaseEntity {
     private String outBatchNo;
     private String batchTransId;
     private BigDecimal totalAmount;
@@ -23,6 +23,8 @@ public class BatchOrderEntity extends PaymentEnterpriseBaseEntity {
     private String status;
     private OffsetDateTime timeExpire;
     private String payerUid;
+    /** 付款主体冗余的服务商(制单时落库)——主体解绑后批次支付/查询/关闭仍可解析 client */
+    private Long serviceProviderId;
     private String agreementNo;
     private String transferSceneName;
     @TableField(typeHandler = JsonbTypeHandler.class)

+ 1 - 1
java/src/main/java/com/payment/platform/module/payment/batch/scheduler/BatchStatusPollScheduler.java

@@ -33,7 +33,7 @@ public class BatchStatusPollScheduler {
         int count = 0;
         for (BatchOrderEntity batch : batches) {
             try {
-                batchPayService.batchQuery(batch.getEnterpriseId(), batch.getOutBatchNo());
+                batchPayService.batchQuery(batch.getOutBatchNo());
                 count++;
             } catch (Exception e) {
                 log.error("[批次状态轮询] 轮询批次异常: out_batch_no={}, error={}",

+ 58 - 255
java/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.java

@@ -3,24 +3,17 @@ package com.payment.platform.module.payment.batch.service;
 import com.alipay.api.AlipayApiException;
 import com.alipay.api.AlipayClient;
 import com.alipay.api.domain.AccDetailModel;
-import com.alipay.api.domain.AlipayFundAuthorizeUniApplyModel;
-import com.alipay.api.domain.AlipayFundAuthorizeUniQueryModel;
 import com.alipay.api.domain.AlipayFundBatchCloseModel;
 import com.alipay.api.domain.AlipayFundBatchCreateModel;
 import com.alipay.api.domain.AlipayFundBatchDetailQueryModel;
 import com.alipay.api.domain.AlipayFundTransRenderPayModel;
-import com.alipay.api.domain.AuthParticipantInfo;
 import com.alipay.api.domain.Participant;
 import com.alipay.api.domain.TransOrderDetail;
 import com.alipay.api.domain.TransferSceneReportInfo;
-import com.alipay.api.request.AlipayFundAuthorizeUniApplyRequest;
-import com.alipay.api.request.AlipayFundAuthorizeUniQueryRequest;
 import com.alipay.api.request.AlipayFundBatchCloseRequest;
 import com.alipay.api.request.AlipayFundBatchCreateRequest;
 import com.alipay.api.request.AlipayFundBatchDetailQueryRequest;
 import com.alipay.api.request.AlipayFundTransRenderPayRequest;
-import com.alipay.api.response.AlipayFundAuthorizeUniApplyResponse;
-import com.alipay.api.response.AlipayFundAuthorizeUniQueryResponse;
 import com.alipay.api.response.AlipayFundBatchCloseResponse;
 import com.alipay.api.response.AlipayFundBatchCreateResponse;
 import com.alipay.api.response.AlipayFundBatchDetailQueryResponse;
@@ -50,8 +43,6 @@ import java.time.OffsetDateTime;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeParseException;
-import com.payment.platform.module.payment.enterprise.entity.EnterpriseEntity;
-import com.payment.platform.module.payment.enterprise.mapper.EnterpriseMapper;
 
 import java.util.ArrayList;
 import java.util.HashSet;
@@ -73,9 +64,6 @@ import java.util.stream.Collectors;
 @RequiredArgsConstructor
 public class AlipayBatchPayService {
 
-    private static final String AUTHORIZE_PRODUCT_CODE = "TRANSFER_API_STANDARD_AUTHORIZATION";
-    private static final String AUTHORIZE_BIZ_SCENE = "STANDARD_CREATE_FUND_ORDER";
-    private static final String AUTHORIZE_LINK_TYPE = "SHORT_URL";
     private static final String BIZ_TYPE = "BATCH_PAY";
 
     /** 静态 ObjectMapper(同 NotificationService 第 55 行模式),用于 ext_info / 报备 JSON 序列化 */
@@ -85,167 +73,6 @@ public class AlipayBatchPayService {
     private final BatchAuthorizeMapper batchAuthorizeMapper;
     private final BatchOrderMapper batchOrderMapper;
     private final BatchDetailMapper batchDetailMapper;
-    private final EnterpriseMapper enterpriseMapper;
-
-    /**
-     * alipay.fund.authorize.uni.apply — 生成制单授权短链接(PC 渠道)
-     * <p>
-     * Ruling 19/22: 付款方即企业自己,不接收客户端传入的 participant_id(防篡改指向他人账号)。
-     * 付款方身份遵循系统惯例(同 AlipayTransferService.createOnboard): 企业入驻身份
-     * pay_enterprise.identity 优先(配套 identityType),为空回退 enterprise_id。
-     */
-    @Transactional
-    public Map<String, String> authorizeApply(String enterpriseId) {
-        EnterpriseEntity ent = requireEnterprise(enterpriseId);
-        return doAuthorizeApply(enterpriseId, payerIdentity(ent), payerIdentityType(ent));
-    }
-
-    /**
-     * 制单授权重新生成: 作废最新一条非终态授权申请(AUTHING → UNBIND,旧链接失效),
-     * 换新 out_biz_no 重新申请。AUTHED 为终态(永久生效授权)拒绝重新生成。
-     * 无既有记录时等价于直接申请。
-     */
-    @Transactional
-    public Map<String, String> authorizeRebind(String enterpriseId) {
-        EnterpriseEntity ent = requireEnterprise(enterpriseId);
-        BatchAuthorizeEntity existing = batchAuthorizeMapper.selectOne(
-                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
-                        .eq(BatchAuthorizeEntity::getEnterpriseId, enterpriseId)
-                        .ne(BatchAuthorizeEntity::getStatus, "UNBIND")
-                        .orderByDesc(BatchAuthorizeEntity::getId)
-                        .last("LIMIT 1"));
-        if (existing != null) {
-            if (isAuthorizedStatus(existing.getStatus()))
-                throw new BusinessException(400, "该企业已存在生效授权,无需重新生成");
-            existing.setStatus("UNBIND");
-            batchAuthorizeMapper.updateById(existing);
-            log.info("制单授权重新生成,作废旧申请: old_out_biz_no={}, enterprise_id={}",
-                    existing.getOutBizNo(), enterpriseId);
-        }
-        return doAuthorizeApply(enterpriseId, payerIdentity(ent), payerIdentityType(ent));
-    }
-
-    /** 授权申请公共逻辑(三态预检 + 调支付宝 + 落库),apply 与 rebind 共用 */
-    private Map<String, String> doAuthorizeApply(String enterpriseId, String participantId, String participantIdType) {
-        if (participantId == null || participantId.isBlank())
-            throw new BusinessException(400, "付款方支付宝账号不能为空");
-        // 重复新增防护三态(设计文档 2.4/7):
-        //   AUTHED            → 拒绝(已有生效授权)
-        //   AUTHING 未过期    → 拒绝(授权链接一次有效,提示先完成授权)
-        //   AUTHING 已过期    → 旧记录置 UNBIND,换新 out_biz_no 重新申请
-        // DB 兜底: uk_batch_authorize_active partial unique 索引(enterprise_id, participant_id)WHERE status <> 'UNBIND'
-        BatchAuthorizeEntity existing = batchAuthorizeMapper.selectOne(
-                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
-                        .eq(BatchAuthorizeEntity::getEnterpriseId, enterpriseId)
-                        .eq(BatchAuthorizeEntity::getParticipantId, participantId)
-                        .ne(BatchAuthorizeEntity::getStatus, "UNBIND"));
-        String outBizNo = SnowflakeIdGenerator.nextIdStr();
-        if (existing != null) {
-            if (isAuthorizedStatus(existing.getStatus()))
-                throw new BusinessException(400, "该付款方已存在制单授权申请,无需重复授权");
-            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 {
-            AlipayFundAuthorizeUniApplyModel model = new AlipayFundAuthorizeUniApplyModel();
-            model.setProductCode(AUTHORIZE_PRODUCT_CODE);
-            model.setBizScene(AUTHORIZE_BIZ_SCENE);
-            model.setOutBizNo(outBizNo);
-            model.setAuthorizeLinkType(AUTHORIZE_LINK_TYPE);
-            model.setChannel("pc");
-            AuthParticipantInfo principal = new AuthParticipantInfo();
-            principal.setParticipantId(participantId);
-            principal.setParticipantIdType(participantIdType);
-            model.setPrincipalInfo(principal);
-
-            AlipayFundAuthorizeUniApplyRequest request = new AlipayFundAuthorizeUniApplyRequest();
-            request.setBizModel(model);
-            AlipayClient client = alipayClientFactory.getClient(enterpriseId, BIZ_TYPE);
-            AlipayFundAuthorizeUniApplyResponse response = client.certificateExecute(request);
-            if (!response.isSuccess())
-                throw new BusinessException(400, "生成授权链接失败: " + response.getMsg());
-
-            BatchAuthorizeEntity entity = new BatchAuthorizeEntity();
-            entity.setEnterpriseId(enterpriseId);
-            entity.setOutBizNo(outBizNo);
-            entity.setParticipantId(participantId);
-            entity.setParticipantIdType("ALIPAY_USER_ID");
-            entity.setStatus("AUTHING");
-            entity.setAuthorizeLink(response.getAuthorizeLink());
-            try {
-                batchAuthorizeMapper.insert(entity);
-            } catch (DuplicateKeyException e) {
-                // 并发双击兜底: 同企业同付款方已有非 UNBIND 授权记录,命中 uk_batch_authorize_active
-                throw new BusinessException(400, "该付款方已存在制单授权申请,请勿重复操作");
-            }
-
-            return Map.of("authorize_link",
-                    response.getAuthorizeLink() != null ? response.getAuthorizeLink() : "",
-                    "out_biz_no", outBizNo, "status", "AUTHING");
-        } catch (AlipayApiException e) {
-            throw new BusinessException(400, "生成授权链接失败: " + e.getMessage());
-        }
-    }
-
-    /**
-     * 授权申请是否已过期: authorize_expire_time 非空且 > now 为未过期;
-     * 为空时按 created_time + 24h 判定(授权链接一次有效,逾期视为过期可重新申请)
-     */
-    private boolean isAuthorizeExpired(BatchAuthorizeEntity entity) {
-        OffsetDateTime now = OffsetDateTime.now();
-        if (entity.getAuthorizeExpireTime() != null)
-            return !entity.getAuthorizeExpireTime().isAfter(now);
-        if (entity.getCreatedTime() != null)
-            return entity.getCreatedTime().plusHours(24).isBefore(now);
-        return true;
-    }
-
-    /** alipay.fund.authorize.uni.query — 查询制单授权状态(单协议) */
-    public Map<String, String> queryAuthorize(String enterpriseId, String outBizNo) {
-        try {
-            AlipayFundAuthorizeUniQueryModel model = new AlipayFundAuthorizeUniQueryModel();
-            model.setProductCode(AUTHORIZE_PRODUCT_CODE);
-            model.setBizScene(AUTHORIZE_BIZ_SCENE);
-            model.setOutBizNo(outBizNo);
-
-            AlipayFundAuthorizeUniQueryRequest request = new AlipayFundAuthorizeUniQueryRequest();
-            request.setBizModel(model);
-            AlipayFundAuthorizeUniQueryResponse response =
-                    alipayClientFactory.getClient(enterpriseId, BIZ_TYPE).certificateExecute(request);
-            if (!response.isSuccess()) {
-                // 未授权(协议不存在)是正常业务状态而非查询失败: 前端应展示「授权中」而不是报错
-                if ("AUTHORIZATION_NOT_EXIST".equals(response.getSubCode())) {
-                    return Map.of("agreement_no", "", "status", "AUTHING");
-                }
-                throw new BusinessException(400, "查询授权状态失败: " + response.getMsg());
-            }
-
-            // 仅生效授权才回写(AUTHED/NORMAL 均视同生效,通知侧 NORMAL 已归一 AUTHED);
-            // UNBIND 也返回协议号, 直接回写会破坏本地状态机(UNBIND 由异步通知回写)
-            if (isAuthorizedStatus(response.getStatus())
-                    && response.getAgreementNo() != null && !response.getAgreementNo().isBlank()) {
-                BatchAuthorizeEntity entity = batchAuthorizeMapper.selectOne(
-                        new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
-                                .eq(BatchAuthorizeEntity::getOutBizNo, outBizNo));
-                if (entity != null) {
-                    entity.setAgreementNo(response.getAgreementNo());
-                    entity.setStatus("AUTHED");
-                    batchAuthorizeMapper.updateById(entity);
-                } else {
-                    log.warn("制单授权查询成功但本地记录不存在, enterpriseId={}, outBizNo={}, agreementNo={}",
-                            enterpriseId, outBizNo, response.getAgreementNo());
-                }
-            }
-            return Map.of("agreement_no", response.getAgreementNo() != null ? response.getAgreementNo() : "",
-                    "status", response.getStatus() != null ? response.getStatus() : "AUTHING");
-        } catch (AlipayApiException e) {
-            throw new BusinessException(400, "查询授权状态失败: " + e.getMessage());
-        }
-    }
 
     // ==================== 批次 ====================
 
@@ -309,25 +136,26 @@ public class AlipayBatchPayService {
             model.setOrderTitle(dto.getOrderTitle());
             if (dto.getTimeExpire() != null) model.setTimeExpire(dto.getTimeExpire());
             if (dto.getRemark() != null) model.setRemark(dto.getRemark());
-            // 付款方 + 制单授权协议(Ruling 19/22: 不接受客户端指定,付款方身份遵循系统惯例 identity 优先回退 enterprise_id)
-            EnterpriseEntity ent = requireEnterprise(dto.getEnterpriseId());
-            String payerUid = payerIdentity(ent);
-            // 未完成授权不允许制单: 取该付款方最新生效授权(AUTHED/NORMAL)→ 无则本地预检拦截;
+            // 付款方 + 制单授权协议(账号级: 付款方 = 表单选择的授权主体,spec 5.3)
+            String payerUid = dto.getParticipantId();
+            // 未完成授权不允许制单: 取该主体最新生效授权(AUTHED/NORMAL)→ 无则本地预检拦截;
             // 支付宝侧 AUTH_INFO_NOT_EXISTS 兜底保留(防本地与支付宝状态不一致的竞态)
             BatchAuthorizeEntity authed = batchAuthorizeMapper.selectOne(
                     new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
-                            .eq(BatchAuthorizeEntity::getEnterpriseId, dto.getEnterpriseId())
                             .eq(BatchAuthorizeEntity::getParticipantId, payerUid)
                             .in(BatchAuthorizeEntity::getStatus, "AUTHED", "NORMAL")
                             .orderByDesc(BatchAuthorizeEntity::getId)
                             .last("LIMIT 1"));
             if (authed == null)
-                throw new BusinessException(400, "该企业尚未完成制单授权,请先在「制单授权」中生成授权链接并完成授权");
+                throw new BusinessException(400, "该主体尚未完成制单授权,请先在「制单授权」中生成授权链接并完成授权");
             // 协议号自动带出: 取该笔生效授权的 agreement_no(支付宝生成,不接受客户端指定)
             String agreementNo = authed.getAgreementNo();
+            // 付款方 identity 映射: 制单接口仅接受 ALIPAY_USER_ID / ALIPAY_OPEN_ID(用户实证),
+            // LOGON_ID 主体必须先用 OAuth 扫码获取支付宝ID(resolvePayerIdentity 内拦截)
+            Map<String, String> payerIdentity = resolvePayerIdentity(authed, payerUid);
             Participant payer = new Participant();
-            payer.setIdentity(payerUid);
-            payer.setIdentityType(payerIdentityType(ent));
+            payer.setIdentity(payerIdentity.get("identity"));
+            payer.setIdentityType(payerIdentity.get("identity_type"));
             if (agreementNo != null && !agreementNo.isBlank()) {
                 Map<String, String> ext = new LinkedHashMap<>();
                 ext.put("agreement_no", agreementNo);
@@ -348,8 +176,9 @@ public class AlipayBatchPayService {
 
             AlipayFundBatchCreateRequest request = new AlipayFundBatchCreateRequest();
             request.setBizModel(model);
+            // client 按主体冗余的服务商解析(getClientByProvider: profile → 服务商默认 → yml 回退)
             AlipayFundBatchCreateResponse response =
-                    alipayClientFactory.getClient(dto.getEnterpriseId(), BIZ_TYPE).certificateExecute(request);
+                    alipayClientFactory.getClientByProvider(authed.getServiceProviderId(), BIZ_TYPE).certificateExecute(request);
             if (!response.isSuccess()) {
                 // 幂等兜底: UNIQUE_VIOLATION 说明支付宝侧已受理同单号批次,查库返回已受理信息而非报错
                 if ("UNIQUE_VIOLATION".equals(response.getSubCode())) {
@@ -367,8 +196,8 @@ public class AlipayBatchPayService {
             }
 
             BatchOrderEntity order = new BatchOrderEntity();
-            order.setEnterpriseId(dto.getEnterpriseId());
             order.setTenantId(dto.getTenantId());
+            order.setServiceProviderId(authed.getServiceProviderId());
             order.setOutBatchNo(dto.getOutBatchNo());
             order.setBatchTransId(response.getBatchTransId());
             order.setTotalAmount(total);
@@ -386,7 +215,6 @@ public class AlipayBatchPayService {
 
             for (BatchCreateDTO.BatchDetailDTO d : dto.getDetails()) {
                 BatchDetailEntity de = new BatchDetailEntity();
-                de.setEnterpriseId(dto.getEnterpriseId());
                 de.setTenantId(dto.getTenantId());
                 de.setBatchId(order.getId());
                 de.setOutBizNo(d.getOutBizNo());
@@ -406,6 +234,28 @@ public class AlipayBatchPayService {
         }
     }
 
+    /**
+     * 制单付款方 identity 解析(制单接口仅接受 ALIPAY_USER_ID / ALIPAY_OPEN_ID,用户实证):
+     *   LOGON_ID(手机号/邮箱)→ 需先扫码授权获取支付宝ID → identity=2088 user_id + ALIPAY_USER_ID(未获取拦截)
+     *     (本应用未开通 open_id 能力,响应实证无 open_id 字段,故不用 open_id 体系)
+     *   USER_ID(2088 uid)→ identity=participant_id + ALIPAY_USER_ID
+     *   OPEN_ID → identity=participant_id + ALIPAY_OPEN_ID
+     *   participant_id_type 为空(存量记录)→ 按支付宝账号ID处理
+     */
+    static Map<String, String> resolvePayerIdentity(BatchAuthorizeEntity authed, String participantId) {
+        String idType = authed.getParticipantIdType();
+        if ("ALIPAY_LOGON_ID".equals(idType)) {
+            if (authed.getAlipayUserId() == null || authed.getAlipayUserId().isBlank())
+                throw new BusinessException(400, "该主体为账号类型(手机号/邮箱),请先在「制单授权」中扫码获取支付宝ID后再制单");
+            return Map.of("identity", authed.getAlipayUserId(), "identity_type", "ALIPAY_USER_ID");
+        }
+        if ("ALIPAY_OPEN_ID".equals(idType)) {
+            return Map.of("identity", participantId, "identity_type", "ALIPAY_OPEN_ID");
+        }
+        // 默认(ALIPAY_USER_ID 与 null 存量): 支付宝账号ID
+        return Map.of("identity", participantId, "identity_type", "ALIPAY_USER_ID");
+    }
+
     /** 幂等返回体: 已受理批次信息(本地 selectCount 命中与 UNIQUE_VIOLATION 兜底共用) */
     private Map<String, Object> acceptedBatchResult(BatchOrderEntity existing) {
         return Map.of("out_batch_no", existing.getOutBatchNo(),
@@ -413,13 +263,11 @@ public class AlipayBatchPayService {
                 "status", existing.getStatus());
     }
 
-    /** alipay.fund.trans.render.pay — 生成 PC 支付页链接 */
-    public Map<String, String> renderPay(String enterpriseId, String outBatchNo) {
-        requireEnterpriseId(enterpriseId);
+    /** alipay.fund.trans.render.pay — 生成 PC 支付页链接(租户隔离由拦截器自动追加) */
+    public Map<String, String> renderPay(String outBatchNo) {
         BatchOrderEntity order = batchOrderMapper.selectOne(
                 new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
-                        .eq(BatchOrderEntity::getOutBatchNo, outBatchNo)
-                        .eq(BatchOrderEntity::getEnterpriseId, enterpriseId));
+                        .eq(BatchOrderEntity::getOutBatchNo, outBatchNo));
         if (order == null) throw new BusinessException(404, "批次不存在");
         // INIT=已受理 / WAIT_PAY=等待支付(render 过支付链接但未支付)均可生成(或重新生成)支付链接——
         // 用户实测: 本地被定时同步成 WAIT_PAY 后无法二次发起支付
@@ -434,8 +282,9 @@ public class AlipayBatchPayService {
             model.setInitializeCodeType(RENDER_INITIALIZE_CODE_TYPE);
             AlipayFundTransRenderPayRequest request = new AlipayFundTransRenderPayRequest();
             request.setBizModel(model);
+            // client 按订单冗余的服务商解析(主体解绑后批次仍可支付,spec D5)
             AlipayFundTransRenderPayResponse response =
-                    alipayClientFactory.getClient(enterpriseId, BIZ_TYPE).certificateExecute(request);
+                    alipayClientFactory.getClientByProvider(order.getServiceProviderId(), BIZ_TYPE).certificateExecute(request);
             if (response == null)
                 throw new BusinessException(400, "生成支付页面失败: 无响应");
             // 先校验成功再给前端(同文件其他 5 处调用同款)——业务失败时若直接把 body 当链接返回,
@@ -455,13 +304,11 @@ public class AlipayBatchPayService {
         }
     }
 
-    /** alipay.fund.batch.detail.query — 查询批次+明细状态并回写 DB */
-    public Map<String, Object> batchQuery(String enterpriseId, String outBatchNo) {
-        requireEnterpriseId(enterpriseId);
+    /** alipay.fund.batch.detail.query — 查询批次+明细状态并回写 DB(租户隔离由拦截器自动追加) */
+    public Map<String, Object> batchQuery(String outBatchNo) {
         BatchOrderEntity order = batchOrderMapper.selectOne(
                 new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
-                        .eq(BatchOrderEntity::getOutBatchNo, outBatchNo)
-                        .eq(BatchOrderEntity::getEnterpriseId, enterpriseId));
+                        .eq(BatchOrderEntity::getOutBatchNo, outBatchNo));
         if (order == null) throw new BusinessException(404, "批次不存在");
         try {
             AlipayFundBatchDetailQueryModel model = new AlipayFundBatchDetailQueryModel();
@@ -471,7 +318,7 @@ public class AlipayBatchPayService {
             AlipayFundBatchDetailQueryRequest request = new AlipayFundBatchDetailQueryRequest();
             request.setBizModel(model);
             AlipayFundBatchDetailQueryResponse response =
-                    alipayClientFactory.getClient(enterpriseId, BIZ_TYPE).certificateExecute(request);
+                    alipayClientFactory.getClientByProvider(order.getServiceProviderId(), BIZ_TYPE).certificateExecute(request);
             if (!response.isSuccess())
                 throw new BusinessException(400, "查询批次失败: " + response.getMsg());
             // 批次状态回写(SDK 字段 batch_status,非 status — 已 javap 实证)
@@ -512,13 +359,11 @@ public class AlipayBatchPayService {
         }
     }
 
-    /** alipay.fund.batch.close — 主动关闭未支付批次 */
-    public Map<String, String> batchClose(String enterpriseId, String outBatchNo) {
-        requireEnterpriseId(enterpriseId);
+    /** alipay.fund.batch.close — 主动关闭未支付批次(租户隔离由拦截器自动追加) */
+    public Map<String, String> batchClose(String outBatchNo) {
         BatchOrderEntity order = batchOrderMapper.selectOne(
                 new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
-                        .eq(BatchOrderEntity::getOutBatchNo, outBatchNo)
-                        .eq(BatchOrderEntity::getEnterpriseId, enterpriseId));
+                        .eq(BatchOrderEntity::getOutBatchNo, outBatchNo));
         if (order == null) throw new BusinessException(404, "批次不存在");
         // INIT=已受理 / WAIT_PAY=等待支付(未支付)均可关闭——用户实测: 本地被定时同步成 WAIT_PAY 后关闭按钮直接消失
         if (!"INIT".equals(order.getStatus()) && !"WAIT_PAY".equals(order.getStatus()))
@@ -531,11 +376,11 @@ public class AlipayBatchPayService {
             AlipayFundBatchCloseRequest request = new AlipayFundBatchCloseRequest();
             request.setBizModel(model);
             AlipayFundBatchCloseResponse response =
-                    alipayClientFactory.getClient(enterpriseId, BIZ_TYPE).certificateExecute(request);
+                    alipayClientFactory.getClientByProvider(order.getServiceProviderId(), BIZ_TYPE).certificateExecute(request);
             if (!response.isSuccess()) {
                 // 关闭失败多为支付宝侧批次已不可关闭(如 INVALID 明细全部无效)——回写真实状态,
                 // 避免本地残留 INIT 让用户反复点关闭(用户实测: BATCH_ORDER_STATUS_INVALID)
-                syncBatchStatusFromAlipay(order, enterpriseId);
+                syncBatchStatusFromAlipay(order);
                 throw new BusinessException(400, "关闭批次失败: " + response.getMsg() + " (" + response.getSubCode() + ")");
             }
             order.setStatus("DISUSE");
@@ -547,7 +392,7 @@ public class AlipayBatchPayService {
     }
 
     /** 关闭失败后回写支付宝侧真实批次状态(detail.query),失败静默(不掩盖关闭错误本身) */
-    private void syncBatchStatusFromAlipay(BatchOrderEntity order, String enterpriseId) {
+    private void syncBatchStatusFromAlipay(BatchOrderEntity order) {
         try {
             AlipayFundBatchDetailQueryModel model = new AlipayFundBatchDetailQueryModel();
             model.setOutBatchNo(order.getOutBatchNo());
@@ -556,7 +401,7 @@ public class AlipayBatchPayService {
             AlipayFundBatchDetailQueryRequest request = new AlipayFundBatchDetailQueryRequest();
             request.setBizModel(model);
             AlipayFundBatchDetailQueryResponse response =
-                    alipayClientFactory.getClient(enterpriseId, BIZ_TYPE).certificateExecute(request);
+                    alipayClientFactory.getClientByProvider(order.getServiceProviderId(), BIZ_TYPE).certificateExecute(request);
             if (response.isSuccess() && response.getBatchStatus() != null
                     && !response.getBatchStatus().equals(order.getStatus())) {
                 order.setStatus(response.getBatchStatus());
@@ -585,20 +430,11 @@ public class AlipayBatchPayService {
 
     // ==================== 列表 / 详情 / 导出 ====================
 
-    /** 授权列表(分页) */
-    public PageResult<BatchAuthorizeEntity> authorizeList(String enterpriseId, int pageNo, int pageSize) {
-        var w = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
-                .eq(enterpriseId != null && !enterpriseId.isBlank(), BatchAuthorizeEntity::getEnterpriseId, enterpriseId)
-                .orderByDesc(BatchAuthorizeEntity::getId);
-        var r = batchAuthorizeMapper.selectPage(new Page<>(pageNo, pageSize), w);
-        return PageResult.of(pageNo, pageSize, r.getTotal(), r.getRecords());
-    }
-
-    /** 批次列表(分页,状态/时间筛选)— 时间解析照抄 AccountService.parseDateTime 模式 */
-    public PageResult<BatchOrderEntity> batchList(String enterpriseId, String status,
+    /** 批次列表(分页,付款主体/状态/时间筛选)— 时间解析照抄 AccountService.parseDateTime 模式 */
+    public PageResult<BatchOrderEntity> batchList(String participantId, String status,
             String startTime, String endTime, int pageNo, int pageSize) {
         var w = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
-                .eq(enterpriseId != null && !enterpriseId.isBlank(), BatchOrderEntity::getEnterpriseId, enterpriseId)
+                .eq(participantId != null && !participantId.isBlank(), BatchOrderEntity::getPayerUid, participantId)
                 .eq(status != null && !status.isBlank(), BatchOrderEntity::getStatus, status)
                 .orderByDesc(BatchOrderEntity::getId);
         if (startTime != null && !startTime.isBlank()) {
@@ -630,44 +466,11 @@ public class AlipayBatchPayService {
         }
     }
 
-    /** 企业校验 + 身份解析: 付款方/参与方支付宝身份 = 企业入驻身份 identity(配套 identityType),为空回退 enterprise_id(同 AlipayTransferService.createOnboard 惯例) */
-    private EnterpriseEntity requireEnterprise(String enterpriseId) {
-        requireEnterpriseId(enterpriseId);
-        EnterpriseEntity ent = enterpriseMapper.selectByEnterpriseIdIgnoreTenant(enterpriseId);
-        if (ent == null)
-            throw new BusinessException(400, "企业不存在");
-        return ent;
-    }
-
-    private static String payerIdentity(EnterpriseEntity ent) {
-        return ent.getIdentity() != null ? ent.getIdentity() : ent.getEnterpriseId();
-    }
-
-    private static String payerIdentityType(EnterpriseEntity ent) {
-        return ent.getIdentityType() != null ? ent.getIdentityType() : "ALIPAY_USER_ID";
-    }
-
-    /**
-     * 授权状态是否生效: 本地值域 AUTHED(已授权)与 NORMAL(生效中,支付宝授权签约通知回写的原始状态)均视同生效。
-     * 源头归一在 BatchPayHandler(通知 NORMAL → 落库 AUTHED),此处兼容存量 NORMAL 记录。
-     */
-    private static boolean isAuthorizedStatus(String status) {
-        return "AUTHED".equals(status) || "NORMAL".equals(status);
-    }
-
-    /** 租户隔离: 企业 ID 是业务必需参数,为空直接拒绝(防御 Controller body 路径的零校验) */
-    private static void requireEnterpriseId(String enterpriseId) {
-        if (enterpriseId == null || enterpriseId.isBlank())
-            throw new BusinessException(400, "缺少企业ID");
-    }
-
-    /** 批次详情 + 明细分页(enterprise_id 租户隔离,参照 batchList 过滤写法) */
-    public Map<String, Object> batchDetail(String enterpriseId, String outBatchNo, int pageNo, int pageSize) {
-        requireEnterpriseId(enterpriseId);
+    /** 批次详情 + 明细分页(租户隔离由拦截器自动追加) */
+    public Map<String, Object> batchDetail(String outBatchNo, int pageNo, int pageSize) {
         BatchOrderEntity order = batchOrderMapper.selectOne(
                 new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
-                        .eq(BatchOrderEntity::getOutBatchNo, outBatchNo)
-                        .eq(BatchOrderEntity::getEnterpriseId, enterpriseId));
+                        .eq(BatchOrderEntity::getOutBatchNo, outBatchNo));
         if (order == null) throw new BusinessException(404, "批次不存在");
         Page<BatchDetailEntity> detailPage = batchDetailMapper.selectPage(new Page<>(pageNo, pageSize),
                 new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchDetailEntity>()
@@ -678,10 +481,10 @@ public class AlipayBatchPayService {
     }
 
     /** 批次导出 — 照抄 AccountService.transferExport 的组装写法(ExcelUtil.exportToExcel(listData, mappingDict)) */
-    public byte[] batchExport(String enterpriseId, String status, String startTime, String endTime) {
+    public byte[] batchExport(String participantId, String status, String startTime, String endTime) {
         try {
             var w = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchOrderEntity>()
-                    .eq(enterpriseId != null && !enterpriseId.isBlank(), BatchOrderEntity::getEnterpriseId, enterpriseId)
+                    .eq(participantId != null && !participantId.isBlank(), BatchOrderEntity::getPayerUid, participantId)
                     .eq(status != null && !status.isBlank(), BatchOrderEntity::getStatus, status)
                     .orderByDesc(BatchOrderEntity::getId);
             if (startTime != null && !startTime.isBlank()) {

+ 340 - 0
java/src/main/java/com/payment/platform/module/payment/batch/service/BatchSubjectService.java

@@ -0,0 +1,340 @@
+package com.payment.platform.module.payment.batch.service;
+
+import com.alipay.api.AlipayApiException;
+import com.alipay.api.AlipayClient;
+import com.alipay.api.domain.AlipayFundAuthorizeUniApplyModel;
+import com.alipay.api.domain.AlipayFundAuthorizeUniQueryModel;
+import com.alipay.api.domain.AuthParticipantInfo;
+import com.alipay.api.request.AlipayFundAuthorizeUniApplyRequest;
+import com.alipay.api.request.AlipayFundAuthorizeUniQueryRequest;
+import com.alipay.api.request.AlipaySystemOauthTokenRequest;
+import com.alipay.api.request.AlipayUserInfoShareRequest;
+import com.alipay.api.response.AlipayFundAuthorizeUniApplyResponse;
+import com.alipay.api.response.AlipayFundAuthorizeUniQueryResponse;
+import com.alipay.api.response.AlipaySystemOauthTokenResponse;
+import com.alipay.api.response.AlipayUserInfoShareResponse;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.common.utils.SnowflakeIdGenerator;
+import com.payment.platform.core.alipay.AlipayClientFactory;
+import com.payment.platform.module.payment.batch.entity.BatchAuthorizeEntity;
+import com.payment.platform.module.payment.batch.mapper.BatchAuthorizeMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.OffsetDateTime;
+import java.util.Map;
+
+/**
+ * 账号级制单授权 — 授权主体(Subject)管理
+ * <p>
+ * 与企业管理功能隔离(spec D1/D6): 一个租户可维护多个授权主体(个人/企业支付宝账号均可签约),
+ * 主体参数全部来自表单(participant_name / participant_id / service_provider_id),不再从企业解析;
+ * client 按服务商直取({@link AlipayClientFactory#getClientByProvider(Long, String)});
+ * 租户隔离依赖 TenantInnerInterceptor 自动过滤(insert 自动填充 tenant_id,select 自动追加条件)。
+ * <p>
+ * 授权链路: apply 生成短链(PC) → 付款方端内授权(永久生效) →
+ * fund.authorize.status.notify 异步通知回写 agreement_no(BatchPayHandler 处理)。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class BatchSubjectService {
+
+    private static final String AUTHORIZE_PRODUCT_CODE = "TRANSFER_API_STANDARD_AUTHORIZATION";
+    private static final String AUTHORIZE_BIZ_SCENE = "STANDARD_CREATE_FUND_ORDER";
+    private static final String AUTHORIZE_LINK_TYPE = "SHORT_URL";
+    private static final String BIZ_TYPE = "BATCH_PAY";
+    /** 授权主体类型(AuthParticipantInfo.participantIdType 文档枚举) */
+    private static final String DEFAULT_PARTICIPANT_ID_TYPE = "ALIPAY_USER_ID";
+    private static final java.util.Set<String> PARTICIPANT_ID_TYPES = java.util.Set.of(
+            "ALIPAY_LOGON_ID", "ALIPAY_OPEN_ID", "ALIPAY_USER_ID");
+
+    /** openauth.alipay.com OAuth 授权(获取支付宝用户信息)— 个人账号(LOGON_ID)制单换 open_id 用 */
+    private static final String OAUTH_AUTHORIZE_URL = "https://openauth.alipay.com/oauth2/publicAppAuthorize.htm";
+    /** user.info.share 需 scope=auth_user(用户基本信息+身份核验信息) */
+    private static final String OAUTH_SCOPE = "auth_user";
+
+    private final AlipayClientFactory alipayClientFactory;
+    private final BatchAuthorizeMapper batchAuthorizeMapper;
+
+    /**
+     * 主体授权申请 — 表单主体参数(名称/uid/服务商),不再从企业解析(spec 5.1/D8)
+     * <p>
+     * 重复新增防护三态(迁移自原 doAuthorizeApply):
+     *   AUTHED            → 拒绝(已签约,对应支付宝 USER_AUTHORIZATION_EXIST: 前端提示直接选择使用)
+     *   AUTHING 未过期    → 拒绝(授权链接一次有效,提示先完成授权)
+     *   AUTHING 已过期    → 旧记录置 UNBIND,换新 out_biz_no 重新申请(对应 EXISTS_STOPPED_AUTHORIZE 语义)
+     * DB 兜底: uk_batch_authorize_active partial unique 索引 (tenant_id, participant_id) WHERE status <> 'UNBIND'
+     */
+    @Transactional
+    public Map<String, String> apply(String participantName, String participantId, String participantIdType, 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, "请选择服务商");
+        return doApply(participantName, participantId, normalizeParticipantIdType(participantIdType), serviceProviderId);
+    }
+
+    /** 主体类型归一: 空 → 默认支付宝账号(UID);非法值 → 拒绝 */
+    private String normalizeParticipantIdType(String participantIdType) {
+        if (participantIdType == null || participantIdType.isBlank())
+            return DEFAULT_PARTICIPANT_ID_TYPE;
+        if (!PARTICIPANT_ID_TYPES.contains(participantIdType))
+            throw new BusinessException(400, "主体类型非法: " + participantIdType);
+        return participantIdType;
+    }
+
+    /**
+     * 重新生成授权链接: 作废该主体非终态授权申请(AUTHING → UNBIND,旧链接失效),
+     * 换新 out_biz_no 重新申请(EXISTS_STOPPED_AUTHORIZE 语义)。AUTHED 为终态(永久生效授权)拒绝重新生成。
+     */
+    @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);
+        log.info("制单授权重新生成,作废旧申请: old_out_biz_no={}, participant_id={}",
+                existing.getOutBizNo(), existing.getParticipantId());
+        return doApply(existing.getParticipantName(), existing.getParticipantId(),
+                normalizeParticipantIdType(existing.getParticipantIdType()), existing.getServiceProviderId());
+    }
+
+    /** 授权申请公共逻辑(三态预检 + 调支付宝 + 落库),apply 与 rebind 共用 */
+    private Map<String, String> doApply(String participantName, String participantId, String participantIdType, Long serviceProviderId) {
+        // 重复预检: 同租户同主体非 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 {
+            AlipayFundAuthorizeUniApplyModel model = new AlipayFundAuthorizeUniApplyModel();
+            model.setProductCode(AUTHORIZE_PRODUCT_CODE);
+            model.setBizScene(AUTHORIZE_BIZ_SCENE);
+            model.setOutBizNo(outBizNo);
+            model.setAuthorizeLinkType(AUTHORIZE_LINK_TYPE);
+            model.setChannel("pc");
+            AuthParticipantInfo principal = new AuthParticipantInfo();
+            principal.setParticipantId(participantId);
+            principal.setParticipantIdType(participantIdType);
+            principal.setName(participantName);
+            model.setPrincipalInfo(principal);
+
+            AlipayFundAuthorizeUniApplyRequest request = new AlipayFundAuthorizeUniApplyRequest();
+            request.setBizModel(model);
+            AlipayClient client = alipayClientFactory.getClientByProvider(serviceProviderId, BIZ_TYPE);
+            AlipayFundAuthorizeUniApplyResponse response = client.certificateExecute(request);
+            if (!response.isSuccess())
+                throw new BusinessException(400, "生成授权链接失败: " + response.getMsg());
+
+            BatchAuthorizeEntity entity = new BatchAuthorizeEntity();
+            entity.setOutBizNo(outBizNo);
+            entity.setParticipantId(participantId);
+            entity.setParticipantIdType(participantIdType);
+            entity.setParticipantName(participantName);
+            entity.setServiceProviderId(serviceProviderId);
+            entity.setStatus("AUTHING");
+            entity.setAuthorizeLink(response.getAuthorizeLink());
+            try {
+                batchAuthorizeMapper.insert(entity);
+            } catch (DuplicateKeyException e) {
+                // 并发双击兜底: 同租户同主体已有非 UNBIND 授权记录,命中 uk_batch_authorize_active
+                throw new BusinessException(400, "该支付宝账号已存在授权申请,请勿重复操作");
+            }
+
+            return Map.of("authorize_link",
+                    response.getAuthorizeLink() != null ? response.getAuthorizeLink() : "",
+                    "out_biz_no", outBizNo, "status", "AUTHING");
+        } catch (AlipayApiException e) {
+            throw new BusinessException(400, "生成授权链接失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * alipay.fund.authorize.uni.query — 查询制单授权状态(单协议)
+     * <p>
+     * 本地记录按 out_biz_no 反查(client 解析依据 = 记录冗余的服务商),不存在时回退默认客户端
+     */
+    public Map<String, String> query(String outBizNo) {
+        BatchAuthorizeEntity record = batchAuthorizeMapper.selectOne(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
+                        .eq(BatchAuthorizeEntity::getOutBizNo, outBizNo));
+        AlipayClient client = record != null
+                ? alipayClientFactory.getClientByProvider(record.getServiceProviderId(), BIZ_TYPE)
+                : alipayClientFactory.getClient();
+        try {
+            AlipayFundAuthorizeUniQueryModel model = new AlipayFundAuthorizeUniQueryModel();
+            model.setProductCode(AUTHORIZE_PRODUCT_CODE);
+            model.setBizScene(AUTHORIZE_BIZ_SCENE);
+            model.setOutBizNo(outBizNo);
+
+            AlipayFundAuthorizeUniQueryRequest request = new AlipayFundAuthorizeUniQueryRequest();
+            request.setBizModel(model);
+            AlipayFundAuthorizeUniQueryResponse response = client.certificateExecute(request);
+            if (!response.isSuccess()) {
+                // 未授权(协议不存在)是正常业务状态而非查询失败: 前端应展示「授权中」而不是报错
+                if ("AUTHORIZATION_NOT_EXIST".equals(response.getSubCode())) {
+                    return Map.of("agreement_no", "", "status", "AUTHING");
+                }
+                throw new BusinessException(400, "查询授权状态失败: " + response.getMsg());
+            }
+
+            // 仅生效授权才回写(AUTHED/NORMAL 均视同生效,通知侧 NORMAL 已归一 AUTHED);
+            // UNBIND 也返回协议号, 直接回写会破坏本地状态机(UNBIND 由异步通知回写)
+            if (record != null && isAuthorizedStatus(response.getStatus())
+                    && response.getAgreementNo() != null && !response.getAgreementNo().isBlank()) {
+                record.setAgreementNo(response.getAgreementNo());
+                record.setStatus("AUTHED");
+                batchAuthorizeMapper.updateById(record);
+            }
+            return Map.of("agreement_no", response.getAgreementNo() != null ? response.getAgreementNo() : "",
+                    "status", response.getStatus() != null ? response.getStatus() : "AUTHING");
+        } catch (AlipayApiException e) {
+            throw new BusinessException(400, "查询授权状态失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 生成 user.info.share OAuth 授权链接(ALIPAY_LOGON_ID 主体扫码获取支付宝账号ID 制单用)
+     * <p>
+     * 链路: 主体本人支付宝授权 → 回跳支付宝白名单回调地址(aplipay/auth,redirect_uri 携带 out_biz_no,无登录态)→
+     * oauth.token 换 access_token → user.info.share 取支付宝账号ID(2088)→ 落库 → 302 回前端。
+     * 制单接口不接受 LOGON_ID(用户实证),LOGON_ID 主体必须先扫码获取支付宝ID;
+     * 本应用未开通 open_id 能力(响应实证),故不使用 open_id 体系。
+     * <p>
+     * oauthRedirectUri 为支付宝开放平台「授权回调地址」白名单配置(当面付已占用,只能配一个,
+     * openid 与 app_auth_code 授权共用该地址按参数分流)— 白名单前缀匹配,redirect_uri 可带 out_biz_no query。
+     */
+    public String openIdAuthorizeUrl(Long id, String oauthRedirectUri, String frontUrl) {
+        if (frontUrl == null || frontUrl.isBlank())
+            throw new BusinessException(400, "未配置前端跳转地址(alipay.oauth-front-url),无法生成授权链接");
+        if (oauthRedirectUri == null || oauthRedirectUri.isBlank())
+            throw new BusinessException(400, "未配置回调地址(alipay.oauth-redirect-uri),无法生成授权链接");
+        if (id == null) throw new BusinessException(400, "缺少授权记录ID");
+        BatchAuthorizeEntity entity = batchAuthorizeMapper.selectById(id);
+        if (entity == null) throw new BusinessException(404, "授权记录不存在");
+        if (!isAuthorizedStatus(entity.getStatus()))
+            throw new BusinessException(400, "仅已生效授权的主体可获取支付宝ID");
+        if (!"ALIPAY_LOGON_ID".equals(entity.getParticipantIdType()))
+            throw new BusinessException(400, "仅账号类型(手机号/邮箱)主体需要扫码获取支付宝ID,其他类型可直接制单");
+        String appId = alipayClientFactory.getAppIdByProvider(entity.getServiceProviderId(), BIZ_TYPE);
+        if (appId == null || appId.isBlank())
+            throw new BusinessException(400, "服务商未配置 app_id,无法生成授权链接");
+        String redirectUri = URLEncoder.encode(
+                oauthRedirectUri + "?out_biz_no=" + entity.getOutBizNo(),
+                StandardCharsets.UTF_8);
+        return OAUTH_AUTHORIZE_URL + "?app_id=" + appId + "&scope=" + OAUTH_SCOPE + "&redirect_uri=" + redirectUri;
+    }
+
+    /**
+     * OAuth 授权回调: auth_code 换 access_token → user.info.share 取支付宝账号ID(2088 user_id)→ 落库。
+     * <p>
+     * 注: 本应用未开通支付宝 open_id 能力(info.share 响应实证无 open_id 字段),
+     * 制单身份直接使用 user_id(ALIPAY_USER_ID 为制单接口标准支持)。
+     * 浏览器跳转无登录态,租户隔离由 TenantInnerInterceptor 条件表放行
+     * (out_biz_no 为雪花ID全局唯一,无跨租户风险,与授权签约通知同模式)。
+     */
+    @Transactional
+    public String openIdCallback(String authCode, String outBizNo) {
+        if (authCode == null || authCode.isBlank())
+            throw new BusinessException(400, "缺少授权码 auth_code");
+        if (outBizNo == null || outBizNo.isBlank())
+            throw new BusinessException(400, "缺少授权单号 out_biz_no");
+        BatchAuthorizeEntity entity = batchAuthorizeMapper.selectOne(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
+                        .eq(BatchAuthorizeEntity::getOutBizNo, outBizNo));
+        if (entity == null) throw new BusinessException(404, "授权记录不存在");
+        if (!"ALIPAY_LOGON_ID".equals(entity.getParticipantIdType()))
+            throw new BusinessException(400, "仅账号类型主体需获取支付宝ID");
+        try {
+            AlipayClient client = alipayClientFactory.getClientByProvider(entity.getServiceProviderId(), BIZ_TYPE);
+            // 1. oauth.token: 授权码换用户授权令牌(SDK 实证: code + grant_type=authorization_code)
+            AlipaySystemOauthTokenRequest tokenRequest = new AlipaySystemOauthTokenRequest();
+            tokenRequest.setCode(authCode);
+            tokenRequest.setGrantType("authorization_code");
+            AlipaySystemOauthTokenResponse tokenResponse = client.certificateExecute(tokenRequest);
+            if (!tokenResponse.isSuccess())
+                throw new BusinessException(400, "换取授权令牌失败: " + tokenResponse.getMsg());
+            String accessToken = tokenResponse.getAccessToken();
+            if (accessToken == null || accessToken.isBlank())
+                throw new BusinessException(400, "换取授权令牌失败: 未返回 access_token");
+            // 2. user.info.share: 令牌换用户信息取 user_id(2088;access_token 走 execute 参数,非 bizModel)
+            AlipayUserInfoShareRequest shareRequest = new AlipayUserInfoShareRequest();
+            AlipayUserInfoShareResponse shareResponse = client.certificateExecute(shareRequest, accessToken);
+            if (!shareResponse.isSuccess())
+                throw new BusinessException(400, "获取用户信息失败: " + shareResponse.getMsg());
+            String userId = shareResponse.getUserId();
+            // 兜底: oauth.token 响应同样返回 user_id(SDK 字段实证: user_id/alipay_user_id),info.share 未返回时回退
+            if (userId == null || userId.isBlank()) {
+                if (tokenResponse.getAlipayUserId() != null && !tokenResponse.getAlipayUserId().isBlank())
+                    userId = tokenResponse.getAlipayUserId();
+                else if (tokenResponse.getUserId() != null && !tokenResponse.getUserId().isBlank())
+                    userId = tokenResponse.getUserId();
+            }
+            if (userId == null || userId.isBlank()) {
+                // 实证日志: info.share 原始响应体(无敏感字段,排查 user_id 缺失)
+                log.info("授权回调响应排查: shareBody={}", shareResponse.getBody());
+                throw new BusinessException(400, "获取用户信息失败: 未返回支付宝账号ID");
+            }
+            entity.setAlipayUserId(userId);
+            batchAuthorizeMapper.updateById(entity);
+            log.info("获取支付宝ID成功: out_biz_no={}, user_id={}", outBizNo, userId);
+            return userId;
+        } catch (AlipayApiException e) {
+            throw new BusinessException(400, "获取支付宝ID失败: " + e.getMessage());
+        }
+    }
+
+    /** 主体授权列表(分页,主体可选筛选)— 租户隔离由拦截器自动追加,不显式传 tenant_id */
+    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());
+    }
+
+    /**
+     * 授权申请是否已过期: authorize_expire_time 非空且 > now 为未过期;
+     * 为空时按 created_time + 24h 判定(授权链接一次有效,逾期视为过期可重新申请)
+     */
+    private boolean isAuthorizeExpired(BatchAuthorizeEntity entity) {
+        OffsetDateTime now = OffsetDateTime.now();
+        if (entity.getAuthorizeExpireTime() != null)
+            return !entity.getAuthorizeExpireTime().isAfter(now);
+        if (entity.getCreatedTime() != null)
+            return entity.getCreatedTime().plusHours(24).isBefore(now);
+        return true;
+    }
+
+    /**
+     * 授权状态是否生效: 本地值域 AUTHED(已授权)与 NORMAL(生效中,支付宝授权签约通知回写的原始状态)均视同生效。
+     * 源头归一在 BatchPayHandler(通知 NORMAL → 落库 AUTHED),此处兼容存量 NORMAL 记录。
+     */
+    private static boolean isAuthorizedStatus(String status) {
+        return "AUTHED".equals(status) || "NORMAL".equals(status);
+    }
+}

+ 40 - 7
java/src/main/java/com/payment/platform/module/payment/facetoface/controller/AlipayAuthController.java

@@ -1,6 +1,9 @@
 package com.payment.platform.module.payment.facetoface.controller;
 
+import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.response.Result;
+import com.payment.platform.core.alipay.AlipayConfig;
+import com.payment.platform.module.payment.batch.service.BatchSubjectService;
 import com.payment.platform.module.payment.facetoface.service.FacetofaceService;
 import jakarta.servlet.http.HttpServletResponse;
 import lombok.RequiredArgsConstructor;
@@ -9,10 +12,15 @@ import org.springframework.stereotype.Controller;
 import org.springframework.web.bind.annotation.*;
 
 import java.io.IOException;
+import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 
 /**
  * 支付宝授权相关接口 — @Controller 统一用 sendRedirect
+ * <p>
+ * 该地址同时是支付宝开放平台「授权回调地址」白名单(只能配一个),openid 用户授权
+ * (publicAppAuthorize, scope=auth_user)与当面付 app_auth_code 授权共用,按参数互斥分流:
+ * auth_code 仅 openid 授权回跳携带,app_auth_code 仅当面付 ISV 授权携带。
  */
 @Slf4j
 @Controller
@@ -21,37 +29,62 @@ import java.nio.charset.StandardCharsets;
 public class AlipayAuthController {
 
     private final FacetofaceService service;
+    private final BatchSubjectService batchSubjectService;
+    private final AlipayConfig alipayConfig;
 
     private static final String FRONTEND_URL = "https://qcsj88888.com/#/payment/enterprise";
 
     /**
-     * 授权回调 — 支付宝 302 回跳,用 app_auth_code 换 token,完成后重定向回前端
+     * 授权回调 — 支付宝 302 回跳(无登录态,SecurityConfig 白名单放行):
+     * 携带 auth_code(+ redirect_uri 的 out_biz_no)→ openid 业务(batch 制单主体获取 OpenID);
+     * 携带 app_auth_code → 当面付服务商授权原流程。均完成后重定向回前端。
      */
     @GetMapping("/auth")
     public void auth(
-            @RequestParam("app_id") String appId,
-            @RequestParam("source") String source,
-            @RequestParam("state") String state,
-            @RequestParam("app_auth_code") String appAuthCode,
+            @RequestParam(value = "app_id", required = false) String appId,
+            @RequestParam(value = "source", required = false) String source,
+            @RequestParam(value = "state", required = false) String state,
+            @RequestParam(value = "app_auth_code", required = false) String appAuthCode,
+            @RequestParam(value = "auth_code", required = false) String authCode,
+            @RequestParam(value = "out_biz_no", required = false) String outBizNo,
             HttpServletResponse response) throws IOException {
 
+        // openid 业务: auth_code 仅用户授权(publicAppAuthorize)回跳携带,与 app_auth_code 互斥
+        if (authCode != null && !authCode.isBlank()) {
+            handleOpenIdCallback(authCode, outBizNo, response);
+            return;
+        }
+
         String redirect;
 
         log.info("收到支付宝授权回调: app_id={}, source={}, enterprise_id={}, code_prefix={}",
                 appId, source, state,
-                appAuthCode.length() > 10 ? appAuthCode.substring(0, 10) : appAuthCode);
+                appAuthCode != null && appAuthCode.length() > 10 ? appAuthCode.substring(0, 10) : appAuthCode);
 
         try {
             service.exchangeAppAuthCode(state, appId, appAuthCode);
             redirect = FRONTEND_URL + "?auth=success";
         } catch (Exception e) {
             log.error("授权回调处理失败: enterprise_id={}, error={}", state, e.getMessage());
-            redirect = FRONTEND_URL + "?auth=fail&msg=" + java.net.URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8);
+            redirect = FRONTEND_URL + "?auth=fail&msg=" + URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8);
         }
 
         response.sendRedirect(redirect);
     }
 
+    /** openid 授权回跳: auth_code 换 open_id 落库后 302 回前端(openid=success/fail 提示) */
+    private void handleOpenIdCallback(String authCode, String outBizNo, HttpServletResponse response) throws IOException {
+        String frontBase = alipayConfig.getOauthFrontUrl();
+        try {
+            batchSubjectService.openIdCallback(authCode, outBizNo);
+            response.sendRedirect(frontBase + "/#/payment/batch?openid=success");
+        } catch (BusinessException e) {
+            log.error("OpenID授权回调处理失败: out_biz_no={}, error={}", outBizNo, e.getMessage());
+            response.sendRedirect(frontBase + "/#/payment/batch?openid=fail&msg="
+                    + URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8));
+        }
+    }
+
     /**
      * 生成授权链接 — 返回支付宝授权 URL,由前端完成重定向
      */

+ 4 - 0
java/src/main/resources/application.yml

@@ -94,6 +94,10 @@ alipay:
   sandbox: false
   notify-url: ""
   return-url: ""
+  # OAuth 授权回调完成后的前端跳转地址(如 http://localhost:5180)— openid 授权 URL 生成时校验非空
+  oauth-front-url: "https://qcsj88888.com"
+  # OAuth 授权回调地址(完整 URL)— 支付宝开放平台「授权回调地址」白名单只能配一个,与当面付 app_auth_code 授权共用(按参数分流),须与白名单完全一致
+  oauth-redirect-uri: "https://qcsj88888.com/api/v1/payment/aplipay/auth"
   max-retries: 3
   request-timeout: 30
   rate-limit: 100

+ 27 - 0
java/src/main/resources/db/batch-account-level.sql

@@ -0,0 +1,27 @@
+-- 批量付款账号级改造(2026-08-26)
+-- 手工执行: psycopg2 连接 payment_platform_java 库
+-- 幂等: 已存在列/索引则跳过
+
+-- 1. 授权主体字段
+ALTER TABLE pay_batch_authorize
+    ADD COLUMN IF NOT EXISTS participant_name varchar(128),
+    ADD COLUMN IF NOT EXISTS service_provider_id bigint;
+
+-- 1b. 去企业化: 实体已无 enterprise_id 字段,INSERT 不含该列;原 NOT NULL 无默认值
+--     会导致授权申请/批次创建落库失败(null value in column "enterprise_id")→ 改为可空
+ALTER TABLE pay_batch_authorize ALTER COLUMN enterprise_id DROP NOT NULL;
+ALTER TABLE pay_batch_order     ALTER COLUMN enterprise_id DROP NOT NULL;
+
+-- 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;

+ 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());

+ 44 - 0
java/src/test/java/com/payment/platform/core/alipay/AlipayClientFactoryProfileTest.java

@@ -4,6 +4,7 @@ import com.alipay.api.AlipayClient;
 import com.payment.platform.module.payment.enterprise.entity.EnterpriseEntity;
 import com.payment.platform.module.payment.enterprise.mapper.EnterpriseMapper;
 import com.payment.platform.module.payment.openapi.mapper.OpenConfMapper;
+import com.payment.platform.module.payment.serviceprovider.entity.ServiceProviderEntity;
 import com.payment.platform.module.payment.serviceprovider.entity.ServiceProviderProfileEntity;
 import com.payment.platform.module.payment.serviceprovider.mapper.ServiceProviderMapper;
 import com.payment.platform.module.payment.serviceprovider.mapper.ServiceProviderProfileMapper;
@@ -17,7 +18,10 @@ import java.lang.reflect.Field;
 
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 /**
@@ -141,4 +145,44 @@ class AlipayClientFactoryProfileTest {
         assertNotNull(client);
         assertNull(readCertField(client));
     }
+
+    // ==================== getClientByProvider(providerId, bizType) — 账号级直取 ====================
+
+    @Test
+    void getClientByProvider_bizType_profileFirst() throws Exception {
+        // 账号级: providerId + bizType 命中 profile → 业务专属客户端(不再落到服务商默认)
+        when(profileMapper.selectOne(any())).thenReturn(profile(true, false));
+
+        AlipayClient client = factory.getClientByProvider(1L, "BATCH_PAY");
+
+        assertNotNull(client, "profile 命中时应创建业务专属客户端");
+        verify(serviceProviderMapper, never()).selectById(any());
+    }
+
+    @Test
+    void getClientByProvider_bizType_noProfile_fallsBackToProvider() throws Exception {
+        // 无 profile → 回退服务商默认凭证(cert 模式)
+        when(profileMapper.selectOne(any())).thenReturn(null);
+        ServiceProviderEntity sp = new ServiceProviderEntity();
+        sp.setId(1L);
+        sp.setProviderStatus("ACTIVE");
+        sp.setAppId("app-provider");
+        sp.setAppPrivateKey("PRIVATE_KEY");
+        sp.setAlipayPublicKey("PUBLIC_KEY");
+        sp.setAppCertContent(TEST_CERT_PEM);
+        sp.setAlipayPublicCertContent(TEST_CERT_PEM);
+        sp.setRootCertContent(TEST_CERT_PEM);
+        when(serviceProviderMapper.selectById(1L)).thenReturn(sp);
+
+        AlipayClient client = factory.getClientByProvider(1L, "BATCH_PAY");
+
+        assertNotNull(client, "无 profile 时应回退服务商默认凭证客户端");
+        verify(serviceProviderMapper).selectById(1L);
+    }
+
+    @Test
+    void getClientByProvider_nullProvider_throwsWithoutFallback() {
+        // providerId 为 null → getClient(): 无 yml 默认配置且无 ACTIVE 服务商 → 明确抛异常(不静默)
+        assertThrows(IllegalStateException.class, () -> factory.getClientByProvider(null, "BATCH_PAY"));
+    }
 }

+ 9 - 10
java/src/test/java/com/payment/platform/module/payment/batch/scheduler/BatchStatusPollSchedulerTest.java

@@ -27,9 +27,8 @@ class BatchStatusPollSchedulerTest {
         scheduler = new BatchStatusPollScheduler(batchPayService);
     }
 
-    private BatchOrderEntity batch(String enterpriseId, String outBatchNo) {
+    private BatchOrderEntity batch(String outBatchNo) {
         BatchOrderEntity b = new BatchOrderEntity();
-        b.setEnterpriseId(enterpriseId);
         b.setOutBatchNo(outBatchNo);
         return b;
     }
@@ -37,15 +36,15 @@ class BatchStatusPollSchedulerTest {
     @Test
     void pollPendingBatches_singleFailure_doesNotInterrupt() {
         // 照抄 F2fTradePollScheduler 模式: 单条异常 catch 记日志不中断
-        BatchOrderEntity b1 = batch("E1", "B1");
-        BatchOrderEntity b2 = batch("E2", "B2");
+        BatchOrderEntity b1 = batch("B1");
+        BatchOrderEntity b2 = batch("B2");
         when(batchPayService.getPendingBatches()).thenReturn(List.of(b1, b2));
-        doThrow(new BusinessException(400, "查询批次失败")).when(batchPayService).batchQuery(eq("E1"), eq("B1"));
+        doThrow(new BusinessException(400, "查询批次失败")).when(batchPayService).batchQuery(eq("B1"));
 
         assertDoesNotThrow(scheduler::pollPendingBatches);
 
         // 第二条仍被执行
-        verify(batchPayService).batchQuery("E2", "B2");
+        verify(batchPayService).batchQuery("B2");
     }
 
     @Test
@@ -54,16 +53,16 @@ class BatchStatusPollSchedulerTest {
 
         scheduler.pollPendingBatches();
 
-        verify(batchPayService, never()).batchQuery(any(), any());
+        verify(batchPayService, never()).batchQuery(any());
     }
 
     @Test
     void pollPendingBatches_allSuccess_pollsEach() {
-        when(batchPayService.getPendingBatches()).thenReturn(List.of(batch("E1", "B1"), batch("E1", "B2")));
+        when(batchPayService.getPendingBatches()).thenReturn(List.of(batch("B1"), batch("B2")));
 
         scheduler.pollPendingBatches();
 
-        verify(batchPayService).batchQuery("E1", "B1");
-        verify(batchPayService).batchQuery("E1", "B2");
+        verify(batchPayService).batchQuery("B1");
+        verify(batchPayService).batchQuery("B2");
     }
 }

+ 187 - 499
java/src/test/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayServiceTest.java

@@ -3,18 +3,12 @@ package com.payment.platform.module.payment.batch.service;
 import com.alipay.api.AlipayClient;
 import com.alipay.api.AlipayApiException;
 import com.alipay.api.domain.AccDetailModel;
-import com.alipay.api.domain.AlipayFundAuthorizeUniApplyModel;
-import com.alipay.api.domain.AlipayFundAuthorizeUniQueryModel;
 import com.alipay.api.domain.AlipayFundBatchCreateModel;
 import com.alipay.api.domain.AlipayFundTransRenderPayModel;
-import com.alipay.api.request.AlipayFundAuthorizeUniApplyRequest;
-import com.alipay.api.request.AlipayFundAuthorizeUniQueryRequest;
 import com.alipay.api.request.AlipayFundBatchCloseRequest;
 import com.alipay.api.request.AlipayFundBatchCreateRequest;
 import com.alipay.api.request.AlipayFundBatchDetailQueryRequest;
 import com.alipay.api.request.AlipayFundTransRenderPayRequest;
-import com.alipay.api.response.AlipayFundAuthorizeUniApplyResponse;
-import com.alipay.api.response.AlipayFundAuthorizeUniQueryResponse;
 import com.alipay.api.response.AlipayFundBatchCloseResponse;
 import com.alipay.api.response.AlipayFundBatchCreateResponse;
 import com.alipay.api.response.AlipayFundBatchDetailQueryResponse;
@@ -33,8 +27,6 @@ import com.payment.platform.module.payment.batch.entity.BatchOrderEntity;
 import com.payment.platform.module.payment.batch.mapper.BatchAuthorizeMapper;
 import com.payment.platform.module.payment.batch.mapper.BatchDetailMapper;
 import com.payment.platform.module.payment.batch.mapper.BatchOrderMapper;
-import com.payment.platform.module.payment.enterprise.entity.EnterpriseEntity;
-import com.payment.platform.module.payment.enterprise.mapper.EnterpriseMapper;
 import jakarta.validation.ConstraintViolation;
 import jakarta.validation.Validation;
 import jakarta.validation.Validator;
@@ -65,19 +57,15 @@ class AlipayBatchPayServiceTest {
     @Mock private BatchAuthorizeMapper batchAuthorizeMapper;
     @Mock private BatchOrderMapper batchOrderMapper;
     @Mock private BatchDetailMapper batchDetailMapper;
-    @Mock private EnterpriseMapper enterpriseMapper;
     private AlipayBatchPayService service;
 
     @BeforeEach
     void setUp() {
         service = new AlipayBatchPayService(alipayClientFactory, batchAuthorizeMapper, batchOrderMapper,
-                batchDetailMapper, enterpriseMapper);
-        // lenient: 重复授权预检测试用例在到达 getClient 前即抛异常,该 stub 不会被使用
-        lenient().when(alipayClientFactory.getClient("E100", "BATCH_PAY")).thenReturn(alipayClient);
-        // 付款方身份解析: E100 企业无 identity → 回退 enterprise_id(createOnboard 惯例);identity 有值用例单独 stub
-        EnterpriseEntity ent = new EnterpriseEntity();
-        ent.setEnterpriseId("E100");
-        lenient().when(enterpriseMapper.selectByEnterpriseIdIgnoreTenant("E100")).thenReturn(ent);
+                batchDetailMapper);
+        // lenient: 预检拦截/本地不存在的用例在到达 getClient 前即抛异常,该 stub 不会被使用
+        // 账号级: client 按订单冗余的 service_provider_id 解析(订单 mock 统一 setServiceProviderId(1L))
+        lenient().when(alipayClientFactory.getClientByProvider(1L, "BATCH_PAY")).thenReturn(alipayClient);
         // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在无 Spring 上下文的单测中工作
         MybatisConfiguration configuration = new MybatisConfiguration();
         TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), BatchOrderEntity.class);
@@ -85,96 +73,6 @@ class AlipayBatchPayServiceTest {
         TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), BatchAuthorizeEntity.class);
     }
 
-    @Test
-    void authorizeApply_returnsShortLinkAndPersists() throws AlipayApiException {
-        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
-        resp.setAuthorizeLink("https://ur.alipay.com/abc");
-        resp.setOutBizNo("A1");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.authorizeApply("E100");
-
-        assertEquals("https://ur.alipay.com/abc", result.get("authorize_link"));
-        assertEquals("AUTHING", result.get("status"));
-
-        ArgumentCaptor<AlipayFundAuthorizeUniApplyRequest> cap = ArgumentCaptor.forClass(AlipayFundAuthorizeUniApplyRequest.class);
-        verify(alipayClient).certificateExecute(cap.capture());
-        AlipayFundAuthorizeUniApplyModel m = (AlipayFundAuthorizeUniApplyModel) cap.getValue().getBizModel();
-        assertEquals("TRANSFER_API_STANDARD_AUTHORIZATION", m.getProductCode());
-        assertEquals("STANDARD_CREATE_FUND_ORDER", m.getBizScene());
-        assertEquals("SHORT_URL", m.getAuthorizeLinkType());
-        assertEquals("pc", m.getChannel());
-        // Ruling 19/22: 付款方恒为企业自身 UID(enterprise_id),不接受客户端指定
-        assertEquals("E100", m.getPrincipalInfo().getParticipantId());
-
-        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
-        verify(batchAuthorizeMapper).insert(ent.capture());
-        assertEquals("E100", ent.getValue().getEnterpriseId());
-        assertEquals("AUTHING", ent.getValue().getStatus());
-    }
-
-    @Test
-    void authorizeApply_failure_throwsBusinessException() throws AlipayApiException {
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class)))
-                .thenThrow(new AlipayApiException("network error"));
-
-        assertThrows(BusinessException.class, () -> service.authorizeApply("E100"));
-    }
-
-    @Test
-    void queryAuthorize_returnsAgreementNo() throws AlipayApiException {
-        AlipayFundAuthorizeUniQueryResponse resp = new AlipayFundAuthorizeUniQueryResponse();
-        resp.setAgreementNo("AGMT001");
-        resp.setStatus("AUTHED");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniQueryRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.queryAuthorize("E100", "A1");
-
-        assertEquals("AGMT001", result.get("agreement_no"));
-        assertEquals("AUTHED", result.get("status"));
-    }
-
-    @Test
-    void authorizeApply_existingAuthorization_throwsBusinessException() throws AlipayApiException {
-        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
-        existing.setEnterpriseId("E100");
-        existing.setParticipantId("2088123412341234");
-        existing.setStatus("AUTHED");
-        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
-
-        assertThrows(BusinessException.class, () -> service.authorizeApply("E100"));
-
-        verify(alipayClient, never()).certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class));
-        verify(batchAuthorizeMapper, never()).insert(any());
-    }
-
-    @Test
-    void authorizeApply_nullAuthorizeLink_returnsEmptyString() throws AlipayApiException {
-        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.authorizeApply("E100");
-
-        assertEquals("", result.get("authorize_link"));
-        assertEquals("AUTHING", result.get("status"));
-    }
-
-    @Test
-    void queryAuthorize_unbind_doesNotOverwriteLocalStatus() throws AlipayApiException {
-        AlipayFundAuthorizeUniQueryResponse resp = new AlipayFundAuthorizeUniQueryResponse();
-        resp.setAgreementNo("AGMT001");
-        resp.setStatus("UNBIND");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniQueryRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.queryAuthorize("E100", "A1");
-
-        assertEquals("AGMT001", result.get("agreement_no"));
-        assertEquals("UNBIND", result.get("status"));
-        // UNBIND 下协议号非空也不得回写本地状态
-        verify(batchAuthorizeMapper, never()).selectOne(any());
-        verify(batchAuthorizeMapper, never()).updateById(any());
-    }
-
     @Test
     void batchCreate_persistsOrderAndDetails() throws AlipayApiException {
         AlipayFundBatchCreateResponse resp = new AlipayFundBatchCreateResponse();
@@ -185,11 +83,12 @@ class AlipayBatchPayServiceTest {
         // 制单预检: 已有 AUTHED 授权(协议号由系统自动带出,不接受客户端指定)
         BatchAuthorizeEntity authed = new BatchAuthorizeEntity();
         authed.setStatus("AUTHED");
+        authed.setServiceProviderId(1L);
         authed.setAgreementNo("AGMT001");
         when(batchAuthorizeMapper.selectOne(any())).thenReturn(authed);
 
         BatchCreateDTO dto = new BatchCreateDTO();
-        dto.setEnterpriseId("E100");
+        dto.setParticipantId("2088111122223333");
         dto.setOutBatchNo("B1");
         dto.setOrderTitle("202608报销");
         dto.setTransferSceneName("佣金报酬");
@@ -270,11 +169,12 @@ class AlipayBatchPayServiceTest {
         when(batchOrderMapper.selectOne(any())).thenReturn(existing);
         BatchAuthorizeEntity authed = new BatchAuthorizeEntity();
         authed.setStatus("AUTHED");
+        authed.setServiceProviderId(1L);
         authed.setAgreementNo("AGMT001");
         when(batchAuthorizeMapper.selectOne(any())).thenReturn(authed);
 
         BatchCreateDTO dto = new BatchCreateDTO();
-        dto.setEnterpriseId("E100");
+        dto.setParticipantId("2088111122223333");
         dto.setOutBatchNo("B1");
         dto.setOrderTitle("t");
         BatchCreateDTO.BatchDetailDTO detail = new BatchCreateDTO.BatchDetailDTO();
@@ -297,7 +197,7 @@ class AlipayBatchPayServiceTest {
     void batchCreate_duplicateDetailOutBizNo_throwsBusinessException() throws AlipayApiException {
         // Ruling 5-2: 每笔 out_biz_no 批内唯一
         BatchCreateDTO dto = new BatchCreateDTO();
-        dto.setEnterpriseId("E100");
+        dto.setParticipantId("2088111122223333");
         dto.setOutBatchNo("B1");
         dto.setOrderTitle("t");
         BatchCreateDTO.BatchDetailDTO d1 = new BatchCreateDTO.BatchDetailDTO();
@@ -324,7 +224,7 @@ class AlipayBatchPayServiceTest {
     @Test
     void renderPay_returnsPayUrl() throws AlipayApiException {
         BatchOrderEntity order = new BatchOrderEntity();
-        order.setEnterpriseId("E100");
+        order.setServiceProviderId(1L);
         order.setOutBatchNo("B1");
         order.setBatchTransId("BT1");
         order.setStatus("INIT");
@@ -334,7 +234,7 @@ class AlipayBatchPayServiceTest {
         resp.setInitializeCode("https://p.tb.cn/_2PG4jfHMVvg9vqBUdtx1HZ");
         when(alipayClient.certificateExecute(any(AlipayFundTransRenderPayRequest.class))).thenReturn(resp);
 
-        Map<String, String> result = service.renderPay("E100", "B1");
+        Map<String, String> result = service.renderPay("B1");
 
         // render.pay 返回收银台短链接 initialize_code(用户实测成功响应实证),直接作为 pay_url
         assertEquals("https://p.tb.cn/_2PG4jfHMVvg9vqBUdtx1HZ", result.get("pay_url"));
@@ -356,7 +256,7 @@ class AlipayBatchPayServiceTest {
         // 支付宝业务失败(非 10000)必须抛错且不落库——
         // 否则错误响应会被当 pay_url 返回、前端拿错误内容开新窗口(用户实测: 窗口显示整页 INVALID_PARAMETER JSON)
         BatchOrderEntity order = new BatchOrderEntity();
-        order.setEnterpriseId("E100");
+        order.setServiceProviderId(1L);
         order.setOutBatchNo("B1");
         order.setBatchTransId("BT1");
         order.setStatus("INIT");
@@ -367,7 +267,7 @@ class AlipayBatchPayServiceTest {
         resp.setSubMsg("参数[target_terminal_type]错误, 原因: 必须传入参数[target_terminal_type]");
         when(alipayClient.certificateExecute(any(AlipayFundTransRenderPayRequest.class))).thenReturn(resp);
 
-        BusinessException ex = assertThrows(BusinessException.class, () -> service.renderPay("E100", "B1"));
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.renderPay("B1"));
 
         assertEquals(400, ex.getCode());
         assertTrue(ex.getMessage().contains("INVALID_PARAMETER"), ex.getMessage());
@@ -378,7 +278,7 @@ class AlipayBatchPayServiceTest {
     void renderPay_successWithoutInitializeCode_throws() throws AlipayApiException {
         // 成功响应但支付宝未返回跳转链接(防御: 不把 null 当 pay_url 返回)
         BatchOrderEntity order = new BatchOrderEntity();
-        order.setEnterpriseId("E100");
+        order.setServiceProviderId(1L);
         order.setOutBatchNo("B1");
         order.setBatchTransId("BT1");
         order.setStatus("INIT");
@@ -387,7 +287,7 @@ class AlipayBatchPayServiceTest {
         resp.setCode("10000");
         when(alipayClient.certificateExecute(any(AlipayFundTransRenderPayRequest.class))).thenReturn(resp);
 
-        BusinessException ex = assertThrows(BusinessException.class, () -> service.renderPay("E100", "B1"));
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.renderPay("B1"));
 
         assertEquals(400, ex.getCode());
         assertTrue(ex.getMessage().contains("跳转链接"), ex.getMessage());
@@ -398,7 +298,7 @@ class AlipayBatchPayServiceTest {
     void batchQuery_syncsBatchStatus() throws AlipayApiException {
         BatchOrderEntity order = new BatchOrderEntity();
         order.setId(1L);
-        order.setEnterpriseId("E100");
+        order.setServiceProviderId(1L);
         order.setOutBatchNo("B1");
         order.setBatchTransId("BT1");
         order.setStatus("INIT");
@@ -408,7 +308,7 @@ class AlipayBatchPayServiceTest {
         resp.setBatchStatus("SUCCESS");
         when(alipayClient.certificateExecute(any(AlipayFundBatchDetailQueryRequest.class))).thenReturn(resp);
 
-        Map<String, Object> result = service.batchQuery("E100", "B1");
+        Map<String, Object> result = service.batchQuery("B1");
 
         assertEquals("SUCCESS", result.get("status"));
         assertEquals("SUCCESS", order.getStatus());
@@ -418,7 +318,7 @@ class AlipayBatchPayServiceTest {
     @Test
     void batchClose_callsCloseApi() throws AlipayApiException {
         BatchOrderEntity order = new BatchOrderEntity();
-        order.setEnterpriseId("E100");
+        order.setServiceProviderId(1L);
         order.setOutBatchNo("B1");
         order.setBatchTransId("BT1");
         order.setStatus("INIT");
@@ -426,7 +326,7 @@ class AlipayBatchPayServiceTest {
         when(alipayClient.certificateExecute(any(AlipayFundBatchCloseRequest.class)))
                 .thenReturn(new AlipayFundBatchCloseResponse());
 
-        service.batchClose("E100", "B1");
+        service.batchClose("B1");
         verify(alipayClient).certificateExecute(any(AlipayFundBatchCloseRequest.class));
     }
 
@@ -434,11 +334,11 @@ class AlipayBatchPayServiceTest {
     void batchList_invalidTime_throwsBusinessException() {
         // Ruling 5-3: 非法时间输入应抛 400 而非 DateTimeParseException→500
         BusinessException ex = assertThrows(BusinessException.class,
-                () -> service.batchList("E100", null, "2026-13-45 99:99", null, 1, 20));
+                () -> service.batchList(null, null, "2026-13-45 99:99", null, 1, 20));
         assertEquals(400, ex.getCode());
 
         BusinessException ex2 = assertThrows(BusinessException.class,
-                () -> service.batchList("E100", null, null, "2026-08-25 25:61", 1, 20));
+                () -> service.batchList(null, null, null, "2026-08-25 25:61", 1, 20));
         assertEquals(400, ex2.getCode());
     }
 
@@ -446,26 +346,10 @@ class AlipayBatchPayServiceTest {
     void batchExport_invalidTime_throwsBusinessException() {
         // Ruling 5-3: 导出 catch-all 需区分 DateTimeParseException 抛 400
         BusinessException ex = assertThrows(BusinessException.class,
-                () -> service.batchExport("E100", null, "bad-time", null));
+                () -> service.batchExport(null, null, "bad-time", null));
         assertEquals(400, ex.getCode());
     }
 
-    @Test
-    void queryAuthorize_notExist_returnsAUTHINGInsteadOfError() throws AlipayApiException {
-        // Task 8 全链路验证: 支付宝 40004 AUTHORIZATION_NOT_EXIST(未授权)是正常业务状态而非查询失败
-        AlipayFundAuthorizeUniQueryResponse resp = new AlipayFundAuthorizeUniQueryResponse();
-        resp.setCode("40004");
-        resp.setSubCode("AUTHORIZATION_NOT_EXIST");
-        resp.setMsg("Business Failed");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniQueryRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.queryAuthorize("E100", "A1");
-
-        assertEquals("", result.get("agreement_no"));
-        assertEquals("AUTHING", result.get("status"));
-        verify(batchAuthorizeMapper, never()).updateById(any());
-    }
-
     @Test
     void batchCreate_authInfoNotExists_friendlyMessage() throws AlipayApiException {
         // Task 8 全链路验证: AUTH_INFO_NOT_EXISTS(付款方未授权)应提示明确动作而非透传支付宝原文案
@@ -477,11 +361,12 @@ class AlipayBatchPayServiceTest {
         // 本地预检通过(有 AUTHED 记录)后仍触发支付宝侧 AUTH_INFO_NOT_EXISTS 兜底路径
         BatchAuthorizeEntity authed = new BatchAuthorizeEntity();
         authed.setStatus("AUTHED");
+        authed.setServiceProviderId(1L);
         authed.setAgreementNo("AGMT001");
         when(batchAuthorizeMapper.selectOne(any())).thenReturn(authed);
 
         BatchCreateDTO dto = new BatchCreateDTO();
-        dto.setEnterpriseId("E100");
+        dto.setParticipantId("2088111122223333");
         dto.setOutBatchNo("B1");
         dto.setOrderTitle("202608报销");
         BatchCreateDTO.BatchDetailDTO detail = new BatchCreateDTO.BatchDetailDTO();
@@ -497,50 +382,50 @@ class AlipayBatchPayServiceTest {
         assertTrue(ex.getMessage().contains("制单授权"), "应提示先完成制单授权: " + ex.getMessage());
     }
 
-    // ==================== C2: 租户隔离(renderPay/batchQuery/batchClose 缺 enterpriseId 条件) ====================
+    // ==================== C2: 批次不存在(本地查不到 → 404,租户隔离由 TenantInnerInterceptor 自动追加) ====================
 
     @Test
-    void renderPay_otherEnterpriseBatch_notFound() {
-        // 他人批次: selectOne 带 enterpriseId 条件查不到 → 404
+    void renderPay_batchNotFound_throws404() {
+        // 本地批次不存在(他租户批次或被拦截器过滤)→ 404
         when(batchOrderMapper.selectOne(any())).thenReturn(null);
 
-        BusinessException ex = assertThrows(BusinessException.class, () -> service.renderPay("E999", "B1"));
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.renderPay("B1"));
 
         assertEquals(404, ex.getCode());
         @SuppressWarnings({ "unchecked", "rawtypes" })
         ArgumentCaptor<LambdaQueryWrapper<BatchOrderEntity>> cap = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
         verify(batchOrderMapper).selectOne(cap.capture());
-        assertTrue(cap.getValue().getSqlSegment().contains("enterprise_id"),
-                "renderPay 查询必须带 enterpriseId 条件: " + cap.getValue().getSqlSegment());
+        assertTrue(cap.getValue().getSqlSegment().contains("out_batch_no"),
+                "renderPay 查询必须按 out_batch_no: " + cap.getValue().getSqlSegment());
     }
 
     @Test
-    void batchQuery_otherEnterpriseBatch_notFound() {
+    void batchQuery_batchNotFound_throws404() {
         when(batchOrderMapper.selectOne(any())).thenReturn(null);
 
-        BusinessException ex = assertThrows(BusinessException.class, () -> service.batchQuery("E999", "B1"));
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.batchQuery("B1"));
 
         assertEquals(404, ex.getCode());
         @SuppressWarnings({ "unchecked", "rawtypes" })
         ArgumentCaptor<LambdaQueryWrapper<BatchOrderEntity>> cap = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
         verify(batchOrderMapper).selectOne(cap.capture());
-        assertTrue(cap.getValue().getSqlSegment().contains("enterprise_id"),
-                "batchQuery 查询必须带 enterpriseId 条件: " + cap.getValue().getSqlSegment());
+        assertTrue(cap.getValue().getSqlSegment().contains("out_batch_no"),
+                "batchQuery 查询必须按 out_batch_no: " + cap.getValue().getSqlSegment());
     }
 
     @Test
-    void batchClose_otherEnterpriseBatch_notFound() throws AlipayApiException {
+    void batchClose_batchNotFound_throws404() throws AlipayApiException {
         when(batchOrderMapper.selectOne(any())).thenReturn(null);
 
-        BusinessException ex = assertThrows(BusinessException.class, () -> service.batchClose("E999", "B1"));
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.batchClose("B1"));
 
         assertEquals(404, ex.getCode());
         verify(alipayClient, never()).certificateExecute(any());
         @SuppressWarnings({ "unchecked", "rawtypes" })
         ArgumentCaptor<LambdaQueryWrapper<BatchOrderEntity>> cap = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
         verify(batchOrderMapper).selectOne(cap.capture());
-        assertTrue(cap.getValue().getSqlSegment().contains("enterprise_id"),
-                "batchClose 查询必须带 enterpriseId 条件: " + cap.getValue().getSqlSegment());
+        assertTrue(cap.getValue().getSqlSegment().contains("out_batch_no"),
+                "batchClose 查询必须按 out_batch_no: " + cap.getValue().getSqlSegment());
     }
 
     // ==================== I1: 定时同步兜底(getPendingBatches) ====================
@@ -573,7 +458,7 @@ class AlipayBatchPayServiceTest {
         // 两个批次含相同 out_biz_no(out_biz_no 仅批内唯一)→ 不得抛 TooManyResultsException,且只回写本批次明细
         BatchOrderEntity order = new BatchOrderEntity();
         order.setId(1L);
-        order.setEnterpriseId("E100");
+        order.setServiceProviderId(1L);
         order.setOutBatchNo("B1");
         order.setBatchTransId("BT1");
         order.setStatus("INIT");
@@ -597,7 +482,7 @@ class AlipayBatchPayServiceTest {
         own.setStatus("INIT");
         when(batchDetailMapper.selectList(any())).thenReturn(List.of(own));
 
-        Map<String, Object> result = service.batchQuery("E100", "B1");
+        Map<String, Object> result = service.batchQuery("B1");
 
         assertEquals("SUCCESS", result.get("status"));
         assertEquals("SUCCESS", own.getStatus());
@@ -661,7 +546,7 @@ class AlipayBatchPayServiceTest {
     void batchList_pureDateRange_parsesToDayBoundaries() {
         when(batchOrderMapper.selectPage(any(), any())).thenReturn(new Page<>());
 
-        PageResult<BatchOrderEntity> result = service.batchList("E100", null, "2026-08-01", "2026-08-02", 1, 20);
+        PageResult<BatchOrderEntity> result = service.batchList(null, null, "2026-08-01", "2026-08-02", 1, 20);
 
         assertNotNull(result);
         verify(batchOrderMapper).selectPage(any(), any());
@@ -671,7 +556,7 @@ class AlipayBatchPayServiceTest {
     void batchExport_pureDateRange_parses() {
         when(batchOrderMapper.selectList(any())).thenReturn(List.of());
 
-        byte[] bytes = service.batchExport("E100", null, "2026-08-01", "2026-08-02");
+        byte[] bytes = service.batchExport(null, null, "2026-08-01", "2026-08-02");
 
         assertNotNull(bytes);
         assertTrue(bytes.length > 0);
@@ -682,7 +567,7 @@ class AlipayBatchPayServiceTest {
     @Test
     void renderPay_initializeCode_persistsAndReturns() throws AlipayApiException {
         BatchOrderEntity order = new BatchOrderEntity();
-        order.setEnterpriseId("E100");
+        order.setServiceProviderId(1L);
         order.setOutBatchNo("B1");
         order.setBatchTransId("BT1");
         order.setStatus("INIT");
@@ -694,280 +579,28 @@ class AlipayBatchPayServiceTest {
         resp.setInitializeCode("https://p.tb.cn/_2PG4jfHMVvg9vqBUdtx1HZ");
         when(alipayClient.certificateExecute(any(AlipayFundTransRenderPayRequest.class))).thenReturn(resp);
 
-        Map<String, String> result = service.renderPay("E100", "B1");
+        Map<String, String> result = service.renderPay("B1");
 
         assertEquals("https://p.tb.cn/_2PG4jfHMVvg9vqBUdtx1HZ", result.get("pay_url"));
         verify(batchOrderMapper).updateById(order);
     }
 
-    // ==================== M1: 授权并发 DB 兜底 ====================
-
-    @Test
-    void authorizeApply_insertDuplicateKey_throwsFriendlyMessage() throws AlipayApiException {
-        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
-        resp.setAuthorizeLink("https://ur.alipay.com/abc");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
-        // 并发双击: 预检通过后 insert 命中 uk_batch_authorize_active 唯一索引
-        doThrow(new DuplicateKeyException("duplicate key")).when(batchAuthorizeMapper).insert(any());
-
-        BusinessException ex = assertThrows(BusinessException.class,
-                () -> service.authorizeApply("E100"));
-
-        assertEquals(400, ex.getCode());
-        assertTrue(ex.getMessage().contains("请勿重复操作"), ex.getMessage());
-    }
-
-    // ==================== M2: AUTHING 过期重申请 ====================
-
-    @Test
-    void authorizeApply_authingNotExpired_throws() {
-        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
-        existing.setEnterpriseId("E100");
-        existing.setParticipantId("2088123412341234");
-        existing.setStatus("AUTHING");
-        existing.setAuthorizeExpireTime(OffsetDateTime.now().plusHours(1));
-        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
-
-        BusinessException ex = assertThrows(BusinessException.class,
-                () -> service.authorizeApply("E100"));
-
-        assertEquals(400, ex.getCode());
-        assertTrue(ex.getMessage().contains("未完成"), ex.getMessage());
-        verify(batchAuthorizeMapper, never()).updateById(any());
-        verify(batchAuthorizeMapper, never()).insert(any());
-    }
-
-    @Test
-    void authorizeApply_authingFreshCreatedTime_throws() {
-        // authorize_expire_time 为空时按 created_time + 24h 判定: 刚创建的 AUTHING 未过期 → 拒绝
-        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
-        existing.setEnterpriseId("E100");
-        existing.setParticipantId("2088123412341234");
-        existing.setStatus("AUTHING");
-        existing.setCreatedTime(OffsetDateTime.now());
-        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
-
-        assertThrows(BusinessException.class, () -> service.authorizeApply("E100"));
-    }
-
-    @Test
-    void authorizeApply_authingExpired_rebindWithNewOutBizNo() throws AlipayApiException {
-        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
-        existing.setId(1L);
-        existing.setEnterpriseId("E100");
-        existing.setParticipantId("2088123412341234");
-        existing.setOutBizNo("OLD1");
-        existing.setStatus("AUTHING");
-        existing.setAuthorizeExpireTime(OffsetDateTime.now().minusHours(1));
-        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
-
-        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
-        resp.setAuthorizeLink("https://ur.alipay.com/abc");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.authorizeApply("E100");
-
-        assertEquals("AUTHING", result.get("status"));
-        // 旧记录作废置 UNBIND(与 uk_batch_authorize_active 的 WHERE status <> 'UNBIND' 协同)
-        verify(batchAuthorizeMapper).updateById(argThat(e -> "UNBIND".equals(e.getStatus())));
-        // 新记录插入 + 新 out_biz_no
-        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
-        verify(batchAuthorizeMapper).insert(ent.capture());
-        assertEquals("AUTHING", ent.getValue().getStatus());
-        assertFalse(ent.getValue().getOutBizNo().equals("OLD1"), "应换新 out_biz_no 重新申请");
-        assertEquals("E100", ent.getValue().getEnterpriseId());
-        // 支付宝侧使用新 out_biz_no
-        ArgumentCaptor<AlipayFundAuthorizeUniApplyRequest> cap = ArgumentCaptor.forClass(AlipayFundAuthorizeUniApplyRequest.class);
-        verify(alipayClient).certificateExecute(cap.capture());
-        AlipayFundAuthorizeUniApplyModel m = (AlipayFundAuthorizeUniApplyModel) cap.getValue().getBizModel();
-        assertEquals(ent.getValue().getOutBizNo(), m.getOutBizNo());
-    }
-
-    @Test
-    void authorizeApply_authingExpiredByCreatedTimeFallback_allowsReapply() throws AlipayApiException {
-        // expire 为空 + created_time 超过 24h → 视为过期,允许重新申请
-        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
-        existing.setId(1L);
-        existing.setEnterpriseId("E100");
-        existing.setParticipantId("2088123412341234");
-        existing.setOutBizNo("OLD1");
-        existing.setStatus("AUTHING");
-        existing.setCreatedTime(OffsetDateTime.now().minusDays(2));
-        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
-
-        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
-        resp.setAuthorizeLink("https://ur.alipay.com/abc");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.authorizeApply("E100");
-
-        assertNotNull(result.get("authorize_link"));
-        verify(batchAuthorizeMapper).updateById(argThat(e -> "UNBIND".equals(e.getStatus())));
-        verify(batchAuthorizeMapper).insert(any());
-    }
-
-    // ==================== 用户反馈轮 2: 付款方 UID 自动带出 + 授权可重新生成 ====================
-
-    @Test
-    void authorizeApply_alwaysUsesEnterpriseAsPayer() throws AlipayApiException {
-        // Ruling 19/22: 付款方恒为企业自身 UID(enterprise_id),方法不接受客户端传入的 participant_id(防篡改指向他人账号)
-        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
-        resp.setAuthorizeLink("https://ur.alipay.com/abc");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.authorizeApply("E100");
-
-        assertEquals("AUTHING", result.get("status"));
-        ArgumentCaptor<AlipayFundAuthorizeUniApplyRequest> cap = ArgumentCaptor.forClass(AlipayFundAuthorizeUniApplyRequest.class);
-        verify(alipayClient).certificateExecute(cap.capture());
-        AlipayFundAuthorizeUniApplyModel m = (AlipayFundAuthorizeUniApplyModel) cap.getValue().getBizModel();
-        assertEquals("E100", m.getPrincipalInfo().getParticipantId());
-        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
-        verify(batchAuthorizeMapper).insert(ent.capture());
-        assertEquals("E100", ent.getValue().getParticipantId());
-    }
-
-    @Test
-    void authorizeApply_usesEnterpriseIdentityWhenPresent() throws AlipayApiException {
-        // Ruling 23: 企业入驻身份 identity 优先(配套 identityType),与 AlipayTransferService.createOnboard 惯例一致
-        EnterpriseEntity ent = new EnterpriseEntity();
-        ent.setEnterpriseId("E100");
-        ent.setIdentity("2088IDENTITY");
-        ent.setIdentityType("ALIPAY_OPEN_ID");
-        when(enterpriseMapper.selectByEnterpriseIdIgnoreTenant("E100")).thenReturn(ent);
-
-        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
-        resp.setAuthorizeLink("https://ur.alipay.com/abc");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.authorizeApply("E100");
-
-        assertEquals("AUTHING", result.get("status"));
-        ArgumentCaptor<AlipayFundAuthorizeUniApplyRequest> cap = ArgumentCaptor.forClass(AlipayFundAuthorizeUniApplyRequest.class);
-        verify(alipayClient).certificateExecute(cap.capture());
-        AlipayFundAuthorizeUniApplyModel m = (AlipayFundAuthorizeUniApplyModel) cap.getValue().getBizModel();
-        assertEquals("2088IDENTITY", m.getPrincipalInfo().getParticipantId());
-        assertEquals("ALIPAY_OPEN_ID", m.getPrincipalInfo().getParticipantIdType());
-        ArgumentCaptor<BatchAuthorizeEntity> entCap = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
-        verify(batchAuthorizeMapper).insert(entCap.capture());
-        assertEquals("2088IDENTITY", entCap.getValue().getParticipantId());
-    }
-
-    @Test
-    void authorizeApply_normalExisting_throws() throws AlipayApiException {
-        // NORMAL 为通知归一前落库的支付宝生效状态 — 视同 AUTHED,拒绝重复授权
-        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
-        existing.setEnterpriseId("E100");
-        existing.setParticipantId("2088123412341234");
-        existing.setStatus("NORMAL");
-        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
-
-        BusinessException ex = assertThrows(BusinessException.class, () -> service.authorizeApply("E100"));
-
-        assertEquals(400, ex.getCode());
-        verify(alipayClient, never()).certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class));
-        verify(batchAuthorizeMapper, never()).insert(any());
-    }
-
-    @Test
-    void authorizeApply_enterpriseNotFound_throws() throws AlipayApiException {
-        // requireEnterprise 防御: 企业不存在直接拒绝,不发起授权
-        when(enterpriseMapper.selectByEnterpriseIdIgnoreTenant("E100")).thenReturn(null);
-
-        BusinessException ex = assertThrows(BusinessException.class, () -> service.authorizeApply("E100"));
-
-        assertEquals(400, ex.getCode());
-        assertTrue(ex.getMessage().contains("企业不存在"), ex.getMessage());
-        verify(alipayClient, never()).certificateExecute(any());
-    }
-
-    @Test
-    void authorizeApply_nullEnterpriseId_throws() {
-        // requireEnterpriseId 防御: 企业都不存在时直接拒绝而非默认空串申请
-        assertThrows(BusinessException.class, () -> service.authorizeApply(null));
-    }
-
-    @Test
-    void authorizeRebind_noExisting_appliesFresh() 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.authorizeRebind("E100");
-
-        assertEquals("AUTHING", result.get("status"));
-        ArgumentCaptor<AlipayFundAuthorizeUniApplyRequest> cap = ArgumentCaptor.forClass(AlipayFundAuthorizeUniApplyRequest.class);
-        verify(alipayClient).certificateExecute(cap.capture());
-        AlipayFundAuthorizeUniApplyModel m = (AlipayFundAuthorizeUniApplyModel) cap.getValue().getBizModel();
-        assertEquals("E100", m.getPrincipalInfo().getParticipantId());
-        verify(batchAuthorizeMapper, never()).updateById(any());
-    }
-
-    @Test
-    void authorizeRebind_authingExisting_unbindsAndReapplies() throws AlipayApiException {
-        // 最新 AUTHING 记录 → 置 UNBIND(旧链接作废)→ 换新 out_biz_no 重新申请
-        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
-        existing.setId(1L);
-        existing.setEnterpriseId("E100");
-        existing.setParticipantId("E100");
-        existing.setOutBizNo("OLD1");
-        existing.setStatus("AUTHING");
-        // 序列 stub: rebind 预检命中旧记录 → 作废后 doAuthorizeApply 的二次预检(ne UNBIND)应返回 null
-        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing, null);
-
-        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
-        resp.setAuthorizeLink("https://ur.alipay.com/abc");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.authorizeRebind("E100");
-
-        assertEquals("AUTHING", result.get("status"));
-        verify(batchAuthorizeMapper).updateById(argThat(e -> "UNBIND".equals(e.getStatus()) && "OLD1".equals(e.getOutBizNo())));
-        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
-        verify(batchAuthorizeMapper).insert(ent.capture());
-        assertFalse(ent.getValue().getOutBizNo().equals("OLD1"), "应换新 out_biz_no 重新申请");
-        assertEquals("E100", ent.getValue().getParticipantId());
-    }
-
     @Test
-    void authorizeRebind_authedExisting_throws() throws AlipayApiException {
-        // AUTHED 是终态(永久生效授权),重新生成无意义 → 拒绝
-        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
-        existing.setId(1L);
-        existing.setEnterpriseId("E100");
-        existing.setParticipantId("E100");
-        existing.setOutBizNo("OLD1");
-        existing.setStatus("AUTHED");
-        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
-
-        BusinessException ex = assertThrows(BusinessException.class, () -> service.authorizeRebind("E100"));
-
-        assertEquals(400, ex.getCode());
-        assertTrue(ex.getMessage().contains("生效授权"), ex.getMessage());
-        verify(alipayClient, never()).certificateExecute(any());
-        verify(batchAuthorizeMapper, never()).updateById(any());
-        verify(batchAuthorizeMapper, never()).insert(any());
-    }
-
-    @Test
-    void authorizeRebind_blankEnterpriseId_throws() {
-        assertThrows(BusinessException.class, () -> service.authorizeRebind(""));
-    }
-
-    @Test
-    void batchCreate_alwaysUsesEnterpriseAsPayer() throws AlipayApiException {
-        // Ruling 19/22: 付款方恒为企业自身 UID(enterprise_id),DTO 已移除 payer_uid 不接受客户端指定
+    void batchCreate_alwaysUsesAuthorizedSubjectAsPayer() throws AlipayApiException {
+        // 账号级: 付款方恒为 DTO 选定的授权主体(participant_id),协议号由授权记录自动带出,不接受客户端指定
         AlipayFundBatchCreateResponse resp = new AlipayFundBatchCreateResponse();
         resp.setOutBatchNo("B1");
         when(alipayClient.certificateExecute(any(AlipayFundBatchCreateRequest.class))).thenReturn(resp);
         BatchAuthorizeEntity authed = new BatchAuthorizeEntity();
+        authed.setParticipantId("2088111122223333");
+        authed.setServiceProviderId(1L);
         authed.setStatus("AUTHED");
+        authed.setServiceProviderId(1L);
         authed.setAgreementNo("AGMT001");
         when(batchAuthorizeMapper.selectOne(any())).thenReturn(authed);
 
         BatchCreateDTO dto = new BatchCreateDTO();
-        dto.setEnterpriseId("E100");
+        dto.setParticipantId("2088111122223333");
         dto.setOutBatchNo("B1");
         dto.setOrderTitle("t");
         BatchCreateDTO.BatchDetailDTO detail = new BatchCreateDTO.BatchDetailDTO();
@@ -984,11 +617,13 @@ class AlipayBatchPayServiceTest {
         ArgumentCaptor<AlipayFundBatchCreateRequest> cap = ArgumentCaptor.forClass(AlipayFundBatchCreateRequest.class);
         verify(alipayClient).certificateExecute(cap.capture());
         AlipayFundBatchCreateModel m = (AlipayFundBatchCreateModel) cap.getValue().getBizModel();
-        assertEquals("E100", m.getPayerInfo().getIdentity());
-        // DB 回写 payer_uid 恒为企业自身 UID
+        assertEquals("2088111122223333", m.getPayerInfo().getIdentity());
+        assertEquals("ALIPAY_USER_ID", m.getPayerInfo().getIdentityType());
+        // DB 回写 payer_uid 恒为授权主体 UID + 冗余服务商(client 解析依据)
         ArgumentCaptor<BatchOrderEntity> order = ArgumentCaptor.forClass(BatchOrderEntity.class);
         verify(batchOrderMapper).insert(order.capture());
-        assertEquals("E100", order.getValue().getPayerUid());
+        assertEquals("2088111122223333", order.getValue().getPayerUid());
+        assertEquals(1L, order.getValue().getServiceProviderId());
     }
 
     @Test
@@ -997,7 +632,7 @@ class AlipayBatchPayServiceTest {
         when(batchAuthorizeMapper.selectOne(any())).thenReturn(null);
 
         BatchCreateDTO dto = new BatchCreateDTO();
-        dto.setEnterpriseId("E100");
+        dto.setParticipantId("2088111122223333");
         dto.setOutBatchNo("B1");
         dto.setOrderTitle("t");
         BatchCreateDTO.BatchDetailDTO detail = new BatchCreateDTO.BatchDetailDTO();
@@ -1025,11 +660,12 @@ class AlipayBatchPayServiceTest {
         when(alipayClient.certificateExecute(any(AlipayFundBatchCreateRequest.class))).thenReturn(resp);
         BatchAuthorizeEntity authed = new BatchAuthorizeEntity();
         authed.setStatus("NORMAL");
+        authed.setServiceProviderId(1L);
         authed.setAgreementNo("AGMT001");
         when(batchAuthorizeMapper.selectOne(any())).thenReturn(authed);
 
         BatchCreateDTO dto = new BatchCreateDTO();
-        dto.setEnterpriseId("E100");
+        dto.setParticipantId("2088111122223333");
         dto.setOutBatchNo("B1");
         dto.setOrderTitle("t");
         BatchCreateDTO.BatchDetailDTO detail = new BatchCreateDTO.BatchDetailDTO();
@@ -1046,55 +682,17 @@ class AlipayBatchPayServiceTest {
         verify(alipayClient).certificateExecute(any(AlipayFundBatchCreateRequest.class));
     }
 
-    @Test
-    void batchCreate_authorizedIdentity_proceedsWithIdentityAsPayer() throws AlipayApiException {
-        // 反馈轮 4/5 协同: identity 有值时预检与付款方均用 identity(而非 enterprise_id)
-        EnterpriseEntity ent = new EnterpriseEntity();
-        ent.setEnterpriseId("E100");
-        ent.setIdentity("2088IDENTITY");
-        ent.setIdentityType("ALIPAY_OPEN_ID");
-        when(enterpriseMapper.selectByEnterpriseIdIgnoreTenant("E100")).thenReturn(ent);
-        AlipayFundBatchCreateResponse resp = new AlipayFundBatchCreateResponse();
-        resp.setOutBatchNo("B1");
-        when(alipayClient.certificateExecute(any(AlipayFundBatchCreateRequest.class))).thenReturn(resp);
-        BatchAuthorizeEntity authed = new BatchAuthorizeEntity();
-        authed.setStatus("AUTHED");
-        authed.setAgreementNo("AGMT001");
-        when(batchAuthorizeMapper.selectOne(any())).thenReturn(authed);
-
-        BatchCreateDTO dto = new BatchCreateDTO();
-        dto.setEnterpriseId("E100");
-        dto.setOutBatchNo("B1");
-        dto.setOrderTitle("t");
-        BatchCreateDTO.BatchDetailDTO detail = new BatchCreateDTO.BatchDetailDTO();
-        detail.setOutBizNo("D1");
-        detail.setAmount(new BigDecimal("10"));
-        detail.setPayeeIdentity("a@b.com");
-        detail.setPayeeIdentityType("ALIPAY_LOGON_ID");
-        detail.setPayeeName("张三");
-        dto.setDetails(List.of(detail));
-
-        Map<String, Object> result = service.batchCreate(dto);
-
-        assertEquals("B1", result.get("out_batch_no"));
-        ArgumentCaptor<AlipayFundBatchCreateRequest> cap = ArgumentCaptor.forClass(AlipayFundBatchCreateRequest.class);
-        verify(alipayClient).certificateExecute(cap.capture());
-        AlipayFundBatchCreateModel m = (AlipayFundBatchCreateModel) cap.getValue().getBizModel();
-        assertEquals("2088IDENTITY", m.getPayerInfo().getIdentity());
-        assertEquals("ALIPAY_OPEN_ID", m.getPayerInfo().getIdentityType());
-    }
-
     // ==================== M3: batchClose INIT/WAIT_PAY 守卫 ====================
 
     @Test
     void batchClose_nonInitStatus_throwsBusinessException() throws AlipayApiException {
         BatchOrderEntity order = new BatchOrderEntity();
-        order.setEnterpriseId("E100");
+        order.setServiceProviderId(1L);
         order.setOutBatchNo("B1");
         order.setStatus("SUCCESS");
         when(batchOrderMapper.selectOne(any())).thenReturn(order);
 
-        BusinessException ex = assertThrows(BusinessException.class, () -> service.batchClose("E100", "B1"));
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.batchClose("B1"));
 
         assertEquals(400, ex.getCode());
         assertTrue(ex.getMessage().contains("可关闭"), ex.getMessage());
@@ -1105,7 +703,7 @@ class AlipayBatchPayServiceTest {
     void batchClose_waitPayStatus_callsCloseApi() throws AlipayApiException {
         // WAIT_PAY=等待支付(render 过链接未支付)应可关闭(用户实测: 本地被同步成 WAIT_PAY 后关闭按钮消失)
         BatchOrderEntity order = new BatchOrderEntity();
-        order.setEnterpriseId("E100");
+        order.setServiceProviderId(1L);
         order.setOutBatchNo("B1");
         order.setBatchTransId("BT1");
         order.setStatus("WAIT_PAY");
@@ -1113,7 +711,7 @@ class AlipayBatchPayServiceTest {
         when(alipayClient.certificateExecute(any(AlipayFundBatchCloseRequest.class)))
                 .thenReturn(new AlipayFundBatchCloseResponse());
 
-        Map<String, String> result = service.batchClose("E100", "B1");
+        Map<String, String> result = service.batchClose("B1");
 
         assertEquals("DISUSE", result.get("status"));
         verify(alipayClient).certificateExecute(any(AlipayFundBatchCloseRequest.class));
@@ -1124,7 +722,7 @@ class AlipayBatchPayServiceTest {
         // 关闭失败(支付宝侧批次已不可关闭,用户实测 BATCH_ORDER_STATUS_INVALID)→
         // detail.query 回写真实状态到本地,避免残留 INIT 让用户反复点关闭
         BatchOrderEntity order = new BatchOrderEntity();
-        order.setEnterpriseId("E100");
+        order.setServiceProviderId(1L);
         order.setOutBatchNo("B1");
         order.setBatchTransId("BT1");
         order.setStatus("INIT");
@@ -1139,7 +737,7 @@ class AlipayBatchPayServiceTest {
         queryResp.setBatchStatus("INVALID");
         when(alipayClient.certificateExecute(any(AlipayFundBatchDetailQueryRequest.class))).thenReturn(queryResp);
 
-        BusinessException ex = assertThrows(BusinessException.class, () -> service.batchClose("E100", "B1"));
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.batchClose("B1"));
 
         assertEquals(400, ex.getCode());
         assertTrue(ex.getMessage().contains("BATCH_ORDER_STATUS_INVALID"), ex.getMessage());
@@ -1152,7 +750,7 @@ class AlipayBatchPayServiceTest {
     void renderPay_waitPayStatus_regeneratesPayUrl() throws AlipayApiException {
         // WAIT_PAY=等待支付(render 过链接未支付)应可重新生成支付链接(用户实测: 不能二次发起支付)
         BatchOrderEntity order = new BatchOrderEntity();
-        order.setEnterpriseId("E100");
+        order.setServiceProviderId(1L);
         order.setOutBatchNo("B1");
         order.setBatchTransId("BT1");
         order.setStatus("WAIT_PAY");
@@ -1162,43 +760,12 @@ class AlipayBatchPayServiceTest {
         resp.setInitializeCode("https://p.tb.cn/_2PG4jfHMVvg9vqBUdtx1HZ");
         when(alipayClient.certificateExecute(any(AlipayFundTransRenderPayRequest.class))).thenReturn(resp);
 
-        Map<String, String> result = service.renderPay("E100", "B1");
+        Map<String, String> result = service.renderPay("B1");
 
         assertEquals("https://p.tb.cn/_2PG4jfHMVvg9vqBUdtx1HZ", result.get("pay_url"));
         verify(batchOrderMapper).updateById(order);
     }
 
-    // ==================== 二轮复核修复: 空 enterpriseId 拒绝(条件式 eq 的空值漏洞) ====================
-
-    @Test
-    void renderPay_blankEnterpriseId_throws() {
-        // enterprise_id 为空必须拒绝而非静默跳过租户过滤(防御 /pay body 零校验路径)
-        BusinessException ex = assertThrows(BusinessException.class, () -> service.renderPay("", "B1"));
-        assertEquals(400, ex.getCode());
-        verify(batchOrderMapper, never()).selectOne(any());
-    }
-
-    @Test
-    void batchQuery_nullEnterpriseId_throws() {
-        BusinessException ex = assertThrows(BusinessException.class, () -> service.batchQuery(null, "B1"));
-        assertEquals(400, ex.getCode());
-        verify(batchOrderMapper, never()).selectOne(any());
-    }
-
-    @Test
-    void batchClose_blankEnterpriseId_throws() {
-        BusinessException ex = assertThrows(BusinessException.class, () -> service.batchClose(" ", "B1"));
-        assertEquals(400, ex.getCode());
-        verify(batchOrderMapper, never()).selectOne(any());
-    }
-
-    @Test
-    void batchDetail_nullEnterpriseId_throws() {
-        BusinessException ex = assertThrows(BusinessException.class, () -> service.batchDetail(null, "B1", 1, 20));
-        assertEquals(400, ex.getCode());
-        verify(batchOrderMapper, never()).selectOne(any());
-    }
-
     // ==================== 二轮复核修复: parseTimeFilter 边界精确断言 ====================
 
     @Test
@@ -1221,4 +788,125 @@ class AlipayBatchPayServiceTest {
         assertEquals(OffsetDateTime.parse("2026-08-25T10:30:00+08:00"),
                 AlipayBatchPayService.parseTimeFilter("2026-08-25 10:30", true));
     }
+
+    // ==================== 个人账号(LOGON_ID): 制单付款方映射(扫码授权获取 2088 userId) ====================
+
+    private static BatchAuthorizeEntity authed(String idType, String alipayUserId) {
+        BatchAuthorizeEntity e = new BatchAuthorizeEntity();
+        e.setParticipantIdType(idType);
+        e.setAlipayUserId(alipayUserId);
+        return e;
+    }
+
+    @Test
+    void resolvePayerIdentity_logonId_withUserId_mapsToUserId() {
+        // LOGON_ID(手机号/邮箱)→ 制单时用扫码授权获取的 2088 user_id + ALIPAY_USER_ID
+        // (制单接口不接受 LOGON_ID,用户实证;本应用未开通 open_id 能力,故用 user_id)
+        Map<String, String> r = AlipayBatchPayService.resolvePayerIdentity(authed("ALIPAY_LOGON_ID", "2088122583917741"), "18812345678");
+        assertEquals("2088122583917741", r.get("identity"));
+        assertEquals("ALIPAY_USER_ID", r.get("identity_type"));
+    }
+
+    @Test
+    void resolvePayerIdentity_logonId_withoutUserId_rejected() {
+        // 未获取支付宝ID → 本地拦截(提示先获取),空串存量脏数据同样拦截
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> AlipayBatchPayService.resolvePayerIdentity(authed("ALIPAY_LOGON_ID", null), "18812345678"));
+        assertEquals(400, e.getCode());
+        assertTrue(e.getMessage().contains("支付宝ID"), e.getMessage());
+        assertThrows(BusinessException.class,
+                () -> AlipayBatchPayService.resolvePayerIdentity(authed("ALIPAY_LOGON_ID", "  "), "18812345678"));
+    }
+
+    @Test
+    void resolvePayerIdentity_userId_mapsToUid() {
+        Map<String, String> r = AlipayBatchPayService.resolvePayerIdentity(authed("ALIPAY_USER_ID", null), "2088111122223333");
+        assertEquals("2088111122223333", r.get("identity"));
+        assertEquals("ALIPAY_USER_ID", r.get("identity_type"));
+    }
+
+    @Test
+    void resolvePayerIdentity_openIdType_mapsToParticipantId() {
+        Map<String, String> r = AlipayBatchPayService.resolvePayerIdentity(authed("ALIPAY_OPEN_ID", null), "openid-abc");
+        assertEquals("openid-abc", r.get("identity"));
+        assertEquals("ALIPAY_OPEN_ID", r.get("identity_type"));
+    }
+
+    @Test
+    void resolvePayerIdentity_nullIdType_defaultsToUserId() {
+        // 存量记录 participant_id_type 为空 → 按支付宝账号ID处理(与旧行为一致)
+        Map<String, String> r = AlipayBatchPayService.resolvePayerIdentity(authed(null, null), "2088111122223333");
+        assertEquals("2088111122223333", r.get("identity"));
+        assertEquals("ALIPAY_USER_ID", r.get("identity_type"));
+    }
+
+    @Test
+    void batchCreate_logonIdWithoutUserId_throwsFriendlyMessage() throws AlipayApiException {
+        // 全链路: LOGON_ID 主体未获取支付宝ID → 制单被本地拦截,不调支付宝
+        BatchAuthorizeEntity authed = new BatchAuthorizeEntity();
+        authed.setStatus("AUTHED");
+        authed.setParticipantIdType("ALIPAY_LOGON_ID");
+        authed.setServiceProviderId(1L);
+        authed.setAgreementNo("AGMT001");
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(authed);
+
+        BatchCreateDTO dto = new BatchCreateDTO();
+        dto.setParticipantId("18812345678");
+        dto.setOutBatchNo("B1");
+        dto.setOrderTitle("t");
+        BatchCreateDTO.BatchDetailDTO detail = new BatchCreateDTO.BatchDetailDTO();
+        detail.setOutBizNo("D1");
+        detail.setAmount(new BigDecimal("10"));
+        detail.setPayeeIdentity("a@b.com");
+        detail.setPayeeIdentityType("ALIPAY_LOGON_ID");
+        detail.setPayeeName("张三");
+        dto.setDetails(List.of(detail));
+
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.batchCreate(dto));
+
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMessage().contains("支付宝ID"), ex.getMessage());
+        verify(alipayClient, never()).certificateExecute(any());
+        verify(batchOrderMapper, never()).insert(any());
+    }
+
+    @Test
+    void batchCreate_logonIdWithUserId_usesUserIdIdentity() throws AlipayApiException {
+        // 全链路: LOGON_ID 主体已获取支付宝ID → 付款方 identity=2088 user_id + ALIPAY_USER_ID
+        AlipayFundBatchCreateResponse resp = new AlipayFundBatchCreateResponse();
+        resp.setOutBatchNo("B1");
+        when(alipayClient.certificateExecute(any(AlipayFundBatchCreateRequest.class))).thenReturn(resp);
+        BatchAuthorizeEntity authed = new BatchAuthorizeEntity();
+        authed.setStatus("AUTHED");
+        authed.setParticipantIdType("ALIPAY_LOGON_ID");
+        authed.setAlipayUserId("2088122583917741");
+        authed.setServiceProviderId(1L);
+        authed.setAgreementNo("AGMT001");
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(authed);
+
+        BatchCreateDTO dto = new BatchCreateDTO();
+        dto.setParticipantId("18812345678");
+        dto.setOutBatchNo("B1");
+        dto.setOrderTitle("t");
+        BatchCreateDTO.BatchDetailDTO detail = new BatchCreateDTO.BatchDetailDTO();
+        detail.setOutBizNo("D1");
+        detail.setAmount(new BigDecimal("10"));
+        detail.setPayeeIdentity("a@b.com");
+        detail.setPayeeIdentityType("ALIPAY_LOGON_ID");
+        detail.setPayeeName("张三");
+        dto.setDetails(List.of(detail));
+
+        Map<String, Object> result = service.batchCreate(dto);
+
+        assertEquals("B1", result.get("out_batch_no"));
+        ArgumentCaptor<AlipayFundBatchCreateRequest> cap = ArgumentCaptor.forClass(AlipayFundBatchCreateRequest.class);
+        verify(alipayClient).certificateExecute(cap.capture());
+        AlipayFundBatchCreateModel m = (AlipayFundBatchCreateModel) cap.getValue().getBizModel();
+        assertEquals("2088122583917741", m.getPayerInfo().getIdentity());
+        assertEquals("ALIPAY_USER_ID", m.getPayerInfo().getIdentityType());
+        // DB 回写 payer_uid 恒为表单选择的主体账号(非 2088,与列表筛选口径一致)
+        ArgumentCaptor<BatchOrderEntity> order = ArgumentCaptor.forClass(BatchOrderEntity.class);
+        verify(batchOrderMapper).insert(order.capture());
+        assertEquals("18812345678", order.getValue().getPayerUid());
+    }
 }

+ 602 - 0
java/src/test/java/com/payment/platform/module/payment/batch/service/BatchSubjectServiceTest.java

@@ -0,0 +1,602 @@
+package com.payment.platform.module.payment.batch.service;
+
+import com.alipay.api.AlipayApiException;
+import com.alipay.api.AlipayClient;
+import com.alipay.api.domain.AlipayFundAuthorizeUniApplyModel;
+import com.alipay.api.domain.AlipayFundAuthorizeUniQueryModel;
+import com.alipay.api.request.AlipayFundAuthorizeUniApplyRequest;
+import com.alipay.api.request.AlipayFundAuthorizeUniQueryRequest;
+import com.alipay.api.request.AlipaySystemOauthTokenRequest;
+import com.alipay.api.request.AlipayUserInfoShareRequest;
+import com.alipay.api.response.AlipayFundAuthorizeUniApplyResponse;
+import com.alipay.api.response.AlipayFundAuthorizeUniQueryResponse;
+import com.alipay.api.response.AlipaySystemOauthTokenResponse;
+import com.alipay.api.response.AlipayUserInfoShareResponse;
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.core.alipay.AlipayClientFactory;
+import com.payment.platform.module.payment.batch.entity.BatchAuthorizeEntity;
+import com.payment.platform.module.payment.batch.mapper.BatchAuthorizeMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * 账号级制单授权 — BatchSubjectService
+ * <p>
+ * 与 AlipayBatchPayService 授权用例同构(迁移自 AlipayBatchPayServiceTest),差异:
+ * 主体来自表单(participant_name/participant_id/service_provider_id),不再从企业解析;
+ * client 按服务商直取(getClientByProvider),租户隔离依赖 TenantInnerInterceptor(测试不 mock)。
+ */
+@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: 预检拦截用例在到达 getClient 前即抛异常,该 stub 不会被使用
+        lenient().when(alipayClientFactory.getClientByProvider(1L, "BATCH_PAY")).thenReturn(alipayClient);
+        // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在无 Spring 上下文的单测中工作
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), BatchAuthorizeEntity.class);
+    }
+
+    /** 查询用例公共: 本地授权记录(含服务商,client 解析依据) */
+    private void mockLocalRecord() {
+        BatchAuthorizeEntity rec = new BatchAuthorizeEntity();
+        rec.setId(5L);
+        rec.setOutBizNo("A1");
+        rec.setServiceProviderId(1L);
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(rec);
+    }
+
+    @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", "ALIPAY_USER_ID", 1L);
+
+        assertEquals("https://ur.alipay.com/abc", result.get("authorize_link"));
+        assertEquals("AUTHING", result.get("status"));
+
+        ArgumentCaptor<AlipayFundAuthorizeUniApplyRequest> cap = ArgumentCaptor.forClass(AlipayFundAuthorizeUniApplyRequest.class);
+        verify(alipayClient).certificateExecute(cap.capture());
+        AlipayFundAuthorizeUniApplyModel m = (AlipayFundAuthorizeUniApplyModel) cap.getValue().getBizModel();
+        assertEquals("TRANSFER_API_STANDARD_AUTHORIZATION", m.getProductCode());
+        assertEquals("STANDARD_CREATE_FUND_ORDER", m.getBizScene());
+        assertEquals("SHORT_URL", m.getAuthorizeLinkType());
+        assertEquals("pc", m.getChannel());
+        // 账号级: 主体来自表单,非企业身份
+        assertEquals("2088111122223333", m.getPrincipalInfo().getParticipantId());
+        assertEquals("ALIPAY_USER_ID", m.getPrincipalInfo().getParticipantIdType());
+        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("2088111122223333", ent.getValue().getParticipantId());
+        assertEquals("AUTHING", ent.getValue().getStatus());
+        verify(alipayClientFactory).getClientByProvider(1L, "BATCH_PAY");
+    }
+
+    @Test
+    void apply_usesCustomParticipantIdType() throws AlipayApiException {
+        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
+        resp.setAuthorizeLink("https://ur.alipay.com/custom");
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
+
+        service.apply("张三公司", "18812345678", "ALIPAY_LOGON_ID", 1L);
+
+        ArgumentCaptor<AlipayFundAuthorizeUniApplyRequest> cap = ArgumentCaptor.forClass(AlipayFundAuthorizeUniApplyRequest.class);
+        verify(alipayClient).certificateExecute(cap.capture());
+        AlipayFundAuthorizeUniApplyModel m = (AlipayFundAuthorizeUniApplyModel) cap.getValue().getBizModel();
+        assertEquals("ALIPAY_LOGON_ID", m.getPrincipalInfo().getParticipantIdType());
+
+        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
+        verify(batchAuthorizeMapper).insert(ent.capture());
+        assertEquals("ALIPAY_LOGON_ID", ent.getValue().getParticipantIdType());
+    }
+
+    @Test
+    void apply_nullParticipantIdType_defaultsToUserId() throws AlipayApiException {
+        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
+        resp.setAuthorizeLink("https://ur.alipay.com/abc");
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
+
+        service.apply("张三公司", "2088111122223333", null, 1L);
+
+        ArgumentCaptor<AlipayFundAuthorizeUniApplyRequest> cap = ArgumentCaptor.forClass(AlipayFundAuthorizeUniApplyRequest.class);
+        verify(alipayClient).certificateExecute(cap.capture());
+        AlipayFundAuthorizeUniApplyModel m = (AlipayFundAuthorizeUniApplyModel) cap.getValue().getBizModel();
+        assertEquals("ALIPAY_USER_ID", m.getPrincipalInfo().getParticipantIdType());
+    }
+
+    @Test
+    void apply_invalidParticipantIdType_throws() throws AlipayApiException {
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.apply("张三公司", "2088111122223333", "BOGUS_TYPE", 1L));
+
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMessage().contains("主体类型"), ex.getMessage());
+        verify(alipayClient, never()).certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class));
+        verify(batchAuthorizeMapper, never()).insert(any());
+    }
+
+    @Test
+    void rebind_reusesExistingParticipantIdType() throws AlipayApiException {
+        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
+        existing.setId(9L);
+        existing.setOutBizNo("OLD-2");
+        existing.setParticipantName("张三公司");
+        existing.setParticipantId("2088111122223333");
+        existing.setParticipantIdType("ALIPAY_OPEN_ID");
+        existing.setServiceProviderId(1L);
+        existing.setStatus("AUTHING");
+        when(batchAuthorizeMapper.selectById(9L)).thenReturn(existing);
+        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
+        resp.setAuthorizeLink("https://ur.alipay.com/new");
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
+
+        service.rebind(9L);
+
+        ArgumentCaptor<AlipayFundAuthorizeUniApplyRequest> cap = ArgumentCaptor.forClass(AlipayFundAuthorizeUniApplyRequest.class);
+        verify(alipayClient).certificateExecute(cap.capture());
+        AlipayFundAuthorizeUniApplyModel m = (AlipayFundAuthorizeUniApplyModel) cap.getValue().getBizModel();
+        assertEquals("ALIPAY_OPEN_ID", m.getPrincipalInfo().getParticipantIdType());
+        // 新申请落库沿用原主体类型
+        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
+        verify(batchAuthorizeMapper).insert(ent.capture());
+        assertEquals("ALIPAY_OPEN_ID", ent.getValue().getParticipantIdType());
+    }
+
+    @Test
+    void apply_failure_throwsBusinessException() throws AlipayApiException {
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class)))
+                .thenThrow(new AlipayApiException("network error"));
+
+        assertThrows(BusinessException.class, () -> service.apply("张三公司", "2088111122223333", "ALIPAY_USER_ID", 1L));
+    }
+
+    @Test
+    void apply_missingParticipantId_throwsBusinessException() {
+        assertThrows(BusinessException.class, () -> service.apply("张三公司", " ", "ALIPAY_USER_ID", 1L));
+        assertThrows(BusinessException.class, () -> service.apply("张三公司", null, "ALIPAY_USER_ID", 1L));
+    }
+
+    @Test
+    void apply_missingParticipantName_throwsBusinessException() {
+        assertThrows(BusinessException.class, () -> service.apply(" ", "2088111122223333", "ALIPAY_USER_ID", 1L));
+    }
+
+    @Test
+    void apply_missingServiceProvider_throwsBusinessException() {
+        assertThrows(BusinessException.class, () -> service.apply("张三公司", "2088111122223333", "ALIPAY_USER_ID", null));
+    }
+
+    @Test
+    void apply_existingAuthedSubject_throwsBusinessException() throws AlipayApiException {
+        // USER_AUTHORIZATION_EXIST: 同租户同主体已签约 → 拒绝重复发起
+        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
+        existing.setParticipantId("2088111122223333");
+        existing.setStatus("AUTHED");
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
+
+        assertThrows(BusinessException.class, () -> service.apply("张三公司", "2088111122223333", "ALIPAY_USER_ID", 1L));
+
+        verify(alipayClient, never()).certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class));
+        verify(batchAuthorizeMapper, never()).insert(any());
+    }
+
+    @Test
+    void apply_expiredAuthing_revokesAndReapplies() throws AlipayApiException {
+        // AUTHING 已过期(created + 24h)→ 旧记录置 UNBIND,换新 out_biz_no 重新申请(EXISTS_STOPPED_AUTHORIZE 语义)
+        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
+        existing.setId(3L);
+        existing.setOutBizNo("OLD-1");
+        existing.setParticipantId("2088111122223333");
+        existing.setStatus("AUTHING");
+        existing.setCreatedTime(java.time.OffsetDateTime.now().minusHours(25));
+        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.apply("张三公司", "2088111122223333", "ALIPAY_USER_ID", 1L);
+
+        verify(batchAuthorizeMapper).updateById(argThat(e -> "UNBIND".equals(e.getStatus()) && "OLD-1".equals(e.getOutBizNo())));
+        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
+        verify(batchAuthorizeMapper).insert(ent.capture());
+        assertNotEquals("OLD-1", ent.getValue().getOutBizNo());
+    }
+
+    @Test
+    void apply_nullAuthorizeLink_returnsEmptyString() throws AlipayApiException {
+        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
+
+        Map<String, String> result = service.apply("张三公司", "2088111122223333", "ALIPAY_USER_ID", 1L);
+
+        assertEquals("", result.get("authorize_link"));
+        assertEquals("AUTHING", result.get("status"));
+    }
+
+    @Test
+    void rebind_invalidatesOldAuthingAndReapplies() throws AlipayApiException {
+        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
+        existing.setId(9L);
+        existing.setOutBizNo("OLD-2");
+        existing.setParticipantName("张三公司");
+        existing.setParticipantId("2088111122223333");
+        existing.setServiceProviderId(1L);
+        existing.setStatus("AUTHING");
+        when(batchAuthorizeMapper.selectById(9L)).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())));
+        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
+        verify(batchAuthorizeMapper).insert(ent.capture());
+        assertNotEquals("OLD-2", ent.getValue().getOutBizNo());
+        assertEquals("张三公司", ent.getValue().getParticipantName());
+    }
+
+    @Test
+    void rebind_authedRecord_throwsBusinessException() {
+        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
+        existing.setId(9L);
+        existing.setStatus("AUTHED");
+        when(batchAuthorizeMapper.selectById(9L)).thenReturn(existing);
+
+        assertThrows(BusinessException.class, () -> service.rebind(9L));
+        verify(batchAuthorizeMapper, never()).insert(any());
+    }
+
+    @Test
+    void rebind_notFound_throwsBusinessException() {
+        when(batchAuthorizeMapper.selectById(9L)).thenReturn(null);
+
+        assertThrows(BusinessException.class, () -> service.rebind(9L));
+        verify(batchAuthorizeMapper, never()).insert(any());
+    }
+
+    @Test
+    void query_returnsAgreementNo() throws AlipayApiException {
+        mockLocalRecord();
+        AlipayFundAuthorizeUniQueryResponse resp = new AlipayFundAuthorizeUniQueryResponse();
+        resp.setAgreementNo("AGMT001");
+        resp.setStatus("AUTHED");
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniQueryRequest.class))).thenReturn(resp);
+
+        Map<String, String> result = service.query("A1");
+
+        assertEquals("AGMT001", result.get("agreement_no"));
+        assertEquals("AUTHED", result.get("status"));
+        // 生效授权回写本地: 协议号 + AUTHED
+        ArgumentCaptor<BatchAuthorizeEntity> cap = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
+        verify(batchAuthorizeMapper).updateById(cap.capture());
+        assertEquals("AGMT001", cap.getValue().getAgreementNo());
+        assertEquals("AUTHED", cap.getValue().getStatus());
+    }
+
+    @Test
+    void query_unbind_doesNotOverwriteLocalStatus() throws AlipayApiException {
+        mockLocalRecord();
+        AlipayFundAuthorizeUniQueryResponse resp = new AlipayFundAuthorizeUniQueryResponse();
+        resp.setAgreementNo("AGMT001");
+        resp.setStatus("UNBIND");
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniQueryRequest.class))).thenReturn(resp);
+
+        Map<String, String> result = service.query("A1");
+
+        assertEquals("AGMT001", result.get("agreement_no"));
+        assertEquals("UNBIND", result.get("status"));
+        // UNBIND 下协议号非空也不得回写本地状态(UNBIND 由异步通知回写,防破坏状态机)
+        verify(batchAuthorizeMapper, never()).updateById(any());
+    }
+
+    @Test
+    void query_notExist_returnsAUTHINGInsteadOfError() throws AlipayApiException {
+        // 支付宝 40004 AUTHORIZATION_NOT_EXIST(未授权)是正常业务状态而非查询失败
+        mockLocalRecord();
+        AlipayFundAuthorizeUniQueryResponse resp = new AlipayFundAuthorizeUniQueryResponse();
+        resp.setCode("40004");
+        resp.setSubCode("AUTHORIZATION_NOT_EXIST");
+        resp.setMsg("Business Failed");
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniQueryRequest.class))).thenReturn(resp);
+
+        Map<String, String> result = service.query("A1");
+
+        assertEquals("", result.get("agreement_no"));
+        assertEquals("AUTHING", result.get("status"));
+        verify(batchAuthorizeMapper, never()).updateById(any());
+    }
+
+    @Test
+    void apply_insertDuplicateKey_throwsFriendlyMessage() throws AlipayApiException {
+        // M1 并发兜底: 预检通过后 insert 命中 uk_batch_authorize_active 唯一索引
+        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
+        resp.setAuthorizeLink("https://ur.alipay.com/abc");
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
+        doThrow(new org.springframework.dao.DuplicateKeyException("duplicate key")).when(batchAuthorizeMapper).insert(any());
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.apply("张三公司", "2088111122223333", "ALIPAY_USER_ID", 1L));
+
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMessage().contains("请勿重复操作"), ex.getMessage());
+    }
+
+    @Test
+    void apply_authingNotExpired_throws() throws AlipayApiException {
+        // M2: AUTHING 未过期(authorize_expire_time 在未来)→ 拒绝
+        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
+        existing.setParticipantId("2088111122223333");
+        existing.setStatus("AUTHING");
+        existing.setAuthorizeExpireTime(java.time.OffsetDateTime.now().plusHours(1));
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.apply("张三公司", "2088111122223333", "ALIPAY_USER_ID", 1L));
+
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMessage().contains("未完成"), ex.getMessage());
+        verify(batchAuthorizeMapper, never()).updateById(any());
+        verify(batchAuthorizeMapper, never()).insert(any());
+    }
+
+    @Test
+    void apply_authingFreshCreatedTime_throws() {
+        // authorize_expire_time 为空时按 created_time + 24h 判定: 刚创建的 AUTHING 未过期 → 拒绝
+        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
+        existing.setParticipantId("2088111122223333");
+        existing.setStatus("AUTHING");
+        existing.setCreatedTime(java.time.OffsetDateTime.now());
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
+
+        assertThrows(BusinessException.class, () -> service.apply("张三公司", "2088111122223333", "ALIPAY_USER_ID", 1L));
+    }
+
+    @Test
+    void apply_normalExisting_throws() throws AlipayApiException {
+        // NORMAL 为通知归一前落库的支付宝生效状态 — 视同 AUTHED,拒绝重复授权
+        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
+        existing.setParticipantId("2088111122223333");
+        existing.setStatus("NORMAL");
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.apply("张三公司", "2088111122223333", "ALIPAY_USER_ID", 1L));
+
+        assertEquals(400, ex.getCode());
+        verify(alipayClient, never()).certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class));
+        verify(batchAuthorizeMapper, never()).insert(any());
+    }
+
+    @Test
+    void list_filtersByParticipantId() {
+        BatchAuthorizeEntity r1 = new BatchAuthorizeEntity();
+        r1.setId(1L);
+        r1.setParticipantId("2088111122223333");
+        Page<BatchAuthorizeEntity> page = new Page<>(1, 10);
+        page.setRecords(List.of(r1));
+        page.setTotal(1);
+        when(batchAuthorizeMapper.selectPage(any(), any())).thenReturn(page);
+
+        PageResult<BatchAuthorizeEntity> result = service.list("2088111122223333", 1, 10);
+
+        assertEquals(1, result.getTotal());
+        assertEquals(1, result.getList().size());
+        assertEquals("2088111122223333", result.getList().get(0).getParticipantId());
+    }
+
+    // ==================== openid 授权(个人账号获取 OpenID 制单) ====================
+
+    /** openid 用例公共: AUTHED + LOGON_ID 本地授权记录(selectById 与 selectOne 均返回) */
+    private void mockLogonAuthorizedRecord() {
+        BatchAuthorizeEntity rec = new BatchAuthorizeEntity();
+        rec.setId(6L);
+        rec.setOutBizNo("A2");
+        rec.setServiceProviderId(1L);
+        rec.setParticipantId("18812345678");
+        rec.setParticipantIdType("ALIPAY_LOGON_ID");
+        rec.setStatus("AUTHED");
+        // lenient: 各用例只触发其一(openIdAuthorizeUrl 用 selectById,openIdCallback 用 selectOne)
+        lenient().when(batchAuthorizeMapper.selectById(6L)).thenReturn(rec);
+        lenient().when(batchAuthorizeMapper.selectOne(any())).thenReturn(rec);
+    }
+
+    @Test
+    void openIdAuthorizeUrl_logonId_returnsOAuthUrl() {
+        mockLogonAuthorizedRecord();
+        when(alipayClientFactory.getAppIdByProvider(1L, "BATCH_PAY")).thenReturn("2021000000000001");
+
+        // oauthRedirectUri = 支付宝授权回调白名单地址(aplipay/auth 复用),拼 out_biz_no 后整段编码
+        String url = service.openIdAuthorizeUrl(6L, "https://qcsj88888.com/api/v1/payment/aplipay/auth", "http://localhost:5180");
+
+        assertTrue(url.startsWith("https://openauth.alipay.com/oauth2/publicAppAuthorize.htm?app_id=2021000000000001"));
+        assertTrue(url.contains("scope=auth_user"));
+        assertTrue(url.contains("redirect_uri=" + java.net.URLEncoder.encode(
+                "https://qcsj88888.com/api/v1/payment/aplipay/auth?out_biz_no=A2",
+                java.nio.charset.StandardCharsets.UTF_8)));
+        verify(alipayClientFactory).getAppIdByProvider(1L, "BATCH_PAY");
+    }
+
+    @Test
+    void openIdAuthorizeUrl_frontUrlBlank_rejected() {
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.openIdAuthorizeUrl(6L, "https://qcsj88888.com/api/v1/payment/aplipay/auth", ""));
+        assertEquals(400, e.getCode());
+    }
+
+    @Test
+    void openIdAuthorizeUrl_redirectUriBlank_rejected() {
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.openIdAuthorizeUrl(6L, "", "http://localhost:5180"));
+        assertEquals(400, e.getCode());
+    }
+
+    @Test
+    void openIdAuthorizeUrl_nonLogonId_rejected() {
+        BatchAuthorizeEntity rec = new BatchAuthorizeEntity();
+        rec.setId(7L);
+        rec.setParticipantIdType("ALIPAY_USER_ID");
+        rec.setStatus("AUTHED");
+        when(batchAuthorizeMapper.selectById(7L)).thenReturn(rec);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.openIdAuthorizeUrl(7L, "https://qcsj88888.com/api/v1/payment/aplipay/auth", "http://localhost:5180"));
+        assertEquals(400, e.getCode());
+    }
+
+    @Test
+    void openIdAuthorizeUrl_notAuthed_rejected() {
+        BatchAuthorizeEntity rec = new BatchAuthorizeEntity();
+        rec.setId(8L);
+        rec.setParticipantIdType("ALIPAY_LOGON_ID");
+        rec.setStatus("AUTHING");
+        when(batchAuthorizeMapper.selectById(8L)).thenReturn(rec);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.openIdAuthorizeUrl(8L, "https://qcsj88888.com/api/v1/payment/aplipay/auth", "http://localhost:5180"));
+        assertEquals(400, e.getCode());
+    }
+
+    @Test
+    void openIdCallback_tokenAndInfoShare_persistsUserId() throws AlipayApiException {
+        mockLogonAuthorizedRecord();
+        AlipaySystemOauthTokenResponse tokenResp = new AlipaySystemOauthTokenResponse();
+        tokenResp.setAccessToken("at-123");
+        when(alipayClient.certificateExecute(any(AlipaySystemOauthTokenRequest.class))).thenReturn(tokenResp);
+        AlipayUserInfoShareResponse shareResp = new AlipayUserInfoShareResponse();
+        shareResp.setUserId("2088122583917741");
+        when(alipayClient.certificateExecute(any(AlipayUserInfoShareRequest.class), eq("at-123"))).thenReturn(shareResp);
+
+        String userId = service.openIdCallback("auth-code-1", "A2");
+
+        assertEquals("2088122583917741", userId);
+        // oauth.token: auth_code + grant_type=authorization_code 换令牌
+        ArgumentCaptor<AlipaySystemOauthTokenRequest> cap = ArgumentCaptor.forClass(AlipaySystemOauthTokenRequest.class);
+        verify(alipayClient).certificateExecute(cap.capture());
+        assertEquals("auth-code-1", cap.getValue().getCode());
+        assertEquals("authorization_code", cap.getValue().getGrantType());
+        // user.info.share: access_token 走 execute 第二参数(非 bizModel)
+        verify(alipayClient).certificateExecute(any(AlipayUserInfoShareRequest.class), eq("at-123"));
+        // 支付宝账号ID(2088)落库 — 本应用未开通 open_id 能力,制单身份用 user_id(实证)
+        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
+        verify(batchAuthorizeMapper).updateById(ent.capture());
+        assertEquals("2088122583917741", ent.getValue().getAlipayUserId());
+    }
+
+    @Test
+    void openIdCallback_tokenFailed_rejected() throws AlipayApiException {
+        mockLogonAuthorizedRecord();
+        AlipaySystemOauthTokenResponse tokenResp = new AlipaySystemOauthTokenResponse();
+        tokenResp.setCode("40004");
+        tokenResp.setMsg("无效授权码");
+        when(alipayClient.certificateExecute(any(AlipaySystemOauthTokenRequest.class))).thenReturn(tokenResp);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.openIdCallback("bad-code", "A2"));
+        assertEquals(400, e.getCode());
+        verify(batchAuthorizeMapper, never()).updateById(any());
+    }
+
+    @Test
+    void openIdCallback_infoShareFailed_rejected() throws AlipayApiException {
+        mockLogonAuthorizedRecord();
+        AlipaySystemOauthTokenResponse tokenResp = new AlipaySystemOauthTokenResponse();
+        tokenResp.setAccessToken("at-123");
+        when(alipayClient.certificateExecute(any(AlipaySystemOauthTokenRequest.class))).thenReturn(tokenResp);
+        AlipayUserInfoShareResponse shareResp = new AlipayUserInfoShareResponse();
+        shareResp.setCode("40004");
+        shareResp.setMsg("授权令牌失效");
+        when(alipayClient.certificateExecute(any(AlipayUserInfoShareRequest.class), eq("at-123"))).thenReturn(shareResp);
+
+        assertThrows(BusinessException.class, () -> service.openIdCallback("auth-code-1", "A2"));
+        verify(batchAuthorizeMapper, never()).updateById(any());
+    }
+
+    @Test
+    void openIdCallback_userIdFallsBackToTokenResponse() throws AlipayApiException {
+        mockLogonAuthorizedRecord();
+        AlipaySystemOauthTokenResponse tokenResp = new AlipaySystemOauthTokenResponse();
+        tokenResp.setAccessToken("at-123");
+        tokenResp.setUserId("2088-token-uid");
+        when(alipayClient.certificateExecute(any(AlipaySystemOauthTokenRequest.class))).thenReturn(tokenResp);
+        // info.share 成功但未返回 user_id → 回退 oauth.token 响应中的 user_id
+        when(alipayClient.certificateExecute(any(AlipayUserInfoShareRequest.class), eq("at-123"))).thenReturn(new AlipayUserInfoShareResponse());
+
+        String userId = service.openIdCallback("auth-code-1", "A2");
+
+        assertEquals("2088-token-uid", userId);
+        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
+        verify(batchAuthorizeMapper).updateById(ent.capture());
+        assertEquals("2088-token-uid", ent.getValue().getAlipayUserId());
+    }
+
+    @Test
+    void openIdCallback_userIdFallsBackToTokenAlipayUserId() throws AlipayApiException {
+        mockLogonAuthorizedRecord();
+        AlipaySystemOauthTokenResponse tokenResp = new AlipaySystemOauthTokenResponse();
+        tokenResp.setAccessToken("at-123");
+        tokenResp.setAlipayUserId("2088-alipay-uid");
+        when(alipayClient.certificateExecute(any(AlipaySystemOauthTokenRequest.class))).thenReturn(tokenResp);
+        when(alipayClient.certificateExecute(any(AlipayUserInfoShareRequest.class), eq("at-123"))).thenReturn(new AlipayUserInfoShareResponse());
+
+        String userId = service.openIdCallback("auth-code-1", "A2");
+
+        assertEquals("2088-alipay-uid", userId);
+        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
+        verify(batchAuthorizeMapper).updateById(ent.capture());
+        assertEquals("2088-alipay-uid", ent.getValue().getAlipayUserId());
+    }
+
+    @Test
+    void openIdCallback_noUserIdAnywhere_rejected() throws AlipayApiException {
+        mockLogonAuthorizedRecord();
+        AlipaySystemOauthTokenResponse tokenResp = new AlipaySystemOauthTokenResponse();
+        tokenResp.setAccessToken("at-123");
+        when(alipayClient.certificateExecute(any(AlipaySystemOauthTokenRequest.class))).thenReturn(tokenResp);
+        // info.share 与 oauth.token 均未返回 user_id → 明确报错
+        when(alipayClient.certificateExecute(any(AlipayUserInfoShareRequest.class), eq("at-123"))).thenReturn(new AlipayUserInfoShareResponse());
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.openIdCallback("auth-code-1", "A2"));
+        assertEquals(400, e.getCode());
+        verify(batchAuthorizeMapper, never()).updateById(any());
+    }
+
+    @Test
+    void openIdCallback_recordNotFound_rejected() {
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(null);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.openIdCallback("auth-code-1", "NOPE"));
+        assertEquals(404, e.getCode());
+    }
+}

+ 82 - 0
java/src/test/java/com/payment/platform/module/payment/facetoface/controller/AlipayAuthControllerTest.java

@@ -0,0 +1,82 @@
+package com.payment.platform.module.payment.facetoface.controller;
+
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.core.alipay.AlipayConfig;
+import com.payment.platform.module.payment.batch.service.BatchSubjectService;
+import com.payment.platform.module.payment.facetoface.service.FacetofaceService;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+import static org.mockito.Mockito.*;
+
+/**
+ * 白名单地址复用分流: 支付宝「授权回调地址」只能配一个(aplipay/auth),
+ * openid 授权与当面付 app_auth_code 授权共用该地址,按参数互斥分流
+ * (auth_code 仅 openid 授权回跳携带,app_auth_code 仅当面付 ISV 授权携带)。
+ */
+class AlipayAuthControllerTest {
+
+    private FacetofaceService facetofaceService;
+    private BatchSubjectService batchSubjectService;
+    private AlipayConfig alipayConfig;
+    private AlipayAuthController controller;
+    private HttpServletResponse response;
+
+    @BeforeEach
+    void setUp() {
+        facetofaceService = mock(FacetofaceService.class);
+        batchSubjectService = mock(BatchSubjectService.class);
+        alipayConfig = mock(AlipayConfig.class);
+        when(alipayConfig.getOauthFrontUrl()).thenReturn("https://qcsj88888.com");
+        controller = new AlipayAuthController(facetofaceService, batchSubjectService, alipayConfig);
+        response = mock(HttpServletResponse.class);
+    }
+
+    // ==================== openid 分流(auth_code 参数存在 → openid 业务) ====================
+
+    @Test
+    void openidCallback_success_dispatchesOpenIdFlow() throws Exception {
+        controller.auth(null, null, null, null, "auth-code-1", "A2", response);
+
+        verify(batchSubjectService).openIdCallback("auth-code-1", "A2");
+        verify(facetofaceService, never()).exchangeAppAuthCode(anyString(), anyString(), anyString());
+        verify(response).sendRedirect("https://qcsj88888.com/#/payment/batch?openid=success");
+    }
+
+    @Test
+    void openidCallback_bizError_redirectsFailWithMsg() throws Exception {
+        doThrow(new BusinessException(400, "获取OpenID失败: xxx"))
+                .when(batchSubjectService).openIdCallback("auth-code-1", "A2");
+
+        controller.auth(null, null, null, null, "auth-code-1", "A2", response);
+
+        verify(response).sendRedirect("https://qcsj88888.com/#/payment/batch?openid=fail&msg="
+                + URLEncoder.encode("获取OpenID失败: xxx", StandardCharsets.UTF_8));
+    }
+
+    // ==================== 当面付原流程(app_auth_code 参数存在,行为不变) ====================
+
+    @Test
+    void facetofaceCallback_success_keepsOriginalFlow() throws Exception {
+        controller.auth("app-id-1", "source-1", "ent-1", "app-auth-code-1", null, null, response);
+
+        verify(facetofaceService).exchangeAppAuthCode("ent-1", "app-id-1", "app-auth-code-1");
+        verify(batchSubjectService, never()).openIdCallback(anyString(), anyString());
+        verify(response).sendRedirect("https://qcsj88888.com/#/payment/enterprise?auth=success");
+    }
+
+    @Test
+    void facetofaceCallback_error_redirectsFail() throws Exception {
+        doThrow(new RuntimeException("boom")).when(facetofaceService)
+                .exchangeAppAuthCode("ent-1", "app-id-1", "app-auth-code-1");
+
+        controller.auth("app-id-1", "source-1", "ent-1", "app-auth-code-1", null, null, response);
+
+        verify(response).sendRedirect("https://qcsj88888.com/#/payment/enterprise?auth=fail&msg="
+                + URLEncoder.encode("boom", StandardCharsets.UTF_8));
+    }
+}