package service import ( "context" "fmt" "strings" "time" "github.com/topfans/backend/pkg/logger" "github.com/topfans/backend/pkg/models" "github.com/topfans/backend/services/assetService/repository" "go.uber.org/zap" "gorm.io/gorm" ) // AssetLikeService 资产点赞业务逻辑层 type AssetLikeService struct { assetRepo repository.AssetRepository assetLikeRepo repository.AssetLikeRepository db *gorm.DB } // NewAssetLikeService 创建资产点赞Service实例 func NewAssetLikeService( assetRepo repository.AssetRepository, assetLikeRepo repository.AssetLikeRepository, db *gorm.DB, ) *AssetLikeService { return &AssetLikeService{ assetRepo: assetRepo, assetLikeRepo: assetLikeRepo, db: db, } } // isAssetExhibiting 检查资产当前是否在展出中 func (s *AssetLikeService) isAssetExhibiting(assetID int64) (bool, error) { nowMs := time.Now().UnixMilli() var count int64 err := s.db.Table("exhibitions"). Where("asset_id = ? AND expire_at > ?", assetID, nowMs). Count(&count).Error if err != nil { return false, fmt.Errorf("failed to check exhibition status: %w", err) } return count > 0, nil } // isUniqueConstraintViolation 检查错误是否为唯一约束冲突(PostgreSQL error code 23505) func isUniqueConstraintViolation(err error) bool { if err == nil { return false } errStr := err.Error() // PostgreSQL unique violation error code return strings.Contains(errStr, "23505") || strings.Contains(errStr, "unique constraint") } // shouldUpdateMaterialType 判断点赞变化后是否需要更新 material_type // onlyUpdateOnThreshold: true=只在临界点更新, false=每次都更新(用于降级检测) func shouldUpdateMaterialType(oldLikes, newLikes int32, createdAt int64, isIncrement bool) (bool, string) { now := time.Now().UnixMilli() isWithin1Hour := createdAt > 0 && now-createdAt < 3600*1000 // 点赞增加时的临界点 if isIncrement { // new → hot (超过20) if oldLikes <= 20 && newLikes > 20 { return true, "hot" } // new → potential (达到10且在1小时内) if oldLikes < 10 && newLikes >= 10 && isWithin1Hour { return true, "potential" } } // 点赞减少时的临界点(降级检测) if !isIncrement { // hot → new/potential (降到20以下) if oldLikes > 20 && newLikes <= 20 { // 重新计算 if newLikes >= 10 && isWithin1Hour { return true, "potential" } return true, "new" } // potential → new (降到10以下) if oldLikes >= 10 && newLikes < 10 { return true, "new" } // hot 降到 21-20 区间 if oldLikes > 20 && newLikes > 20 { return false, "" // 仍在 hot 区间 } } return false, "" } // LikeAsset 点赞资产(只有展出中的资产可以点赞) func (s *AssetLikeService) LikeAsset(ctx context.Context, assetID, userID, starID int64) (int32, error) { logger.Logger.Debug("AssetLikeService.LikeAsset called", zap.Int64("asset_id", assetID), zap.Int64("user_id", userID), zap.Int64("star_id", starID), ) // 1. 验证资产是否存在 asset, err := s.assetRepo.GetByID(assetID) if err != nil { logger.Logger.Error("Failed to get asset", zap.Error(err), zap.Int64("asset_id", assetID), ) return 0, fmt.Errorf("asset not found: %w", err) } // 1.5 检查资产是否当前在展出中 exhibiting, err := s.isAssetExhibiting(assetID) if err != nil { logger.Logger.Error("Failed to check exhibition status", zap.Error(err), zap.Int64("asset_id", assetID), ) return 0, err } if !exhibiting { return 0, fmt.Errorf("资产未在展示中,无法点赞") } // 2. 检查是否已点赞 exists, err := s.assetLikeRepo.Exists(assetID, userID, starID) if err != nil { logger.Logger.Error("Failed to check if already liked", zap.Error(err), zap.Int64("asset_id", assetID), ) return 0, fmt.Errorf("failed to check like status: %w", err) } if exists { logger.Logger.Warn("Asset already liked", zap.Int64("asset_id", assetID), zap.Int64("user_id", userID), ) return asset.LikeCount, fmt.Errorf("asset already liked") } // 3. 开启事务 err = s.db.Transaction(func(tx *gorm.DB) error { // 3.1 创建点赞记录 like := &models.AssetLike{ AssetID: assetID, UserID: userID, StarID: starID, } if err := tx.Create(like).Error; err != nil { logger.Logger.Error("Failed to create like record", zap.Error(err), zap.Int64("asset_id", assetID), ) // 唯一约束冲突 = 已经点赞过 if isUniqueConstraintViolation(err) { return fmt.Errorf("asset already liked") } return fmt.Errorf("failed to create like record: %w", err) } // 3.2 增加资产点赞数 if err := tx.Model(&models.Asset{}). Where("id = ?", assetID). UpdateColumn("like_count", gorm.Expr("like_count + 1")). Error; err != nil { logger.Logger.Error("Failed to increment like count", zap.Error(err), zap.Int64("asset_id", assetID), ) return fmt.Errorf("failed to increment like count: %w", err) } // 3.3 更新素材类型(只在临界点更新) oldLikes := asset.LikeCount newLikes := oldLikes + 1 shouldUpdate, newMaterialType := shouldUpdateMaterialType(oldLikes, newLikes, asset.CreatedAt, true) if shouldUpdate { if err := tx.Model(&models.Asset{}). Where("id = ?", assetID). UpdateColumn("material_type", newMaterialType). Error; err != nil { logger.Logger.Warn("Failed to update material type", zap.Error(err), zap.Int64("asset_id", assetID), ) } } return nil }) if err != nil { return 0, err } // 4. 获取更新后的点赞数 asset, err = s.assetRepo.GetByID(assetID) if err != nil { logger.Logger.Error("Failed to get updated asset", zap.Error(err), zap.Int64("asset_id", assetID), ) return 0, fmt.Errorf("failed to get updated asset: %w", err) } logger.Logger.Info("Successfully liked asset", zap.Int64("asset_id", assetID), zap.Int64("user_id", userID), zap.Int32("like_count", asset.LikeCount), ) return asset.LikeCount, nil } // UnlikeAsset 取消点赞资产(只有展出中的资产可以取消点赞) func (s *AssetLikeService) UnlikeAsset(ctx context.Context, assetID, userID, starID int64) (int32, error) { logger.Logger.Debug("AssetLikeService.UnlikeAsset called", zap.Int64("asset_id", assetID), zap.Int64("user_id", userID), zap.Int64("star_id", starID), ) // 0. 检查资产是否当前在展出中(展出结束后不允许取消点赞) exhibiting, err := s.isAssetExhibiting(assetID) if err != nil { logger.Logger.Error("Failed to check exhibition status", zap.Error(err), zap.Int64("asset_id", assetID), ) return 0, err } if !exhibiting { return 0, fmt.Errorf("资产未在展示中,无法取消点赞") } // 1. 检查是否已点赞 exists, err := s.assetLikeRepo.Exists(assetID, userID, starID) if err != nil { logger.Logger.Error("Failed to check if liked", zap.Error(err), zap.Int64("asset_id", assetID), ) return 0, fmt.Errorf("failed to check like status: %w", err) } if !exists { logger.Logger.Warn("Asset not liked yet", zap.Int64("asset_id", assetID), zap.Int64("user_id", userID), ) return 0, fmt.Errorf("asset not liked yet") } // 2. 开启事务 err = s.db.Transaction(func(tx *gorm.DB) error { // 2.1 删除点赞记录 if err := tx.Where("asset_id = ? AND user_id = ? AND star_id = ?", assetID, userID, starID). Delete(&models.AssetLike{}).Error; err != nil { logger.Logger.Error("Failed to delete like record", zap.Error(err), zap.Int64("asset_id", assetID), ) return fmt.Errorf("failed to delete like record: %w", err) } // 2.2 减少资产点赞数(不会减到负数) if err := tx.Model(&models.Asset{}). Where("id = ? AND like_count > 0", assetID). UpdateColumn("like_count", gorm.Expr("like_count - 1")). Error; err != nil { logger.Logger.Error("Failed to decrement like count", zap.Error(err), zap.Int64("asset_id", assetID), ) return fmt.Errorf("failed to decrement like count: %w", err) } // 2.3 获取资产信息用于计算 material_type var assetForType models.Asset if err := tx.Where("id = ?", assetID).First(&assetForType).Error; err != nil { logger.Logger.Warn("Failed to get asset for material_type update", zap.Error(err), zap.Int64("asset_id", assetID), ) } else { // 2.4 更新素材类型(只在临界点更新,降级时重算) // assetForType.LikeCount 已经是减 1 后的值 oldLikes := assetForType.LikeCount + 1 // 还原为减之前的值 newLikes := assetForType.LikeCount shouldUpdate, newMaterialType := shouldUpdateMaterialType(oldLikes, newLikes, assetForType.CreatedAt, false) if shouldUpdate { if err := tx.Model(&models.Asset{}). Where("id = ?", assetID). UpdateColumn("material_type", newMaterialType). Error; err != nil { logger.Logger.Warn("Failed to update material type", zap.Error(err), zap.Int64("asset_id", assetID), ) } } } return nil }) if err != nil { return 0, err } // 3. 获取更新后的点赞数 asset, err := s.assetRepo.GetByID(assetID) if err != nil { logger.Logger.Error("Failed to get updated asset", zap.Error(err), zap.Int64("asset_id", assetID), ) return 0, fmt.Errorf("failed to get updated asset: %w", err) } logger.Logger.Info("Successfully unliked asset", zap.Int64("asset_id", assetID), zap.Int64("user_id", userID), zap.Int32("like_count", asset.LikeCount), ) return asset.LikeCount, nil } // CheckAssetLike 检查是否已点赞 func (s *AssetLikeService) CheckAssetLike(ctx context.Context, assetID, userID, starID int64) (bool, error) { logger.Logger.Debug("AssetLikeService.CheckAssetLike called", zap.Int64("asset_id", assetID), zap.Int64("user_id", userID), zap.Int64("star_id", starID), ) exists, err := s.assetLikeRepo.Exists(assetID, userID, starID) if err != nil { logger.Logger.Error("Failed to check like status", zap.Error(err), zap.Int64("asset_id", assetID), ) return false, fmt.Errorf("failed to check like status: %w", err) } return exists, nil } // GetAssetLikes 获取资产点赞列表 func (s *AssetLikeService) GetAssetLikes(ctx context.Context, assetID int64, page, pageSize int32) ([]*models.AssetLike, int64, error) { logger.Logger.Debug("AssetLikeService.GetAssetLikes called", zap.Int64("asset_id", assetID), zap.Int32("page", page), zap.Int32("page_size", pageSize), ) // 验证分页参数 if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } // 计算偏移量 offset := int((page - 1) * pageSize) // 获取点赞列表 likes, err := s.assetLikeRepo.GetByAsset(assetID, int(pageSize), offset) if err != nil { logger.Logger.Error("Failed to get asset likes", zap.Error(err), zap.Int64("asset_id", assetID), ) return nil, 0, fmt.Errorf("failed to get asset likes: %w", err) } // 获取总数 total, err := s.assetLikeRepo.CountByAsset(assetID) if err != nil { logger.Logger.Error("Failed to count asset likes", zap.Error(err), zap.Int64("asset_id", assetID), ) return nil, 0, fmt.Errorf("failed to count asset likes: %w", err) } return likes, total, nil } // ClearAssetLikeRecords 清除资产的点赞记录(不修改 like_count) // 在藏品下架时调用,目的是允许同一用户在下次展出时可以再次点赞 func (s *AssetLikeService) ClearAssetLikeRecords(ctx context.Context, assetID int64) error { logger.Logger.Info("AssetLikeService.ClearAssetLikeRecords called", zap.Int64("asset_id", assetID), ) result := s.db.WithContext(ctx). Where("asset_id = ?", assetID). Delete(&models.AssetLike{}) if result.Error != nil { logger.Logger.Error("Failed to clear asset like records", zap.Error(result.Error), zap.Int64("asset_id", assetID), ) return fmt.Errorf("failed to clear asset like records: %w", result.Error) } logger.Logger.Info("Successfully cleared asset like records", zap.Int64("asset_id", assetID), zap.Int64("rows_deleted", result.RowsAffected), ) return nil }