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, reason 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, reason 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 // 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} } 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 } q := r.db.WithContext(ctx).Model(&model.BrandRunway{}).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.BrandRunway{}, 0, nil } var rows []model.BrandRunway if err := q. 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。 func (r *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error { res := r.db.WithContext(ctx). Model(&model.BrandRunway{}). Where("id = ?", id). Updates(map[string]any{ "status": status, "reviewer": reviewer, "reject_reason": reason, "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 } q := r.db.WithContext(ctx).Model(&model.StreetSnap{}).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.StreetSnap{}, 0, nil } var rows []model.StreetSnap if err := q. 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)。 func (r *reviewRepository) SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error { res := r.db.WithContext(ctx). Model(&model.StreetSnap{}). Where("id = ?", id). Updates(map[string]any{ "status": status, "reviewer": reviewer, "reject_reason": reason, "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 }