From 12d05f75e902b0f903c10d6153b2a85750cf5ed2 Mon Sep 17 00:00:00 2001 From: toom1996 <23cm.cn@gmail.com> Date: Wed, 23 Sep 2026 13:14:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(publish):=20=E8=BF=81=E7=A7=BB03=E8=A1=97?= =?UTF-8?q?=E6=8B=8D=E4=BD=93=E6=A3=80=E5=90=8C=E6=AD=A5=20city+year+title?= =?UTF-8?q?=20=E5=8F=A3=E5=BE=84=EF=BC=8C=E8=A1=A5=2004=20=E4=BD=93?= =?UTF-8?q?=E6=A3=80=E4=B8=8E=E6=9F=A5=E9=87=8D=E5=8F=A3=E5=BE=84=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-09-22-03-entity-key-unique.sql | 6 +- .../2026-09-22-04-street-entity-key-title.sql | 16 +++++ .../entity_key_unique_integration_test.go | 61 +++++++++++++++++++ .../street_main_detail_integration_test.go | 6 +- 4 files changed, 84 insertions(+), 5 deletions(-) diff --git a/db/migrations/2026-09-22-03-entity-key-unique.sql b/db/migrations/2026-09-22-03-entity-key-unique.sql index 85f89c0..7f20c91 100644 --- a/db/migrations/2026-09-22-03-entity-key-unique.sql +++ b/db/migrations/2026-09-22-03-entity-key-unique.sql @@ -20,10 +20,12 @@ BEGIN RAISE EXCEPTION 'brand_runways 有 % 组重复实体键,请先人工合并再加唯一索引', dup; END IF; + -- 口径已随任务 3c 的实体键细化同步为 (city, year, title):否则本文件在「同城同年多专题」 + -- 的合法数据上不再幂等(重复执行会误报重复而中止)。 SELECT count(*) INTO dup FROM ( - SELECT city, year + SELECT city, year, COALESCE(title, '') FROM street_snaps WHERE is_deleted = 0 - GROUP BY 1, 2 HAVING count(*) > 1 + GROUP BY 1, 2, 3 HAVING count(*) > 1 ) t; IF dup > 0 THEN RAISE EXCEPTION 'street_snaps 有 % 组重复实体键,请先人工合并再加唯一索引', dup; diff --git a/db/migrations/2026-09-22-04-street-entity-key-title.sql b/db/migrations/2026-09-22-04-street-entity-key-title.sql index cb56bfa..d0afab0 100644 --- a/db/migrations/2026-09-22-04-street-entity-key-title.sql +++ b/db/migrations/2026-09-22-04-street-entity-key-title.sql @@ -6,6 +6,22 @@ -- COALESCE(title, '') 是刻意的:唯一索引中 NULL 互不冲突,若直接用 title, -- 两条 title 为 NULL 的行会被判为不同实体而共存。入库始终会写 title, -- 这里只是把「NULL 也要参与唯一性」这件事钉死。 + +-- 建索引前先体检(与 03 同一口径 (city, year, COALESCE(title, ''))): +-- 直接建索引只会抛原生 23505,这里先给出可读的报错原因。 +DO $$ +DECLARE dup int; +BEGIN + SELECT count(*) INTO dup FROM ( + SELECT city, year, COALESCE(title, '') + FROM street_snaps WHERE is_deleted = 0 + GROUP BY 1, 2, 3 HAVING count(*) > 1 + ) t; + IF dup > 0 THEN + RAISE EXCEPTION 'street_snaps 有 % 组重复实体键,请先人工合并再加唯一索引', dup; + END IF; +END $$; + DROP INDEX IF EXISTS uq_ss_entity; CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_entity diff --git a/internal/repository/entity_key_unique_integration_test.go b/internal/repository/entity_key_unique_integration_test.go index 1adc806..a50fd69 100644 --- a/internal/repository/entity_key_unique_integration_test.go +++ b/internal/repository/entity_key_unique_integration_test.go @@ -113,3 +113,64 @@ func TestStreetEntityKeyUnique(t *testing.T) { db.Exec("DELETE FROM street_snaps WHERE id = ?", newID) }) } + +// TestStreetEntityStateMatchesIndexKey 覆盖 StreetSnapEntityState 的查重口径: +// 它必须与唯一索引 uq_ss_entity 的表达式 COALESCE(title, '') 完全一致, +// 否则查重会比索引更严(漏判 → 写入注定被索引拒绝的行)或更松(重复内容被当成新实体)。 +func TestStreetEntityStateMatchesIndexKey(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + applyMigration(t, db, "2026-09-22-03-entity-key-unique.sql") + applyMigration(t, db, "2026-09-22-04-street-entity-key-title.sql") + repo := NewIngestRepository(db) + ctx := context.Background() + + const city = "EntityStateTestCity" + const year = 1996 + + // 种子行:title = Day 2(经 ORM 写入,title 为普通空串语义)。 + snap := &model.StreetSnap{JobID: 1, Title: "EntityState Day 2", Year: year, City: city, Status: model.StatusPending} + id, err := repo.CreateStreetSnapWithImages(ctx, snap, nil) + if err != nil { + t.Fatalf("种子行应能插入: %v", err) + } + t.Cleanup(func() { db.Exec("DELETE FROM street_snaps WHERE id = ?", id) }) + + // ① 同城同年**不同 title** → 不命中(同城同年不同专题是两个实体)。 + if _, _, found, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 3"); err != nil { + t.Fatalf("查重出错: %v", err) + } else if found { + t.Fatalf("不同 title 不应命中既有行,实际命中") + } + + // ② 同城同年**同 title** → 命中,且带出正确 id / status。 + hitID, status, found, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 2") + if err != nil { + t.Fatalf("查重出错: %v", err) + } + if !found || hitID != id || status != model.StatusPending { + t.Fatalf("同 title 应命中 id=%d status=pending,实际 found=%v id=%d status=%q", id, found, hitID, status) + } + + // ③ title 为 NULL 的历史行 + 传入空串 title → 必须命中。 + // 这是 COALESCE(title, '') 与裸 title = ? 唯一的分歧点:裸写法下 NULL 永不等于空串, + // 这里会误判「未命中」并插入一行与 NULL 行同键(COALESCE 后同为 '')的记录,被索引拒绝。 + // 用原生 SQL 插入,因为 GORM 的 string 字段会写空串而非 NULL。 + var nullID uint32 + if err := db.Raw( + "INSERT INTO street_snaps (title, year, city, is_deleted, status, created_at, updated_at) "+ + "VALUES (NULL, ?, ?, 0, ?, 0, 0) RETURNING id", + year, city, model.StatusPending, + ).Scan(&nullID).Error; err != nil { + t.Fatalf("插入 NULL title 行失败: %v", err) + } + t.Cleanup(func() { db.Exec("DELETE FROM street_snaps WHERE id = ?", nullID) }) + + nid, _, nfound, err := repo.StreetSnapEntityState(ctx, city, year, "") + if err != nil { + t.Fatalf("查重出错: %v", err) + } + if !nfound || nid != nullID { + t.Fatalf("NULL title 行 + 空串应命中 id=%d,实际 found=%v id=%d", nullID, nfound, nid) + } +} diff --git a/internal/repository/street_main_detail_integration_test.go b/internal/repository/street_main_detail_integration_test.go index 9bbb209..5894b8b 100644 --- a/internal/repository/street_main_detail_integration_test.go +++ b/internal/repository/street_main_detail_integration_test.go @@ -22,9 +22,9 @@ import ( "fashionapi/internal/model" ) -// mainDetailSeedSeq 让每次 seed 出来的实体键(city + year)互不相同。 -// 正式表上有部分唯一索引 uq_ss_entity (city, year) WHERE is_deleted = 0, -// 若测试沿用同一 city/year,第二条记录会被唯一索引拒绝。 +// mainDetailSeedSeq 让每次 seed 出来的实体键(city + year + title)互不相同。 +// 正式表上有部分唯一索引 uq_ss_entity (city, year, COALESCE(title, '')) WHERE is_deleted = 0, +// 若测试沿用同一 city/year/title,第二条记录会被唯一索引拒绝。 var mainDetailSeedSeq int // seedMainDetailSnap 插入一条街拍正式记录(status=pending)并返回其 id。