topfans/backend/services/assetService/service/asset_like_service.go
2026-05-16 02:42:32 +08:00

427 lines
12 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 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
}