From ce4f8b786d5a28936c58c9440df100421a000d1d Mon Sep 17 00:00:00 2001 From: toom1996 <23cm.cn@gmail.com> Date: Wed, 23 Sep 2026 13:04:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(publish):=20=E8=A1=97=E6=8B=8D=E5=AE=9E?= =?UTF-8?q?=E4=BD=93=E9=94=AE=E7=BB=86=E5=8C=96=E4=B8=BA=20city+year+title?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E5=90=8C=E5=9F=8E=E5=90=8C=E5=B9=B4?= =?UTF-8?q?=E5=A4=9A=E4=B8=93=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-09-22-04-street-entity-key-title.sql | 12 +++++++ .../entity_key_unique_integration_test.go | 36 +++++++++++++++---- internal/repository/ingest_repository.go | 16 ++++++--- internal/service/ingest_service.go | 6 ++-- 4 files changed, 57 insertions(+), 13 deletions(-) create mode 100644 db/migrations/2026-09-22-04-street-entity-key-title.sql 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 new file mode 100644 index 0000000..cb56bfa --- /dev/null +++ b/db/migrations/2026-09-22-04-street-entity-key-title.sql @@ -0,0 +1,12 @@ +-- 街拍实体键细化:由 (city, year) 改为 (city, year, title)。 +-- +-- 背景:同城同年可能有多个专题(如 London 2027 Day 2 / Day 3),原键把它们判为同一实体, +-- 导致唯一索引不允许共存、且入库查重把第二个专题当成重复直接放弃。 +-- +-- COALESCE(title, '') 是刻意的:唯一索引中 NULL 互不冲突,若直接用 title, +-- 两条 title 为 NULL 的行会被判为不同实体而共存。入库始终会写 title, +-- 这里只是把「NULL 也要参与唯一性」这件事钉死。 +DROP INDEX IF EXISTS uq_ss_entity; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_entity + ON street_snaps (city, year, COALESCE(title, '')) WHERE is_deleted = 0; diff --git a/internal/repository/entity_key_unique_integration_test.go b/internal/repository/entity_key_unique_integration_test.go index cd12663..1adc806 100644 --- a/internal/repository/entity_key_unique_integration_test.go +++ b/internal/repository/entity_key_unique_integration_test.go @@ -50,17 +50,28 @@ func TestRunwayEntityKeyUnique(t *testing.T) { }) } -// TestStreetEntityKeyUnique 街拍侧同理(实体键 = city + year)。 +// TestStreetEntityKeyUnique 街拍实体键 = city + year + title(2026-09-22-04 细化后)。 +// +// 为什么键里要有 title:同城同年可以有多个专题(开发库实例 London 2027 Day 2 / Day 3), +// title 是数据里唯一能区分它们的字段。因此: +// ① 同城同年**同标题**的第二行必须被拒(实体键仍具备唯一性); +// ② 同城同年**不同标题**的第二行必须允许(对应「Day 2 / Day 3 分成两个文章」)。 func TestStreetEntityKeyUnique(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") + // 04 是替换索引:先 DROP 掉 03 建的 (city, year),再按 (city, year, title) 重建同名索引。 + // 两个都执行才真实还原「迁移链走完」的库结构,也才能验证旧键确实不再生效。 + applyMigration(t, db, "2026-09-22-04-street-entity-key-title.sql") repo := NewIngestRepository(db) ctx := context.Background() const city = "UniqTestCity" const year = 1994 - snap := &model.StreetSnap{JobID: 1, Title: "uniq", Year: year, City: city, Status: model.StatusPending} + const titleDay2 = "UniqTest Spring 1994 Day 2" + const titleDay3 = "UniqTest Spring 1994 Day 3" + + snap := &model.StreetSnap{JobID: 1, Title: titleDay2, Year: year, City: city, Status: model.StatusPending} id, err := repo.CreateStreetSnapWithImages(ctx, snap, []model.StreetSnapImage{{Image: "uniq-a.jpg", SortOrder: 1}}) if err != nil { t.Fatalf("首行应能插入: %v", err) @@ -70,17 +81,30 @@ func TestStreetEntityKeyUnique(t *testing.T) { db.Exec("DELETE FROM street_snaps WHERE id = ?", id) }) - dup := &model.StreetSnap{JobID: 1, Title: "uniq", Year: year, City: city, Status: model.StatusPending} + // ① 同城同年同标题:实体键命中,唯一索引必须拒绝。 + dup := &model.StreetSnap{JobID: 1, Title: titleDay2, Year: year, City: city, Status: model.StatusPending} if _, err := repo.CreateStreetSnapWithImages(ctx, dup, []model.StreetSnapImage{{Image: "uniq-b.jpg", SortOrder: 1}}); err == nil { - t.Fatalf("同一实体键的第二行应被唯一索引拒绝,实际插入成功") + t.Fatalf("同城同年同标题的第二行应被唯一索引拒绝,实际插入成功") } + // ② 同城同年不同标题:不同专题,必须允许共存。 + // 这一条是本任务的核心:若还原成 (city, year) 的旧键,这里会插入失败。 + other := &model.StreetSnap{JobID: 1, Title: titleDay3, Year: year, City: city, Status: model.StatusPending} + otherID, err := repo.CreateStreetSnapWithImages(ctx, other, []model.StreetSnapImage{{Image: "uniq-c.jpg", SortOrder: 1}}) + if err != nil { + t.Fatalf("同城同年不同标题应可插入,实际失败: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", otherID) + db.Exec("DELETE FROM street_snaps WHERE id = ?", otherID) + }) + // 软删首行后,实体键不再占用(与 is_deleted = 0 的部分索引口径一致)。 if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("is_deleted", 1).Error; err != nil { t.Fatalf("软删失败: %v", err) } - again := &model.StreetSnap{JobID: 1, Title: "uniq", Year: year, City: city, Status: model.StatusPending} - newID, err := repo.CreateStreetSnapWithImages(ctx, again, []model.StreetSnapImage{{Image: "uniq-c.jpg", SortOrder: 1}}) + again := &model.StreetSnap{JobID: 1, Title: titleDay2, Year: year, City: city, Status: model.StatusPending} + newID, err := repo.CreateStreetSnapWithImages(ctx, again, []model.StreetSnapImage{{Image: "uniq-d.jpg", SortOrder: 1}}) if err != nil { t.Fatalf("软删后应可再插入同实体键: %v", err) } diff --git a/internal/repository/ingest_repository.go b/internal/repository/ingest_repository.go index b86d2af..975b0af 100644 --- a/internal/repository/ingest_repository.go +++ b/internal/repository/ingest_repository.go @@ -15,7 +15,8 @@ import ( // IngestRepository 爬虫入库管线专属仓储:任务队列(ingest_jobs)+ nonce 防重放 // (ingest_nonces)+ 走秀正式表写入(brand_runways / brand_runway_images)。 // -// 实体键:走秀 = brand_id + season_code + collection_type,街拍 = city + year。 +// 实体键:走秀 = brand_id + season_code + collection_type,街拍 = city + year + title +// (同城同年可有多个专题,title 是数据里区分专题的字段)。 // 单表发布模型下入库直写正式表(status=pending),实体键命中后按既有行状态分三支: // pending/published → 放弃本次入库;rejected → 复用该行重审。因此一个秀/街拍始终只留一行。 // @@ -41,8 +42,9 @@ type IngestRepository interface { // ReuseRejectedRunway 复用一条 rejected 走秀:覆盖内容字段、软删旧图、写入新图、置回 pending(事务内完成)。 // 驳回因此不是永久黑名单:重爬同一实体即重新送审,且始终「一个实体一行」。 ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error - // StreetSnapEntityState 按实体键(city + year)查正式表既有行,返回其状态。 - StreetSnapEntityState(ctx context.Context, city string, year uint16) (id uint32, status string, found bool, err error) + // StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。 + // title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。 + StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (id uint32, status string, found bool, err error) // CreateStreetSnapWithImages 新建街拍正式行(status=pending)并写入图片(事务内完成)。 CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error) // ReuseRejectedStreetSnap 复用一条 rejected 街拍记录(事务内完成)。 @@ -333,7 +335,11 @@ func (r *ingestRepository) ReuseRejectedRunway(ctx context.Context, id uint32, r }) } -func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city string, year uint16) (uint32, string, bool, error) { +// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。 +// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致:唯一索引里 NULL 互不冲突, +// 若这里写成裸 title,查重口径会比索引更严,导致重爬同城同年(title 为 NULL 的历史行)时误判未命中、 +// 进而插入一行注定被索引拒绝的记录。 +func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (uint32, string, bool, error) { var row struct { ID uint32 `gorm:"column:id"` Status string `gorm:"column:status"` @@ -341,7 +347,7 @@ func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city strin err := r.db.WithContext(ctx). Model(&model.StreetSnap{}). Select("id, status"). - Where("city = ? AND year = ? AND is_deleted = 0", city, year). + Where("city = ? AND year = ? AND COALESCE(title, '') = ? AND is_deleted = 0", city, year, title). Limit(1). Scan(&row).Error if err != nil { diff --git a/internal/service/ingest_service.go b/internal/service/ingest_service.go index ffd4abd..29ceea7 100644 --- a/internal/service/ingest_service.go +++ b/internal/service/ingest_service.go @@ -417,8 +417,10 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL // processStreet 街拍入库:实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 street_snaps(无品牌)。 func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) { - // 1) 实体键查重并决定分支(单表模型,同 runway) - reuseID, existStatus, exist, err := s.repo.StreetSnapEntityState(ctx, p.City, p.Year) + // 1) 实体键查重并决定分支(单表模型,同 runway)。 + // 实体键含 title:与下方 snap.Title 用的是同一个 p.TitleEn,保证查重口径与落库内容一致, + // 同城同年的不同专题(Day 2 / Day 3)因此不会被误判为重复。 + reuseID, existStatus, exist, err := s.repo.StreetSnapEntityState(ctx, p.City, p.Year, p.TitleEn) if err != nil { // 同 runway:读失败不得当「未命中」,否则会给同一实体留两行。fail-closed + 退避重试。 s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())