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 } // AdminRecordStats 是一条记录(图集)在列表上要展示的派生统计:原图字节数之和 + 主图 / 副图行数。 // // 为什么主副要分开、而不能直接用 brand_runways.image_count: // 那一列的口径**只数主图**(见 service.countMainImages),并驱动公开卡片的「N 张」角标 // 与 FeaturedIDs 的 SUM(image_count) 热度,不能改。但后台「图数」列若照搬它,就会和编辑页 // 看到的图片总数对不上(实测 image_count=49 的图集实际有 224 行:49 主图 + 175 细节图)。 // 因此列表另取真实行数,主副分别展示。 type AdminRecordStats struct { Size int64 // 原图字节数之和(image_size 列,worker 下载时写入) Main int // 主图行数(is_detail = 0) Detail int // 副图(细节图)行数(is_detail = 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) // ImageSizeByRecords 批量返回若干记录的原图统计(字节数之和 + 主/副图行数,按 kind 分别查对应图片表); // 返回 owner id → 统计,不存在的 id 不出现在 map 中。供文章管理混合列表展示「图数」「原图总大小」。 ImageSizeByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]AdminRecordStats, 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 } // ImageSizeByRecords 批量返回若干记录的原图统计(字节数之和 + 主/副图行数,按 kind 分别查对应图片表)。 // 返回 owner id → 统计,不存在的 id 不出现在 map 中。 // // 字节数、主图数、副图数在**同一次扫描**里算完(都只依赖同一批图片行),避免为「图数」再查一遍库。 // 一律过滤 is_deleted = 0:软删的图不参与展示,也不该计入图数。 func (r *adminRepository) ImageSizeByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]AdminRecordStats, error) { out := make(map[uint32]AdminRecordStats) 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 int64 `gorm:"column:total"` Main int `gorm:"column:main"` Detail int `gorm:"column:detail"` } var rows []agg if err := r.db.WithContext(ctx). Table(table). Select(fk + " AS owner, COALESCE(SUM(image_size), 0) AS total, " + "COUNT(*) FILTER (WHERE is_detail = 0) AS main, " + "COUNT(*) FILTER (WHERE is_detail = 1) AS detail"). 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] = AdminRecordStats{Size: row.Total, Main: row.Main, Detail: row.Detail} } 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 }