2026-09-22-lsy-tenant-level.md 10 KB

乐收赢去企业化(租户账号级)实现计划

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 状态、返回码、库里的行数变化写进报告。没跑通的步骤如实写没跑通,不写「应该可以」。