diff --git a/internal/model/street_snap.go b/internal/model/street_snap.go index 0601cd6..2ce3ccb 100644 --- a/internal/model/street_snap.go +++ b/internal/model/street_snap.go @@ -38,6 +38,11 @@ type StreetSnapImage struct { Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"` IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"` DupOf string `gorm:"column:dup_of;type:varchar(32);not null;default:''" json:"dup_of"` + + // 主副图分组(列由 db/migrations/2026-09-21-01-street-main-detail.sql 添加): + // is_detail=0 为主图(parent_image_id=0);=1 为副图,parent_image_id 指向同专辑内主图的行 id。 + IsDetail uint8 `gorm:"column:is_detail;not null;default:0" json:"is_detail"` + ParentImageID uint32 `gorm:"column:parent_image_id;not null;default:0" json:"parent_image_id"` } // TableName 指定图片明细表名。 diff --git a/internal/repository/review_repository.go b/internal/repository/review_repository.go index 35f1743..f579d25 100644 --- a/internal/repository/review_repository.go +++ b/internal/repository/review_repository.go @@ -449,22 +449,74 @@ func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID return dErr } if len(imgs) > 0 { - rows := make([]model.StreetSnapImage, 0, len(imgs)) - for i, im := range imgs { - rows = append(rows, model.StreetSnapImage{ - SnapID: snapID, - Image: im.Image, - Name: im.Name, - SortOrder: uint32(i + 1), - Phash: im.Phash, - IsDuplicate: im.IsDuplicate, - DupOf: im.DupOf, - CreatedAt: now, - UpdatedAt: now, - }) + // 第一遍:插入主图(is_detail=0),并记下「草稿行 id → 新正式行 id」的映射。 + // + // 必须两遍插入:副图的 parent_image_id 存的是**草稿表**的行 id, + // 而这里插入的正式行拿到的是全新 id。若先插副图,父引用就会指向草稿表的旧 id, + // 详情页折叠结构随之错乱(见计划「任务 2」标题下的说明)。 + newIDByDraftID := make(map[uint32]uint32, len(imgs)) + mains := make([]model.StreetSnapImage, 0, len(imgs)) + for _, im := range imgs { + if im.IsDetail == 1 { + continue + } + mains = append(mains, model.StreetSnapImage{ + SnapID: snapID, + Image: im.Image, + Name: im.Name, + SortOrder: uint32(len(mains) + 1), + IsDetail: 0, + Phash: im.Phash, + IsDuplicate: im.IsDuplicate, + DupOf: im.DupOf, + CreatedAt: now, + UpdatedAt: now, + }) } - if cErr := tx.Create(&rows).Error; cErr != nil { - return cErr + for i := range mains { + if cErr := tx.Create(&mains[i]).Error; cErr != nil { + return cErr + } + } + mainIdx := 0 + for _, im := range imgs { + if im.IsDetail == 1 { + continue + } + newIDByDraftID[im.ID] = mains[mainIdx].ID + mainIdx++ + } + + // 第二遍:插入副图,把父引用改写成新主图行 id。 + details := make([]model.StreetSnapImage, 0, len(imgs)-len(mains)) + for _, im := range imgs { + if im.IsDetail != 1 { + continue + } + newParent, ok := newIDByDraftID[im.ParentImageID] + row := model.StreetSnapImage{ + SnapID: snapID, + Image: im.Image, + Name: im.Name, + SortOrder: uint32(len(mains) + len(details) + 1), + IsDetail: 1, + Phash: im.Phash, + IsDuplicate: im.IsDuplicate, + DupOf: im.DupOf, + CreatedAt: now, + UpdatedAt: now, + } + if ok { + row.ParentImageID = newParent + } else { + row.IsDetail = 0 // 父行缺失:按主图渲染,与读侧容错口径一致 + } + details = append(details, row) + } + if len(details) > 0 { + if cErr := tx.Create(&details).Error; cErr != nil { + return cErr + } } } return nil diff --git a/internal/repository/street_main_detail_integration_test.go b/internal/repository/street_main_detail_integration_test.go index 7b69871..c949e9c 100644 --- a/internal/repository/street_main_detail_integration_test.go +++ b/internal/repository/street_main_detail_integration_test.go @@ -159,3 +159,50 @@ func TestStreetMainDetailReparentGrandchildren(t *testing.T) { } } } + +// TestStreetPromoteRebuildsParentRef 晋升时必须把副图的父引用从「草稿行 id」改写为「新正式行 id」。 +func TestStreetPromoteRebuildsParentRef(t *testing.T) { + repo := NewReviewRepository(testDB(t)) + ctx := context.Background() + + draftID := seedStreetDraft(t, repo) + m1 := seedStreetDraftImage(t, repo, draftID, "main", 1) + d2 := seedStreetDraftImage(t, repo, draftID, "detail-a", 2) + d3 := seedStreetDraftImage(t, repo, draftID, "detail-b", 3) + if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil { + t.Fatalf("预置主副图失败: %v", err) + } + + snapID, err := repo.SaveStreetSnapFromDraft(ctx, draftID) + if err != nil { + t.Fatalf("晋升失败: %v", err) + } + t.Cleanup(func() { + db := repo.(*reviewRepository).db + db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snapID) + db.Exec("DELETE FROM street_snaps WHERE id = ?", snapID) + }) + + db := repo.(*reviewRepository).db + var rows []model.StreetSnapImage + if err := db.WithContext(ctx).Where("snap_id = ? AND is_deleted = 0", snapID). + Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil { + t.Fatalf("读正式图失败: %v", err) + } + if len(rows) != 3 { + t.Fatalf("期望 3 行正式图,实际 %d", len(rows)) + } + + mainRow := rows[0] + if mainRow.IsDetail != 0 || mainRow.ParentImageID != 0 { + t.Fatalf("首行应是主图,实际 is_detail=%d parent=%d", mainRow.IsDetail, mainRow.ParentImageID) + } + for _, row := range rows[1:] { + if row.IsDetail != 1 { + t.Fatalf("第 %d 行应是副图", row.ID) + } + if row.ParentImageID != mainRow.ID { + t.Fatalf("副图 parent 应指向新主图行 id %d,实际 %d", mainRow.ID, row.ParentImageID) + } + } +}