topfans/backend/services/userService/repository/fan_profile_repository.go
2026-05-14 15:59:07 +08:00

773 lines
21 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 repository
import (
"errors"
"fmt"
"math"
"strings"
"time"
"github.com/topfans/backend/pkg/database"
appErrors "github.com/topfans/backend/pkg/errors"
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/pkg/models"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// contains 检查字符串是否包含子串(不区分大小写)
func contains(s, substr string) bool {
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
}
// CalculateLevel 根据经验值计算等级
// 公式: 升级到等级L需要的累计经验 = (L-1) * L * 50
// Level 1: 0经验, Level 2: 100经验, Level 3: 300经验, Level 4: 600经验...
func CalculateLevel(experience int64) int32 {
if experience < 0 {
return 1
}
// 使用公式: (L-1) * L * 50 <= experience
// 解方程: L^2 - L - experience/50 <= 0
// L = (1 + sqrt(1 + 4*experience/50)) / 2
level := int32((1 + math.Sqrt(1+4*float64(experience)/50)) / 2)
if level < 1 {
level = 1
}
return level
}
// GetExperienceForLevel 获取指定等级需要的经验值
func GetExperienceForLevel(level int32) int64 {
if level <= 1 {
return 0
}
return int64((level-1) * level * 50)
}
// FanProfileRepository 粉丝档案Repository接口
type FanProfileRepository interface {
// Create 创建粉丝档案
Create(profile *models.FanProfile) error
// GetByUserAndStar 根据user_id + star_id查询
GetByUserAndStar(userID, starID int64) (*models.FanProfile, error)
// GetByUserID 查询用户的所有粉丝身份
GetByUserID(userID int64) ([]*models.FanProfile, error)
// ExistsByNickname 检查昵称是否已存在
ExistsByNickname(nickname string) (bool, error)
// CountByUserID 统计用户粉丝身份数量
CountByUserID(userID int64) (int64, error)
// Update 更新粉丝档案
Update(profile *models.FanProfile) error
// UpdateNickname 更新昵称
UpdateNickname(userID, starID int64, nickname string) error
// IncrementAssetsCount 增加资产计数
IncrementAssetsCount(userID, starID int64, delta int32) error
// SyncLevelFromExperience 根据经验值同步等级(只升级不降级)
SyncLevelFromExperience(userID, starID int64) (int32, error)
// DecrementAssetsCount 减少资产计数
DecrementAssetsCount(userID, starID int64, delta int32) error
// UpdateChainAddress 更新链地址
UpdateChainAddress(userID, starID int64, address string) error
// UpdateSocial 更新好友数量social字段
UpdateSocial(userID, starID int64, delta int32) (int32, error)
// UpdateCrystalBalance 更新水晶余额(支持流水记录)
// changeType: 变化类型,如 task_reward/mint_cost/mint_reward/exhibition_revenue/level_up_bonus/manual_adjust
// sourceID: 关联业务ID
// description: 可读描述
UpdateCrystalBalance(userID, starID int64, delta int64, changeType string, sourceID string, description string) (int64, error)
// AddExhibitionHours 增加用户累计上架时长并同步等级(事务性)
// 返回: newLevel, levelDelta, crystalReward, error
AddExhibitionHours(userID, starID int64, hours int64) (int32, int32, int64, error)
// UpdateExperience 更新经验值
UpdateExperience(userID, starID int64, delta int64) (int64, error)
// UpdateAvatar 更新头像
UpdateAvatar(userID, starID int64, avatarURL string) error
}
// fanProfileRepository 粉丝档案Repository实现
type fanProfileRepository struct {
db *gorm.DB
}
// NewFanProfileRepository 创建粉丝档案Repository实例
func NewFanProfileRepository() FanProfileRepository {
return &fanProfileRepository{
db: database.GetDB(),
}
}
// Create 创建粉丝档案
func (r *fanProfileRepository) Create(profile *models.FanProfile) error {
if profile == nil {
return errors.New("profile cannot be nil")
}
if profile.UserID <= 0 {
return errors.New("user_id must be greater than 0")
}
if profile.StarID <= 0 {
return errors.New("star_id must be greater than 0")
}
if err := r.db.Create(profile).Error; err != nil {
// 检查是否是唯一索引冲突
errStr := err.Error()
if contains(errStr, "duplicate") || contains(errStr, "unique") || contains(errStr, "violates unique constraint") {
// 区分不同的唯一约束冲突
if contains(errStr, "uk_fan_profiles_star_nickname") {
// star_id + nickname 唯一约束冲突
return appErrors.ErrNicknameAlreadyExists
} else if contains(errStr, "uk_fan_profiles_user_star") {
// user_id + star_id 唯一约束冲突
return appErrors.ErrFanProfileAlreadyExists
}
// 其他唯一约束冲突
return appErrors.ErrFanProfileAlreadyExists
}
return err
}
return nil
}
// GetByUserAndStar 根据user_id + star_id查询
func (r *fanProfileRepository) GetByUserAndStar(userID, starID int64) (*models.FanProfile, error) {
if userID <= 0 {
return nil, errors.New("user_id must be greater than 0")
}
if starID <= 0 {
return nil, errors.New("star_id must be greater than 0")
}
var profile models.FanProfile
if err := r.db.Where("user_id = ? AND star_id = ? AND is_active = ?", userID, starID, true).
First(&profile).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, appErrors.ErrFanProfileNotFound
}
return nil, err
}
return &profile, nil
}
// GetByUserID 查询用户的所有粉丝身份
func (r *fanProfileRepository) GetByUserID(userID int64) ([]*models.FanProfile, error) {
if userID <= 0 {
return nil, errors.New("user_id must be greater than 0")
}
var profiles []*models.FanProfile
if err := r.db.Where("user_id = ? AND is_active = ?", userID, true).
Order("created_at ASC").
Find(&profiles).Error; err != nil {
return nil, err
}
return profiles, nil
}
// CountByUserID 统计用户粉丝身份数量
func (r *fanProfileRepository) CountByUserID(userID int64) (int64, error) {
if userID <= 0 {
return 0, errors.New("user_id must be greater than 0")
}
var count int64
if err := r.db.Model(&models.FanProfile{}).
Where("user_id = ? AND is_active = ?", userID, true).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// ExistsByNickname 检查昵称是否已存在
func (r *fanProfileRepository) ExistsByNickname(nickname string) (bool, error) {
if nickname == "" {
return false, errors.New("nickname cannot be empty")
}
var count int64
if err := r.db.Model(&models.FanProfile{}).
Where("nickname = ? AND is_active = ?", nickname, true).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
// Update 更新粉丝档案
func (r *fanProfileRepository) Update(profile *models.FanProfile) error {
if profile == nil {
return errors.New("profile cannot be nil")
}
if profile.ID == 0 {
return errors.New("profile id cannot be zero")
}
if err := r.db.Model(profile).Updates(profile).Error; err != nil {
return err
}
return nil
}
// UpdateNickname 更新昵称
func (r *fanProfileRepository) UpdateNickname(userID, starID int64, nickname string) error {
if userID <= 0 {
return errors.New("user_id must be greater than 0")
}
if starID <= 0 {
return errors.New("star_id must be greater than 0")
}
if nickname == "" {
return errors.New("nickname cannot be empty")
}
result := r.db.Model(&models.FanProfile{}).
Where("user_id = ? AND star_id = ?", userID, starID).
Update("nickname", nickname)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("fan profile not found")
}
return nil
}
// IncrementAssetsCount 增加资产计数
func (r *fanProfileRepository) IncrementAssetsCount(userID, starID int64, delta int32) error {
if userID <= 0 {
return errors.New("user_id must be greater than 0")
}
if starID <= 0 {
return errors.New("star_id must be greater than 0")
}
if delta < 0 {
return errors.New("delta must be greater than or equal to 0")
}
return r.db.Model(&models.FanProfile{}).
Where("user_id = ? AND star_id = ?", userID, starID).
UpdateColumn("assets_count", gorm.Expr("assets_count + ?", delta)).Error
}
// DecrementAssetsCount 减少资产计数
func (r *fanProfileRepository) DecrementAssetsCount(userID, starID int64, delta int32) error {
if userID <= 0 {
return errors.New("user_id must be greater than 0")
}
if starID <= 0 {
return errors.New("star_id must be greater than 0")
}
if delta < 0 {
return errors.New("delta must be greater than or equal to 0")
}
return r.db.Model(&models.FanProfile{}).
Where("user_id = ? AND star_id = ? AND assets_count >= ?", userID, starID, delta).
UpdateColumn("assets_count", gorm.Expr("assets_count - ?", delta)).Error
}
// UpdateChainAddress 更新链地址
func (r *fanProfileRepository) UpdateChainAddress(userID, starID int64, address string) error {
if userID <= 0 {
return errors.New("user_id must be greater than 0")
}
if starID <= 0 {
return errors.New("star_id must be greater than 0")
}
if address == "" {
return errors.New("chain address cannot be empty")
}
result := r.db.Model(&models.FanProfile{}).
Where("user_id = ? AND star_id = ?", userID, starID).
Update("chain_address", address)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("fan profile not found")
}
return nil
}
// UpdateSocial 更新好友数量social字段
// delta: 变化量,正数表示增加,负数表示减少
// 返回: 更新后的好友数量
func (r *fanProfileRepository) UpdateSocial(userID, starID int64, delta int32) (int32, error) {
if userID <= 0 {
return 0, errors.New("user_id must be greater than 0")
}
if starID <= 0 {
return 0, errors.New("star_id must be greater than 0")
}
// 使用事务确保原子性
var newSocial int32
err := r.db.Transaction(func(tx *gorm.DB) error {
// 先查询当前的 social 值
var profile models.FanProfile
if err := tx.Where("user_id = ? AND star_id = ?", userID, starID).
First(&profile).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return appErrors.ErrFanProfileNotFound
}
return err
}
// 计算新值
newSocial = profile.Social + delta
// 确保不会小于 0
if newSocial < 0 {
newSocial = 0
}
// 更新 social 字段
if err := tx.Model(&models.FanProfile{}).
Where("user_id = ? AND star_id = ?", userID, starID).
Update("social", newSocial).Error; err != nil {
return err
}
return nil
})
if err != nil {
return 0, err
}
return newSocial, nil
}
// UpdateCrystalBalance 更新水晶余额(支持流水记录)
// delta: 变化量,正数表示增加,负数表示减少
// changeType: 变化类型,如 task_reward/mint_cost/mint_reward/exhibition_revenue/level_up_bonus/manual_adjust
// sourceID: 关联业务ID
// description: 可读描述
// 返回: 更新后的水晶余额
func (r *fanProfileRepository) UpdateCrystalBalance(userID, starID int64, delta int64, changeType string, sourceID string, description string) (int64, error) {
if userID <= 0 {
return 0, errors.New("user_id must be greater than 0")
}
if starID <= 0 {
return 0, errors.New("star_id must be greater than 0")
}
// 使用事务确保原子性
var newBalance int64
err := r.db.Transaction(func(tx *gorm.DB) error {
// 1. SELECT FOR UPDATE 加行锁(悲观锁策略)
var profile models.FanProfile
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_id = ? AND star_id = ?", userID, starID).
First(&profile).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return appErrors.ErrFanProfileNotFound
}
return err
}
// 2. 计算余额变化
balanceBefore := profile.CrystalBalance
newBalance = balanceBefore + delta
// 确保不会小于 0
if newBalance < 0 {
newBalance = 0
}
// 3. 写入水晶流水(复式记账,包含余额快照)
crystalRecord := &models.CrystalTransactionRecord{
UserID: userID,
StarID: starID,
ChangeType: changeType,
Delta: delta,
BalanceBefore: balanceBefore,
BalanceAfter: newBalance,
SourceID: sourceID,
Description: description,
CreatedAt: time.Now().UnixMilli(),
}
if err := tx.Create(crystalRecord).Error; err != nil {
return err
}
// 4. 更新 crystal_balance 字段
if err := tx.Model(&models.FanProfile{}).
Where("user_id = ? AND star_id = ?", userID, starID).
Update("crystal_balance", newBalance).Error; err != nil {
return err
}
return nil
})
if err != nil {
return 0, err
}
return newBalance, nil
}
// GetOrCreateExhibitionHours 获取或创建用户累计上架时长记录
func (r *fanProfileRepository) GetOrCreateExhibitionHours(tx *gorm.DB, userID, starID int64) (*models.UserExhibitionHours, error) {
var existing models.UserExhibitionHours
err := tx.Where("user_id = ? AND star_id = ?", userID, starID).First(&existing).Error
if err == nil {
return &existing, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
// 创建新记录
now := time.Now().UnixMilli()
newRecord := &models.UserExhibitionHours{
UserID: userID,
StarID: starID,
TotalExhibitionHours: 0,
UpdatedAt: now,
}
if err := tx.Create(newRecord).Error; err != nil {
return nil, err
}
return newRecord, nil
}
// CalculateLevelFromExhibitionHours 根据累计上架时长计算等级
func CalculateLevelFromExhibitionHours(totalHours int64) int32 {
db := database.GetDB()
if db == nil {
return 1
}
var threshold models.LevelThreshold
err := db.Where("max_exhibition_hours <= ?", totalHours).
Order("level DESC").
First(&threshold).Error
if err != nil || threshold.Level == 0 {
return 1
}
return threshold.Level
}
// GetLevelCap 获取当前等级上限
func GetLevelCap() int32 {
db := database.GetDB()
if db == nil {
return 20
}
var config models.LevelCapConfig
err := db.First(&config).Error
if err != nil {
return 20 // 默认20级
}
return config.MaxLevel
}
// AddExhibitionHours 增加用户累计上架时长并同步等级(事务性)
// 返回: newLevel, levelDelta, crystalReward, error
func (r *fanProfileRepository) AddExhibitionHours(userID, starID int64, hours int64) (int32, int32, int64, error) {
var result struct {
OldLevel int32
NewLevel int32
CrystalReward int64
}
err := r.db.Transaction(func(tx *gorm.DB) error {
// 1. 获取或创建累计时长记录
exhibitionHours, err := r.GetOrCreateExhibitionHours(tx, userID, starID)
if err != nil {
return err
}
// 2. 原子性累加时长(避免竞态条件)
now := time.Now().UnixMilli()
if err := tx.Model(&models.UserExhibitionHours{}).
Where("user_id = ? AND star_id = ?", userID, starID).
Updates(map[string]interface{}{
"total_exhibition_hours": gorm.Expr("total_exhibition_hours + ?", hours),
"updated_at": now,
}).Error; err != nil {
return err
}
// 重新查询更新后的时长
if err := tx.Where("user_id = ? AND star_id = ?", userID, starID).First(exhibitionHours).Error; err != nil {
return err
}
// 3. 获取当前等级上限
maxLevel := GetLevelCap()
// 4. 计算新等级(基于累计时长)
newLevel := CalculateLevelFromExhibitionHours(exhibitionHours.TotalExhibitionHours)
if newLevel > maxLevel {
newLevel = maxLevel
}
// 5. SELECT FOR UPDATE 加行锁获取粉丝档案当前等级
var profile models.FanProfile
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_id = ? AND star_id = ?", userID, starID).First(&profile).Error; err != nil {
return err
}
result.OldLevel = profile.Level
result.NewLevel = newLevel
// 6. 如有升级,发放奖励
if newLevel > profile.Level {
// 查询升级奖励
rewards, err := r.getLevelUpRewards(tx, newLevel)
if err != nil {
logger.Logger.Warn("Failed to get level up rewards",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Int32("level", newLevel),
zap.Error(err))
}
// 计算水晶奖励总额
var crystalReward int64 = 0
var likeBetCountReward int32 = 0
for _, reward := range rewards {
if reward.RewardType == "crystal" && reward.IsEnabled {
crystalReward += reward.RewardValue
}
if reward.RewardType == "like_bet_count" && reward.IsEnabled {
likeBetCountReward += int32(reward.RewardValue)
}
}
result.CrystalReward = crystalReward
// 发放升级奖励(水晶 + 点赞押注次数)
if crystalReward > 0 || likeBetCountReward > 0 {
balanceBefore := profile.CrystalBalance
balanceAfter := balanceBefore + crystalReward
// 写入水晶流水(只有水晶有流水)
if crystalReward > 0 {
crystalRecord := &models.CrystalTransactionRecord{
UserID: userID,
StarID: starID,
ChangeType: "level_up_bonus",
Delta: crystalReward,
BalanceBefore: balanceBefore,
BalanceAfter: balanceAfter,
SourceID: "",
Description: fmt.Sprintf("升级到%d级奖励", newLevel),
CreatedAt: time.Now().UnixMilli(),
}
if err := tx.Create(crystalRecord).Error; err != nil {
return err
}
}
// 更新 FanProfile等级 + 水晶余额 + 点赞押注次数)
updates := map[string]interface{}{
"level": newLevel,
}
if crystalReward > 0 {
updates["crystal_balance"] = balanceAfter
}
if likeBetCountReward > 0 {
updates["like_bet_count"] = gorm.Expr("like_bet_count + ?", likeBetCountReward)
}
if err := tx.Model(&profile).Updates(updates).Error; err != nil {
return err
}
} else {
// 只更新等级
if err := tx.Model(&profile).Update("level", newLevel).Error; err != nil {
return err
}
}
logger.Logger.Info("Level up from exhibition hours",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Int32("old_level", profile.Level),
zap.Int32("new_level", newLevel),
zap.Int64("crystal_reward", crystalReward),
zap.Int32("like_bet_count_reward", likeBetCountReward),
zap.Int64("total_hours", exhibitionHours.TotalExhibitionHours))
}
return nil
})
if err != nil {
return 0, 0, 0, err
}
levelDelta := result.NewLevel - result.OldLevel
return result.NewLevel, levelDelta, result.CrystalReward, nil
}
// getLevelUpRewards 获取指定等级的升级奖励
func (r *fanProfileRepository) getLevelUpRewards(tx *gorm.DB, level int32) ([]*models.LevelUpRewardConfig, error) {
var rewards []*models.LevelUpRewardConfig
err := tx.Where("level = ? AND is_enabled = ?", level, true).Find(&rewards).Error
return rewards, err
}
// UpdateExperience 更新经验值(同时自动更新等级)
// UpdateExperience 更新经验值(同时自动更新等级)
// delta: 变化量,正数表示增加,负数表示减少
// 返回: 更新后的经验值
func (r *fanProfileRepository) UpdateExperience(userID, starID int64, delta int64) (int64, error) {
if userID <= 0 {
return 0, errors.New("user_id must be greater than 0")
}
if starID <= 0 {
return 0, errors.New("star_id must be greater than 0")
}
// 使用事务确保原子性
var newExperience int64
err := r.db.Transaction(func(tx *gorm.DB) error {
// 先查询当前的 experience 值
var profile models.FanProfile
if err := tx.Where("user_id = ? AND star_id = ?", userID, starID).
First(&profile).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return appErrors.ErrFanProfileNotFound
}
return err
}
// 计算新经验值
newExperience = profile.Experience + delta
// 确保不会小于 0
if newExperience < 0 {
newExperience = 0
}
// 根据新经验值计算新等级
newLevel := CalculateLevel(newExperience)
// 更新 experience 和 level 字段
if err := tx.Model(&models.FanProfile{}).
Where("user_id = ? AND star_id = ?", userID, starID).
Updates(map[string]interface{}{
"experience": newExperience,
"level": newLevel,
}).Error; err != nil {
return err
}
return nil
})
if err != nil {
return 0, err
}
return newExperience, nil
}
// UpdateAvatar 更新头像
func (r *fanProfileRepository) UpdateAvatar(userID, starID int64, avatarURL string) error {
if userID <= 0 {
return errors.New("user_id must be greater than 0")
}
if starID <= 0 {
return errors.New("star_id must be greater than 0")
}
if avatarURL == "" {
return errors.New("avatar_url cannot be empty")
}
result := r.db.Model(&models.FanProfile{}).
Where("user_id = ? AND star_id = ?", userID, starID).
Update("avatar_url", avatarURL)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return appErrors.ErrFanProfileNotFound
}
return nil
}
// SyncLevelFromExperience 根据经验值同步等级(只升级不降级)
// 在获取用户信息时调用,确保等级与经验值匹配
func (r *fanProfileRepository) SyncLevelFromExperience(userID, starID int64) (int32, error) {
var profile models.FanProfile
if err := r.db.Where("user_id = ? AND star_id = ?", userID, starID).First(&profile).Error; err != nil {
return 0, err
}
newLevel := CalculateLevel(profile.Experience)
// 只升级,不降级
if newLevel > profile.Level {
if err := r.db.Model(&profile).Update("level", newLevel).Error; err != nil {
return profile.Level, err
}
return newLevel, nil
}
return profile.Level, nil
}