diff --git a/api/proto/room/v1/room.pb.go b/api/proto/room/v1/room.pb.go index da656982..3c395406 100644 --- a/api/proto/room/v1/room.pb.go +++ b/api/proto/room/v1/room.pb.go @@ -774,8 +774,10 @@ type RoomRocketRewardItem struct { Weight int64 `protobuf:"varint,3,opt,name=weight,proto3" json:"weight,omitempty"` DisplayName string `protobuf:"bytes,4,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` IconUrl string `protobuf:"bytes,5,opt,name=icon_url,json=iconUrl,proto3" json:"icon_url,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // quantity 表示该奖项在一次火箭发射中最多发给多少个在房用户;为空或 0 时兼容旧配置按 1 份处理。 + Quantity int64 `protobuf:"varint,6,opt,name=quantity,proto3" json:"quantity,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RoomRocketRewardItem) Reset() { @@ -843,6 +845,13 @@ func (x *RoomRocketRewardItem) GetIconUrl() string { return "" } +func (x *RoomRocketRewardItem) GetQuantity() int64 { + if x != nil { + return x.Quantity + } + return 0 +} + // RoomRocketLevel 暴露单个等级火箭的物料、阈值和三类奖励池。 type RoomRocketLevel struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -9520,13 +9529,14 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\rcreated_at_ms\x18\x11 \x01(\x03R\vcreatedAtMs\x122\n" + "\x15wallet_transaction_id\x18\x12 \x01(\tR\x13walletTransactionId\x12,\n" + "\x12coin_balance_after\x18\x13 \x01(\x03R\x10coinBalanceAfter\x12$\n" + - "\x0etarget_user_id\x18\x14 \x01(\x03R\ftargetUserId\"\xbe\x01\n" + + "\x0etarget_user_id\x18\x14 \x01(\x03R\ftargetUserId\"\xda\x01\n" + "\x14RoomRocketRewardItem\x12$\n" + "\x0ereward_item_id\x18\x01 \x01(\tR\frewardItemId\x12*\n" + "\x11resource_group_id\x18\x02 \x01(\x03R\x0fresourceGroupId\x12\x16\n" + "\x06weight\x18\x03 \x01(\x03R\x06weight\x12!\n" + "\fdisplay_name\x18\x04 \x01(\tR\vdisplayName\x12\x19\n" + - "\bicon_url\x18\x05 \x01(\tR\aiconUrl\"\xd3\x03\n" + + "\bicon_url\x18\x05 \x01(\tR\aiconUrl\x12\x1a\n" + + "\bquantity\x18\x06 \x01(\x03R\bquantity\"\xd3\x03\n" + "\x0fRoomRocketLevel\x12\x14\n" + "\x05level\x18\x01 \x01(\x05R\x05level\x12%\n" + "\x0efuel_threshold\x18\x02 \x01(\x03R\rfuelThreshold\x12\x1b\n" + diff --git a/api/proto/room/v1/room.proto b/api/proto/room/v1/room.proto index b2e90081..7fbe5f7c 100644 --- a/api/proto/room/v1/room.proto +++ b/api/proto/room/v1/room.proto @@ -112,6 +112,8 @@ message RoomRocketRewardItem { int64 weight = 3; string display_name = 4; string icon_url = 5; + // quantity 表示该奖项在一次火箭发射中最多发给多少个在房用户;为空或 0 时兼容旧配置按 1 份处理。 + int64 quantity = 6; } // RoomRocketLevel 暴露单个等级火箭的物料、阈值和三类奖励池。 diff --git a/server/admin/internal/integration/roomclient/client.go b/server/admin/internal/integration/roomclient/client.go index a136a463..fbd69775 100644 --- a/server/admin/internal/integration/roomclient/client.go +++ b/server/admin/internal/integration/roomclient/client.go @@ -127,6 +127,7 @@ type RoomRocketRewardItem struct { RewardItemID string ResourceGroupID int64 Weight int64 + Quantity int64 DisplayName string IconURL string } @@ -623,6 +624,7 @@ func roomRocketRewardsFromProto(input []*roomv1.RoomRocketRewardItem) []RoomRock RewardItemID: reward.GetRewardItemId(), ResourceGroupID: reward.GetResourceGroupId(), Weight: reward.GetWeight(), + Quantity: reward.GetQuantity(), DisplayName: reward.GetDisplayName(), IconURL: reward.GetIconUrl(), }) @@ -637,6 +639,7 @@ func roomRocketRewardsToProto(input []RoomRocketRewardItem) []*roomv1.RoomRocket RewardItemId: reward.RewardItemID, ResourceGroupId: reward.ResourceGroupID, Weight: reward.Weight, + Quantity: reward.Quantity, DisplayName: reward.DisplayName, IconUrl: reward.IconURL, }) diff --git a/server/admin/internal/modules/roomrocket/service.go b/server/admin/internal/modules/roomrocket/service.go index ecb66b32..d96c9d8a 100644 --- a/server/admin/internal/modules/roomrocket/service.go +++ b/server/admin/internal/modules/roomrocket/service.go @@ -63,6 +63,7 @@ type RoomRocketRewardItem struct { RewardItemID string `json:"rewardItemId"` ResourceGroupID int64 `json:"resourceGroupId"` Weight int64 `json:"weight"` + Quantity int64 `json:"quantity"` DisplayName string `json:"displayName"` IconURL string `json:"iconUrl"` } @@ -246,7 +247,7 @@ func normalizeRewardPool(level int32, role string, rewards []RoomRocketRewardIte reward.RewardItemID = strings.TrimSpace(reward.RewardItemID) reward.DisplayName = strings.TrimSpace(reward.DisplayName) reward.IconURL = strings.TrimSpace(reward.IconURL) - if reward.RewardItemID == "" && reward.ResourceGroupID == 0 && reward.Weight == 0 && reward.DisplayName == "" && reward.IconURL == "" { + if reward.RewardItemID == "" && reward.ResourceGroupID == 0 && reward.Weight == 0 && reward.Quantity == 0 && reward.DisplayName == "" && reward.IconURL == "" { continue } if reward.ResourceGroupID <= 0 { @@ -255,6 +256,10 @@ func normalizeRewardPool(level int32, role string, rewards []RoomRocketRewardIte if reward.Weight <= 0 { return nil, fmt.Errorf("%w: 第 %d 级奖励权重必须大于 0", ErrInvalidArgument, level) } + if reward.Quantity <= 0 { + // 历史配置没有 quantity 字段;后台读写都按 1 份兼容,避免旧配置打开页面即失败。 + reward.Quantity = 1 + } if reward.RewardItemID == "" { reward.RewardItemID = fmt.Sprintf("level_%d_%s_%d", level, role, index+1) } @@ -382,6 +387,7 @@ func rewardsFromClient(input []roomclient.RoomRocketRewardItem) []RoomRocketRewa RewardItemID: reward.RewardItemID, ResourceGroupID: reward.ResourceGroupID, Weight: reward.Weight, + Quantity: reward.Quantity, DisplayName: reward.DisplayName, IconURL: reward.IconURL, }) @@ -396,6 +402,7 @@ func rewardsToClient(input []RoomRocketRewardItem) []roomclient.RoomRocketReward RewardItemID: reward.RewardItemID, ResourceGroupID: reward.ResourceGroupID, Weight: reward.Weight, + Quantity: reward.Quantity, DisplayName: reward.DisplayName, IconURL: reward.IconURL, }) diff --git a/server/admin/internal/modules/roomrocket/service_test.go b/server/admin/internal/modules/roomrocket/service_test.go index e2f3da1e..666c3db1 100644 --- a/server/admin/internal/modules/roomrocket/service_test.go +++ b/server/admin/internal/modules/roomrocket/service_test.go @@ -42,6 +42,22 @@ func TestNormalizeConfigDefaultsAndSortsLevels(t *testing.T) { } } +func TestNormalizeConfigKeepsRewardQuantityAndDefaultsOldItems(t *testing.T) { + config := defaultConfig("lalu") + config.Levels[0].InRoomRewards = []RoomRocketRewardItem{ + {RewardItemID: "coin", ResourceGroupID: 1001, Weight: 100, Quantity: 2}, + {RewardItemID: "mic-wave", ResourceGroupID: 1002, Weight: 100}, + } + normalized, err := normalizeConfig(config) + if err != nil { + t.Fatalf("normalizeConfig failed: %v", err) + } + rewards := normalized.Levels[0].InRoomRewards + if rewards[0].Quantity != 2 || rewards[1].Quantity != 1 { + t.Fatalf("reward quantity must be preserved and defaulted: %+v", rewards) + } +} + func TestDefaultConfigKeepsOperationalDefaults(t *testing.T) { config, err := normalizeConfig(defaultConfig("lalu")) if err != nil { diff --git a/services/room-service/internal/room/service/admin_rocket.go b/services/room-service/internal/room/service/admin_rocket.go index 22256d8f..a52f686c 100644 --- a/services/room-service/internal/room/service/admin_rocket.go +++ b/services/room-service/internal/room/service/admin_rocket.go @@ -113,6 +113,7 @@ func roomRocketRewardsFromProto(input []*roomv1.RoomRocketRewardItem) []RoomRock RewardItemID: reward.GetRewardItemId(), ResourceGroupID: reward.GetResourceGroupId(), Weight: reward.GetWeight(), + Quantity: reward.GetQuantity(), DisplayName: reward.GetDisplayName(), IconURL: reward.GetIconUrl(), }) diff --git a/services/room-service/internal/room/service/repository.go b/services/room-service/internal/room/service/repository.go index 8adf9395..8c18f4cf 100644 --- a/services/room-service/internal/room/service/repository.go +++ b/services/room-service/internal/room/service/repository.go @@ -294,6 +294,7 @@ type RoomRocketRewardItem struct { RewardItemID string `json:"rewardItemId"` ResourceGroupID int64 `json:"resourceGroupId"` Weight int64 `json:"weight"` + Quantity int64 `json:"quantity"` DisplayName string `json:"displayName"` IconURL string `json:"iconUrl"` } diff --git a/services/room-service/internal/room/service/room_rocket.go b/services/room-service/internal/room/service/room_rocket.go index f2851457..696b9803 100644 --- a/services/room-service/internal/room/service/room_rocket.go +++ b/services/room-service/internal/room/service/room_rocket.go @@ -195,6 +195,10 @@ func normalizeRoomRocketRewardPool(input []RoomRocketRewardItem) []RoomRocketRew // 后台保存层会拒绝非法奖励;运行时跳过历史脏项,避免一个奖励池配置污染送礼主链路。 continue } + if reward.Quantity <= 0 { + // 旧配置没有 quantity 字段;按 1 份兼容,避免上线后历史火箭奖励池被当成 0 份直接停发。 + reward.Quantity = 1 + } out = append(out, reward) } return out @@ -340,6 +344,7 @@ func roomRocketRewardsToProto(input []RoomRocketRewardItem) []*roomv1.RoomRocket RewardItemId: reward.RewardItemID, ResourceGroupId: reward.ResourceGroupID, Weight: reward.Weight, + Quantity: reward.Quantity, DisplayName: reward.DisplayName, IconUrl: reward.IconURL, }) @@ -1115,6 +1120,9 @@ func validRoomRocketRewardPool(pool []RoomRocketRewardItem) []RoomRocketRewardIt if item.Weight <= 0 || item.ResourceGroupID <= 0 { continue } + if item.Quantity <= 0 { + item.Quantity = 1 + } valid = append(valid, item) } return valid @@ -1129,18 +1137,16 @@ func selectRoomRocketRewardsForUsers(rocketID string, role string, userIDs []int if len(basePool) == 0 { return assignments } - available := append([]RoomRocketRewardItem(nil), basePool...) - cycle := 0 + available := expandRoomRocketRewardSlots(basePool) for index, userID := range userIDs { if userID <= 0 { continue } if len(available) == 0 { - // 一轮内每个配置项最多发一次;房间人数超过奖池大小时再开启下一轮,让大房间仍可继续按配置重复分配。 - available = append([]RoomRocketRewardItem(nil), basePool...) - cycle++ + // 在房奖励数量是单次发射的硬上限;配置总数发完后,其余在线用户本次拿空,不生成 grant 和 IM 弹窗。 + break } - selectedIndex, ok := selectRoomRocketRewardIndex(rocketID, role, userID, available, fmt.Sprintf("cycle:%d:index:%d", cycle, index)) + selectedIndex, ok := selectRoomRocketRewardIndex(rocketID, role, userID, available, fmt.Sprintf("limited:index:%d", index)) if !ok { continue } @@ -1150,12 +1156,20 @@ func selectRoomRocketRewardsForUsers(rocketID string, role string, userIDs []int return assignments } -func selectRoomRocketReward(rocketID string, role string, userID int64, pool []RoomRocketRewardItem) (RoomRocketRewardItem, bool) { - index, ok := selectRoomRocketRewardIndex(rocketID, role, userID, pool, "") - if !ok { - return RoomRocketRewardItem{}, false +func expandRoomRocketRewardSlots(pool []RoomRocketRewardItem) []RoomRocketRewardItem { + total := 0 + for _, item := range pool { + if item.Quantity > 0 { + total += int(item.Quantity) + } } - return pool[index], true + slots := make([]RoomRocketRewardItem, 0, total) + for _, item := range pool { + for count := int64(0); count < item.Quantity; count++ { + slots = append(slots, item) + } + } + return slots } func selectRoomRocketRewardIndex(rocketID string, role string, userID int64, pool []RoomRocketRewardItem, salt string) (int, bool) { diff --git a/services/room-service/internal/room/service/room_rocket_test.go b/services/room-service/internal/room/service/room_rocket_test.go index ce26cbde..d99272f0 100644 --- a/services/room-service/internal/room/service/room_rocket_test.go +++ b/services/room-service/internal/room/service/room_rocket_test.go @@ -578,6 +578,72 @@ func TestRoomRocketLaunchStacksAllSpecialPoolsAndRandomInRoomReward(t *testing.T } } +func TestRoomRocketInRoomRewardQuantityCapsWinners(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 14, 0, 0, 0, time.UTC)} + wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{ + {BillingReceiptId: "receipt-quantity", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"}, + }} + svc := newRocketTestService(t, repository, wallet, now) + config := rocketConfigForTest(100, 1_000) + config.Levels[0].InRoomRewards = []roomservice.RoomRocketRewardItem{ + {RewardItemID: "level-1-in-room-coin", ResourceGroupID: 1001, Weight: 1, Quantity: 2, DisplayName: "Coin"}, + {RewardItemID: "level-1-in-room-mic", ResourceGroupID: 1101, Weight: 1, Quantity: 3, DisplayName: "Mic Wave"}, + } + config.Levels[0].Top1Rewards = nil + config.Levels[0].IgniterRewards = nil + writeRocketConfig(t, ctx, repository, config) + + roomID := "room-rocket-quantity" + ownerID := int64(501) + igniterID := int64(502) + createRocketRoom(t, ctx, svc, roomID, ownerID, 9004) + for userID := igniterID; userID <= 507; userID++ { + joinRocketRoom(t, ctx, svc, roomID, userID) + } + + if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ + Meta: rocketMeta(roomID, igniterID, "quantity"), + TargetType: "user", + TargetUserId: ownerID, + GiftId: "gift-quantity", + GiftCount: 1, + }); err != nil { + t.Fatalf("ignite quantity rocket failed: %v", err) + } + + now.now = now.now.Add(1100 * time.Millisecond) + if err := svc.SweepRoomRocketLaunchings(ctx); err != nil { + t.Fatalf("sweep quantity room rocket launches failed: %v", err) + } + + info, err := svc.GetRoomRocket(ctx, &roomv1.GetRoomRocketRequest{ + Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID}, + RoomId: roomID, + ViewerUserId: ownerID, + }) + if err != nil { + t.Fatalf("get quantity rocket after launch failed: %v", err) + } + rewards := info.GetRocket().GetState().GetLastRewards() + if countRocketRewardsByRole(rewards, "in_room") != 5 { + t.Fatalf("in-room rewards must stop at configured quantity 5, got %+v", rewards) + } + if countRocketRewardsByRoleAndGroup(rewards, "in_room", 1001) != 2 { + t.Fatalf("coin resource group must be granted exactly 2 times: %+v", rewards) + } + if countRocketRewardsByRoleAndGroup(rewards, "in_room", 1101) != 3 { + t.Fatalf("mic wave resource group must be granted exactly 3 times: %+v", rewards) + } + if len(wallet.grants) != 5 { + t.Fatalf("wallet must only receive 5 in-room grant calls, got %+v", wallet.grants) + } + if len(rocketRewardUserIDs(rewards)) != 5 { + t.Fatalf("only 5 users should have reward rows; remaining online users receive empty: %+v", rewards) + } +} + func TestRoomRocketUTCResetCancelsCrossDayIgnited(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) @@ -841,6 +907,16 @@ func countRocketRewardsByRoleAndGroup(rewards []*roomv1.RoomRocketRewardGrant, r return count } +func countRocketRewardsByRole(rewards []*roomv1.RoomRocketRewardGrant, role string) int { + count := 0 + for _, reward := range rewards { + if reward.GetRewardRole() == role { + count++ + } + } + return count +} + func rocketRewardUserIDs(rewards []*roomv1.RoomRocketRewardGrant) []int64 { userIDs := make([]int64, 0, len(rewards)) for _, reward := range rewards {