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