fix(publish): 补齐实体键唯一索引的测试覆盖与守卫消息
This commit is contained in:
@ -74,4 +74,18 @@ func TestStreetEntityKeyUnique(t *testing.T) {
|
|||||||
if _, err := repo.CreateStreetSnapWithImages(ctx, dup, []model.StreetSnapImage{{Image: "uniq-b.jpg", SortOrder: 1}}); err == nil {
|
if _, err := repo.CreateStreetSnapWithImages(ctx, dup, []model.StreetSnapImage{{Image: "uniq-b.jpg", SortOrder: 1}}); err == nil {
|
||||||
t.Fatalf("同一实体键的第二行应被唯一索引拒绝,实际插入成功")
|
t.Fatalf("同一实体键的第二行应被唯一索引拒绝,实际插入成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 软删首行后,实体键不再占用(与 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}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("软删后应可再插入同实体键: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", newID)
|
||||||
|
db.Exec("DELETE FROM street_snaps WHERE id = ?", newID)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -315,7 +315,7 @@ func (r *ingestRepository) ReuseRejectedRunway(ctx context.Context, id uint32, r
|
|||||||
return uErr
|
return uErr
|
||||||
}
|
}
|
||||||
if upd.RowsAffected == 0 {
|
if upd.RowsAffected == 0 {
|
||||||
return fmt.Errorf("runway %d 已不是 rejected 状态,复用前置条件失效", id)
|
return fmt.Errorf("runway %d 复用前置条件失效:该行已不是 rejected 状态或已被软删", id)
|
||||||
}
|
}
|
||||||
if dErr := tx.Model(&model.BrandRunwayImage{}).
|
if dErr := tx.Model(&model.BrandRunwayImage{}).
|
||||||
Where("runway_id = ? AND is_deleted = 0", id).
|
Where("runway_id = ? AND is_deleted = 0", id).
|
||||||
@ -398,7 +398,7 @@ func (r *ingestRepository) ReuseRejectedStreetSnap(ctx context.Context, id uint3
|
|||||||
return uErr
|
return uErr
|
||||||
}
|
}
|
||||||
if upd.RowsAffected == 0 {
|
if upd.RowsAffected == 0 {
|
||||||
return fmt.Errorf("street snap %d 已不是 rejected 状态,复用前置条件失效", id)
|
return fmt.Errorf("street snap %d 复用前置条件失效:该行已不是 rejected 状态或已被软删", id)
|
||||||
}
|
}
|
||||||
if dErr := tx.Model(&model.StreetSnapImage{}).
|
if dErr := tx.Model(&model.StreetSnapImage{}).
|
||||||
Where("snap_id = ? AND is_deleted = 0", id).
|
Where("snap_id = ? AND is_deleted = 0", id).
|
||||||
|
|||||||
@ -9,6 +9,7 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@ -39,7 +40,13 @@ func applyMigration(t *testing.T, db *gorm.DB, name string) {
|
|||||||
func seedRunway(t *testing.T, db *gorm.DB, status string, year uint16) uint32 {
|
func seedRunway(t *testing.T, db *gorm.DB, status string, year uint16) uint32 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
now := uint32(1_800_000_000)
|
now := uint32(1_800_000_000)
|
||||||
rw := model.BrandRunway{TitleEn: "view-test", Year: year, Status: status, CreatedAt: now, UpdatedAt: now}
|
// 实体键必须非空且按 year 派生:2026-09-22-03 的唯一索引落库后,brand_id=0 且
|
||||||
|
// season_code / collection_type 均为空会让同一次运行里的多条种子行(乃至上次运行
|
||||||
|
// 未清理的遗留行)被当成「同一实体」而互相冲突,失败点还会伪装成迁移体检。
|
||||||
|
rw := model.BrandRunway{
|
||||||
|
TitleEn: "view-test", Year: year, Status: status, CreatedAt: now, UpdatedAt: now,
|
||||||
|
SeasonCode: fmt.Sprintf("T%04d", year), CollectionType: "t",
|
||||||
|
}
|
||||||
if err := db.Create(&rw).Error; err != nil {
|
if err := db.Create(&rw).Error; err != nil {
|
||||||
t.Fatalf("插入走秀失败: %v", err)
|
t.Fatalf("插入走秀失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -246,8 +246,10 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
seasonCode := season.Derive(p.Year, p.CollectionType, p.Season)
|
seasonCode := season.Derive(p.Year, p.CollectionType, p.Season)
|
||||||
reuseID, existStatus, exist, err := s.repo.RunwayEntityState(ctx, brandID, seasonCode, p.CollectionType)
|
reuseID, existStatus, exist, err := s.repo.RunwayEntityState(ctx, brandID, seasonCode, p.CollectionType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 读失败绝不能当成「未命中」:实体键上没有唯一约束兜底,误判为新建会给同一实体留两行,
|
// 读失败绝不能当成「未命中」:误判为新建会走一次注定冲突的写入——实体键唯一索引
|
||||||
// 破坏「一个实体一行」的不变式。fail-closed + 退避重试。
|
//(2026-09-22-03)会把它拒成一次可重试的写库错误,重试时实体键已能查到该行(pending)
|
||||||
|
// 而直接跳过。但唯一索引是最后一道防线,不该靠它来替代这里的前置判断,
|
||||||
|
// 故仍 fail-closed + 退避重试,不做无谓的写尝试。
|
||||||
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user