381 lines
14 KiB
Go
381 lines
14 KiB
Go
package repository
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strconv"
|
||
"time"
|
||
|
||
"fashionapi/internal/dto"
|
||
"fashionapi/internal/model"
|
||
|
||
"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)
|
||
// 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
|
||
}
|
||
|
||
// 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
|
||
}
|