瀏覽代碼

feat(进件): 交付 pay_lsy_merchant.mch_no 唯一索引脚本(守闸版,待用户执行)

三-B 的审核通知入口(免登录)只能按 mch_no 定位本地行,而 2026-09-23 实测该列没有
唯一索引(pg_indexes 4 条里没有 uk_lsy_merchant_mch_no)。本脚本建它,但**不裸建**:
库里真有重复 —— mch_no='Y52211090004' 两行(id 1 与 2102313081860202497),
裸跑 CREATE UNIQUE INDEX 必然抛「could not create unique index」,
那句话既不点名重复行也不说该怎么处置。守闸 DO 块把失败换成一句带 mch_no 与 id 的人话。

★ 本脚本**不是**免登录路径的安全前提。第一道闸是查询形状(0 行 ⇒ 应答 FAIL;
多行 ⇒ 记 ERROR + 应答 FAIL;不得 LIMIT 1 / selectOne),它在没有索引时也成立。
索引是第二道闸。文件里原以「必须先把唯一索引补上」作为义务来源的读法已标注作废。

重复行删哪一行是数据决策,脚本不自动执行任何 DELETE;两句 DELETE 以注释形式留在
「第 3 步」,供用户原样确认 id 后手动跑。候选 A(id=1,种子行,自述「应删除」)
与候选 B(id=2102313081860202497,租户级映射行)的来历都写在文件里。

三步:0 只读预检 → 1 建索引(带守闸)→ 2 建后复核(期望 5 条索引,
且旧的非唯一索引 idx_lsy_merchant_mch_no **必须仍在**)。

本提交只落文件,**不执行**(Global Constraint 6:SQL 只有用户执行)。
alphaH 3 周之前
父節點
當前提交
058f392bf4
共有 1 個文件被更改,包括 149 次插入 和 0 次删除
  1. 149 0
      java/src/main/resources/db/lsy-merchant-mch-no-uk.sql

+ 149 - 0
java/src/main/resources/db/lsy-merchant-mch-no-uk.sql

@@ -0,0 +1,149 @@
+-- 乐收赢进件(三-B)Task 6 **配套**(★ **不是**前置 —— 理由见文末「第 3 步」末尾)
+--   —— pay_lsy_merchant.mch_no 唯一索引(2026-09-23)
+-- 手工执行: psycopg2 连接 payment_platform_java 库
+--
+-- ★ 本脚本**不是**「跑一下就完事」的建索引脚本。它自带守闸:
+--   库里现在**真的有重复**,所以 CREATE UNIQUE INDEX 会失败。守闸把失败换成一句人话。
+--   重复的处置是**用户的数据决策**,脚本不替你删任何行。
+--
+-- 为什么仍然值得建这个索引(背景,非本计划新立):
+--   三-B 的审核通知入口(POST /payment/collection/notify/onboarding)**免登录**,
+--   没有认证上下文 ⇒ 租户拦截器**不追加**租户谓词 ⇒ 它只能按 `mch_no` 定位本地行;
+--   而 `pay_lsy_merchant.mch_no` **今天没有唯一索引**。
+--   ★★ 但**这个索引不是那条路径的安全前提**。2026-09-23 订正:本段原以「必须先把
+--      uk_lsy_merchant_mch_no 补上 —— 唯一性是无认证查询不串租户的前提」作为义务来源,
+--      那读起来就是**把索引当成前置**,该读法已作废(计划 Task 6 Step 0 有同一处订正)。
+--      第一道闸是**查询形状**:0 行 ⇒ 应答 FAIL;>1 行 ⇒ 记 ERROR + 应答 FAIL;**不得** LIMIT 1 / selectOne。
+--      它**在没有索引时也是安全的** —— 详见文末「第 3 步」末尾。
+--
+-- 背景(是本脚本与计划正文不一致的地方,以本文件为准):
+--   ★ 旧计划正文写「查出现有重复 —— **预期 0 行**」。**2026-09-23 实测:1 行**(见下)。
+--     那句预期是**错的**,不要照它判。
+--   ★ 旧计划正文写 pay_lsy_merchant「全表 8 列」。**实测 11 列**(含 description 与两个时间戳)。
+--     少了的是 description(建表语句 V1.8 里逐字有)。这条只影响叙述,不影响本脚本。
+
+
+-- ============================================================================
+-- 第 0 步:只读预检 —— 先跑,先看读数,**先不要往下跑**
+-- ============================================================================
+
+-- 0.1 现有重复(这条决定本脚本能不能建成)
+SELECT mch_no, count(*) AS n, array_agg(id ORDER BY id) AS ids
+  FROM pay_lsy_merchant
+ GROUP BY mch_no
+HAVING count(*) > 1;
+-- 期望: 0 行
+-- ★ 2026-09-23 实测: **1 行** —— mch_no='Y52211090004',n=2,ids={1, 2102313081860202497}
+--   ⇒ 现状下 CREATE UNIQUE INDEX 必然失败。处置见本文件末尾「第 3 步」。
+
+-- 0.2 这张表现在到底有什么(两行都是测试夹具,看 description 列即可确认)
+SELECT id, tenant_id, enterprise_id, mch_no, review_status, status, description
+  FROM pay_lsy_merchant ORDER BY id;
+-- 2026-09-23 实测:
+--   1                    | tenant 2 | enterprise_id='2088780645490698' | mch_no='Y52211090004'
+--                        | APPROVED | ENABLED | description='2026-09-22 联调临时数据;正式进件(子项目 B)后应删除'
+--   2102313081860202497  | tenant 2 | enterprise_id=NULL               | mch_no='Y52211090004'
+--                        | APPROVED | ENABLED | description='租户级映射(Task5 Step1 端到端验证用)'
+
+-- 0.3 现有索引(第 2 步要拿它对比)
+SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'pay_lsy_merchant' ORDER BY indexname;
+-- 2026-09-23 实测 4 条:
+--   idx_lsy_merchant_mch_no          (mch_no)                              ← 非唯一,**留着**
+--   pay_lsy_merchant_pkey            (id) UNIQUE
+--   uk_lsy_merchant_tenant_ent       (tenant_id, enterprise_id) UNIQUE
+--   uk_lsy_merchant_tenant_level     (tenant_id) UNIQUE WHERE enterprise_id IS NULL
+--   ★ uk_lsy_merchant_mch_no **不在其中**(即本脚本要建的那个,尚未存在)
+
+
+-- ============================================================================
+-- 第 1 步:建索引(带守闸)
+-- ============================================================================
+-- 守闸的理由: 直接跑 CREATE UNIQUE INDEX 在有重复时会抛
+--   「could not create unique index ... Key (mch_no)=(...) is duplicated」——
+--   那句话不告诉你**该怎么处置**,也不点名是哪些行。守闸把两件事都说了。
+
+DO $$
+DECLARE
+    dup_mch_no TEXT;
+    dup_n      BIGINT;
+    dup_ids    TEXT;
+BEGIN
+    SELECT mch_no, count(*), string_agg(id::text, ', ' ORDER BY id)
+      INTO dup_mch_no, dup_n, dup_ids
+      FROM pay_lsy_merchant
+     GROUP BY mch_no
+    HAVING count(*) > 1
+     ORDER BY count(*) DESC, mch_no
+     LIMIT 1;
+
+    IF dup_mch_no IS NOT NULL THEN
+        RAISE EXCEPTION
+            E'拒绝建索引:pay_lsy_merchant 里 mch_no = % 出现 % 次(id: %)。\n'
+            '重复的处置是数据决策,本脚本不替你删行。\n'
+            '请先看本文件「第 3 步:重复行的处置」,裁定后再重跑本步。',
+            dup_mch_no, dup_n, dup_ids;
+    END IF;
+END $$;
+
+-- 守闸通过后才会走到这里。
+-- ★ 不带 tenant_id:无认证查询没有租户可带,**跨租户唯一**才是它要的性质。
+--   若写成 (tenant_id, mch_no),两个租户撞号仍是可能的,而免登录入口正是靠 mch_no 定位
+--   ⇒ 那等于把唯一性这道闸放回拦截器手里(而免登录请求的 ignoreTable 返回 true,拦截器不管)。
+CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_merchant_mch_no
+    ON pay_lsy_merchant (mch_no);
+
+
+-- ============================================================================
+-- 第 2 步:建后复核(只读)
+-- ============================================================================
+SELECT indexname FROM pg_indexes WHERE tablename = 'pay_lsy_merchant' ORDER BY indexname;
+-- 期望: **5 条** —— 上面 0.3 实测的 4 条,**加** uk_lsy_merchant_mch_no。
+--   ★ 旧的非唯一索引 idx_lsy_merchant_mch_no **必须仍在**:两个都留,
+--     查询计划可以自行选旧的。它不在 = 有人把旧的删了,那就是一处未声明的行为改变。
+
+-- 复核约束本身确实在(pg_indexes 只说明索引在)
+SELECT conname, contype FROM pg_constraint
+ WHERE conrelid = 'pay_lsy_merchant'::regclass AND contype = 'u' ORDER BY conname;
+-- 说明: CREATE UNIQUE INDEX 建的是**唯一索引**,不登记成约束(contype='u'),
+--   所以这里**看不到** uk_lsy_merchant_mch_no —— 这是正常的,以第 2 步的 pg_indexes 为准。
+--   把它写出来是为了防止有人把「约束表里没有」误读成「索引没建成」。
+
+
+-- ============================================================================
+-- 第 3 步:重复行的处置(**用户裁定,脚本不自动执行任何一条**)
+-- ============================================================================
+-- 现状: 两行都是**测试夹具**,同 tenant_id=2、同 mch_no='Y52211090004'。
+--   哪一行该留,取决于你要保留哪种身份 —— 这不是脚本能判的,也不该由脚本猜。
+--
+--   候选 A(id = 1)                    enterprise_id='2088780645490698'
+--     description 自述:「2026-09-22 联调临时数据;正式进件(子项目 B)后应删除」
+--     ⇒ 它是 lsy-merchant-seed-test.sql 落的**种子行**,自述说该删。
+--   候选 B(id = 2102313081860202497)  enterprise_id=NULL
+--     description 自述:「租户级映射(Task5 Step1 端到端验证用)」
+--     ⇒ 它是 lsy-tenant-level.sql 落的**租户级映射行**(受 uk_lsy_merchant_tenant_level 约束,每租户至多一行)。
+--
+-- ★ 删任意一行都**不可逆**。先把下面两行原样跑一遍,确认要删的 id 之后再执行删除。
+--    SELECT * FROM pay_lsy_merchant WHERE id IN (1, 2102313081860202497);
+--
+-- 若裁定删 A:
+--    DELETE FROM pay_lsy_merchant WHERE id = 1;
+-- 若裁定删 B:
+--    DELETE FROM pay_lsy_merchant WHERE id = 2102313081860202497;
+--
+-- 删完**重跑第 0.1 步**确认 0 行,再跑第 1 步与第 2 步。
+--
+-- ★ 不删也行 —— 但那就**不建这个索引**,并且在三-B 里接受这条已知缺口:
+--   Task 6 的审核通知查询**不得用 LIMIT 1**,必须「查出 0 行 ⇒ 应答 FAIL;查出多行 ⇒ 记 ERROR + 应答 FAIL」。
+--   ★ 2026-09-23 订正:本行原写「那段代码**本来就是这么写的**(用例 onboardingNotify_duplicateMchNo_returnsFail
+--     钉着它)」—— **是假话**。实测 `grep -rn 'onboardingNotify' java/src` 只命中配置项 `onboardingNotifyUrl`:
+--     Task 6 当时**尚未实现**,那条查询与那个用例在仓库里**都不存在**。
+--     真话是:「**计划要求**它这么写,并且规定了用例 onboardingNotify_duplicateMchNo_returnsFail 去钉它」。
+--   ⇒ **免登录入口在无索引时也是安全的**(前提:那段代码照计划里的查询形状落地);索引是第二道闸,不是第一道。
+--   (这条与**旧计划正文**「必须先补唯一约束再实现查询」相抵 —— 那句已在计划 Task 6 Step 0 里作废。)
+
+
+-- ============================================================================
+-- 只读核对(执行后可手工跑一遍确认)
+-- ============================================================================
+--   SELECT indexname FROM pg_indexes WHERE tablename = 'pay_lsy_merchant';           -- 期望 5 条
+--   SELECT mch_no, count(*) FROM pay_lsy_merchant GROUP BY mch_no HAVING count(*) > 1; -- 期望 0 行