Files
backend_v2/internal/repository/street_snap_repository.go
toom1996 f04b5707cb fix(publish): 最终审查补修(编辑页删图重算计数、迁移编号后置、图片 id 稳定断言)
D1 编辑页删图:DeleteRunwayImage/DeleteSnapImage 在单事务内取父 id→软删→按存活行重算 image_count,口径与审核侧 SoftDelete*Image 完全一致(走秀只计主图、街拍计全部),并补集成测试(含重复删同一张)

D2 不可逆删表迁移 02→05(git mv),使文件名顺序 01→01b→03→04→05 与必需执行顺序一致;同步 README/文件头/规格/计划全部引用

D3 新增「通过审核后图片行 id 集合完全一致」的集成断言,锁住单表发布头号收益

D4 修正 01 迁移文件头「只做三件事」自相矛盾表述;D5 删除受版本控制的陈旧产物 cmd/dbtool/db_dump.json 并加 gitignore 规则;D6 清理遗留空目录 scripts/tmp_verify/
2026-09-23 16:27:14 +08:00

345 lines
13 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,
"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").Where("id = ?", imageID).First(&img).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if 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
}
return 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
})
}
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
}
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
}