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