|
@@ -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 行
|