|
@@ -0,0 +1,181 @@
|
|
|
|
|
+# 乐收赢去企业化(租户账号级)实现计划
|
|
|
|
|
+
|
|
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: 用 superpowers:subagent-driven-development 或 superpowers:executing-plans 逐任务实现。步骤用 `- [ ]` 跟踪。
|
|
|
|
|
+
|
|
|
|
|
+**Goal:** 乐收赢的商户映射与下单支持**租户账号级**——不关联任何企业;同时保留原有的按企业寻址能力,行为不变。
|
|
|
|
|
+
|
|
|
|
|
+**Architecture:** 不新增列、不新增表。租户级商户 = `pay_lsy_merchant` 里 `enterprise_id IS NULL` 的那一行。下单入口按 `enterpriseId` 是否为空**分流**到 `requireByTenant` / `requireByEnterprise`,不是替换。
|
|
|
|
|
+
|
|
|
|
|
+**Tech Stack:** Java 17 / Spring Boot 3 / MyBatis-Plus / PostgreSQL / JUnit 5 + Mockito
|
|
|
|
|
+
|
|
|
|
|
+**Spec:** `docs/superpowers/specs/2026-09-22-receive-workbench-design.md` §3、§7
|
|
|
|
|
+
|
|
|
|
|
+## Global Constraints
|
|
|
|
|
+
|
|
|
|
|
+- **验签绝不跳过**:不得新增任何绕过签名校验的分支、开关或后门。
|
|
|
|
|
+- **文案不得出现上游名称、域名、其专有字段名**。
|
|
|
|
|
+- **`requireByEnterprise` 一字不动**(含它的 400/404 文案)。改动只在新增路径上。
|
|
|
|
|
+- **DDL 只由用户执行**。SQL 我写进 `java/src/main/resources/db/`,本计划不含任何执行 DDL 的步骤。
|
|
|
|
|
+- **不给可粘贴的代码模板**:本计划只给**契约**(名字、常量、文案、断言)。原因见 `.claude/plan/` 历史教训——曾有一份计划里的代码模板被照抄成生产缺陷。
|
|
|
|
|
+- 构建命令:`C:\Users\1\tools\apache-maven-3.9.16\bin\mvn.cmd -f D:\project2\payment-platform\java\pom.xml test`
|
|
|
|
|
+ - **每轮跑之前删掉 `java/target/surefire-reports/`**(该目录跨运行累积,全目录求和会读出假的测试数)
|
|
|
|
|
+ - 不用 `-q`、不用 `-proc:none`(两者都会悄悄毁掉验证)
|
|
|
|
|
+- 基线:改动前全套 **418 个测试全绿**(HEAD `2693ab6`)。本计划的每个任务结束后,总数不得低于 418 + 本任务新增数。
|
|
|
|
|
+- `mvn test` 需要库隧道在位(`PaymentApplicationContextTest` 的已知依赖)。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 1: DDL 脚本
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `java/src/main/resources/db/lsy-tenant-level.sql`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 写 SQL**
|
|
|
|
|
+
|
|
|
|
|
+内容为两条(语义见 spec §7):
|
|
|
|
|
+
|
|
|
|
|
+1. `ALTER TABLE pay_lsy_merchant ALTER COLUMN enterprise_id DROP NOT NULL;`
|
|
|
|
|
+2. 建局部唯一索引 `uk_lsy_merchant_tenant_level`,列 `(tenant_id)`,条件 `WHERE enterprise_id IS NULL`。
|
|
|
|
|
+
|
|
|
|
|
+第 2 条**必须**有:现有唯一索引 `uk_lsy_merchant_tenant_ent` 建在 `(tenant_id, enterprise_id)` 上,PostgreSQL 唯一索引里 NULL 互不相等,缺了这条局部索引,同一租户可以插入任意多行租户级商户而数据库不报错。
|
|
|
|
|
+
|
|
|
|
|
+写法参照同目录先例 `batch-account-level.sql`(`ALTER COLUMN ... DROP NOT NULL` + `DROP INDEX IF EXISTS` + 带 `WHERE` 的 `CREATE UNIQUE INDEX`)。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 头部注释写清前提与后果**
|
|
|
|
|
+
|
|
|
|
|
+参照 `V1.8__create_pay_lsy_tables.sql` 的头部注释格式(本工程**未接 Flyway**,脚本由人工按顺序执行,执行不可逆,需用户放行)。注释里要写明:**第一条不可逆**(`NOT NULL` 加不回来,除非先清掉所有 NULL 行)。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 交给用户执行,并做只读核对**
|
|
|
|
|
+
|
|
|
|
|
+执行后核对(只读):`pay_lsy_merchant.enterprise_id` 的 `is_nullable` 变为 `YES`;`pg_indexes` 里出现 `uk_lsy_merchant_tenant_level`;原有 `uk_lsy_merchant_tenant_ent` 仍在。
|
|
|
|
|
+
|
|
|
|
|
+**在核对通过前,Task 2 及之后不要开始**——`save` 放开校验后若索引未建,会静默产生同租户多行租户级商户。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 2: `requireByTenant` + `save` 放开企业字段
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantService.java`
|
|
|
|
|
+- Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantServiceTest.java`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Produces: `LsyMerchantEntity requireByTenant(Long tenantId)` —— 供 Task 3 的开放接口调用。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 先写失败测试**
|
|
|
|
|
+
|
|
|
|
|
+在 `LsyMerchantServiceTest` 里加用例(沿用现有 `setUp` 的 mock 风格):
|
|
|
|
|
+
|
|
|
|
|
+1. `requireByTenant_found_returnsEntity` —— 查到返回。
|
|
|
|
|
+2. `requireByTenant_missing_throws404` —— 文案含「未配置商户号」。
|
|
|
|
|
+3. `requireByTenant_queriesNullEnterpriseId` —— **钉住查询条件本身**:断言 wrapper 上带的租户条件,且**带 `enterprise_id IS NULL`**。这条是本任务的核心不变量:漏掉 `isNull` 就会捞到该租户的**企业级**商户,而那是一个语义完全不同的东西,其余用例都不会红。
|
|
|
|
|
+4. `save_insertBranch_blankEnterpriseId_inserts` —— 企业字段留空时,新增分支**不再抛 400**,且插入行的 `enterpriseId` 为 null。
|
|
|
|
|
+5. `save_updateBranch_blankEnterpriseId_writesNull` —— 编辑分支把 `enterprise_id` 写成 NULL。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 跑测试确认失败**
|
|
|
|
|
+
|
|
|
|
|
+跑 `LsyMerchantServiceTest`。预期:新增的 5 条全红(`requireByTenant` 方法不存在 ⇒ 编译失败)。**编译失败也算 RED**,但要在跑完全套后确认它确实是「方法未定义」而不是别的原因。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 实现**
|
|
|
|
|
+
|
|
|
|
|
+- **新增** `requireByTenant(Long tenantId)`:租户条件 + `isNull(enterpriseId)` + `LIMIT 1`;查不到抛 404,文案「该租户未配置商户号,请先在「商户映射」中配置」。**`requireByEnterprise` 不动。**
|
|
|
|
|
+- `save` 开头对 `enterpriseId` 的 400 校验**删掉**。理由:那条 400 的存在前提是数据库列 `NOT NULL`,Task 1 之后前提消失。留空 ⇒ 租户级商户。
|
|
|
|
|
+- `save` 的 javadoc 要**一并订正**:现在写着「`enterprise_id` / `mch_no` 没有『被清空』这个结果 —— 缺键或空白一律 400」。改后只有 `mch_no` 还是这个语义。**javadoc 与代码不一致是缺陷,不是文档瑕疵。**
|
|
|
|
|
+
|
|
|
|
|
+⚠ `save` 编辑分支的 SET 列表是**六列字面枚举**,其中有 `enterprise_id`,它用的是 `wrapper.set(col, null)` 而不是实体 ⇒ 能真正写 NULL。这一列**不需要动**,别顺手改。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 跑测试确认通过**
|
|
|
|
|
+
|
|
|
|
|
+`LsyMerchantServiceTest` 全绿。确认 `requireByEnterprise_blankEnterpriseId_throws` **仍然绿**(它测的是未改动的老方法)。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 两向变异验证**
|
|
|
|
|
+
|
|
|
|
|
+把读数写进报告,两个方向各跑一次:
|
|
|
|
|
+
|
|
|
|
|
+- **该红的**:把 `requireByTenant` 里的 `isNull(enterpriseId)` 摘掉 ⇒ `requireByTenant_queriesNullEnterpriseId` 必须红。
|
|
|
|
|
+- **不该红的**:把 `save` 新增分支里的默认值补全(`review_status` / `status`)挪走 ⇒ `save_updateBranch_doesNotBackfillStatusDefaults` 与新增用例都应保持**绿**(我们没碰那条路径)。
|
|
|
|
|
+
|
|
|
|
|
+跑完立刻还原,`md5sum -c` 或 `git diff --stat` 复核工作区干净。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: 提交**
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 3: 三个 DTO 的企业字段改为可选 + 开放接口分流
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `.../leshouying/dto/LsyQrCreateDTO.java`、`LsyBarcodePayDTO.java`、`LsyH5CreateDTO.java`
|
|
|
|
|
+- Modify: `.../leshouying/controller/open/LeshouyingOpenController.java`
|
|
|
|
|
+- Test: `.../controller/open/LeshouyingOpenControllerTest.java`、`.../service/LsyQrPayTest.java`、`.../service/LsyBarcodePayTest.java`、`.../service/LsyH5PayTest.java`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: Task 2 的 `requireByTenant(Long)`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 先写失败测试**
|
|
|
|
|
+
|
|
|
|
|
+在 `LeshouyingOpenControllerTest` 加用例:
|
|
|
|
|
+
|
|
|
|
|
+1. `qrcodeCreate_blankEnterpriseId_usesTenantLevelMerchant` —— 请求体不带 `enterprise_id`,断言走的是租户级取商户(校验返回的 `mchNo` 来自租户级映射)。
|
|
|
|
|
+2. `qrcodeCreate_withEnterpriseId_usesEnterpriseLevelMerchant` —— 带上 `enterprise_id` 时**仍然**按企业取,向后兼容。
|
|
|
|
|
+3. 被扫、H5 各一组同上(共 6 条)。
|
|
|
|
|
+
|
|
|
|
|
+⚠ 现有 `LeshouyingOpenControllerTest` 里有一条注释写着「enterprise_id 也必须在 insert 前装配」——那条断言的是**装配顺序**,与本任务正交,不要动它。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 跑测试确认失败**
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 实现**
|
|
|
|
|
+
|
|
|
|
|
+- 三个 DTO 的 `enterpriseId`:去掉 `@NotBlank`(`LsyBarcodePayDTO` 上还带 message 文案,一并去掉)。**字段本身保留**,向后兼容靠它。
|
|
|
|
|
+- `LeshouyingOpenController` 的三个下单方法(`qrcodeCreate` / `barcodePay` / `h5Create`):把 `merchantService.requireByEnterprise(tenantId, dto.getEnterpriseId())` 换成**分流**——`enterpriseId` 空白走 `requireByTenant(tenantId)`,非空走 `requireByEnterprise(tenantId, enterpriseId)`。
|
|
|
|
|
+- `order.setEnterpriseId(dto.getEnterpriseId())` 保留(留空时写入 null,列本身可空)。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 跑测试确认通过 + 全套回归**
|
|
|
|
|
+
|
|
|
|
|
+**全套 418 + 新增数全绿。** 特别确认这三个测试文件没被带红:`LsyOpenApiJsonContractTest`(断言 DTO 的 JSON 字段名集合——我们只改注解不改字段名,应保持绿)、`LeshouyingOpenApiErrorContractTest`、`LeshouyingOpenControllerTest`。
|
|
|
|
|
+
|
|
|
|
|
+若 `LsyOpenApiJsonContractTest` 红了,说明它断言里有**必填性**而注释说是字段名集合 ⇒ 停下来读它,不要直接改断言。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 提交**
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 4: 管理端页面允许企业字段留空
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `frontend/src/views/module_payment/collection/merchant/index.vue`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 改表单**
|
|
|
|
|
+
|
|
|
|
|
+- `rules.enterprise_id` 的 `required: true` 去掉。
|
|
|
|
|
+- 表单项标签改为表明「留空 = 租户级」。
|
|
|
|
|
+- 列表列的表头同步。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 手工验证**
|
|
|
|
|
+
|
|
|
|
|
+起前端,打开商户映射页,新增一条**不带企业 ID** 的映射 ⇒ 保存成功 ⇒ 列表里企业列显示为空。再编辑它 ⇒ 仍然成功。
|
|
|
|
|
+
|
|
|
|
|
+⚠ 后端 Task 2 未落地时这一步会 400(`save` 的校验),所以本任务必须排在 Task 2 之后。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 5: 端到端验收
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 建一条真实的租户级映射**
|
|
|
|
|
+
|
|
|
|
|
+管理端建一条 `enterprise_id` 为空的映射,`mch_no` 用现有测试商户号。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 走一笔主扫**
|
|
|
|
|
+
|
|
|
|
|
+带 JWT 调开放接口 `/payment/openapi/pay/qrcode/create`,**不带** `enterprise_id` ⇒ 应返回 `PAY_SUCCESS` 且拿到 `code_url`。库里 `pay_lsy_order` 多一行、`enterprise_id` 为 NULL。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 走一笔被扫(可选,若手头有可用付款码)**
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 核对兼容性没被破坏**
|
|
|
|
|
+
|
|
|
|
|
+带 `enterprise_id` 的老请求(用现有的 `2088780645490698` 映射)⇒ 照常成功。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 报告读数**
|
|
|
|
|
+
|
|
|
|
|
+把上面每步的**实际** HTTP 状态、返回码、库里的行数变化写进报告。**没跑通的步骤如实写没跑通,不写「应该可以」。**
|