fix(publish): 迁移03街拍体检同步 city+year+title 口径,补 04 体检与查重口径测试

This commit is contained in:
toom1996
2026-09-23 13:14:48 +08:00
parent ce4f8b786d
commit 12d05f75e9
4 changed files with 84 additions and 5 deletions

View File

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

View File

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

View File

@ -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)
}
}

View File

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