From 019ffa9a4ad775058317b86e31e8d06e00fcbbbd Mon Sep 17 00:00:00 2001 From: hy001 Date: Fri, 15 May 2026 12:19:42 +0800 Subject: [PATCH] =?UTF-8?q?=E7=81=AB=E7=AE=AD=E4=BB=BD=E6=95=B0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/model/voice_room_rocket_models.go | 1 + internal/service/voiceroomrocket/config.go | 7 ++ .../service/voiceroomrocket/event_test.go | 46 +++++++- .../service/voiceroomrocket/in_room_reward.go | 103 ++++++++++++------ internal/service/voiceroomrocket/mapper.go | 1 + internal/service/voiceroomrocket/types.go | 1 + migrations/032_voice_room_rocket.sql | 1 + .../038_voice_room_rocket_reward_copies.sql | 16 +++ 8 files changed, 141 insertions(+), 35 deletions(-) create mode 100644 migrations/038_voice_room_rocket_reward_copies.sql diff --git a/internal/model/voice_room_rocket_models.go b/internal/model/voice_room_rocket_models.go index 4eae016..d40f3cb 100644 --- a/internal/model/voice_room_rocket_models.go +++ b/internal/model/voice_room_rocket_models.go @@ -52,6 +52,7 @@ type VoiceRoomRocketRewardConfig struct { RewardCover string `gorm:"column:reward_cover;size:512"` RewardAmount int64 `gorm:"column:reward_amount"` ExpireDays *int `gorm:"column:expire_days"` + RewardCopies int `gorm:"column:reward_copies;default:1"` Weight int `gorm:"column:weight"` Sort int `gorm:"column:sort"` Enabled bool `gorm:"column:enabled;index:idx_voice_room_rocket_reward_scene,priority:4"` diff --git a/internal/service/voiceroomrocket/config.go b/internal/service/voiceroomrocket/config.go index 9389828..a71a064 100644 --- a/internal/service/voiceroomrocket/config.go +++ b/internal/service/voiceroomrocket/config.go @@ -265,6 +265,12 @@ func (s *Service) SaveRewardConfigs(ctx context.Context, req SaveRewardConfigsRe } else if amount <= 0 { amount = 1 } + rewardCopies := item.RewardCopies + if scene != rewardSceneInRoom { + rewardCopies = 1 + } else if rewardCopies <= 0 { + rewardCopies = 1 + } var row model.VoiceRoomRocketRewardConfig if item.ID > 0 { if err := tx.Where("id = ? AND sys_origin = ?", item.ID, sysOrigin).First(&row).Error; err != nil { @@ -289,6 +295,7 @@ func (s *Service) SaveRewardConfigs(ctx context.Context, req SaveRewardConfigsRe row.RewardCover = strings.TrimSpace(item.RewardCover) row.RewardAmount = amount row.ExpireDays = item.ExpireDays + row.RewardCopies = rewardCopies row.Weight = item.Weight row.Sort = item.Sort row.Enabled = item.Enabled diff --git a/internal/service/voiceroomrocket/event_test.go b/internal/service/voiceroomrocket/event_test.go index 292e00a..39dae01 100644 --- a/internal/service/voiceroomrocket/event_test.go +++ b/internal/service/voiceroomrocket/event_test.go @@ -176,7 +176,7 @@ func TestInRoomRewardWorkerSnapshotsSelectsAndGrants(t *testing.T) { seedLevelConfigs(t, db, []int64{100, 200, 300, 400, 500, 600}) seedRewardConfig(t, db, 1, rewardSceneTop1, rewardTypeGold, 10) seedRewardConfig(t, db, 1, rewardSceneIgnite, rewardTypeGold, 5) - seedRewardConfig(t, db, 1, rewardSceneInRoom, rewardTypeGold, 1) + seedRewardConfigWithCopies(t, db, 1, rewardSceneInRoom, rewardTypeGold, 1, 3) roomID := int64(9001) for _, userID := range []int64{1001, 1002, 1003, 1004} { @@ -336,6 +336,38 @@ func TestPickInRoomRewardCanBeEmpty(t *testing.T) { } } +func TestInRoomRewardCopiesLimitSelection(t *testing.T) { + service, _ := newTestService(t) + service.cfg.VoiceRoomRocket.InRoomRewardUserLimit = 10 + launch := model.VoiceRoomRocketLaunch{LaunchNo: "VRR-copies"} + audience := []roomAudienceUser{ + {UserID: 1001}, + {UserID: 1002}, + {UserID: 1003}, + {UserID: 1004}, + } + rewards := []model.VoiceRoomRocketRewardConfig{ + { + ID: 1, + RewardScene: rewardSceneInRoom, + RewardType: rewardTypeGold, + RewardName: "金币", + RewardAmount: 100, + RewardCopies: 2, + Weight: 1, + }, + } + + selected := service.selectInRoomRewardUsers(launch, audience, rewards) + if len(selected) != 2 { + t.Fatalf("selected users = %+v, want 2 users", selected) + } + picked := service.pickInRoomRewardsForUsers(launch, rewards, selected) + if len(picked) != 2 { + t.Fatalf("picked rewards = %+v, want 2 users with rewards", picked) + } +} + func TestNotifyLaunchPublishesIMAndRedisStream(t *testing.T) { gateway := newFakeRocketGateway() service, db, rdb, mr := newTestServiceWithRedis(t, gateway) @@ -457,10 +489,11 @@ func TestSaveRewardConfigsReplacesAndClearsSelectedLevel(t *testing.T) { Rewards: []RewardConfigPayload{ { Level: 1, - RewardScene: rewardSceneTop1, + RewardScene: rewardSceneInRoom, RewardType: rewardTypeGold, RewardName: "new gold", RewardAmount: 99, + RewardCopies: 10, Enabled: true, Sort: 1, }, @@ -469,8 +502,8 @@ func TestSaveRewardConfigsReplacesAndClearsSelectedLevel(t *testing.T) { if err != nil { t.Fatalf("SaveRewardConfigs(replace) error = %v", err) } - if len(saved) != 1 || saved[0].RewardAmount != 99 { - t.Fatalf("saved rewards = %+v, want one amount 99", saved) + if len(saved) != 1 || saved[0].RewardAmount != 99 || saved[0].RewardCopies != 10 { + t.Fatalf("saved rewards = %+v, want one amount 99 and 10 copies", saved) } var level1Count int64 @@ -711,6 +744,10 @@ func seedLevelConfigs(t *testing.T, db *gorm.DB, energy []int64) { } func seedRewardConfig(t *testing.T, db *gorm.DB, level int, scene string, rewardType string, amount int64) { + seedRewardConfigWithCopies(t, db, level, scene, rewardType, amount, 1) +} + +func seedRewardConfigWithCopies(t *testing.T, db *gorm.DB, level int, scene string, rewardType string, amount int64, copies int) { t.Helper() now := time.Now() row := model.VoiceRoomRocketRewardConfig{ @@ -721,6 +758,7 @@ func seedRewardConfig(t *testing.T, db *gorm.DB, level int, scene string, reward RewardType: rewardType, RewardName: scene + " reward", RewardAmount: amount, + RewardCopies: copies, Enabled: true, CreateTime: now, UpdateTime: now, diff --git a/internal/service/voiceroomrocket/in_room_reward.go b/internal/service/voiceroomrocket/in_room_reward.go index ce23d87..86c0e0f 100644 --- a/internal/service/voiceroomrocket/in_room_reward.go +++ b/internal/service/voiceroomrocket/in_room_reward.go @@ -88,7 +88,11 @@ func (s *Service) processInRoomRewardLaunch(ctx context.Context, launchNo string if err != nil { return err } - selected := s.selectInRoomRewardUsers(launch, audience) + 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 } @@ -100,12 +104,9 @@ func (s *Service) processInRoomRewardLaunch(ctx context.Context, launchNo string return nil } - rewards, err := s.loadInRoomRewardConfigs(ctx, launch.SysOrigin, launch.Level) - if err != nil { - return err - } + pickedRewards := s.pickInRoomRewardsForUsers(launch, rewards, selected) for _, userID := range selected { - for _, reward := range s.pickInRoomRewardsForUser(launch, rewards, userID) { + for _, reward := range pickedRewards[userID] { record, err := s.createRewardRecord(ctx, tx, launch, reward, userID, now) if err != nil { return err @@ -201,11 +202,8 @@ func (s *Service) loadInRoomRewardConfigs(ctx context.Context, sysOrigin string, return result, nil } -func (s *Service) selectInRoomRewardUsers(launch model.VoiceRoomRocketLaunch, audience []roomAudienceUser) []int64 { - limit := s.cfg.VoiceRoomRocket.InRoomRewardUserLimit - if limit <= 0 { - limit = defaultInRoomRewardUserLimit - } +func (s *Service) selectInRoomRewardUsers(launch model.VoiceRoomRocketLaunch, audience []roomAudienceUser, rewards []model.VoiceRoomRocketRewardConfig) []int64 { + limit := s.inRoomRewardSelectionLimit(rewards) if len(audience) == 0 { return nil } @@ -228,37 +226,80 @@ func (s *Service) selectInRoomRewardUsers(launch model.VoiceRoomRocketLaunch, au return selected } -func (s *Service) pickInRoomRewardsForUser(launch model.VoiceRoomRocketLaunch, rewards []model.VoiceRoomRocketRewardConfig, userID int64) []model.VoiceRoomRocketRewardConfig { - if len(rewards) == 0 { - return nil +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) } - weighted := make([]model.VoiceRoomRocketRewardConfig, 0, len(rewards)) - totalWeight := 0 + 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, reward) - totalWeight += weight + weighted = append(weighted, rewardCandidate{ + reward: reward, + remaining: positiveOrDefault(reward.RewardCopies, 1), + }) } - if totalWeight <= 0 { - return nil - } - 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 { - if strings.EqualFold(strings.TrimSpace(reward.RewardType), rewardTypeNone) { - return nil + + 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 []model.VoiceRoomRocketRewardConfig{reward} } } - return nil + return result } func seedFromStrings(parts ...string) int64 { diff --git a/internal/service/voiceroomrocket/mapper.go b/internal/service/voiceroomrocket/mapper.go index db05321..aec9892 100644 --- a/internal/service/voiceroomrocket/mapper.go +++ b/internal/service/voiceroomrocket/mapper.go @@ -160,6 +160,7 @@ func rewardPayload(row model.VoiceRoomRocketRewardConfig) RewardConfigPayload { RewardCover: row.RewardCover, RewardAmount: row.RewardAmount, ExpireDays: row.ExpireDays, + RewardCopies: positiveOrDefault(row.RewardCopies, 1), Weight: row.Weight, Sort: row.Sort, Enabled: row.Enabled, diff --git a/internal/service/voiceroomrocket/types.go b/internal/service/voiceroomrocket/types.go index 2ed35dc..17ba6b2 100644 --- a/internal/service/voiceroomrocket/types.go +++ b/internal/service/voiceroomrocket/types.go @@ -333,6 +333,7 @@ type RewardConfigPayload struct { RewardCover string `json:"rewardCover,omitempty"` RewardAmount int64 `json:"rewardAmount"` ExpireDays *int `json:"expireDays,omitempty"` + RewardCopies int `json:"rewardCopies"` Weight int `json:"weight"` Sort int `json:"sort"` Enabled bool `json:"enabled"` diff --git a/migrations/032_voice_room_rocket.sql b/migrations/032_voice_room_rocket.sql index 4b050dc..daf1590 100644 --- a/migrations/032_voice_room_rocket.sql +++ b/migrations/032_voice_room_rocket.sql @@ -43,6 +43,7 @@ CREATE TABLE IF NOT EXISTS voice_room_rocket_reward_config ( reward_cover VARCHAR(512) DEFAULT NULL, reward_amount BIGINT NOT NULL DEFAULT 1 COMMENT '金币数量或道具数量/天数', expire_days INT DEFAULT NULL COMMENT '时效类奖励有效天数', + reward_copies INT NOT NULL DEFAULT 1 COMMENT 'IN_ROOM 本奖励每次发射可发放份数', weight INT NOT NULL DEFAULT 0 COMMENT 'IN_ROOM 随机权重;0 表示固定发放', sort INT NOT NULL DEFAULT 0, enabled TINYINT(1) NOT NULL DEFAULT 1, diff --git a/migrations/038_voice_room_rocket_reward_copies.sql b/migrations/038_voice_room_rocket_reward_copies.sql new file mode 100644 index 0000000..39ca58a --- /dev/null +++ b/migrations/038_voice_room_rocket_reward_copies.sql @@ -0,0 +1,16 @@ +SET @current_schema := DATABASE(); + +SET @add_voice_room_rocket_reward_copies_sql := IF( + NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @current_schema + AND table_name = 'voice_room_rocket_reward_config' + AND column_name = 'reward_copies' + ), + 'ALTER TABLE `voice_room_rocket_reward_config` ADD COLUMN `reward_copies` int NOT NULL DEFAULT 1 COMMENT ''IN_ROOM 本奖励每次发射可发放份数'' AFTER `expire_days`', + 'SELECT 1' +); +PREPARE add_voice_room_rocket_reward_copies_stmt FROM @add_voice_room_rocket_reward_copies_sql; +EXECUTE add_voice_room_rocket_reward_copies_stmt; +DEALLOCATE PREPARE add_voice_room_rocket_reward_copies_stmt;