package repository import ( "context" "errors" "time" "fashionapi/internal/dto" "fashionapi/internal/model" "gorm.io/gorm" ) // ReviewRepository 审核草稿的数据访问接口。 // // 草稿由爬虫 worker 写入(见 IngestRepository),本接口负责「读 + 晋升」: // 列出待审草稿、查看详情、审核前微调字段、通过时晋升到正式表、驳回时标记状态。 type ReviewRepository interface { // ListDrafts 分页列出草稿;status 为空表示全部,否则按状态过滤。 // 排序:pending 优先,其次按 id 倒序(最新在前)。 ListDrafts(ctx context.Context, status string, page, size int) ([]model.BrandRunwayDraft, int64, error) // DraftDetail 查单条草稿(不含图片)。 DraftDetail(ctx context.Context, id uint32) (*model.BrandRunwayDraft, error) // DraftImages 查草稿的全部图片,按排序值升序。 DraftImages(ctx context.Context, draftID uint32) ([]model.BrandRunwayDraftImage, error) // UpdateDraft 审核前微调草稿字段(白名单,杜绝任意列注入)。 UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error // SetDraftStatus 设置草稿状态(通过/驳回)与审核人/驳回理由。 SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error // SaveRunwayFromDraft 晋升:按实体键(brand_id + season_code + collection_type)幂等 upsert 到 brand_runway 正式表并替换图片,返回正式表主键。 SaveRunwayFromDraft(ctx context.Context, draftID uint32) (uint32, error) // 街拍草稿镜像方法(与 runway 同构,但对象为 street_snap_draft / street_snap)。 ListStreetDrafts(ctx context.Context, status string, page, size int) ([]model.StreetSnapDraft, int64, error) StreetDraftDetail(ctx context.Context, id uint32) (*model.StreetSnapDraft, error) StreetDraftImages(ctx context.Context, draftID uint32) ([]model.StreetSnapDraftImage, error) // SoftDeleteRunwayDraftImage 软删单张走秀草稿图片(审核阶段剔除坏图),并同步 image_count。 SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error // SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。 SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error // GetDraftImageKey 取单张草稿图片的七牛 key(删除前清理用);不存在返回空串。 GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error) UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error SetStreetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error) // CountDraftPending 统计某草稿表(dest 传表对应的零值指针,如 &model.BrandRunwayDraft{})中指定状态的草稿数。 // 供各 ReviewModule 复用,新增模块无需再单独写计数方法。 CountDraftPending(ctx context.Context, status string, dest any) (int64, error) } type reviewRepository struct { db *gorm.DB } // NewReviewRepository 创建审核仓储。 func NewReviewRepository(db *gorm.DB) ReviewRepository { return &reviewRepository{db: db} } func (r *reviewRepository) ListDrafts(ctx context.Context, status string, page, size int) ([]model.BrandRunwayDraft, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 100 { size = 20 } q := r.db.WithContext(ctx).Model(&model.BrandRunwayDraft{}).Where("is_deleted = 0") if status != "" { q = q.Where("status = ?", status) } var total int64 if err := q.Count(&total).Error; err != nil { return nil, 0, err } if total == 0 { return []model.BrandRunwayDraft{}, 0, nil } var rows []model.BrandRunwayDraft if err := q. Order("CASE status WHEN '" + model.DraftStatusPending + "' THEN 0 ELSE 1 END ASC, id DESC"). Offset((page - 1) * size). Limit(size). Find(&rows).Error; err != nil { return nil, 0, err } return rows, total, nil } func (r *reviewRepository) DraftDetail(ctx context.Context, id uint32) (*model.BrandRunwayDraft, error) { var d model.BrandRunwayDraft if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } return nil, err } return &d, nil } func (r *reviewRepository) DraftImages(ctx context.Context, draftID uint32) ([]model.BrandRunwayDraftImage, error) { var imgs []model.BrandRunwayDraftImage err := r.db.WithContext(ctx). Where("draft_id = ? AND is_deleted = 0", draftID). Order("sort_order ASC, id ASC"). Find(&imgs).Error return imgs, err } // draftEditable 审核前可微调的字段白名单(键即列名)。 var draftEditable = map[string]bool{ "title_en": true, "title_cn": true, "description_en": true, "description_cn": true, "year": true, "season": true, "collection_type": true, "season_code": true, } // UpdateDraft 只更新白名单内的字段,其余忽略,杜绝任意列注入。 func (r *reviewRepository) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error { clean := map[string]any{} for k, v := range fields { if draftEditable[k] { clean[k] = v } } if len(clean) == 0 { return nil } clean["updated_at"] = uint32(time.Now().Unix()) return r.db.WithContext(ctx). Model(&model.BrandRunwayDraft{}). Where("id = ?", id). Updates(clean).Error } func (r *reviewRepository) SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error { return r.db.WithContext(ctx). Model(&model.BrandRunwayDraft{}). Where("id = ?", id). Updates(map[string]any{ "status": status, "reviewer": reviewer, "reject_reason": reason, "updated_at": uint32(time.Now().Unix()), }).Error } // SaveRunwayFromDraft 晋升草稿到正式表: // 1. 按实体键(brand_id + season_code + collection_type)查找已存在的正式走秀(幂等:重审同一草稿只更新,不重复建行); // 2. 存在则更新全部可编辑字段 + 图片数;不存在则新建; // 3. 替换图片:软删旧图(is_deleted=1)+ 按草稿图片重建; // 4. 返回正式表主键。 // // 在事务内完成,保证草稿与正式表一致性。 func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint32) (uint32, error) { var draft model.BrandRunwayDraft if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", draftID).First(&draft).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return 0, ErrNotFound } return 0, err } // 多来源聚合:当前草稿图 + 同实体已 approved 的其他草稿图(按 image URL 去重, // 七牛 key 已是 sha1,同图 URL 必相同)。当前正被审核的草稿强制纳入,其余只取已审核来源。 curImgs, err := r.DraftImages(ctx, draftID) if err != nil { return 0, err } sibImgs, err := r.runwayApprovedSiblingImages(ctx, draftID, draft.BrandID, draft.SeasonCode, draft.CollectionType) if err != nil { return 0, err } imgs := unionRunwayDraftImages(curImgs, sibImgs) // image_count 仅计主图(is_detail=0):对外列表卡片「N 张」与详情默认画廊都只展示主图, // 细节图作为主图的附属按需展开,不计入主图数。 mainCount := 0 for _, im := range imgs { if im.IsDetail == 0 { mainCount++ } } now := uint32(time.Now().Unix()) var runwayID uint32 err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 1) 按实体键查找既有正式走秀 var existing model.BrandRunway if eErr := tx.Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", draft.BrandID, draft.SeasonCode, draft.CollectionType). Limit(1).Find(&existing).Error; eErr != nil { return eErr } common := map[string]any{ "title_en": draft.TitleEn, "title_cn": draft.TitleCn, "description_en": draft.DescriptionEn, "description_cn": draft.DescriptionCn, "year": draft.Year, "season": draft.Season, "collection_type": draft.CollectionType, "season_code": draft.SeasonCode, "cover": draft.Cover, "image_count": uint16(mainCount), "updated_at": now, } if existing.ID != 0 { runwayID = existing.ID common["brand_id"] = existing.BrandID // 保留正式表的品牌归属 if uErr := tx.Model(&model.BrandRunway{}).Where("id = ?", runwayID).Updates(common).Error; uErr != nil { return uErr } } else { rw := &model.BrandRunway{ BrandID: draft.BrandID, TitleEn: draft.TitleEn, TitleCn: draft.TitleCn, DescriptionEn: draft.DescriptionEn, DescriptionCn: draft.DescriptionCn, Year: draft.Year, Season: draft.Season, CollectionType: draft.CollectionType, SeasonCode: draft.SeasonCode, Cover: draft.Cover, ImageCount: uint16(mainCount), CreatedAt: now, UpdatedAt: now, } if cErr := tx.Create(rw).Error; cErr != nil { return cErr } runwayID = rw.ID } // 2) 替换图片:软删旧 + 重建 if dErr := tx.Model(&model.BrandRunwayImage{}). Where("runway_id = ? AND is_deleted = 0", runwayID). Updates(map[string]any{"is_deleted": 1, "updated_at": now}).Error; dErr != nil { return dErr } if len(imgs) > 0 { rows := make([]model.BrandRunwayImage, 0, len(imgs)) for i, im := range imgs { rows = append(rows, model.BrandRunwayImage{ RunwayID: runwayID, BrandID: draft.BrandID, Image: im.Image, Name: im.Name, SortOrder: uint32(i + 1), LookIndex: im.LookIndex, IsDetail: im.IsDetail, Phash: im.Phash, IsDuplicate: im.IsDuplicate, DupOf: im.DupOf, CreatedAt: now, UpdatedAt: now, }) } if cErr := tx.Create(&rows).Error; cErr != nil { return cErr } } return nil }) if err != nil { return 0, err } return runwayID, nil } // CountDraftPending 统计传入表(dest 零值指针)中指定状态的草稿数;供各 ReviewModule 复用。 func (r *reviewRepository) CountDraftPending(ctx context.Context, status string, dest any) (int64, error) { var n int64 if err := r.db.WithContext(ctx). Model(dest). Where("status = ? AND is_deleted = 0", status). Count(&n).Error; err != nil { return 0, err } return n, nil } // ---- 街拍草稿镜像实现(与 runway 同构) ---- func (r *reviewRepository) ListStreetDrafts(ctx context.Context, status string, page, size int) ([]model.StreetSnapDraft, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 100 { size = 20 } q := r.db.WithContext(ctx).Model(&model.StreetSnapDraft{}).Where("is_deleted = 0") if status != "" { q = q.Where("status = ?", status) } var total int64 if err := q.Count(&total).Error; err != nil { return nil, 0, err } if total == 0 { return []model.StreetSnapDraft{}, 0, nil } var rows []model.StreetSnapDraft if err := q. Order("CASE status WHEN '" + model.DraftStatusPending + "' THEN 0 ELSE 1 END ASC, id DESC"). Offset((page - 1) * size). Limit(size). Find(&rows).Error; err != nil { return nil, 0, err } return rows, total, nil } func (r *reviewRepository) StreetDraftDetail(ctx context.Context, id uint32) (*model.StreetSnapDraft, error) { var d model.StreetSnapDraft if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } return nil, err } return &d, nil } func (r *reviewRepository) StreetDraftImages(ctx context.Context, draftID uint32) ([]model.StreetSnapDraftImage, error) { var imgs []model.StreetSnapDraftImage err := r.db.WithContext(ctx). Where("draft_id = ? AND is_deleted = 0", draftID). Order("sort_order ASC, id ASC"). Find(&imgs).Error return imgs, err } // streetDraftEditable 街拍草稿可微调字段白名单(title / year / city)。 var streetDraftEditable = map[string]bool{ "title": true, "year": true, "city": true, } func (r *reviewRepository) UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error { clean := map[string]any{} for k, v := range fields { if streetDraftEditable[k] { clean[k] = v } } if len(clean) == 0 { return nil } clean["updated_at"] = uint32(time.Now().Unix()) return r.db.WithContext(ctx). Model(&model.StreetSnapDraft{}). Where("id = ?", id). Updates(clean).Error } func (r *reviewRepository) SetStreetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error { return r.db.WithContext(ctx). Model(&model.StreetSnapDraft{}). Where("id = ?", id). Updates(map[string]any{ "status": status, "reviewer": reviewer, "reject_reason": reason, "updated_at": uint32(time.Now().Unix()), }).Error } // SaveStreetSnapFromDraft 晋升街拍草稿到正式表 street_snap(按实体键 city+year 幂等 upsert,替换图片)。 // 逻辑与 SaveRunwayFromDraft 同构,但无 brand_id、有 city,图片明细落到 street_snap_images。 // 在事务内完成,保证草稿与正式表一致性。 func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error) { var draft model.StreetSnapDraft if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", draftID).First(&draft).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return 0, ErrNotFound } return 0, err } curImgs, err := r.StreetDraftImages(ctx, draftID) if err != nil { return 0, err } sibImgs, err := r.streetApprovedSiblingImages(ctx, draftID, draft.City, draft.Year) if err != nil { return 0, err } imgs := unionStreetDraftImages(curImgs, sibImgs) now := uint32(time.Now().Unix()) var snapID uint32 err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var existing model.StreetSnap if eErr := tx.Where("city = ? AND year = ? AND is_deleted = 0", draft.City, draft.Year). Limit(1).Find(&existing).Error; eErr != nil { return eErr } common := map[string]any{ "title": draft.Title, "year": draft.Year, "city": draft.City, "cover": draft.Cover, "image_count": uint16(len(imgs)), "updated_at": now, } if existing.ID != 0 { snapID = existing.ID if uErr := tx.Model(&model.StreetSnap{}).Where("id = ?", snapID).Updates(common).Error; uErr != nil { return uErr } } else { snap := &model.StreetSnap{ Title: draft.Title, Year: draft.Year, City: draft.City, Cover: draft.Cover, ImageCount: uint16(len(imgs)), CreatedAt: now, UpdatedAt: now, } if cErr := tx.Create(snap).Error; cErr != nil { return cErr } snapID = snap.ID } // 替换图片:软删旧 + 重建 if dErr := tx.Model(&model.StreetSnapImage{}). Where("snap_id = ? AND is_deleted = 0", snapID). Updates(map[string]any{"is_deleted": 1, "updated_at": now}).Error; dErr != nil { 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, }) } if cErr := tx.Create(&rows).Error; cErr != nil { return cErr } } return nil }) if err != nil { return 0, err } return snapID, nil } // runwayApprovedSiblingImages 返回同实体(品牌+季节码+系列)且已 approved 的其他草稿的图片, // 用于多来源聚合:当前正被审核的草稿强制纳入,其余仅合并已审核来源,避免未审图泄露进正式表。 func (r *reviewRepository) runwayApprovedSiblingImages(ctx context.Context, excludeDraftID, brandID uint32, seasonCode, collectionType string) ([]model.BrandRunwayDraftImage, error) { var drafts []model.BrandRunwayDraft if err := r.db.WithContext(ctx). Where("brand_id = ? AND season_code = ? AND collection_type = ? AND status = ? AND is_deleted = 0 AND id <> ?", brandID, seasonCode, collectionType, model.DraftStatusApproved, excludeDraftID). Find(&drafts).Error; err != nil { return nil, err } out := make([]model.BrandRunwayDraftImage, 0) for _, d := range drafts { imgs, err := r.DraftImages(ctx, d.ID) if err != nil { return nil, err } out = append(out, imgs...) } return out, nil } // unionRunwayDraftImages 按 image URL 去重合并(七牛 key 已是 sha1,同图 URL 必相同)。当前草稿图优先。 func unionRunwayDraftImages(a, b []model.BrandRunwayDraftImage) []model.BrandRunwayDraftImage { seen := make(map[string]struct{}, len(a)+len(b)) out := make([]model.BrandRunwayDraftImage, 0, len(a)+len(b)) for _, im := range a { if _, ok := seen[im.Image]; ok { continue } seen[im.Image] = struct{}{} out = append(out, im) } for _, im := range b { if _, ok := seen[im.Image]; ok { continue } seen[im.Image] = struct{}{} out = append(out, im) } return out } // streetApprovedSiblingImages 同 city+year 已 approved 的其他街拍草稿图(多来源聚合)。 func (r *reviewRepository) streetApprovedSiblingImages(ctx context.Context, excludeDraftID uint32, city string, year uint16) ([]model.StreetSnapDraftImage, error) { var drafts []model.StreetSnapDraft if err := r.db.WithContext(ctx). Where("city = ? AND year = ? AND status = ? AND is_deleted = 0 AND id <> ?", city, year, model.DraftStatusApproved, excludeDraftID). Find(&drafts).Error; err != nil { return nil, err } out := make([]model.StreetSnapDraftImage, 0) for _, d := range drafts { imgs, err := r.StreetDraftImages(ctx, d.ID) if err != nil { return nil, err } out = append(out, imgs...) } return out, nil } // unionStreetDraftImages 按 image URL 去重合并。当前草稿图优先。 func unionStreetDraftImages(a, b []model.StreetSnapDraftImage) []model.StreetSnapDraftImage { seen := make(map[string]struct{}, len(a)+len(b)) out := make([]model.StreetSnapDraftImage, 0, len(a)+len(b)) for _, im := range a { if _, ok := seen[im.Image]; ok { continue } seen[im.Image] = struct{}{} out = append(out, im) } for _, im := range b { if _, ok := seen[im.Image]; ok { continue } seen[im.Image] = struct{}{} out = append(out, im) } return out } // ── 草稿图片删除 ──────────────────────────────────────────────────────────────── // SoftDeleteRunwayDraftImage 软删单张走秀草稿图片(审核阶段剔除坏图),并同步草稿 image_count。 // 不删七牛文件(与正式表 DeleteRunwayImage 行为一致:仅 DB 标记,避免误删被引用文件)。 func (r *reviewRepository) SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error { if err := r.db.WithContext(ctx). Model(&model.BrandRunwayDraftImage{}). Where("id = ? AND draft_id = ? AND is_deleted = 0", imgID, draftID). Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil { return err } return r.db.WithContext(ctx). Exec("UPDATE brand_runway_draft SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error } // SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。 func (r *reviewRepository) SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error { if err := r.db.WithContext(ctx). Model(&model.StreetSnapDraftImage{}). Where("id = ? AND draft_id = ? AND is_deleted = 0", imgID, draftID). Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil { return err } return r.db.WithContext(ctx). Exec("UPDATE street_snap_draft SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error } // GetDraftImageKey 取单张草稿图片的七牛 key(删除前清理用);不存在返回空串。 // kind 决定查哪张草稿图片表(runway / street);未知 kind 返回 ErrNotFound。 func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error) { switch kind { case dto.IngestKindRunway: var im model.BrandRunwayDraftImage err := r.db.WithContext(ctx).Where("id = ? AND draft_id = ?", imgID, draftID).First(&im).Error if errors.Is(err, gorm.ErrRecordNotFound) { return "", nil } if err != nil { return "", err } return im.Image, nil case dto.IngestKindStreet: var im model.StreetSnapDraftImage err := r.db.WithContext(ctx).Where("id = ? AND draft_id = ?", imgID, draftID).First(&im).Error if errors.Is(err, gorm.ErrRecordNotFound) { return "", nil } if err != nil { return "", err } return im.Image, nil default: return "", ErrNotFound } }