From e0ce81318d68925f677c0b0cfa5065885625d812 Mon Sep 17 00:00:00 2001 From: toom1996 <23cm.cn@gmail.com> Date: Wed, 23 Sep 2026 10:56:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(publish):=20=E8=A1=A5=E9=BD=90=E5=AE=9E?= =?UTF-8?q?=E4=BD=93=E9=94=AE=E5=94=AF=E4=B8=80=E7=B4=A2=E5=BC=95=E7=9A=84?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96=E4=B8=8E=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity_key_unique_integration_test.go | 14 ++++++++++++++ internal/repository/ingest_repository.go | 4 ++-- .../repository/publish_status_integration_test.go | 9 ++++++++- internal/service/ingest_service.go | 6 ++++-- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/internal/repository/entity_key_unique_integration_test.go b/internal/repository/entity_key_unique_integration_test.go index 10b876d..cd12663 100644 --- a/internal/repository/entity_key_unique_integration_test.go +++ b/internal/repository/entity_key_unique_integration_test.go @@ -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 { 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) + }) } diff --git a/internal/repository/ingest_repository.go b/internal/repository/ingest_repository.go index 6c3a1e6..b86d2af 100644 --- a/internal/repository/ingest_repository.go +++ b/internal/repository/ingest_repository.go @@ -315,7 +315,7 @@ func (r *ingestRepository) ReuseRejectedRunway(ctx context.Context, id uint32, r return uErr } if upd.RowsAffected == 0 { - return fmt.Errorf("runway %d 已不是 rejected 状态,复用前置条件失效", id) + return fmt.Errorf("runway %d 复用前置条件失效:该行已不是 rejected 状态或已被软删", id) } if dErr := tx.Model(&model.BrandRunwayImage{}). Where("runway_id = ? AND is_deleted = 0", id). @@ -398,7 +398,7 @@ func (r *ingestRepository) ReuseRejectedStreetSnap(ctx context.Context, id uint3 return uErr } 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{}). Where("snap_id = ? AND is_deleted = 0", id). diff --git a/internal/repository/publish_status_integration_test.go b/internal/repository/publish_status_integration_test.go index 71a8076..c62a44f 100644 --- a/internal/repository/publish_status_integration_test.go +++ b/internal/repository/publish_status_integration_test.go @@ -9,6 +9,7 @@ package repository import ( "context" + "fmt" "os" "path/filepath" "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 { t.Helper() 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 { t.Fatalf("插入走秀失败: %v", err) } diff --git a/internal/service/ingest_service.go b/internal/service/ingest_service.go index f9fdd05..ffd4abd 100644 --- a/internal/service/ingest_service.go +++ b/internal/service/ingest_service.go @@ -246,8 +246,10 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob, seasonCode := season.Derive(p.Year, p.CollectionType, p.Season) reuseID, existStatus, exist, err := s.repo.RunwayEntityState(ctx, brandID, seasonCode, p.CollectionType) if err != nil { - // 读失败绝不能当成「未命中」:实体键上没有唯一约束兜底,误判为新建会给同一实体留两行, - // 破坏「一个实体一行」的不变式。fail-closed + 退避重试。 + // 读失败绝不能当成「未命中」:误判为新建会走一次注定冲突的写入——实体键唯一索引 + //(2026-09-22-03)会把它拒成一次可重试的写库错误,重试时实体键已能查到该行(pending) + // 而直接跳过。但唯一索引是最后一道防线,不该靠它来替代这里的前置判断, + // 故仍 fail-closed + 退避重试,不做无谓的写尝试。 s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error()) return }