459 lines
14 KiB
Go
459 lines
14 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
|
||
assetLevelService AssetLevelService // 资产等级服务
|
||
}
|
||
|
||
// NewAssetLikeService 创建资产点赞Service实例
|
||
func NewAssetLikeService(
|
||
assetRepo repository.AssetRepository,
|
||
assetLikeRepo repository.AssetLikeRepository,
|
||
db *gorm.DB,
|
||
assetLevelService AssetLevelService,
|
||
) *AssetLikeService {
|
||
return &AssetLikeService{
|
||
assetRepo: assetRepo,
|
||
assetLikeRepo: assetLikeRepo,
|
||
db: db,
|
||
assetLevelService: assetLevelService,
|
||
}
|
||
}
|
||
|
||
// isAssetExhibiting 检查资产当前是否在展出中,返回 exhibition_id
|
||
func (s *AssetLikeService) isAssetExhibiting(assetID int64) (int64, error) {
|
||
nowMs := time.Now().UnixMilli()
|
||
var exhibition struct {
|
||
ID int64
|
||
}
|
||
err := s.db.Table("exhibitions").
|
||
Select("id").
|
||
Where("asset_id = ? AND expire_at > ?", assetID, nowMs).
|
||
First(&exhibition).Error
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
// 资产不在任何展出中,返回 0 表示未展出
|
||
return 0, nil
|
||
}
|
||
return 0, fmt.Errorf("failed to check exhibition status: %w", err)
|
||
}
|
||
return exhibition.ID, 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 {
|
||
// 超过20 → 添加 hot
|
||
if newLikes > 20 {
|
||
return true, "hot"
|
||
}
|
||
// 1小时内达到10 → 添加 potential
|
||
if newLikes >= 10 && isWithin1Hour {
|
||
return true, "potential"
|
||
}
|
||
|
||
}
|
||
|
||
// 点赞减少时不降级(保留原类型)
|
||
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 检查资产是否当前在展出中,获取 exhibition_id
|
||
exhibitionID, 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 exhibitionID == 0 {
|
||
return 0, fmt.Errorf("资产未在展示中,无法点赞")
|
||
}
|
||
|
||
// 2. 检查是否已点赞(同一展览同用户只能点赞一次)
|
||
exists, err := s.assetLikeRepo.Exists(assetID, userID, starID, exhibitionID)
|
||
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("本次展示已点赞")
|
||
}
|
||
|
||
// 3. 开启事务
|
||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||
// 3.1 创建点赞记录(包含 exhibition_id)
|
||
like := &models.AssetLike{
|
||
AssetID: assetID,
|
||
UserID: userID,
|
||
StarID: starID,
|
||
ExhibitionID: exhibitionID,
|
||
}
|
||
if err := tx.Create(like).Error; err != nil {
|
||
logger.Logger.Error("Failed to create like record",
|
||
zap.Error(err),
|
||
zap.Int64("asset_id", assetID),
|
||
zap.Int64("exhibition_id", exhibitionID),
|
||
)
|
||
// 唯一约束冲突 = 已经点赞过
|
||
if isUniqueConstraintViolation(err) {
|
||
return fmt.Errorf("本次展示已点赞")
|
||
}
|
||
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 && newMaterialType != "" {
|
||
// 使用 PostgreSQL CONCAT 函数追加类型
|
||
if err := tx.Raw("UPDATE assets SET material_type = CONCAT(COALESCE(material_type, ''), ',', ?) WHERE id = ?", newMaterialType, assetID).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),
|
||
)
|
||
|
||
// 5. 更新资产等级记录(点赞数变化)
|
||
if s.assetLevelService != nil {
|
||
if newLevel, upgraded, err := s.assetLevelService.AddLikes(assetID, 1); err != nil {
|
||
logger.Logger.Warn("Failed to add likes to asset level",
|
||
zap.Int64("asset_id", assetID),
|
||
zap.Error(err))
|
||
} else if upgraded {
|
||
logger.Logger.Info("Asset leveled up due to likes",
|
||
zap.Int64("asset_id", assetID),
|
||
zap.String("new_level", newLevel))
|
||
}
|
||
}
|
||
|
||
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. 检查资产是否当前在展出中,获取 exhibition_id(展出结束后不允许取消点赞)
|
||
exhibitionID, 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 exhibitionID == 0 {
|
||
return 0, fmt.Errorf("资产未在展示中,无法取消点赞")
|
||
}
|
||
|
||
// 1. 检查是否已点赞(同一展览同用户只能点赞一次)
|
||
exists, err := s.assetLikeRepo.Exists(assetID, userID, starID, exhibitionID)
|
||
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 删除点赞记录(按 exhibition_id 删除)
|
||
if err := tx.Where("asset_id = ? AND user_id = ? AND star_id = ? AND exhibition_id = ?", assetID, userID, starID, exhibitionID).
|
||
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),
|
||
)
|
||
|
||
// 4. 更新资产等级记录(取消点赞)
|
||
if s.assetLevelService != nil {
|
||
if newLevel, downgraded, err := s.assetLevelService.RemoveLikes(assetID, 1); err != nil {
|
||
logger.Logger.Warn("Failed to remove likes from asset level",
|
||
zap.Int64("asset_id", assetID),
|
||
zap.Error(err))
|
||
} else if downgraded {
|
||
logger.Logger.Info("Asset downgraded due to like removal",
|
||
zap.Int64("asset_id", assetID),
|
||
zap.String("new_level", newLevel))
|
||
}
|
||
}
|
||
|
||
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),
|
||
)
|
||
|
||
// 获取当前展出中的 exhibition_id
|
||
exhibitionID, err := s.isAssetExhibiting(assetID)
|
||
if err != nil {
|
||
logger.Logger.Error("Failed to check exhibition status",
|
||
zap.Error(err),
|
||
zap.Int64("asset_id", assetID),
|
||
)
|
||
return false, fmt.Errorf("failed to check exhibition status: %w", err)
|
||
}
|
||
|
||
if exhibitionID == 0 {
|
||
// 资产不在展出中,视为未点赞
|
||
return false, nil
|
||
}
|
||
|
||
exists, err := s.assetLikeRepo.Exists(assetID, userID, starID, exhibitionID)
|
||
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
|
||
}
|