feat(publish): 街拍实体键细化为 city+year+title,支持同城同年多专题

This commit is contained in:
toom1996
2026-09-23 13:04:37 +08:00
parent d8b3424663
commit ce4f8b786d
4 changed files with 57 additions and 13 deletions

View File

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

View File

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