package repository import ( "context" "errors" "time" "fashionapi/internal/dto" "fashionapi/internal/model" "gorm.io/gorm" ) // ReviewRepository 审核数据访问接口。 // // 单表发布模型下,审核对象就是正式表里的记录本身(不再有草稿表): // 列出待审记录、查看详情、审核前微调字段、通过(置 published)/ 驳回(置 rejected)。 type ReviewRepository interface { // ListRecords 分页列出记录;status 为空表示全部,否则按状态过滤。排序:pending 优先,其次 id 倒序。 ListRecords(ctx context.Context, status string, page, size int) ([]model.BrandRunway, int64, error) // RecordDetail 查单条走秀记录(不含图片)。 RecordDetail(ctx context.Context, id uint32) (*model.BrandRunway, error) // RecordImages 查走秀记录的全部未删图片,按排序值升序。 RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error) // UpdateRecord 审核前微调字段(白名单,杜绝任意列注入)。 UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error // SetRecordStatus 设置状态(通过/驳回)与审核人;记录不存在返回 ErrNotFound。 SetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error // 街拍镜像方法(对象为 street_snaps / street_snap_images)。 ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error) StreetRecordDetail(ctx context.Context, id uint32) (*model.StreetSnap, error) StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error) UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error // SetStreetRecordStatus 设置状态;记录不存在返回 ErrNotFound。 SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error // SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并按实际存活**主图**行数重算 image_count。 SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error // SoftDeleteStreetImage 软删单张街拍图片,并按实际存活行数重算 image_count。 SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error // AttachStreetSnapImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。 // mainImgID 必须属于同一记录、未被软删、且自身是主图(is_detail=0),否则返回 ErrInvalidImageGroup。 // 若某个被并入的图自身还带着副图,则它原有的副图一并改挂到 mainImgID —— 避免「副图的副图」。 // 幂等:已是 mainImgID 副图的行重复并入不报错。 AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error // DetachStreetSnapImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。对主图调用是幂等成功。 DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error // AttachRunwayImages 走秀版「合并选中为一组」:把 imgIDs 变为主图 mainImgID 的明细图。 // 与 AttachStreetSnapImages 同构(按 parent_image_id 分组):先改挂孙辈,再把被并入图标为副图。 AttachRunwayImages(ctx context.Context, runwayID, mainImgID uint32, imgIDs []uint32) error // DetachRunwayImage 走秀版「拆出」:把明细图恢复为独立主图(is_detail=0,parent_image_id 清零)。 DetachRunwayImage(ctx context.Context, runwayID, imgID uint32) error // GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。 GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error) // CountByStatus 统计某表(dest 传表对应的零值指针,如 &model.BrandRunway{})中指定状态的行数。 CountByStatus(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} } // reviewListScope 把「未软删 + 可选状态过滤」编译为 GORM Scope。 // // 用 Scope 而非在 Count 之后复用同一个 *gorm.DB:GORM v2 中终结方法(Count 等)之后再复用 // 同一实例会带上残留的 Statement 状态,Scope 每次作用于全新查询即可杜绝该隐患 //(与 article_repository.filterScope 同一理由)。 func reviewListScope(status string) func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { db = db.Where("is_deleted = 0") if status != "" { db = db.Where("status = ?", status) } return db } } func (r *reviewRepository) ListRecords(ctx context.Context, status string, page, size int) ([]model.BrandRunway, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 100 { size = 20 } scope := reviewListScope(status) var total int64 if err := r.db.WithContext(ctx).Model(&model.BrandRunway{}).Scopes(scope).Count(&total).Error; err != nil { return nil, 0, err } if total == 0 { return []model.BrandRunway{}, 0, nil } var rows []model.BrandRunway if err := r.db.WithContext(ctx).Model(&model.BrandRunway{}).Scopes(scope). Order("CASE status WHEN '" + model.StatusPending + "' 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) RecordDetail(ctx context.Context, id uint32) (*model.BrandRunway, error) { var d model.BrandRunway 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) RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error) { var imgs []model.BrandRunwayImage err := r.db.WithContext(ctx). Where("runway_id = ? AND is_deleted = 0", recordID). Order("sort_order ASC, id ASC"). Find(&imgs).Error return imgs, err } // recordEditable 审核前可微调的字段白名单(键即列名)。 var recordEditable = 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, } // UpdateRecord 只更新白名单内的字段,其余忽略,杜绝任意列注入。 func (r *reviewRepository) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error { clean := map[string]any{} for k, v := range fields { if recordEditable[k] { clean[k] = v } } if len(clean) == 0 { return nil } clean["updated_at"] = uint32(time.Now().Unix()) return r.db.WithContext(ctx). Model(&model.BrandRunway{}). Where("id = ?", id). Updates(clean).Error } // SetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound。 // 否则「对不存在的 id 通过 / 驳回」会被当作成功:handler 只校验 id != 0,id 又来自 URL, // 竞态窗口(记录刚被删除)下会给出假成功 302。 // // 有意**不校验前置状态**:允许把 rejected 直接置 published(或反向),不做状态机约束。 // 列表页只对 pending 显示通过 / 驳回按钮,但 POST 端点本身不设防 —— 这是已知取舍 //(幂等重放 / 管理端纠正误操作都依赖它);若日后要收紧,应改在 handler 或显式状态机里做, // 而不是悄悄在这个仓储方法里加限制。 func (r *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error { res := r.db.WithContext(ctx). Model(&model.BrandRunway{}). Where("id = ?", id). Updates(map[string]any{ "status": status, "reviewer": reviewer, "updated_at": uint32(time.Now().Unix()), }) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return ErrNotFound } return nil } // CountByStatus 统计传入表(dest 零值指针)中指定状态的行数;供各 ReviewModule 复用。 func (r *reviewRepository) CountByStatus(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) ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 100 { size = 20 } scope := reviewListScope(status) var total int64 if err := r.db.WithContext(ctx).Model(&model.StreetSnap{}).Scopes(scope).Count(&total).Error; err != nil { return nil, 0, err } if total == 0 { return []model.StreetSnap{}, 0, nil } var rows []model.StreetSnap if err := r.db.WithContext(ctx).Model(&model.StreetSnap{}).Scopes(scope). Order("CASE status WHEN '" + model.StatusPending + "' 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) StreetRecordDetail(ctx context.Context, id uint32) (*model.StreetSnap, error) { var d model.StreetSnap 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) StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error) { var imgs []model.StreetSnapImage err := r.db.WithContext(ctx). Where("snap_id = ? AND is_deleted = 0", recordID). Order("sort_order ASC, id ASC"). Find(&imgs).Error return imgs, err } // streetRecordEditable 街拍记录可微调字段白名单(title / year / city)。 var streetRecordEditable = map[string]bool{ "title": true, "year": true, "city": true, } func (r *reviewRepository) UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error { clean := map[string]any{} for k, v := range fields { if streetRecordEditable[k] { clean[k] = v } } if len(clean) == 0 { return nil } clean["updated_at"] = uint32(time.Now().Unix()) return r.db.WithContext(ctx). Model(&model.StreetSnap{}). Where("id = ?", id). Updates(clean).Error } // SetStreetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound(理由同 SetRecordStatus)。 // 同样有意不校验前置状态:允许 rejected 直接置 published,属已知取舍(见 SetRecordStatus)。 func (r *reviewRepository) SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error { res := r.db.WithContext(ctx). Model(&model.StreetSnap{}). Where("id = ?", id). Updates(map[string]any{ "status": status, "reviewer": reviewer, "updated_at": uint32(time.Now().Unix()), }) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return ErrNotFound } return nil } // ── 图片软删 ──────────────────────────────────────────────────────────────── // SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),按实际存活主图行数重算 image_count。 // 不删S4文件(与正式表既有删除行为一致:仅 DB 标记,避免误删被引用文件)。 // // 为什么是「重算」而不是「递减 image_count - 1」:递减的前提是「本次调用必然删掉一行」, // 而 imgID 完全来自 URL 路径、没有归属校验 —— 过期页面 / 重复提交 / 跨记录的重放都会命中 0 行, // GORM 不报错,递减却照常执行,于是只减计数不删图(GREATEST 只钳到 0、永不回补)。 // 旧流程靠审核通过时按实际图片行重算能抹平这种漂移;单表发布模型删掉了晋升重建, // 漂移会永久固化在已发布记录上,并直接显示在公开列表卡片的「N 张」角标里。 // // 口径与入库一致:只计主图(is_detail = 0)——见 ingest_service.processRunway 的 imageCount = len(p.Looks), // 以及旧 SaveRunwayFromDraft 的 mainCount。细节图不计入。 func (r *reviewRepository) SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error { if err := r.db.WithContext(ctx). Model(&model.BrandRunwayImage{}). Where("id = ? AND runway_id = ? AND is_deleted = 0", imgID, recordID). 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_runways SET image_count = (SELECT COUNT(*) FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 AND is_detail = 0) WHERE id = ?`, recordID, recordID).Error } // SoftDeleteStreetImage 软删单张街拍图片,按实际存活行数重算 image_count(理由同 SoftDeleteRunwayImage)。 // // 口径与街拍正式表的历史行为一致:计全部存活图片行(主图 + 副图), // 见旧 SaveStreetSnapFromDraft 的 image_count = len(imgs);街拍副图是审核期人工并入的, // 与走秀「is_detail 是入库时的细节图」语义不同。 func (r *reviewRepository) SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error { if err := r.db.WithContext(ctx). Model(&model.StreetSnapImage{}). Where("id = ? AND snap_id = ? AND is_deleted = 0", imgID, recordID). 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_snaps SET image_count = (SELECT COUNT(*) FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0) WHERE id = ?`, recordID, recordID).Error } // GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。 // kind 决定查哪张图片表(runway / street);未知 kind 返回 ErrNotFound。 func (r *reviewRepository) GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error) { switch kind { case dto.IngestKindRunway: var im model.BrandRunwayImage err := r.db.WithContext(ctx).Where("id = ? AND runway_id = ?", imgID, recordID).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.StreetSnapImage err := r.db.WithContext(ctx).Where("id = ? AND snap_id = ?", imgID, recordID).First(&im).Error if errors.Is(err, gorm.ErrRecordNotFound) { return "", nil } if err != nil { return "", err } return im.Image, nil default: return "", ErrNotFound } } // ── 街拍主副图分组 ────────────────────────────────────────────────────────── // AttachStreetSnapImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。 // // 事务内三步:1) 校验主图存在、未软删、本身是主图;2) 先「改挂孙辈」——被并入图原有的副图 // 改挂到新主图(避免副图的副图);3) 再把被并入图自身标为副图。第 2 步必须在第 3 步之前。 func (r *reviewRepository) AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error { ids := make([]uint32, 0, len(imgIDs)) for _, id := range imgIDs { if id != 0 && id != mainImgID { ids = append(ids, id) } } if mainImgID == 0 || len(imgIDs) == 0 { // 主图无效,或传入集合本身就是空的(调用方连一张待并入图都没给)。 return ErrInvalidImageGroup } if len(ids) == 0 { // 集合里只有主图自身:视为无操作成功(忽略),不报错也不改动主图。 return nil } return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var main model.StreetSnapImage if err := tx.Where("id = ? AND snap_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, snapID). First(&main).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrInvalidImageGroup } return err } now := uint32(time.Now().Unix()) if err := tx.Model(&model.StreetSnapImage{}). Where("snap_id = ? AND is_deleted = 0 AND parent_image_id IN ?", snapID, ids). Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil { return err } return tx.Model(&model.StreetSnapImage{}). Where("snap_id = ? AND is_deleted = 0 AND id IN ?", snapID, ids). Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error }) } // DetachStreetSnapImage 把副图恢复为主图。对主图调用为幂等成功。 // 拆出的图若自身带着副图,那些副图保持挂在它下面——它重新成为主图,不变量依然成立。 func (r *reviewRepository) DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error { return r.db.WithContext(ctx). Model(&model.StreetSnapImage{}). Where("id = ? AND snap_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, snapID). Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}). Error } // AttachRunwayImages 走秀版「合并选中为一组」:把 imgIDs 变为主图 mainImgID 的明细图。 // // 与 AttachStreetSnapImages 同构(均按 parent_image_id 分组):事务内先「改挂孙辈」 // (被并入图原有的副图 parent_image_id IN ids 改挂到新主图),再把被并入图自身标为副图并指向新主图。 func (r *reviewRepository) AttachRunwayImages(ctx context.Context, runwayID, mainImgID uint32, imgIDs []uint32) error { ids := make([]uint32, 0, len(imgIDs)) for _, id := range imgIDs { if id != 0 && id != mainImgID { ids = append(ids, id) } } if mainImgID == 0 || len(imgIDs) == 0 { return ErrInvalidImageGroup } if len(ids) == 0 { return nil // 集合里只有主图自身:无操作成功 } return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var main model.BrandRunwayImage if err := tx.Where("id = ? AND runway_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, runwayID). First(&main).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrInvalidImageGroup } return err } now := uint32(time.Now().Unix()) // 先改挂孙辈:被并入图原有的副图(parent_image_id IN ids)改挂到新主图。 if err := tx.Model(&model.BrandRunwayImage{}). Where("runway_id = ? AND is_deleted = 0 AND parent_image_id IN ?", runwayID, ids). Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil { return err } // 再把被并入图自身标为副图并指向新主图。 return tx.Model(&model.BrandRunwayImage{}). Where("runway_id = ? AND is_deleted = 0 AND id IN ?", runwayID, ids). Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error }) } // DetachRunwayImage 走秀版「拆出」:把明细图恢复为独立主图(is_detail=0,parent_image_id 清零)。 // 同主图下其余明细图仍挂在该主图之下,不变量成立。 func (r *reviewRepository) DetachRunwayImage(ctx context.Context, runwayID, imgID uint32) error { return r.db.WithContext(ctx). Model(&model.BrandRunwayImage{}). Where("id = ? AND runway_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, runwayID). Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}). Error } // RepairRunwayDetailParents 按 sort_order 顺序回填走秀细节图的 parent_image_id: // 每张 is_detail=1 的图挂到它之前最近的一张 is_detail=0(主图)之下。 // 入库时细节图紧随其主图之后(同一 Look 先主图后细节),故「最近主图」即其归属主图。 // 供入库后调用,使新入库的走秀自动成组,无需人工合并。 func (r *reviewRepository) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error { var imgs []model.BrandRunwayImage if err := r.db.WithContext(ctx). Where("runway_id = ? AND is_deleted = 0", runwayID). Order("sort_order ASC, id ASC"). Find(&imgs).Error; err != nil { return err } lastMain := uint32(0) type fix struct{ id, parent uint32 } fixes := make([]fix, 0) for _, im := range imgs { if im.IsDetail == 0 { lastMain = im.ID continue } if im.IsDetail == 1 && im.ParentImageID == 0 && lastMain != 0 { fixes = append(fixes, fix{im.ID, lastMain}) } } for _, f := range fixes { if err := r.db.WithContext(ctx). Model(&model.BrandRunwayImage{}). Where("id = ? AND runway_id = ? AND is_deleted = 0", f.id, runwayID). Update("parent_image_id", f.parent).Error; err != nil { return err } } return nil }