浏览代码

chore(去企业化): DDL 脚本 + 实现计划(脚本待执行)

db/lsy-tenant-level.sql 两条:DROP NOT NULL + 局部唯一索引。
局部索引**不可省**:现有 uk_lsy_merchant_tenant_ent 建在 (tenant_id, enterprise_id) 上,
而 PostgreSQL 唯一索引里 NULL 互不相等 ⇒ 缺了它,同一租户能插入任意多行租户级商户,
数据库一声不吭,之后 requireByTenant 的 LIMIT 1 随机返回其中一行。

2026-09-22 只读核对(脚本**尚未执行**):
- pay_lsy_merchant.enterprise_id is_nullable = NO
- 索引只有 idx_lsy_merchant_mch_no / pay_lsy_merchant_pkey / uk_lsy_merchant_tenant_ent
- 全表 1 行:id=1, tenant_id=2, enterprise_id='2088780645490698', mch_no='Y52211090004'
  ⇒ enterprise_id 非空,落在局部索引谓词之外,不冲突、无需清理

★ 脚本注释原先写「本脚本执行时表里是 0 行」—— 那是照模型写的,实测是 1 行。
  已改为实测值。凡是「表里有几行」这类断言,写之前必须先量。

★ 执行顺序:先跑本脚本 → 只读核对 → 再部署配套代码。
  反过来的话,管理端留空保存会从 400 变成 500。
alphaH 3 周之前
父节点
当前提交
5c51600f78
共有 2 个文件被更改,包括 220 次插入 和 0 次删除
  1. 181 0
      .claude/plan/2026-09-22-lsy-tenant-level.md
  2. 39 0
      java/src/main/resources/db/lsy-tenant-level.sql

+ 181 - 0
.claude/plan/2026-09-22-lsy-tenant-level.md

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

+ 39 - 0
java/src/main/resources/db/lsy-tenant-level.sql

@@ -0,0 +1,39 @@
+-- 乐收赢去企业化(租户账号级)2026-09-22
+-- 手工执行: psycopg2 连接 payment_platform_java 库
+--
+-- 背景: 乐收赢与原有企业逻辑完全不相干,商户映射应按**租户账号**寻址。
+--       本脚本让 pay_lsy_merchant 支持「租户级商户」—— enterprise_id 为空的那一行。
+--       不新增列、不新增表;订单表 pay_lsy_order.enterprise_id 建表时已可空,本脚本不碰它。
+--
+-- ⚠ 第一条**不可逆**: NOT NULL 加不回来,除非先把所有 enterprise_id IS NULL 的行清掉
+--    或补上企业 ID。执行前请确认可以接受。
+--
+-- ⚠ **先执行本脚本,再部署代码**(顺序不能反)。配套的代码提交删掉了 save 里
+--    「企业 ID 不能为空」的 400 校验,而列还是 NOT NULL 的话,留空保存会从 400
+--    变成 500(NOT NULL 违约落进 GlobalExceptionHandler 兜底)。
+--    本脚本执行前,现有行为一切照旧(老调用方都带 enterprise_id)。
+
+-- 1. 允许租户级商户
+ALTER TABLE pay_lsy_merchant ALTER COLUMN enterprise_id DROP NOT NULL;
+
+-- 2. 租户级商户的唯一性
+--    现有唯一索引 uk_lsy_merchant_tenant_ent 建在 (tenant_id, enterprise_id) 上,
+--    而 PostgreSQL 唯一索引里 **NULL 互不相等** ⇒ 缺了下面这条局部索引,
+--    同一个租户可以插入任意多行租户级商户,数据库一声不吭,
+--    之后 requireByTenant 的 LIMIT 1 会随机返回其中一行。
+--
+--    存量数据核对(2026-09-22 只读实测,非推断):全表 1 行
+--      id=1, tenant_id=2, enterprise_id='2088780645490698', mch_no='Y52211090004'
+--    该行 enterprise_id **非空**,落在局部索引的谓词之外 ⇒ 不冲突,无需清理。
+--    将来若真出现同租户多行租户级商户(只可能来自索引建成之前的窗口),先清理再建索引:
+--      DELETE FROM pay_lsy_merchant a USING pay_lsy_merchant b
+--      WHERE a.id < b.id AND a.tenant_id = b.tenant_id
+--        AND a.enterprise_id IS NULL AND b.enterprise_id IS NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_merchant_tenant_level
+    ON pay_lsy_merchant (tenant_id)
+    WHERE enterprise_id IS NULL;
+
+-- 3. 只读核对(执行后可手工跑一遍确认)
+--    SELECT is_nullable FROM information_schema.columns
+--     WHERE table_name='pay_lsy_merchant' AND column_name='enterprise_id';   -- 期望 YES
+--    SELECT indexname FROM pg_indexes WHERE tablename='pay_lsy_merchant';    -- 期望含上述两条