package repository import ( "errors" "time" "github.com/topfans/backend/pkg/models" "github.com/topfans/backend/services/galleryService/config" "gorm.io/gorm" "gorm.io/gorm/clause" ) // GalleryRepository 展馆数据访问层接口 type GalleryRepository interface { // 展位相关 GetSlotsByUser(userID, starID int64) ([]*models.BoothSlot, error) GetSlotByID(slotID int64) (*models.BoothSlot, error) GetSlotCount(userID, starID int64) (int64, error) CreateInitialSlots(userID, starID, hostProfileID int64) error CreateSlot(slot *models.BoothSlot) error UnlockSlot(slotID int64) error // 展品相关 GetExhibitionByAsset(assetID int64) (*models.Exhibition, error) GetExhibitionBySlot(slotID int64) (*models.Exhibition, error) GetExhibitionsByUser(userID, starID int64) ([]*models.Exhibition, error) CreateExhibition(exhibition *models.Exhibition) error DeleteExhibition(exhibitionID int64) error DeleteExhibitionByAsset(assetID int64) error GetExpiredExhibitions(beforeTime int64) ([]*models.Exhibition, error) GetAssetsWithInvalidDisplayStatus() ([]int64, error) // 资产注册表相关 UpdateAssetRegistryDisplayStatus(assetID int64, displayStatus int32) error // 事务性操作:创建展品并更新展示状态(原子操作) PlaceExhibitionTx(exhibition *models.Exhibition, displayStatus int32) error // 事务性操作:删除展品并更新展示状态(原子操作) RemoveExhibitionTx(exhibitionID int64, assetID int64) error // ========== 我的作品相关 ========== // GetMyExhibitedAssets 获取我展出的作品列表(只返回展出中且未过期的,含收益) // userID: 用户ID // starID: 明星ID // page: 页码(从1开始) // pageSize: 每页数量 // 返回: 作品列表、总数量 GetMyExhibitedAssets(userID, starID int64, page, pageSize int) ([]*ExhibitedAssetInfo, int64, error) // GetUserExhibitedAssets 获取他人展出的作品列表(只返回展出中且未过期的) // userID: 他人用户ID // starID: 明星ID // page: 页码(从1开始) // pageSize: 每页数量 // 返回: 作品列表、总数量 GetUserExhibitedAssets(userID, starID int64, page, pageSize int) ([]*ExhibitedAssetInfo, int64, error) // ========== 灵感瀑布相关 ========== // CountValidExhibitions 统计有效展品数量 // starID: 明星ID // materialType: 素材类型过滤(空字符串表示不过滤) CountValidExhibitions(starID int64, materialType string) (int64, error) // GetRandomExhibitions 获取随机展品列表 // starID: 明星ID // materialType: 素材类型过滤(空字符串表示不过滤) // excludeIDs: 排除的展品ID列表(用于去重) // limit: 返回数量 // offset: 偏移量(随机生成) GetRandomExhibitions(starID int64, materialType string, excludeIDs []int64, limit, offset int) ([]*InspirationFlowItem, error) } // InspirationFlowItem 灵感瀑布展品项 type InspirationFlowItem struct { AssetID int64 Name string CoverURL string LikeCount int32 OwnerNickname string Span int32 // 卡片大小: 0-30→1, 31-100→2, 101-200→3, 200+→4 MaterialType string // 素材类型: hot(人气王者), potential(潜力之星), new(新鲜上架) CreatedAt int64 // 创建时间(用于判断是否为潜力之星) } // ExhibitedAssetInfo 我展出的作品信息 type ExhibitedAssetInfo struct { AssetID int64 Name string CoverURL string LikeCount int32 ExhibitedAt int64 ExpireAt int64 Earnings int64 } // galleryRepository Repository实现 type galleryRepository struct { db *gorm.DB } // NewGalleryRepository 创建Repository实例 func NewGalleryRepository(db *gorm.DB) GalleryRepository { return &galleryRepository{db: db} } // ==================== 展位相关 ==================== // GetSlotsByUser 获取用户的所有展位 func (r *galleryRepository) GetSlotsByUser(userID, starID int64) ([]*models.BoothSlot, error) { var slots []*models.BoothSlot err := r.db.Where("user_id = ? AND star_id = ?", userID, starID). Order("slot_index ASC"). Find(&slots).Error return slots, err } // GetSlotByID 根据ID获取展位 func (r *galleryRepository) GetSlotByID(slotID int64) (*models.BoothSlot, error) { var slot models.BoothSlot err := r.db.Where("slot_id = ?", slotID).First(&slot).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("展位不存在") } return nil, err } return &slot, nil } // GetSlotCount 获取用户的展位数量 func (r *galleryRepository) GetSlotCount(userID, starID int64) (int64, error) { var count int64 err := r.db.Model(&models.BoothSlot{}). Where("user_id = ? AND star_id = ?", userID, starID). Count(&count).Error return count, err } // CreateInitialSlots 创建初始展位(懒加载,支持并发安全) func (r *galleryRepository) CreateInitialSlots(userID, starID int64, hostProfileID int64) error { // 使用 PostgreSQL 的 ON CONFLICT 保证并发安全性 return r.db.Transaction(func(tx *gorm.DB) error { now := time.Now().UnixMilli() initialSlotCount := config.GalleryRules.InitialSlotCount for i := 1; i <= initialSlotCount; i++ { vis := "public" if i > 3 { vis = "private" } slot := &models.BoothSlot{ HostProfileID: hostProfileID, UserID: userID, StarID: starID, SlotIndex: i, Visibility: vis, IsEnabled: true, UnlockType: "free", UnlockValue: 0, CreatedAt: now, UpdatedAt: now, } // 使用 Clause 处理冲突,确保幂等性 err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "host_profile_id"}, {Name: "slot_index"}}, DoNothing: true, }).Create(slot).Error if err != nil { return err } } return nil }) } // CreateSlot 创建新展位(用于解锁) func (r *galleryRepository) CreateSlot(slot *models.BoothSlot) error { now := time.Now().UnixMilli() slot.CreatedAt = now slot.UpdatedAt = now return r.db.Create(slot).Error } // UnlockSlot 解锁展位 func (r *galleryRepository) UnlockSlot(slotID int64) error { now := time.Now().UnixMilli() return r.db.Model(&models.BoothSlot{}). Where("slot_id = ?", slotID). Updates(map[string]interface{}{ "is_enabled": true, "updated_at": now, }).Error } // ==================== 展品相关 ==================== // GetExhibitionByAsset 根据资产ID获取展品展示记录(不含已删除) func (r *galleryRepository) GetExhibitionByAsset(assetID int64) (*models.Exhibition, error) { var exhibition models.Exhibition err := r.db.Where("asset_id = ? AND deleted_at IS NULL", assetID).First(&exhibition).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil // 未找到记录,返回 nil(不是错误) } return nil, err } return &exhibition, nil } // GetExhibitionBySlot 根据展位ID获取展品展示记录(不含已删除) func (r *galleryRepository) GetExhibitionBySlot(slotID int64) (*models.Exhibition, error) { var exhibition models.Exhibition err := r.db.Where("slot_id = ? AND deleted_at IS NULL", slotID).First(&exhibition).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil // 未找到记录,返回 nil(不是错误) } return nil, err } return &exhibition, nil } // GetExhibitionsByUser 获取用户的所有展品展示记录(不含已删除) func (r *galleryRepository) GetExhibitionsByUser(userID, starID int64) ([]*models.Exhibition, error) { var exhibitions []*models.Exhibition err := r.db.Where("occupier_uid = ? AND occupier_star_id = ? AND deleted_at IS NULL", userID, starID). Find(&exhibitions).Error return exhibitions, err } // CreateExhibition 创建展品展示记录(支持软删除后重新展出) // asset_id 唯一索引冲突时:若旧记录已软删除则物理删除后重新插入;否则报错 func (r *galleryRepository) CreateExhibition(exhibition *models.Exhibition) error { now := time.Now().UnixMilli() exhibition.CreatedAt = now exhibition.UpdatedAt = now exhibition.DeletedAt = nil // 在事务中:先物理删除已软删除的旧记录,再插入新记录 return r.db.Transaction(func(tx *gorm.DB) error { if err := tx.Exec(` DELETE FROM exhibitions WHERE asset_id = ? AND deleted_at IS NOT NULL `, exhibition.AssetID).Error; err != nil { return err } return tx.Create(exhibition).Error }) } // DeleteExhibition 软删除展品展示记录(根据ID) func (r *galleryRepository) DeleteExhibition(exhibitionID int64) error { now := time.Now().UnixMilli() return r.db.Model(&models.Exhibition{}). Where("id = ?", exhibitionID). Updates(map[string]interface{}{ "deleted_at": now, "updated_at": now, }).Error } // DeleteExhibitionByAsset 软删除展品展示记录(根据资产ID) func (r *galleryRepository) DeleteExhibitionByAsset(assetID int64) error { now := time.Now().UnixMilli() return r.db.Model(&models.Exhibition{}). Where("asset_id = ?", assetID). Updates(map[string]interface{}{ "deleted_at": now, "updated_at": now, }).Error } // GetExpiredExhibitions 获取过期的展品展示记录(不含已删除) func (r *galleryRepository) GetExpiredExhibitions(beforeTime int64) ([]*models.Exhibition, error) { var exhibitions []*models.Exhibition err := r.db.Where("expire_at <= ? AND deleted_at IS NULL", beforeTime).Find(&exhibitions).Error return exhibitions, err } // GetAssetsWithInvalidDisplayStatus 获取 display_status=1 但没有有效 exhibition 的资产ID列表 // 用于清理手动软删除导致的 display_status 不同步问题 func (r *galleryRepository) GetAssetsWithInvalidDisplayStatus() ([]int64, error) { var assetIDs []int64 // 子查询:查找有有效 exhibition 记录的 asset_id subQuery := r.db.Model(&models.Exhibition{}). Select("asset_id"). Where("deleted_at IS NULL AND expire_at > ?", time.Now().UnixMilli()) // 查找 display_status=1 但没有有效 exhibition 记录的资产 err := r.db.Model(&models.AssetRegistry{}). Select("asset_id"). Where("display_status = ? AND asset_id NOT IN (?)", int32(1), subQuery). Pluck("asset_id", &assetIDs).Error return assetIDs, err } // UpdateAssetRegistryDisplayStatus 更新资产注册表的展示状态 func (r *galleryRepository) UpdateAssetRegistryDisplayStatus(assetID int64, displayStatus int32) error { return r.db.Model(&models.AssetRegistry{}). Where("asset_id = ?", assetID). Update("display_status", displayStatus).Error } // PlaceExhibitionTx 事务性创建展品并更新展示状态(原子操作) func (r *galleryRepository) PlaceExhibitionTx(exhibition *models.Exhibition, displayStatus int32) error { now := time.Now().UnixMilli() exhibition.CreatedAt = now exhibition.UpdatedAt = now exhibition.DeletedAt = nil return r.db.Transaction(func(tx *gorm.DB) error { // 1. 物理删除已软删除的旧记录 if err := tx.Exec(` DELETE FROM exhibitions WHERE asset_id = ? AND deleted_at IS NOT NULL `, exhibition.AssetID).Error; err != nil { return err } // 2. 插入新记录 if err := tx.Create(exhibition).Error; err != nil { return err } // 3. 更新展示状态(与展出操作在同一事务中) if err := tx.Model(&models.AssetRegistry{}). Where("asset_id = ?", exhibition.AssetID). Update("display_status", displayStatus).Error; err != nil { return err } return nil }) } // RemoveExhibitionTx 事务性删除展品并更新展示状态(原子操作) func (r *galleryRepository) RemoveExhibitionTx(exhibitionID int64, assetID int64) error { now := time.Now().UnixMilli() return r.db.Transaction(func(tx *gorm.DB) error { // 1. 软删除展品记录 if err := tx.Model(&models.Exhibition{}). Where("id = ?", exhibitionID). Updates(map[string]interface{}{ "deleted_at": now, "updated_at": now, }).Error; err != nil { return err } // 2. 更新展示状态为未展示(与删除操作在同一事务中) if err := tx.Model(&models.AssetRegistry{}). Where("asset_id = ?", assetID). Update("display_status", int32(0)).Error; err != nil { return err } return nil }) } // ========== 我的作品相关实现 ========== // GetMyExhibitedAssets 获取我展出的作品列表(只返回展出中且未过期的,含收益) func (r *galleryRepository) GetMyExhibitedAssets(userID, starID int64, page, pageSize int) ([]*ExhibitedAssetInfo, int64, error) { var items []*ExhibitedAssetInfo var total int64 now := time.Now().UnixMilli() // 计数查询 err := r.db.Model(&models.Exhibition{}). Where("occupier_uid = ? AND occupier_star_id = ? AND deleted_at IS NULL AND expire_at > ?", userID, starID, now). Count(&total).Error if err != nil { return nil, 0, err } // 数据查询 offset := (page - 1) * pageSize err = r.db.Model(&models.Exhibition{}). Raw(` SELECT exhibitions.asset_id, a.name, a.cover_url, a.like_count, exhibitions.start_time as exhibited_at, exhibitions.expire_at, bs.slot_index, COALESCE(CAST(SUM(err.crystal_amount) / 10 AS bigint), 0) as earnings FROM exhibitions JOIN assets a ON a.id = exhibitions.asset_id JOIN booth_slots bs ON bs.slot_id = exhibitions.slot_id LEFT JOIN exhibition_revenue_records err ON err.asset_id = a.id AND err.status = 'claimable' WHERE exhibitions.occupier_uid = ? AND exhibitions.occupier_star_id = ? AND exhibitions.deleted_at IS NULL AND exhibitions.expire_at > ? GROUP BY exhibitions.asset_id, a.name, a.cover_url, a.like_count, exhibitions.start_time, exhibitions.expire_at, bs.slot_index ORDER BY bs.slot_index ASC LIMIT ? OFFSET ? `, userID, starID, now, pageSize, offset).Scan(&items).Error if err != nil { return nil, 0, err } return items, total, nil } // GetUserExhibitedAssets 获取他人展出的作品列表(只返回展出中且未过期的) func (r *galleryRepository) GetUserExhibitedAssets(userID, starID int64, page, pageSize int) ([]*ExhibitedAssetInfo, int64, error) { var items []*ExhibitedAssetInfo var total int64 now := time.Now().UnixMilli() // 计数查询 err := r.db.Model(&models.Exhibition{}). Where("occupier_uid = ? AND occupier_star_id = ? AND deleted_at IS NULL AND expire_at > ?", userID, starID, now). Count(&total).Error if err != nil { return nil, 0, err } // 数据查询 offset := (page - 1) * pageSize err = r.db.Model(&models.Exhibition{}). Raw(` SELECT exhibitions.asset_id, a.name, a.cover_url, a.like_count, exhibitions.start_time as exhibited_at, exhibitions.expire_at, bs.slot_index, COALESCE(CAST(SUM(err.crystal_amount) / 10 AS bigint), 0) as earnings FROM exhibitions JOIN assets a ON a.id = exhibitions.asset_id JOIN booth_slots bs ON bs.slot_id = exhibitions.slot_id LEFT JOIN exhibition_revenue_records err ON err.asset_id = a.id AND err.status = 'claimable' WHERE exhibitions.occupier_uid = ? AND exhibitions.occupier_star_id = ? AND exhibitions.deleted_at IS NULL AND exhibitions.expire_at > ? GROUP BY exhibitions.asset_id, a.name, a.cover_url, a.like_count, exhibitions.start_time, exhibitions.expire_at, bs.slot_index ORDER BY bs.slot_index ASC LIMIT ? OFFSET ? `, userID, starID, now, pageSize, offset).Scan(&items).Error if err != nil { return nil, 0, err } return items, total, nil } // ========== 灵感瀑布相关实现 ========== // CountValidExhibitions 统计有效展品数量 func (r *galleryRepository) CountValidExhibitions(starID int64, materialType string) (int64, error) { var count int64 now := time.Now().UnixMilli() query := r.db.Model(&models.Exhibition{}). Where("exhibitions.occupier_star_id = ? AND exhibitions.expire_at > ? AND exhibitions.deleted_at IS NULL", starID, now) if materialType != "" && materialType != "all" && materialType != "random" { query = query.Joins("JOIN assets a ON a.id = exhibitions.asset_id"). Where("a.material_type = ?", materialType) } err := query.Count(&count).Error return count, err } // GetRandomExhibitions 获取随机展品列表 func (r *galleryRepository) GetRandomExhibitions(starID int64, materialType string, excludeIDs []int64, limit, offset int) ([]*InspirationFlowItem, error) { var items []*InspirationFlowItem now := time.Now().UnixMilli() // 构建基础查询 baseQuery := r.db.Model(&models.Exhibition{}). Where("exhibitions.occupier_star_id = ? AND exhibitions.expire_at > ? AND exhibitions.deleted_at IS NULL", starID, now) if materialType != "" && materialType != "all" && materialType != "random" { baseQuery = baseQuery.Joins("JOIN assets a ON a.id = exhibitions.asset_id"). Where("a.material_type = ?", materialType) } // 排除已展示的ID if len(excludeIDs) > 0 { baseQuery = baseQuery.Where("exhibitions.id NOT IN ?", excludeIDs) } // 执行随机排序查询 var err error if materialType == "" || materialType == "all" || materialType == "random" { err = baseQuery. Select(`exhibitions.asset_id, a.name, a.cover_url, a.like_count, fp.nickname as owner_nickname, a.material_type, a.created_at`). Joins("JOIN assets a ON a.id = exhibitions.asset_id"). Joins("JOIN fan_profiles fp ON exhibitions.occupier_uid = fp.user_id AND exhibitions.occupier_star_id = fp.star_id"). Where("a.status = 1 AND a.is_active = true"). Order("RANDOM()"). Limit(limit). Offset(offset). Scan(&items).Error } else { // baseQuery 已经包含了 assets JOIN,不需要重复添加 err = baseQuery. Select(`exhibitions.asset_id, a.name, a.cover_url, a.like_count, fp.nickname as owner_nickname, a.material_type, a.created_at`). Joins("JOIN fan_profiles fp ON exhibitions.occupier_uid = fp.user_id AND exhibitions.occupier_star_id = fp.star_id"). Where("a.status = 1 AND a.is_active = true"). Order("RANDOM()"). Limit(limit). Offset(offset). Scan(&items).Error } if err != nil { return nil, err } // Read-repair: 检查并修正 material_type(查询时自动修正数据不一致) for _, item := range items { item.Span = calcSpanByLikes(item.LikeCount) correctType := calcMaterialType(item.LikeCount, item.CreatedAt) if item.MaterialType != correctType { // 异步修正,不阻塞返回(使用 goroutine 避免影响响应时间) go func(assetID int64, correct string) { r.db.Model(&models.Asset{}). Where("id = ?", assetID). Update("material_type", correct) }(item.AssetID, correctType) // 同时修正当前对象的值,保证返回给前端的是正确的 item.MaterialType = correctType } } return items, nil } // calcMaterialType 根据点赞数计算素材类型 func calcMaterialType(likes int32, createdAt int64) string { now := time.Now().UnixMilli() isWithin1Hour := createdAt > 0 && now-createdAt < 3600*1000 if likes > 20 { return "hot" } // 上架1小时内且点赞>=10为潜力之星 if isWithin1Hour && likes >= 10 { return "potential" } return "new" } // calcSpanByLikes 根据点赞数计算卡片大小 // 0-30 → span 1, 31-100 → span 2, 101-200 → span 3, 200+ → span 4 func calcSpanByLikes(likes int32) int32 { if likes <= 30 { return 1 } else if likes <= 100 { return 2 } else if likes <= 200 { return 3 } return 4 } // ==================== 辅助函数 ==================== // generateHostProfileID 生成 host_profile_id // 注意:这里使用简单的生成逻辑,实际应该与 fan_profiles 表的逻辑一致 func generateHostProfileID(userID, starID int64) int64 { // 使用简单的组合方式:userID * 1000000 + starID // 实际项目中应该使用与 User Service 一致的逻辑 return userID*1000000 + starID }