package service import ( "context" "fmt" "strconv" "time" "github.com/topfans/backend/pkg/logger" "github.com/topfans/backend/pkg/models" eventPb "github.com/topfans/backend/pkg/proto/event" "github.com/topfans/backend/pkg/statistic" "github.com/topfans/backend/services/assetService/repository" "go.uber.org/zap" "gorm.io/gorm" "gorm.io/gorm/clause" ) 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, sourceID string) (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 assetRepo repository.AssetRepository // 用于同步等级到AssetRegistry.Grade(可选) } 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, sourceID string) (string, bool, error) { if sourceID == "" { logger.Logger.Warn("AssetLevelService.AddExhibitionHours called with empty sourceID, idempotency degraded", zap.Int64("asset_id", assetID)) } db := s.levelRepo.GetDB() if db == nil { return "", false, fmt.Errorf("asset level repository db not initialized") } var resultLevel string var upgraded bool // 整个累积时长流程(log INSERT + 判 RowsAffected + 累加 Save + 升级 changelog)放在同一事务: // - log 写入了但累加崩了 → 一起回滚(避免"log 记了但 hours 没加"孤儿态) // - 重复路径 RowsAffected==0 直接 return,事务早结束 err := db.Transaction(func(tx *gorm.DB) error { txRepo := s.levelRepo.WithTx(tx) // 0. 幂等闸口 if sourceID != "" { logRow := &models.AssetExhibitionHoursLog{ SourceID: sourceID, AssetID: assetID, Hours: hours, CreatedAt: time.Now().UnixMilli(), } res := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "source_id"}}, DoNothing: true, }).Create(logRow) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { // 重复 sourceID:回查当前等级后 return,事务提前结束 rec, qerr := txRepo.GetByAssetID(assetID) if qerr != nil && qerr != gorm.ErrRecordNotFound { return qerr } logger.Logger.Info("AssetLevelService.AddExhibitionHours: duplicate source_id, skip", zap.Int64("asset_id", assetID), zap.String("source_id", sourceID)) if rec != nil { resultLevel = rec.CurrentLevel } upgraded = false return nil } } // 1. 获取或创建累计时长记录 record, err := getOrCreateRecordTx(tx, assetID) if err != nil { return err } oldLevel := record.CurrentLevel if record.SeasonID == "" { season, err := s.GetCurrentSeason() if err != nil { season = s.getDefaultSeason() } record.SeasonID = season.ID } // 2. 累加时长(原子更新,避免竞态) now := time.Now().UnixMilli() if err := tx.Model(&models.AssetLevelRecord{}). Where("asset_id = ?", assetID). Updates(map[string]any{ "season_exhibition_hours": gorm.Expr("season_exhibition_hours + ?", hours), "lifetime_exhibition_hours": gorm.Expr("lifetime_exhibition_hours + ?", hours), "updated_at": now, }).Error; err != nil { return err } // 3. 重新读最新 record(供 CheckUpgrade 用最新 hours) updated, err := txRepo.GetByAssetID(assetID) if err != nil { return err } updated.CurrentLevel = record.CurrentLevel updated.SeasonID = record.SeasonID record = updated // 4. 计算新等级 newLevel, isUpgraded := s.CheckUpgrade(record) if isUpgraded { record.CurrentLevel = newLevel } // 5. 落库累计/等级 if err := txRepo.Save(record); err != nil { return err } // 6. 升级 changelog + asset_registry.grade 同步(同事务) if isUpgraded && newLevel != oldLevel { changeLog := &models.AssetLevelChangeLog{ AssetID: record.AssetID, FromLevel: oldLevel, ToLevel: newLevel, TriggerType: "exhibition_complete", TriggerHours: record.SeasonExhibitionHours, TriggerLikes: record.SeasonLikes, ChangeReason: fmt.Sprintf("展出完成,时长+%d小时", hours), CreatedAt: now, } if err := txRepo.CreateChangeLog(changeLog); err != nil { return err } grade := models.LevelToGrade(newLevel) if err := tx.Table("public.asset_registry"). Where("asset_id = ?", record.AssetID). Update("grade", grade).Error; err != nil { return err } } resultLevel = newLevel upgraded = isUpgraded return nil }) if err != nil { return "", false, err } // 升级事件埋点(statistic)走事务外:fire-and-forget,不应阻塞核心事务 if upgraded && resultLevel != "" { // 升级事件埋点(沿用 logLevelChange 既有行为,但不在事务内) statistic.Get().TrackEvent(context.Background(), &eventPb.Event{ EventType: "asset.level_up", UserId: 0, StarId: 0, OccurredAt: time.Now().UnixMilli(), Properties: map[string]string{ "asset_id": strconv.FormatInt(assetID, 10), "to": resultLevel, }, }) } return resultLevel, upgraded, nil } // getOrCreateRecordTx 事务内获取或创建 asset_level_records 行。 // 走 INSERT ... ON CONFLICT(asset_id) DO NOTHING 保证并发安全。 func getOrCreateRecordTx(tx *gorm.DB, assetID int64) (*models.AssetLevelRecord, error) { var record models.AssetLevelRecord err := tx.Where("asset_id = ?", assetID).First(&record).Error if err == nil { return &record, nil } if err != gorm.ErrRecordNotFound { return nil, err } // 不存在则插入空记录;并发场景下 ON CONFLICT DO NOTHING 防重复插入 newRec := &models.AssetLevelRecord{ AssetID: assetID, CurrentLevel: models.LevelN, } if err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "asset_id"}}, DoNothing: true, }).Create(newRec).Error; err != nil { return nil, err } // 重读一次拿稳定 record(包含数据库默认值) if err := tx.Where("asset_id = ?", assetID).First(&record).Error; err != nil { return nil, err } return &record, 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)) // 同步等级到AssetRegistry.Grade s.syncGradeToAssetRegistry(record.AssetID, newLevel) } 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)) // 同步等级到AssetRegistry.Grade s.syncGradeToAssetRegistry(record.AssetID, newLevel) } return newLevel, downgraded, nil } // syncGradeToAssetRegistry 将等级同步到AssetRegistry.Grade // 直接使用gorm.DB更新,不依赖repository层 func (s *assetLevelService) syncGradeToAssetRegistry(assetID int64, level string) { if s.assetRepo == nil { // 直接使用levelRepo的db进行更新 grade := models.LevelToGrade(level) db := s.levelRepo.GetDB() if db != nil { if err := db.Table("public.asset_registry"). Where("asset_id = ?", assetID). Update("grade", grade).Error; err != nil { logger.Logger.Warn("syncGradeToAssetRegistry failed", zap.Int64("asset_id", assetID), zap.String("level", level), zap.Int("grade", grade), zap.Error(err)) } else { logger.Logger.Info("syncGradeToAssetRegistry success", zap.Int64("asset_id", assetID), zap.String("level", level), zap.Int("grade", grade)) } } return } grade := models.LevelToGrade(level) if err := s.assetRepo.UpdateGradeByAssetID(assetID, int32(grade)); err != nil { logger.Logger.Warn("syncGradeToAssetRegistry failed", zap.Int64("asset_id", assetID), zap.String("level", level), zap.Int("grade", grade), zap.Error(err)) } else { logger.Logger.Info("syncGradeToAssetRegistry success", zap.Int64("asset_id", assetID), zap.String("level", level), zap.Int("grade", grade)) } } 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)) // 同步等级到AssetRegistry.Grade s.syncGradeToAssetRegistry(record.AssetID, newLevel) } } 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)) } // 事件埋点:asset.level_up(fire-and-forget) // 简化:直接提交,不查 user_id/star_id(metric_recent_level_ups 表后续可补) statistic.Get().TrackEvent(context.Background(), &eventPb.Event{ EventType: "asset.level_up", UserId: 0, // 占位,TODO 后续从 public.assets 查 StarId: 0, // 占位 OccurredAt: time.Now().UnixMilli(), Properties: map[string]string{ "asset_id": strconv.FormatInt(assetID, 10), "from": fromLevel, "to": toLevel, }, }) } func (s *assetLevelService) GetChangeLogs(assetID int64, page, pageSize int) ([]*models.AssetLevelChangeLog, error) { offset := (page - 1) * pageSize return s.levelRepo.GetChangeLogs(assetID, pageSize, offset) }