package repository import ( "context" "strings" "gorm.io/gorm" ) // AdminRecordCard 是「文章管理」混合列表的统一卡片:跨 brand_runways 与 street_snaps // 两张表,字段取两者并集的最小可读子集(类型由 Kind 区分)。 // // 为什么是 UNION 而非分别查两张表再在 Go 里拼:混合列表需要按(待审优先 + id 倒序) // 做跨表统一排序与分页,分别查两表再分页会得到错误的边界;一条 UNION 查询在数据库侧 // 完成排序与 LIMIT/OFFSET,分页语义才正确,也避免把整张表拉进内存。 type AdminRecordCard struct { Kind string // "runway" | "street" ID uint32 // 内部数值主键(供通过/驳回/批量接口使用) Title string Subtitle string // 走秀=品牌名;街拍=城市 ImageCount int Status string // pending / published / rejected } // DupStat 某记录的图片去重统计(仅统计 is_deleted=0 的存活行)。 type DupStat struct { Total int // 存活图片总数(含主图与细节图) Dup int // is_duplicate=1 的张数 } // AdminRepository 文章管理页所需的跨表只读查询。 type AdminRepository interface { // ListMixed 按状态 + 类型过滤(status 为空表示全部;kind 为空表示走秀+街拍, // "runway"/"street" 则只取对应一类),跨表统一排序后分页返回。 // 排序:pending 优先,其次 id 倒序(最新待审排最前)。 ListMixed(ctx context.Context, status, kind string, page, size int) ([]AdminRecordCard, int64, error) // CountMixed 统计某状态 + 类型下的记录总数(用于 tab 角标)。 CountMixed(ctx context.Context, status, kind string) (int64, error) // DupRateByRecords 批量统计若干记录的图片去重情况(仅存活行)。 // 返回 owner id → {Total 存活图片总数, Dup is_duplicate=1 张数};不存在的 id 不出现在 map 中。 DupRateByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]DupStat, error) } type adminRepository struct { db *gorm.DB } // NewAdminRepository 创建文章管理仓储。 func NewAdminRepository(db *gorm.DB) AdminRepository { return &adminRepository{db: db} } // legWhere 单条腿(带表别名)的过滤条件:is_deleted=0 是后台可见性的硬约束, // status 过滤可选。别名必须显式指定——两表 LEFT JOIN 时 is_deleted 会歧义。 func legWhere(alias, status string) (string, []interface{}) { where := alias + ".is_deleted = 0" args := []interface{}{} if status != "" { where += " AND " + alias + ".status = ?" args = append(args, status) } return where, args } func (r *adminRepository) ListMixed(ctx context.Context, status, kind string, page, size int) ([]AdminRecordCard, int64, error) { if page < 1 { page = 1 } if size < 1 { size = 24 } rw, rargs := legWhere("r", status) sw, sargs := legWhere("s", status) // kind 决定本次跨表 UNION 取哪几腿:""=两表都取,"street"=只取街拍,"runway"=只取走秀。 var legs []string var args []interface{} if kind != "street" { legs = append(legs, ` SELECT 'runway' AS kind, r.id, COALESCE(r.title_en, '') AS title, COALESCE(b.name_en, '') AS subtitle, CAST(r.image_count AS BIGINT) AS image_count, r.status FROM brand_runways r LEFT JOIN brands b ON b.id = r.brand_id WHERE `+rw) args = append(args, rargs...) } if kind != "runway" { legs = append(legs, ` SELECT 'street' AS kind, s.id, COALESCE(s.title, '') AS title, COALESCE(s.city, '') AS subtitle, CAST(s.image_count AS BIGINT) AS image_count, s.status FROM street_snaps s WHERE `+sw) args = append(args, sargs...) } q := ` SELECT * FROM (` + strings.Join(legs, " UNION ALL ") + `) sub ORDER BY (status = 'pending') DESC, id DESC LIMIT ? OFFSET ?` listArgs := append(append([]interface{}{}, args...), size, (page-1)*size) var cards []AdminRecordCard if err := r.db.WithContext(ctx).Raw(q, listArgs...).Scan(&cards).Error; err != nil { return nil, 0, err } total, err := r.CountMixed(ctx, status, kind) if err != nil { return nil, 0, err } return cards, total, nil } // DupRateByRecords 批量统计若干记录的图片去重情况。 // kind=runway 查 brand_runway_images(外键 runway_id);kind=street 查 street_snap_images(外键 snap_id)。 // 只统计 is_deleted=0 的存活行;命中阈值为 phash.DefaultThreshold(当前 4)。 func (r *adminRepository) DupRateByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]DupStat, error) { out := make(map[uint32]DupStat) if len(ids) == 0 { return out, nil } var table, fk string switch kind { case "runway": table, fk = "brand_runway_images", "runway_id" case "street": table, fk = "street_snap_images", "snap_id" default: return out, nil } type agg struct { Owner uint32 `gorm:"column:owner"` Total int `gorm:"column:total"` Dup int `gorm:"column:dup"` } var rows []agg if err := r.db.WithContext(ctx). Table(table). Select(fk + " AS owner, COUNT(*) AS total, COALESCE(SUM(CASE WHEN is_duplicate = 1 THEN 1 ELSE 0 END), 0) AS dup"). Where(fk+" IN ? AND is_deleted = 0", ids). Group(fk). Scan(&rows).Error; err != nil { return nil, err } for _, row := range rows { out[row.Owner] = DupStat{Total: row.Total, Dup: row.Dup} } return out, nil } func (r *adminRepository) CountMixed(ctx context.Context, status, kind string) (int64, error) { rw, rargs := legWhere("r", status) sw, sargs := legWhere("s", status) var legs []string var args []interface{} if kind != "street" { legs = append(legs, `SELECT COUNT(*) AS c FROM brand_runways r WHERE `+rw) args = append(args, rargs...) } if kind != "runway" { legs = append(legs, `SELECT COUNT(*) AS c FROM street_snaps s WHERE `+sw) args = append(args, sargs...) } q := ` SELECT COALESCE(SUM(c), 0) FROM (` + strings.Join(legs, " UNION ALL ") + `) t` var total int64 if err := r.db.WithContext(ctx).Raw(q, args...).Scan(&total).Error; err != nil { return 0, err } return total, nil }