fix(publish): 迁移03街拍体检同步 city+year+title 口径,补 04 体检与查重口径测试
This commit is contained in:
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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。
|
||||
|
||||
Reference in New Issue
Block a user