diff --git a/services/room-service/internal/room/service/gift.go b/services/room-service/internal/room/service/gift.go index 0a5210cb..f81289c6 100644 --- a/services/room-service/internal/room/service/gift.go +++ b/services/room-service/internal/room/service/gift.go @@ -282,9 +282,10 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob } records := make([]outbox.Record, 0, len(giftEvents)+4) - robotRecords := make([]outbox.Record, 0, len(giftEvents)+3) - if cmd.RobotGift { - // 机器人送礼只保留客户端展示所需事实,并写入 robot outbox,由独立 worker/topic 补偿投递。 + robotRecords := make([]outbox.Record, 0, len(giftEvents)+5) + // RobotWalletGift 只表示这笔礼物由机器人钱包结算;真实房机器人仍会同步写房间贡献、火箭和麦位热度,但展示事件必须走独立 outbox,避免机器人流量抢占真人主 outbox。 + robotOutboxGift := cmd.RobotWalletGift + if robotOutboxGift { robotRecords = append(robotRecords, giftEvents...) } else { records = append(records, giftEvents...) @@ -306,27 +307,35 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob if err != nil { return mutationResult{}, nil, err } - if cmd.RobotGift { - // 房间热度和麦位收礼热度仍要给客户端展示,但走机器人 outbox,不能占用真人房间主流程。 + if robotOutboxGift { + // 房间热度和麦位收礼热度仍要给客户端展示;是否真实计入统计由 RobotGift 语义单独控制,不能再拿投递通道反推业务真实性。 robotRecords = append(robotRecords, heatEvent, rankEvent) } else { records = append(records, heatEvent, rankEvent) } - if !cmd.RobotGift && !cmd.RobotWalletGift { - // 火箭只属于真人礼物主业务链路,机器人展示礼物不能触发火箭燃料 outbox。 + if !cmd.RobotGift { + // 真实房间礼物才广播火箭进度;真人房机器人送礼使用机器人钱包结算,但仍是真实房间贡献,所以火箭事件跟随机器人通道投递。 if rocketApply.progressEvent != nil { progressEvent, err := outbox.Build(current.RoomID, "RoomRocketFuelChanged", current.Version, now, rocketApply.progressEvent) if err != nil { return mutationResult{}, nil, err } - records = append(records, progressEvent) + if robotOutboxGift { + robotRecords = append(robotRecords, progressEvent) + } else { + records = append(records, progressEvent) + } } if rocketApply.ignited != nil { ignitedEvent, err := outbox.Build(current.RoomID, "RoomRocketIgnited", current.Version, now, rocketApply.ignited) if err != nil { return mutationResult{}, nil, err } - records = append(records, ignitedEvent) + if robotOutboxGift { + robotRecords = append(robotRecords, ignitedEvent) + } else { + records = append(records, ignitedEvent) + } } } if cmd.SyntheticLuckyGift && luckyGift != nil { @@ -351,7 +360,7 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob if err != nil { return mutationResult{}, nil, err } - if cmd.RobotGift { + if robotOutboxGift { robotRecords = append(robotRecords, drawEvent) } else { records = append(records, drawEvent) diff --git a/services/room-service/internal/room/service/user_gift_value_test.go b/services/room-service/internal/room/service/user_gift_value_test.go index a0f41bc4..1a5e01e8 100644 --- a/services/room-service/internal/room/service/user_gift_value_test.go +++ b/services/room-service/internal/room/service/user_gift_value_test.go @@ -153,6 +153,98 @@ func TestRobotGiftSkipsMainHeatAndRankOutbox(t *testing.T) { } } +func TestRealRoomRobotGiftKeepsRoomContributionAndUsesRobotOutbox(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 11, 30, 0, 0, time.UTC)} + wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{ + BillingReceiptId: "receipt-real-room-robot", + CoinSpent: 100, + ChargeAmount: 100, + HeatValue: 100, + ChargeAssetType: "ROBOT_COIN", + GiftTypeCode: "normal", + }}} + leaderboard := &recordingRoomGiftLeaderboard{} + svc := roomservice.New(roomservice.Config{ + NodeID: "node-real-room-robot-outbox", + LeaseTTL: 10 * time.Second, + RankLimit: 20, + SnapshotEveryN: 100, + Clock: now, + RoomGiftLeaderboard: leaderboard, + }, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) + writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000)) + + roomID := "real-room-robot-outbox" + senderID := int64(64001) + targetID := int64(64002) + createRocketRoom(t, ctx, svc, roomID, senderID, 9001) + joinRocketRoom(t, ctx, svc, roomID, targetID) + if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{ + Meta: rocketMeta(roomID, targetID, "real-room-robot-target-up"), + SeatNo: 1, + }); err != nil { + t.Fatalf("target mic up failed: %v", err) + } + beforeMain := outboxEventCounts(t, ctx, repository) + beforeRobot := robotOutboxEventCounts(t, ctx, repository) + + resp, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{ + Meta: rocketMeta(roomID, senderID, "real-room-robot-gift"), + TargetUserID: targetID, + GiftID: "real-room-robot-gift", + GiftCount: 1, + PoolID: "human_robot_super_lucky_display", + RobotUserIDs: []int64{senderID, targetID}, + SyntheticMultiplierPPM: 5_000_000, + RealRoomHeat: true, + }) + if err != nil { + t.Fatalf("real room robot gift failed: %v", err) + } + + if resp.GetRoomHeat() != 100 { + t.Fatalf("real room robot gift must update room heat, got %d", resp.GetRoomHeat()) + } + if user := onlineUserByID(resp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 100 { + t.Fatalf("real room robot gift must update target gift value: %+v", user) + } + if seat := seatByNo(resp.GetRoom(), 1); seat == nil || seat.GetGiftValue() != 100 { + t.Fatalf("real room robot gift must update mic gift value: %+v", seat) + } + if len(resp.GetGiftRank()) == 0 || resp.GetGiftRank()[0].GetUserId() != senderID || resp.GetGiftRank()[0].GetGiftValue() != 100 { + t.Fatalf("real room robot gift must update room gift rank: %+v", resp.GetGiftRank()) + } + if resp.GetRocket() == nil || len(resp.GetRocket().GetPendingLaunches()) != 1 { + t.Fatalf("real room robot gift must fuel and ignite room rocket: %+v", resp.GetRocket()) + } + if len(leaderboard.increments) != 1 || leaderboard.increments[0].RoomID != roomID || leaderboard.increments[0].CoinSpent != 100 { + t.Fatalf("real room robot gift must keep cross-room contribution increment: %+v", leaderboard.increments) + } + + afterMain := outboxEventCounts(t, ctx, repository) + for _, eventType := range []string{"RoomGiftSent", "RoomHeatChanged", "RoomRankChanged", "RoomRobotLuckyGiftDrawn", "RoomRocketFuelChanged", "RoomRocketIgnited"} { + if delta := afterMain[eventType] - beforeMain[eventType]; delta != 0 { + t.Fatalf("real room robot gift must not write %s to main outbox, delta=%d counts=%+v", eventType, delta, afterMain) + } + } + afterRobot := robotOutboxEventCounts(t, ctx, repository) + wantRobotDeltas := map[string]int{ + "RoomGiftSent": 1, + "RoomHeatChanged": 1, + "RoomRankChanged": 1, + "RoomRobotLuckyGiftDrawn": 1, + "RoomRocketFuelChanged": 1, + "RoomRocketIgnited": 1, + } + for eventType, want := range wantRobotDeltas { + if delta := afterRobot[eventType] - beforeRobot[eventType]; delta != want { + t.Fatalf("real room robot gift robot outbox %s delta=%d want=%d counts=%+v", eventType, delta, want, afterRobot) + } + } +} + func TestRoomUserGiftValueRecoversFromCommandLog(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t)