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 } rewards, err := s.loadInRoomRewardConfigs(ctx, launch.SysOrigin, launch.Level) if err != nil { return err } selected := s.selectInRoomRewardUsers(launch, audience, rewards) 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 } pickedRewards := s.pickInRoomRewardsForUsers(launch, rewards, selected) for _, userID := range selected { for _, reward := range pickedRewards[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, rewards []model.VoiceRoomRocketRewardConfig) []int64 { limit := s.inRoomRewardSelectionLimit(rewards) 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) inRoomRewardSelectionLimit(rewards []model.VoiceRoomRocketRewardConfig) int { totalCopies := 0 for _, reward := range rewards { if !strings.EqualFold(strings.TrimSpace(reward.RewardScene), rewardSceneInRoom) { continue } totalCopies += positiveOrDefault(reward.RewardCopies, 1) } if totalCopies > 0 { return totalCopies } limit := s.cfg.VoiceRoomRocket.InRoomRewardUserLimit if limit <= 0 { limit = defaultInRoomRewardUserLimit } return limit } func (s *Service) pickInRoomRewardsForUser(launch model.VoiceRoomRocketLaunch, rewards []model.VoiceRoomRocketRewardConfig, userID int64) []model.VoiceRoomRocketRewardConfig { picked := s.pickInRoomRewardsForUsers(launch, rewards, []int64{userID}) return picked[userID] } func (s *Service) pickInRoomRewardsForUsers(launch model.VoiceRoomRocketLaunch, rewards []model.VoiceRoomRocketRewardConfig, userIDs []int64) map[int64][]model.VoiceRoomRocketRewardConfig { result := make(map[int64][]model.VoiceRoomRocketRewardConfig, len(userIDs)) if len(rewards) == 0 { return result } type rewardCandidate struct { reward model.VoiceRoomRocketRewardConfig remaining int } weighted := make([]rewardCandidate, 0, len(rewards)) for _, reward := range rewards { weight := reward.Weight if weight <= 0 { weight = 1 } reward.Weight = weight weighted = append(weighted, rewardCandidate{ reward: reward, remaining: positiveOrDefault(reward.RewardCopies, 1), }) } for _, userID := range userIDs { totalWeight := 0 for _, candidate := range weighted { if candidate.remaining > 0 { totalWeight += candidate.reward.Weight } } if totalWeight <= 0 { continue } rng := rand.New(rand.NewSource(seedFromStrings(launch.LaunchNo, strconv.FormatInt(userID, 10), "reward"))) point := rng.Intn(totalWeight) + 1 acc := 0 for idx := range weighted { if weighted[idx].remaining <= 0 { continue } acc += weighted[idx].reward.Weight if point <= acc { weighted[idx].remaining-- if !strings.EqualFold(strings.TrimSpace(weighted[idx].reward.RewardType), rewardTypeNone) { result[userID] = []model.VoiceRoomRocketRewardConfig{weighted[idx].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 }