From 11a59b0632cb2bec6a84cd1b61b445595e7426bf Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Mon, 25 May 2026 12:08:29 +0800 Subject: [PATCH] feat: add asset level service with upgrade/downgrade logic Co-Authored-By: Claude Opus 4.7 --- .../service/asset_level_service.go | 429 ++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 backend/services/assetService/service/asset_level_service.go diff --git a/backend/services/assetService/service/asset_level_service.go b/backend/services/assetService/service/asset_level_service.go new file mode 100644 index 0000000..194e9ee --- /dev/null +++ b/backend/services/assetService/service/asset_level_service.go @@ -0,0 +1,429 @@ +package service + +import ( + "fmt" + "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" +) + +type AssetLevelService interface { + GetOrCreateRecord(assetID int64) (*models.AssetLevelRecord, error) + GetRecordByAssetID(assetID int64) (*models.AssetLevelRecord, error) + GetLevelConfig(level string) (*models.AssetLevel, error) + GetAllLevels() ([]*models.AssetLevel, error) + AddExhibitionHours(assetID int64, hours int) (string, bool, error) + AddLikes(assetID int64, count int) (string, bool, error) + RemoveLikes(assetID int64, count int) (string, bool, error) + CalculateRevenue(assetID int64, likeCount int, startTime, endTime int64, revenueBoostBps int) (int64, error) + SeasonReset(seasonID string) error + GetCurrentSeason() (*models.Season, error) + GetChangeLogs(assetID int64, page, pageSize int) ([]*models.AssetLevelChangeLog, error) +} + +type assetLevelService struct { + levelRepo *repository.AssetLevelRepository + seasonRepo *repository.SeasonRepository + decayConfigRepo *repository.SeasonDecayConfigRepository +} + +func NewAssetLevelService( + levelRepo *repository.AssetLevelRepository, + seasonRepo *repository.SeasonRepository, + decayConfigRepo *repository.SeasonDecayConfigRepository, +) AssetLevelService { + return &assetLevelService{ + levelRepo: levelRepo, + seasonRepo: seasonRepo, + decayConfigRepo: decayConfigRepo, + } +} + +func (s *assetLevelService) GetOrCreateRecord(assetID int64) (*models.AssetLevelRecord, error) { + record, err := s.levelRepo.GetByAssetID(assetID) + if err == nil { + return record, nil + } + if err == gorm.ErrRecordNotFound { + record = &models.AssetLevelRecord{ + AssetID: assetID, + CurrentLevel: models.LevelN, + } + if err := s.levelRepo.Create(record); err != nil { + return nil, err + } + return record, nil + } + return nil, err +} + +func (s *assetLevelService) GetRecordByAssetID(assetID int64) (*models.AssetLevelRecord, error) { + return s.levelRepo.GetByAssetID(assetID) +} + +func (s *assetLevelService) GetLevelConfig(level string) (*models.AssetLevel, error) { + return s.levelRepo.GetLevelConfig(level) +} + +func (s *assetLevelService) GetAllLevels() ([]*models.AssetLevel, error) { + return s.levelRepo.GetAllLevels() +} + +func (s *assetLevelService) GetCurrentSeason() (*models.Season, error) { + return s.seasonRepo.GetActiveSeason() +} + +func (s *assetLevelService) getDefaultSeason() *models.Season { + return &models.Season{ + ID: "season_1", + Name: "第一赛季", + DurationDays: 84, + ResetStrategy: "percentage_decay", + ResetLevel: true, + Status: "active", + } +} + +// CheckUpgrade 检查是否可以升级(使用赛季内累计) +func (s *assetLevelService) CheckUpgrade(record *models.AssetLevelRecord) (string, bool) { + levels, err := s.GetAllLevels() + if err != nil { + return record.CurrentLevel, false + } + + currentOrder := models.LevelOrderMap[record.CurrentLevel] + + for i := len(levels) - 1; i >= 0; i-- { + level := levels[i] + if level.LevelOrder <= currentOrder { + continue + } + if record.SeasonExhibitionHours >= level.RequireHours && + record.SeasonLikes >= level.RequireLikes { + return level.Level, true + } + } + + return record.CurrentLevel, false +} + +// CheckDowngrade 检查是否需要降级 +func (s *assetLevelService) CheckDowngrade(record *models.AssetLevelRecord) (string, bool) { + levels, err := s.GetAllLevels() + if err != nil { + return record.CurrentLevel, false + } + + currentOrder := models.LevelOrderMap[record.CurrentLevel] + + for _, level := range levels { + if level.LevelOrder != currentOrder { + continue + } + if record.SeasonExhibitionHours >= level.RequireHours && + record.SeasonLikes >= level.RequireLikes { + return record.CurrentLevel, false + } + } + + newLevel := models.LevelN + for _, level := range levels { + if record.SeasonExhibitionHours >= level.RequireHours && + record.SeasonLikes >= level.RequireLikes { + newLevel = level.Level + } + } + + return newLevel, newLevel != record.CurrentLevel +} + +func (s *assetLevelService) AddExhibitionHours(assetID int64, hours int) (string, bool, error) { + record, err := s.GetOrCreateRecord(assetID) + if err != nil { + return "", false, err + } + + oldLevel := record.CurrentLevel + + if record.SeasonID == "" { + season, err := s.GetCurrentSeason() + if err != nil { + season = s.getDefaultSeason() + } + record.SeasonID = season.ID + } + + record.SeasonExhibitionHours += hours + record.LifetimeExhibitionHours += hours + + newLevel, upgraded := s.CheckUpgrade(record) + if upgraded { + record.CurrentLevel = newLevel + } + + if err := s.levelRepo.Save(record); err != nil { + return "", false, err + } + + if upgraded && newLevel != oldLevel { + s.logLevelChange(record.AssetID, oldLevel, newLevel, + "exhibition_complete", record.SeasonExhibitionHours, record.SeasonLikes, + fmt.Sprintf("展出完成,时长+%d小时", hours)) + } + + return newLevel, upgraded, nil +} + +func (s *assetLevelService) AddLikes(assetID int64, count int) (string, bool, error) { + record, err := s.GetOrCreateRecord(assetID) + if err != nil { + return "", false, err + } + + oldLevel := record.CurrentLevel + + if record.SeasonID == "" { + season, err := s.GetCurrentSeason() + if err != nil { + season = s.getDefaultSeason() + } + record.SeasonID = season.ID + } + + record.SeasonLikes += count + record.LifetimeLikes += count + + newLevel, upgraded := s.CheckUpgrade(record) + if upgraded { + record.CurrentLevel = newLevel + } + + if err := s.levelRepo.Save(record); err != nil { + return "", false, err + } + + if upgraded && newLevel != oldLevel { + s.logLevelChange(record.AssetID, oldLevel, newLevel, + "like_update", record.SeasonExhibitionHours, record.SeasonLikes, + fmt.Sprintf("点赞数达到%d触发升级", record.SeasonLikes)) + } + + return newLevel, upgraded, nil +} + +func (s *assetLevelService) RemoveLikes(assetID int64, count int) (string, bool, error) { + record, err := s.GetOrCreateRecord(assetID) + if err != nil { + return "", false, err + } + + oldLevel := record.CurrentLevel + + record.SeasonLikes -= count + if record.SeasonLikes < 0 { + record.SeasonLikes = 0 + } + record.LifetimeLikes -= count + if record.LifetimeLikes < 0 { + record.LifetimeLikes = 0 + } + + newLevel, downgraded := s.CheckDowngrade(record) + if downgraded { + record.CurrentLevel = newLevel + } + + if err := s.levelRepo.Save(record); err != nil { + return "", false, err + } + + if downgraded && newLevel != oldLevel { + s.logLevelChange(record.AssetID, oldLevel, newLevel, + "like_remove", record.SeasonExhibitionHours, record.SeasonLikes, + fmt.Sprintf("取消点赞,点赞数降至%d触发降级", record.SeasonLikes)) + } + + return newLevel, downgraded, nil +} + +func (s *assetLevelService) CalculateRevenue(assetID int64, likeCount int, startTime, endTime int64, revenueBoostBps int) (int64, error) { + record, err := s.GetRecordByAssetID(assetID) + if err != nil || record == nil { + return s.calculateDefaultRevenue(likeCount, startTime, endTime, revenueBoostBps) + } + + levelConfig, err := s.GetLevelConfig(record.CurrentLevel) + if err != nil { + return s.calculateDefaultRevenue(likeCount, startTime, endTime, revenueBoostBps) + } + + T := (endTime - startTime) / 3600000 + if T <= 0 { + T = 1 + } + + R0 := int64(levelConfig.HourlyRevenue) + baseRevenue := R0 * T + + buff := CalculateBuff(likeCount) + buffedRevenue := baseRevenue * (100 + int64(buff)) / 100 + + if revenueBoostBps > 0 { + boost := buffedRevenue * int64(revenueBoostBps) / 10000 + buffedRevenue += boost + } + + return buffedRevenue, nil +} + +func (s *assetLevelService) calculateDefaultRevenue(likeCount int, startTime, endTime int64, revenueBoostBps int) (int64, error) { + R0 := int64(5) + T := (endTime - startTime) / 3600000 + if T <= 0 { + T = 1 + } + + baseRevenue := R0 * T + buff := CalculateBuff(likeCount) + buffedRevenue := baseRevenue * (100 + int64(buff)) / 100 + + if revenueBoostBps > 0 { + boost := buffedRevenue * int64(revenueBoostBps) / 10000 + buffedRevenue += boost + } + + return buffedRevenue, nil +} + +// CalculateBuff 根据点赞数计算Buff百分比 +func CalculateBuff(likeCount int) int { + switch { + case likeCount >= 30: + return 30 + case likeCount >= 10: + return 20 + case likeCount >= 5: + return 10 + default: + return 0 + } +} + +func (s *assetLevelService) SeasonReset(seasonID string) error { + season, err := s.seasonRepo.GetByID(seasonID) + if err != nil || season == nil { + return fmt.Errorf("season not found: %s", seasonID) + } + + if !season.ResetLevel { + return nil + } + + decayConfigs, err := s.decayConfigRepo.GetBySeason(seasonID) + if err != nil { + return err + } + + decayPercentMap := make(map[string]int) + for _, cfg := range decayConfigs { + decayPercentMap[cfg.Level] = cfg.PreservePercent + } + + records, err := s.levelRepo.GetBySeason(seasonID) + if err != nil { + return err + } + + nextSeasonID := s.calculateNextSeasonID(seasonID) + _, err = s.seasonRepo.GetOrCreate(nextSeasonID, season.DurationDays, season.ResetStrategy) + if err != nil { + return err + } + + for _, record := range records { + oldLevel := record.CurrentLevel + + preservePercent := decayPercentMap[record.CurrentLevel] + if preservePercent <= 0 { + preservePercent = 0 + } else if preservePercent >= 100 { + preservePercent = 100 + } + + record.SeasonExhibitionHours = record.LifetimeExhibitionHours * preservePercent / 100 + record.SeasonLikes = record.LifetimeLikes * preservePercent / 100 + + newLevel := s.recalculateLevelAfterDecay(record) + record.CurrentLevel = newLevel + record.SeasonID = nextSeasonID + + if err := s.levelRepo.Save(record); err != nil { + logger.Logger.Error("SeasonReset: failed to update record", + zap.Int64("asset_id", record.AssetID), + zap.Error(err)) + continue + } + + if oldLevel != newLevel { + s.logLevelChange(record.AssetID, oldLevel, newLevel, + "season_decay", record.SeasonExhibitionHours, record.SeasonLikes, + fmt.Sprintf("赛季%s降序,保留%d%%", seasonID, preservePercent)) + } + } + + season.Status = "ended" + return s.seasonRepo.Save(season) +} + +func (s *assetLevelService) calculateNextSeasonID(currentSeasonID string) string { + if len(currentSeasonID) > 0 && currentSeasonID[:7] == "season_" { + num := 0 + fmt.Sscanf(currentSeasonID[7:], "%d", &num) + return fmt.Sprintf("season_%d", num+1) + } + return currentSeasonID + "_next" +} + +func (s *assetLevelService) recalculateLevelAfterDecay(record *models.AssetLevelRecord) string { + levels, err := s.GetAllLevels() + if err != nil { + return record.CurrentLevel + } + + newLevel := models.LevelN + for _, level := range levels { + if record.SeasonExhibitionHours >= level.RequireHours && + record.SeasonLikes >= level.RequireLikes { + newLevel = level.Level + } + } + + return newLevel +} + +func (s *assetLevelService) logLevelChange(assetID int64, fromLevel, toLevel, triggerType string, triggerHours, triggerLikes int, reason string) { + log := &models.AssetLevelChangeLog{ + AssetID: assetID, + FromLevel: fromLevel, + ToLevel: toLevel, + TriggerType: triggerType, + TriggerHours: triggerHours, + TriggerLikes: triggerLikes, + ChangeReason: reason, + CreatedAt: time.Now().UnixMilli(), + } + if err := s.levelRepo.CreateChangeLog(log); err != nil { + logger.Logger.Error("Failed to create level change log", + zap.Int64("asset_id", assetID), + zap.Error(err)) + } +} + +func (s *assetLevelService) GetChangeLogs(assetID int64, page, pageSize int) ([]*models.AssetLevelChangeLog, error) { + offset := (page - 1) * pageSize + return s.levelRepo.GetChangeLogs(assetID, pageSize, offset) +} \ No newline at end of file