package repository import ( "context" "errors" "strconv" "time" "fashionapi/internal/dto" "fashionapi/internal/model" "gorm.io/gorm" ) // ArticleRepository 走秀档案(文章)数据访问接口。 type ArticleRepository interface { // List 按筛选条件分页查询文章,同时返回符合条件的总数。 List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error) // ListAdmin 后台管理列表:投影列含 season/year/collection_type/season_code 等管理字段。 ListAdmin(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error) // FindByID 公开读:查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。 FindByID(ctx context.Context, id string) (*model.RunwayRow, error) // FindByIDBase 后台读基表:与 FindByID 同投影/同 JOIN,但能看到 pending / rejected。 // 后台列表故意展示待审行并链到详情页,详情必须走基表,否则待审项点进去 404。 FindByIDBase(ctx context.Context, id string) (*model.RunwayRow, error) // ListImages 公开读:查询某篇文章的全部图片,按排序值升序(未发布图片不可见)。 ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) // ListImagesBase 后台读基表:与 ListImages 同形,但含未发布图片(后台详情/编辑需要)。 ListImagesBase(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) // ImagesByRunwayIDs 批量查询多篇文章的图片,单次 IN 查询避免 N+1。 ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error) // GetForEdit 取原始走秀记录(含全部 en/cn 字段)与图片,供后台编辑表单预填。 GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error) // UpdateRunway 审核/编辑后更新字段(白名单,杜绝任意列注入)。 UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error // DeleteRunwayImage 软删单张走秀图片,并按实际存活主图行数重算 image_count。 DeleteRunwayImage(ctx context.Context, imageID uint32) error // GetRunwayImageKey 取单张走秀图片的S4 key(删除前清理用);不存在返回空串。 GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error) // SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用),返回被删图片的 key 列表。 SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error) // SetRunwayCover 更新走秀封面(同时回到图片列表首张)。 SetRunwayCover(ctx context.Context, runwayID uint32, image string) error // SetRunwayDeleted 上下架:deleted=1 下架(对外不可见),0 上架。 SetRunwayDeleted(ctx context.Context, id uint32, deleted uint8) error } type articleRepository struct { db *gorm.DB } // NewArticleRepository 创建文章仓储。 func NewArticleRepository(db *gorm.DB) ArticleRepository { return &articleRepository{db: db} } // selectColumns 列表查询的投影列:只取对外需要的字段,不做 SELECT *。 // 注意:year / season / season_code / collection_type / created_at 等仅用于排序,不 SELECT 到响应里 // (前端按年份/季节筛选是走查询参数,响应无需回带这些元数据)。 const articleListColumns = `brand_runways.id, brand_runways.brand_id, brand_runways.title_en, brand_runways.title_cn, brand_runways.cover, brand_runways.image_count, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn` const articleDetailColumns = `brand_runways.id, brand_runways.title_en, brand_runways.title_cn, brand_runways.description_en, brand_runways.description_cn, brand_runways.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn` // articleAdminColumns 后台管理列表投影列:在列表列基础上补回 season / year / // collection_type / season_code / status 等管理字段(前端列表不展示这些)。 const articleAdminColumns = `brand_runways.id, brand_runways.brand_id, brand_runways.title_en, brand_runways.title_cn, brand_runways.cover, brand_runways.image_count, brand_runways.year, brand_runways.collection_type, brand_runways.season, brand_runways.season_code, brand_runways.status, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn` // brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。 const brandJoin = "LEFT JOIN brands b ON b.id = brand_runways.brand_id AND b.is_deleted = 0" // filterScope 把查询条件编译为 GORM Scope。 // // 用 Scope 而非复用同一个 *gorm.DB:GORM v2 中在 Count 等终结方法之后复用同一实例 // 会带上残留的 Statement 状态,Scope 每次作用于全新查询,杜绝这类隐患。 func filterScope(q dto.ArticleQuery) func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { db = db.Where("brand_runways.is_deleted = 0") // 按品牌过滤来自列表接口的 ?brand_id 查询参数(q.BrandIDs 由 handler 解码注入)。 db = whereMulti(db, "brand_runways.brand_id", toAnySlice(q.BrandIDs)) if q.Collection != "" { db = db.Where("brand_runways.collection_type = ?", q.Collection) } if q.Season != "" { db = db.Where("brand_runways.season = ?", q.Season) } if q.Year != 0 { db = db.Where("brand_runways.year = ?", q.Year) } return db } } // whereMulti 按值数量选择 = 或 IN。 func whereMulti(db *gorm.DB, column string, values []any) *gorm.DB { switch len(values) { case 0: return db case 1: return db.Where(column+" = ?", values[0]) default: return db.Where(column+" IN ?", values) } } func toAnySlice[T any](in []T) []any { if len(in) == 0 { return nil } out := make([]any, 0, len(in)) for _, v := range in { out = append(out, v) } return out } // orderBy 把 sort 参数映射为 ORDER BY 子句。 // // 白名单映射而非直接拼接用户输入,从根上排除 SQL 注入; // 每个分支都以 id 兜底,保证分页结果稳定不跳行。 // // newest(默认):按走秀真实新旧排序 —— created_at 仅是导入时间,不能代表走秀新旧, // 故用 year DESC + season_code 优先级(FW>PF>SS>RES)+ id 兜底,与首页「最新走秀」口径一致。 func orderBy(sort string) string { seasonPrio := `CASE brand_runways.season_code WHEN 'FW' THEN 4 WHEN 'PF' THEN 3 WHEN 'SS' THEN 2 WHEN 'RES' THEN 1 ELSE 0 END` switch sort { case "year_asc": return "brand_runways.year ASC, brand_runways.id ASC" case "year_desc": return "brand_runways.year DESC, brand_runways.id DESC" default: // newest return "brand_runways.year DESC, " + seasonPrio + " DESC, brand_runways.id DESC" } } func (r *articleRepository) List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error) { scope := filterScope(q) var total int64 if err := r.db.WithContext(ctx). Table(publicRunways). Scopes(scope). Count(&total).Error; err != nil { return nil, 0, err } if total == 0 { return []model.RunwayRow{}, 0, nil } var rows []model.RunwayRow if err := r.db.WithContext(ctx). Table(publicRunways). Scopes(scope). Select(articleListColumns). Joins(brandJoin). Order(orderBy(q.Sort)). Offset(q.Offset()). Limit(q.Size). Scan(&rows).Error; err != nil { return nil, 0, err } return rows, total, nil } // ListAdmin 后台管理列表:与 List 共用筛选/排序,但投影列含管理字段。 func (r *articleRepository) ListAdmin(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error) { scope := filterScope(q) var total int64 if err := r.db.WithContext(ctx). Model(&model.BrandRunway{}). Scopes(scope). Count(&total).Error; err != nil { return nil, 0, err } if total == 0 { return []model.RunwayRow{}, 0, nil } var rows []model.RunwayRow if err := r.db.WithContext(ctx). Model(&model.BrandRunway{}). Scopes(scope). Select(articleAdminColumns). Joins(brandJoin). Order(orderBy(q.Sort)). Offset(q.Offset()). Limit(q.Size). Scan(&rows).Error; err != nil { return nil, 0, err } return rows, total, nil } // FindByID 公开读:读公开视图,pending / rejected 视同不存在(返回 ErrNotFound)。 func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.RunwayRow, error) { return r.findByID(r.db.WithContext(ctx).Table(publicRunways), id) } // FindByIDBase 后台读基表:同投影同 JOIN,但不过滤 status(后台详情需渲染待审内容)。 func (r *articleRepository) FindByIDBase(ctx context.Context, id string) (*model.RunwayRow, error) { return r.findByID(r.db.WithContext(ctx).Model(&model.BrandRunway{}), id) } // findByID 用给定查询(视图或基表)取单行;投影与 JOIN 两处保持一致,避免公开/后台口径漂移。 func (r *articleRepository) findByID(q *gorm.DB, id string) (*model.RunwayRow, error) { var row model.RunwayRow err := q.Select(articleDetailColumns). Joins(brandJoin). Where("brand_runways.id = ? AND brand_runways.is_deleted = 0", id). First(&row).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } return nil, err } return &row, nil } // ListImages 公开读:读公开视图,未发布走秀的图片不可见(视图已含 is_deleted 过滤)。 func (r *articleRepository) ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) { var imgs []model.BrandRunwayImage err := r.db.WithContext(ctx). Table(publicRunwayImages). Where("runway_id = ?", runwayID). Order("sort_order ASC, id ASC"). Find(&imgs).Error return imgs, err } // ListImagesBase 读基表(含未发布),供后台 GetForEdit / 后台详情使用 —— 否则待审内容看不到图。 func (r *articleRepository) ListImagesBase(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) { var imgs []model.BrandRunwayImage err := r.db.WithContext(ctx). Where("runway_id = ? AND is_deleted = 0", runwayID). Order("sort_order ASC, id ASC"). Find(&imgs).Error return imgs, err } func (r *articleRepository) ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error) { result := make(map[uint32][]model.BrandRunwayImage, len(ids)) if len(ids) == 0 { return result, nil } var imgs []model.BrandRunwayImage err := r.db.WithContext(ctx). Table(publicRunwayImages). Select("runway_id, image, name, sort_order"). Where("runway_id IN ? AND is_detail = 0", ids). Order("sort_order ASC, id ASC"). Find(&imgs).Error if err != nil { return nil, err } for _, im := range imgs { result[im.RunwayID] = append(result[im.RunwayID], im) } return result, nil } // ── 后台编辑支持 ────────────────────────────────────────────────────────────── // runwayEditable 后台可编辑的走秀字段白名单(键即列名)。 var runwayEditable = 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, } func (r *articleRepository) GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error) { var rw model.BrandRunway if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&rw).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 &rw, imgs, nil } func (r *articleRepository) UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error { clean := map[string]any{} for k, v := range fields { if runwayEditable[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 } // DeleteRunwayImage 软删单张走秀图片(后台正式编辑页删图),并同步重算 image_count。 // // 与审核侧 SoftDeleteRunwayImage 口径一致:删完按**实际存活主图行数**重算 // (`is_deleted = 0 AND is_detail = 0`,细节图不计入)。 // // 为什么必须重算而不是「递减 1」:单表发布模型删掉了「晋升时按实际图片行重算」的自愈路径, // 编辑页删图若只递减,过期页面 / 重复提交 / 跨记录重放(命中 0 行仍递减)造成的漂移会永久固化, // 并直接显示在公开列表卡片的「N 张」角标里。 // // 入参只有 imageID(拿不到父记录 id),故在一个事务里三步走:取 runway_id → 软删 → 重算。 // 图片行不存在时与旧行为一致:静默无操作(不报错、不重算)。 func (r *articleRepository) DeleteRunwayImage(ctx context.Context, imageID uint32) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var img model.BrandRunwayImage err := tx.Select("runway_id, image").Where("id = ?", imageID).First(&img).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil } if err != nil { return err } // 取出父记录的封面,判断被删图是否正是封面(防止封面悬空指向已删图 —— 设计规格 §12 唯一真数据丢失窗口)。 var rw model.BrandRunway if err := tx.Select("cover").Where("id = ?", img.RunwayID).First(&rw).Error; err != nil { return err } if err := tx.Model(&model.BrandRunwayImage{}). 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 brand_runways SET image_count = (SELECT COUNT(*) FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 AND is_detail = 0) WHERE id = ?`, img.RunwayID, img.RunwayID).Error; err != nil { return err } // 被删图是封面:重指向另一张存活图(无存活图则清空),避免封面悬空。 if rw.Cover == img.Image { cover := "" var survivor struct { Image string `gorm:"column:image"` } serr := tx.Raw( "SELECT image FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 ORDER BY id ASC LIMIT 1", img.RunwayID, ).Scan(&survivor).Error if serr == nil { cover = survivor.Image } else if !errors.Is(serr, gorm.ErrRecordNotFound) { return serr } if err := tx.Model(&model.BrandRunway{}).Where("id = ?", img.RunwayID). Updates(map[string]any{"cover": cover, "updated_at": uint32(time.Now().Unix())}).Error; err != nil { return err } } return nil }) } func (r *articleRepository) GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error) { var img model.BrandRunwayImage 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 } // SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用)。 // 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。 func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error) { var imgs []model.BrandRunwayImage if err := r.db.WithContext(ctx). Select("image"). Where("runway_id = ? AND is_deleted = 0", runwayID). 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.BrandRunwayImage{}). Where("runway_id = ? AND is_deleted = 0", runwayID). 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.BrandRunway{}). Where("id = ?", runwayID). Updates(map[string]any{"cover": "", "updated_at": uint32(time.Now().Unix())}).Error; err != nil { return nil, err } return keys, nil } func (r *articleRepository) SetRunwayCover(ctx context.Context, runwayID uint32, image string) error { return r.db.WithContext(ctx). Model(&model.BrandRunway{}). Where("id = ?", runwayID). Updates(map[string]any{"cover": image, "updated_at": uint32(time.Now().Unix())}).Error } func (r *articleRepository) SetRunwayDeleted(ctx context.Context, id uint32, deleted uint8) error { return r.db.WithContext(ctx). Model(&model.BrandRunway{}). Where("id = ?", id). Updates(map[string]any{"is_deleted": deleted, "updated_at": uint32(time.Now().Unix())}).Error }