package repository import ( "context" "errors" "math" "strconv" "time" "fashionapi/internal/dto" "fashionapi/internal/model" "fashionapi/internal/pkg/phash" "gorm.io/gorm" ) // StreetSnapRepository 街拍数据访问接口。 type StreetSnapRepository interface { // List 按筛选条件分页查询街拍,同时返回符合条件的总数。 List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) // ListAdmin 后台管理列表:读基表(含 pending / rejected),与 article_repository.ListAdmin 对称。 ListAdmin(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) // FindByID 公开读:查询单条街拍;不存在时返回 ErrNotFound(pending / rejected 视为不存在)。 FindByID(ctx context.Context, id string) (*model.StreetSnap, error) // FindByIDBase 后台读基表:与 FindByID 同形,但能看到 pending / rejected。 // 后台列表故意展示待审行并链到详情页,详情必须走基表,否则待审项点进去 404。 FindByIDBase(ctx context.Context, id string) (*model.StreetSnap, error) // ListImages 公开读:查询某条街拍的全部图片,按排序值升序(未发布图片不可见)。 ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) // ListImagesBase 后台读基表:与 ListImages 同形,但含未发布图片(后台详情/编辑需要)。 ListImagesBase(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) // ImagesBySnapIDs 批量查询多条街拍的图片,单次 IN 查询避免 N+1。 ImagesBySnapIDs(ctx context.Context, ids []uint32) (map[uint32][]model.StreetSnapImage, error) // Popular 按图片数降序返回热度前 N 个街拍,供 SSG 首页热门街拍使用。 Popular(ctx context.Context, limit int) ([]model.StreetSnap, error) // GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。 GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) // UpdateSnap 编辑后更新字段(白名单,杜绝任意列注入)。 UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error // DeleteSnapImage 软删单张街拍图片,并按实际存活图片行数重算 image_count。 DeleteSnapImage(ctx context.Context, imageID uint32) error // GetSnapImageKey 取单张街拍图片的S4 key(删除前清理用);不存在返回空串。 GetSnapImageKey(ctx context.Context, imageID uint32) (string, error) // GetStreetSnapDupSources 批量反查重复源:给定一组街拍图片 id(dup_of 指向的源图 id), // 返回每张源图及其所属街拍的上下文,供编辑页标记重复图时展示来源。 GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) // GetSnapImageRunwayID 取图片所属 snap_id(不过滤 is_deleted,软删后仍可取,供删图后重算去重)。 GetSnapImageRunwayID(ctx context.Context, imageID uint32) (uint32, error) // ReresolveSnapDedup 删图后重算本专辑去重,消除陈旧 dup_of(见方法注释)。 ReresolveSnapDedup(ctx context.Context, snapID uint32) error // SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用),返回被删图片的 key 列表。 SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error) // SetSnapCover 更新街拍封面。 SetSnapCover(ctx context.Context, snapID uint32, image string) error // SetSnapDeleted 上下架:deleted=1 下架,0 上架。 SetSnapDeleted(ctx context.Context, id uint32, deleted uint8) error } type streetSnapRepository struct { db *gorm.DB } // NewStreetSnapRepository 创建街拍仓储。 func NewStreetSnapRepository(db *gorm.DB) StreetSnapRepository { return &streetSnapRepository{db: db} } // filterScope 把查询条件编译为 GORM Scope(白名单化,杜绝 SQL 注入)。 func snapFilterScope(q dto.StreetSnapQuery) func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { db = db.Where("is_deleted = 0") if q.Year != 0 { db = db.Where("year = ?", q.Year) } if q.City != "" { db = db.Where("city = ?", q.City) } return db } } // snapOrderBy 把 sort 参数映射为 ORDER BY(白名单,杜绝注入)。 func snapOrderBy(sort string) string { switch sort { case "year_asc": return "street_snaps.year ASC, street_snaps.id ASC" case "year_desc": return "street_snaps.year DESC, street_snaps.id DESC" default: // image_count return "street_snaps.image_count DESC, street_snaps.id DESC" } } // List 公开读:读公开视图,pending / rejected 街拍不可见。 func (r *streetSnapRepository) List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) { scope := snapFilterScope(q) var total int64 if err := r.db.WithContext(ctx). Table(publicStreetSnaps). 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). Table(publicStreetSnaps). Scopes(scope). Order(snapOrderBy(q.Sort)). Offset(q.Offset()). Limit(q.Size). Find(&rows).Error; err != nil { return nil, 0, err } return rows, total, nil } // ListAdmin 后台管理列表:与 List 同构,但读基表 —— 后台必须看得到 pending / rejected, // 否则审核员在列表页看不到待审内容(公开 List 读视图,会把这些行挡掉)。 func (r *streetSnapRepository) ListAdmin(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) { scope := snapFilterScope(q) 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(snapOrderBy(q.Sort)). Offset(q.Offset()). Limit(q.Size). Find(&rows).Error; err != nil { return nil, 0, err } return rows, total, nil } // FindByID 公开读:读公开视图,pending / rejected 街拍视同不存在(返回 ErrNotFound)。 func (r *streetSnapRepository) FindByID(ctx context.Context, id string) (*model.StreetSnap, error) { return r.findByID(r.db.WithContext(ctx).Table(publicStreetSnaps), id) } // FindByIDBase 后台读基表:不过滤 status(后台详情需渲染待审内容)。 func (r *streetSnapRepository) FindByIDBase(ctx context.Context, id string) (*model.StreetSnap, error) { return r.findByID(r.db.WithContext(ctx), id) } // findByID 用给定查询(视图或基表)取单行,公开/后台口径只在数据来源上不同。 func (r *streetSnapRepository) findByID(q *gorm.DB, id string) (*model.StreetSnap, error) { var snap model.StreetSnap err := q.Where("id = ? AND is_deleted = 0", id).First(&snap).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } return nil, err } return &snap, nil } // ListImages 公开读:读公开视图,未发布街拍的图片不可见(视图已含 is_deleted 过滤)。 func (r *streetSnapRepository) ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) { var imgs []model.StreetSnapImage err := r.db.WithContext(ctx). Table(publicStreetSnapImages). Where("snap_id = ?", snapID). Order("sort_order ASC, id ASC"). Find(&imgs).Error return imgs, err } // ListImagesBase 读基表(含未发布),供后台 GetForEdit / 后台详情使用 —— 否则待审内容看不到图。 func (r *streetSnapRepository) ListImagesBase(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) { var imgs []model.StreetSnapImage err := r.db.WithContext(ctx). Where("snap_id = ? AND is_deleted = 0", snapID). Order("sort_order ASC, id ASC"). Find(&imgs).Error return imgs, err } func (r *streetSnapRepository) ImagesBySnapIDs(ctx context.Context, ids []uint32) (map[uint32][]model.StreetSnapImage, error) { result := make(map[uint32][]model.StreetSnapImage, len(ids)) if len(ids) == 0 { return result, nil } var imgs []model.StreetSnapImage err := r.db.WithContext(ctx). Table(publicStreetSnapImages). Select("snap_id, image, name, sort_order"). Where("snap_id IN ?", ids). Order("sort_order ASC, id ASC"). Find(&imgs).Error if err != nil { return nil, err } for _, im := range imgs { result[im.SnapID] = append(result[im.SnapID], im) } return result, nil } // Popular 按图片数降序返回热度前 N 个街拍(SSG 首页热门街拍用)。 func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.StreetSnap, error) { var rows []model.StreetSnap if err := r.db.WithContext(ctx). Table(publicStreetSnaps). Where("is_deleted = 0"). Order("image_count DESC, id DESC"). Limit(limit). Find(&rows).Error; err != nil { return nil, err } return rows, nil } // ── 后台编辑支持 ────────────────────────────────────────────────────────────── // snapEditable 后台可编辑的街拍字段白名单(键即列名)。 var snapEditable = map[string]bool{ "title": true, "title_cn": true, "year": true, "city": true, "cover": true, } func (r *streetSnapRepository) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) { var snap model.StreetSnap if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&snap).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, ErrNotFound } return nil, nil, err } imgs, err := r.ListImagesBase(ctx, strconv.FormatUint(uint64(id), 10)) if err != nil { return nil, nil, err } return &snap, imgs, nil } func (r *streetSnapRepository) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error { clean := map[string]any{} for k, v := range fields { if snapEditable[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 } // DeleteSnapImage 软删单张街拍图片(后台正式编辑页删图),并同步重算 image_count。 // // 与审核侧 SoftDeleteStreetImage 口径一致:删完按**实际存活图片行数**重算 // (`is_deleted = 0`,主图 + 副图全部计入)。 // // 为什么必须重算而不是「递减 1」:理由同 articleRepository.DeleteRunwayImage —— 单表发布模型 // 删掉了晋升重建,编辑页删图造成的计数漂移不再被抹平,会永久显示在公开列表卡片的「N 张」上。 // // 入参只有 imageID(拿不到父记录 id),故在一个事务里三步走:取 snap_id → 软删 → 重算。 // 图片行不存在时与旧行为一致:静默无操作(不报错、不重算)。 func (r *streetSnapRepository) DeleteSnapImage(ctx context.Context, imageID uint32) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var img model.StreetSnapImage err := tx.Select("snap_id, image").Where("id = ?", imageID).First(&img).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil } if err != nil { return err } // 取出父记录的封面,判断被删图是否正是封面(防止封面悬空指向已删图 —— 设计规格 §12 唯一真数据丢失窗口)。 var snap model.StreetSnap if err := tx.Select("cover").Where("id = ?", img.SnapID).First(&snap).Error; err != nil { return err } if err := tx.Model(&model.StreetSnapImage{}). Where("id = ? AND is_deleted = 0", imageID). Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil { return err } if err := tx.Exec(`UPDATE street_snaps SET image_count = (SELECT COUNT(*) FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0) WHERE id = ?`, img.SnapID, img.SnapID).Error; err != nil { return err } // 被删图是封面:重指向另一张存活图(无存活图则清空),避免封面悬空。 if snap.Cover == img.Image { cover := "" var survivor struct { Image string `gorm:"column:image"` } serr := tx.Raw( "SELECT image FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0 ORDER BY id ASC LIMIT 1", img.SnapID, ).Scan(&survivor).Error if serr == nil { cover = survivor.Image } else if !errors.Is(serr, gorm.ErrRecordNotFound) { return serr } if err := tx.Model(&model.StreetSnap{}).Where("id = ?", img.SnapID). Updates(map[string]any{"cover": cover, "updated_at": uint32(time.Now().Unix())}).Error; err != nil { return err } } return nil }) } func (r *streetSnapRepository) GetSnapImageKey(ctx context.Context, imageID uint32) (string, error) { var img model.StreetSnapImage err := r.db.WithContext(ctx).Where("id = ?", imageID).First(&img).Error if errors.Is(err, gorm.ErrRecordNotFound) { return "", nil } if err != nil { return "", err } return img.Image, nil } // GetSnapImageRunwayID 取图片所属 snap_id(不过滤 is_deleted,软删后仍可取,供编辑页删图后重算去重)。 func (r *streetSnapRepository) GetSnapImageRunwayID(ctx context.Context, imageID uint32) (uint32, error) { var img model.StreetSnapImage err := r.db.WithContext(ctx).Select("snap_id").Where("id = ?", imageID).First(&img).Error if errors.Is(err, gorm.ErrRecordNotFound) { return 0, nil } if err != nil { return 0, err } return img.SnapID, nil } // nearDupExceptSnap 在 street_snap_images 内做全局近重复检索(is_deleted=0),但排除自身与 // 「同 snap 且 sort_order 在后的图」——防止重算时后入者反成源导致互指环。命中返回源图 id,无则 0。 func nearDupExceptSnap(db *gorm.DB, phashBits string, threshold int, excludeID, snapID, sortOrder uint32) (uint32, error) { l2Limit := math.Sqrt(float64(threshold)) var row struct { ID uint32 `gorm:"column:id"` } if err := db.Table("street_snap_images"). Select("id, (phash <-> ?::vector) AS dist", phashBits). Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit). Where("is_deleted = 0"). Where("id <> ?", excludeID). Where("(snap_id <> ? OR sort_order < ?)", snapID, sortOrder). Order("dist ASC"). Limit(1). Scan(&row).Error; err != nil { return 0, err } return row.ID, nil } // ReresolveSnapDedup 删除图片后重解本专辑去重:先清空全部存活图的 is_duplicate/dup_of, // 再按 sort_order 逐张以「全局近重复」重新判定(与入库 dedupImage 同口径:先入者为准)。 // // 为什么需要:is_duplicate/dup_of 是入库时一次算死的存储标记,编辑页手动删图从不重算;删除「源图」 // 后剩余图仍挂陈旧 dup_of,导致编辑页误显「重复」且「查看重复来源」指向已删源图而打不开。重算后 // 被删源图不再作为参照,剩余近重复图重新确立「先入者为准」的源/副本关系。 func (r *streetSnapRepository) ReresolveSnapDedup(ctx context.Context, snapID uint32) error { var imgs []model.StreetSnapImage if err := r.db.WithContext(ctx). Where("snap_id = ? AND is_deleted = 0", snapID). Order("sort_order ASC, id ASC"). Find(&imgs).Error; err != nil { return err } if len(imgs) == 0 { return nil } return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Model(&model.StreetSnapImage{}). Where("snap_id = ? AND is_deleted = 0", snapID). Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil { return err } for _, im := range imgs { if !im.Phash.Valid || im.Phash.String == "" { continue } src, err := nearDupExceptSnap(tx, im.Phash.String, phash.DefaultThreshold, im.ID, snapID, im.SortOrder) if err != nil { return err } if src != 0 && src != im.ID { if err := tx.Model(&model.StreetSnapImage{}). Where("id = ? AND is_deleted = 0", im.ID). Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(src), 10)}).Error; err != nil { return err } } } return nil }) } // GetStreetSnapDupSources 批量反查重复源(见接口注释)。街拍无品牌关联;一次 IN 查询 + JOIN。 func (r *streetSnapRepository) GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) { out := make(map[uint32]dto.DupSource, len(imageIDs)) if len(imageIDs) == 0 { return out, nil } type srcRow struct { ImageID uint32 Image string SnapID uint32 Title string TitleCn string } var rows []srcRow err := r.db.WithContext(ctx). Table("street_snap_images ssi"). Select("ssi.id AS image_id, ssi.image AS image, ssi.snap_id AS snap_id, ss.title AS title, ss.title_cn AS title_cn"). Joins("JOIN street_snaps ss ON ss.id = ssi.snap_id"). Where("ssi.id IN ?", imageIDs). Where("ssi.is_deleted = 0"). Find(&rows).Error if err != nil { return nil, err } for _, rw := range rows { title := rw.Title if title == "" { title = rw.TitleCn } out[rw.ImageID] = dto.DupSource{Image: rw.Image, RecordID: rw.SnapID, Title: title} } return out, nil } // SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用)。 // 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。 func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error) { var imgs []model.StreetSnapImage if err := r.db.WithContext(ctx). Select("image"). Where("snap_id = ? AND is_deleted = 0", snapID). Find(&imgs).Error; err != nil { return nil, err } keys := make([]string, 0, len(imgs)) for _, im := range imgs { if im.Image != "" { keys = append(keys, im.Image) } } if err := r.db.WithContext(ctx). Model(&model.StreetSnapImage{}). Where("snap_id = ? AND is_deleted = 0", snapID). Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil { return nil, err } // 全部图片软删:封面必然悬空(封面本就指向被删图之一),直接清空避免悬空指针。 if err := r.db.WithContext(ctx). Model(&model.StreetSnap{}). Where("id = ?", snapID). Updates(map[string]any{"cover": "", "updated_at": uint32(time.Now().Unix())}).Error; err != nil { return nil, err } return keys, nil } func (r *streetSnapRepository) SetSnapCover(ctx context.Context, snapID uint32, image string) error { return r.db.WithContext(ctx). Model(&model.StreetSnap{}). Where("id = ?", snapID). Updates(map[string]any{"cover": image, "updated_at": uint32(time.Now().Unix())}).Error } func (r *streetSnapRepository) SetSnapDeleted(ctx context.Context, id uint32, deleted uint8) error { return r.db.WithContext(ctx). Model(&model.StreetSnap{}). Where("id = ?", id). Updates(map[string]any{"is_deleted": deleted, "updated_at": uint32(time.Now().Unix())}).Error }