package voiceroomrocket import ( "context" "errors" "hash/fnv" "math/rand" "sort" "strconv" "strings" "time" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "github.com/redis/go-redis/v9" "gorm.io/gorm" "gorm.io/gorm/clause" ) // ProcessDueInRoomRewards 扫描到期的在房间奖励任务,写入在线用户快照并生成中奖记录。 func (s *Service) ProcessDueInRoomRewards(ctx context.Context, now time.Time, limit int64) (int, error) { if s.repo.Redis == nil { return 0, nil } if limit <= 0 { limit = int64(defaultWorkerBatchSize) } key := s.inRoomDueZSetKey() launchNos, err := s.repo.Redis.ZRangeByScore(ctx, key, &redis.ZRangeBy{ Min: "-inf", Max: strconv.FormatInt(now.Unix(), 10), Count: limit, }).Result() if err != nil { return 0, err } processed := 0 for _, launchNo := range launchNos { launchNo = strings.TrimSpace(launchNo) if launchNo == "" { continue } done, err := s.processInRoomRewardLaunch(ctx, launchNo, now) if err != nil { return processed, err } if done { if err := s.repo.Redis.ZRem(context.Background(), key, launchNo).Err(); err != nil { return processed, err } processed++ } } return processed, nil } func (s *Service) processInRoomRewardLaunch(ctx context.Context, launchNo string, now time.Time) (bool, error) { var launch model.VoiceRoomRocketLaunch var selectedRecords []model.VoiceRoomRocketRewardRecord done := false err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("launch_no = ?", launchNo). First(&launch).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { done = true return nil } return err } switch launch.InRoomSnapshotStatus { case inRoomSnapshotSuccess, inRoomSnapshotEmpty: done = true return nil } if err := tx.Model(&model.VoiceRoomRocketLaunch{}). Where("id = ?", launch.ID). Updates(map[string]any{ "in_room_snapshot_status": inRoomSnapshotProcessing, "update_time": now, }).Error; err != nil { return err } audience, err := s.listOnlineRoomUsers(ctx, launch.SysOrigin, launch.RoomID, now) if err != nil { return err } selected := s.selectInRoomRewardUsers(launch, audience) if err := s.saveAudienceSnapshot(ctx, tx, launch, audience, selected, now); err != nil { return err } if len(selected) == 0 { if err := s.markLaunchInRoomStatus(ctx, tx, launch.ID, inRoomSnapshotEmpty, now); err != nil { return err } done = true return nil } rewards, err := s.loadInRoomRewardConfigs(ctx, launch.SysOrigin, launch.Level) if err != nil { return err } for _, userID := range selected { for _, reward := range s.pickInRoomRewardsForUser(launch, rewards, userID) { record, err := s.createRewardRecord(ctx, tx, launch, reward, userID, now) if err != nil { return err } if record.ID > 0 { selectedRecords = append(selectedRecords, record) } } } if err := s.markLaunchInRoomStatus(ctx, tx, launch.ID, inRoomSnapshotSuccess, now); err != nil { return err } done = true return nil }) if err != nil { _ = s.repo.DB.WithContext(context.Background()). Model(&model.VoiceRoomRocketLaunch{}). Where("launch_no = ?", launchNo). Updates(map[string]any{ "in_room_snapshot_status": inRoomSnapshotFailed, "update_time": time.Now(), }).Error return false, err } if len(selectedRecords) > 0 { _ = s.notifyRewardRecords(context.Background(), launch, selectedRecords) } return done, nil } func (s *Service) saveAudienceSnapshot( ctx context.Context, tx *gorm.DB, launch model.VoiceRoomRocketLaunch, audience []roomAudienceUser, selected []int64, now time.Time, ) error { selectedSet := make(map[int64]struct{}, len(selected)) for _, userID := range selected { selectedSet[userID] = struct{}{} } rows := make([]model.VoiceRoomRocketAudienceSnapshot, 0, len(audience)) for _, user := range audience { id, err := utils.NextID() if err != nil { return err } _, isSelected := selectedSet[user.UserID] rows = append(rows, model.VoiceRoomRocketAudienceSnapshot{ ID: id, LaunchID: launch.ID, SysOrigin: launch.SysOrigin, RoomID: launch.RoomID, UserID: user.UserID, LastSeenTime: user.LastSeenTime, Selected: isSelected, CreateTime: now, }) } if len(rows) == 0 { return nil } return tx.WithContext(ctx). Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "launch_id"}, {Name: "user_id"}}, DoUpdates: clause.AssignmentColumns([]string{"last_seen_time", "selected"}), }). Create(&rows).Error } func (s *Service) markLaunchInRoomStatus(ctx context.Context, tx *gorm.DB, launchID int64, status string, now time.Time) error { return tx.WithContext(ctx).Model(&model.VoiceRoomRocketLaunch{}). Where("id = ?", launchID). Updates(map[string]any{ "in_room_snapshot_status": status, "update_time": now, }).Error } func (s *Service) loadInRoomRewardConfigs(ctx context.Context, sysOrigin string, level int) ([]model.VoiceRoomRocketRewardConfig, error) { rewards, err := s.loadRewardConfigs(ctx, sysOrigin, level) if err != nil { return nil, err } result := make([]model.VoiceRoomRocketRewardConfig, 0, len(rewards)) for _, reward := range rewards { if strings.EqualFold(strings.TrimSpace(reward.RewardScene), rewardSceneInRoom) { result = append(result, reward) } } return result, nil } func (s *Service) selectInRoomRewardUsers(launch model.VoiceRoomRocketLaunch, audience []roomAudienceUser) []int64 { limit := s.cfg.VoiceRoomRocket.InRoomRewardUserLimit if limit <= 0 { limit = defaultInRoomRewardUserLimit } if len(audience) == 0 { return nil } userIDs := make([]int64, 0, len(audience)) for _, user := range audience { if user.UserID > 0 { userIDs = append(userIDs, user.UserID) } } sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] }) if len(userIDs) <= limit { return userIDs } rng := rand.New(rand.NewSource(seedFromStrings(launch.LaunchNo, "audience"))) rng.Shuffle(len(userIDs), func(i, j int) { userIDs[i], userIDs[j] = userIDs[j], userIDs[i] }) selected := append([]int64(nil), userIDs[:limit]...) sort.Slice(selected, func(i, j int) bool { return selected[i] < selected[j] }) return selected } func (s *Service) pickInRoomRewardsForUser(launch model.VoiceRoomRocketLaunch, rewards []model.VoiceRoomRocketRewardConfig, userID int64) []model.VoiceRoomRocketRewardConfig { if len(rewards) == 0 { return nil } fixed := make([]model.VoiceRoomRocketRewardConfig, 0, len(rewards)) weighted := make([]model.VoiceRoomRocketRewardConfig, 0, len(rewards)) totalWeight := 0 for _, reward := range rewards { if reward.Weight <= 0 { fixed = append(fixed, reward) continue } weighted = append(weighted, reward) totalWeight += reward.Weight } result := append([]model.VoiceRoomRocketRewardConfig(nil), fixed...) if totalWeight <= 0 { return result } rng := rand.New(rand.NewSource(seedFromStrings(launch.LaunchNo, strconv.FormatInt(userID, 10), "reward"))) point := rng.Intn(totalWeight) + 1 acc := 0 for _, reward := range weighted { acc += reward.Weight if point <= acc { result = append(result, reward) break } } return result } func seedFromStrings(parts ...string) int64 { hash := fnv.New64a() for _, part := range parts { _, _ = hash.Write([]byte(part)) _, _ = hash.Write([]byte{0}) } return int64(hash.Sum64() & 0x7fffffffffffffff) } func (s *Service) inRoomDueZSetKey() string { key := strings.TrimSpace(s.cfg.VoiceRoomRocket.InRoomRewardDueZSetKey) if key == "" { return "voice_room:rocket:in_room_due" } return key }