Files
backend_v2/internal/repository/article_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

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