Files
backend_v2/internal/repository/street_snap_repository.go
toom1996 42f6316125 update
2026-09-25 11:31:52 +08:00

381 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}