diff --git a/internal/service/voiceroomrocket/event_test.go b/internal/service/voiceroomrocket/event_test.go index 403c3da..eed065c 100644 --- a/internal/service/voiceroomrocket/event_test.go +++ b/internal/service/voiceroomrocket/event_test.go @@ -399,6 +399,9 @@ func TestInRoomRewardWorkerSnapshotsSelectsAndGrants(t *testing.T) { if err := rdb.SAdd(ctx, onlineRoomKey("LIKEI", roomID), userID).Err(); err != nil { t.Fatalf("sadd online: %v", err) } + if err := rdb.Set(ctx, userRoomKey("LIKEI", userID), strconv.FormatInt(roomID, 10), time.Minute).Err(); err != nil { + t.Fatalf("set user room: %v", err) + } if err := rdb.Set(ctx, userSeenKey("LIKEI", userID), strconv.FormatInt(time.Now().UnixMilli(), 10), time.Minute).Err(); err != nil { t.Fatalf("set seen: %v", err) } @@ -471,6 +474,92 @@ func TestInRoomRewardWorkerSnapshotsSelectsAndGrants(t *testing.T) { } } +func TestInRoomRewardWorkerSkipsUsersNoLongerInRoom(t *testing.T) { + gateway := newFakeRocketGateway() + service, db, rdb, mr := newTestServiceWithRedis(t, gateway) + defer mr.Close() + + ctx := context.Background() + now := time.Now() + seedRocketConfig(t, db) + seedLevelConfigs(t, db, []int64{100, 200, 300, 400, 500, 600}) + seedRewardConfig(t, db, 1, rewardSceneTop1, rewardTypeGold, 10) + seedRewardConfig(t, db, 1, rewardSceneIgnite, rewardTypeGold, 5) + seedRewardConfigWithCopies(t, db, 1, rewardSceneInRoom, rewardTypeGold, 1, 4) + + roomID := int64(9001) + for _, userID := range []int64{1001, 1002} { + if err := rdb.SAdd(ctx, onlineRoomKey("LIKEI", roomID), userID).Err(); err != nil { + t.Fatalf("sadd active online: %v", err) + } + if err := rdb.Set(ctx, userRoomKey("LIKEI", userID), strconv.FormatInt(roomID, 10), time.Minute).Err(); err != nil { + t.Fatalf("set active room: %v", err) + } + if err := rdb.Set(ctx, userSeenKey("LIKEI", userID), strconv.FormatInt(now.UnixMilli(), 10), time.Minute).Err(); err != nil { + t.Fatalf("set active seen: %v", err) + } + } + staleUsers := []int64{1003, 1004} + for _, userID := range staleUsers { + if err := rdb.SAdd(ctx, onlineRoomKey("LIKEI", roomID), userID).Err(); err != nil { + t.Fatalf("sadd stale online: %v", err) + } + if err := rdb.Set(ctx, userSeenKey("LIKEI", userID), strconv.FormatInt(now.UnixMilli(), 10), time.Minute).Err(); err != nil { + t.Fatalf("set stale seen: %v", err) + } + } + if err := rdb.Set(ctx, userRoomKey("LIKEI", staleUsers[1]), strconv.FormatInt(roomID+1, 10), time.Minute).Err(); err != nil { + t.Fatalf("set stale room: %v", err) + } + + payload := `{ + "trackId":"track-launch-in-room-stale", + "sysOrigin":"LIKEI", + "sendUserId":"1001", + "roomId":"9001", + "quantity":1, + "giftConfig":{"id":"11","giftCandy":100,"type":"GOLD","giftTab":"NORMAL"}, + "createTime":` + strconvFormatMillis(now) + ` + }` + resp, err := service.ProcessGiftPayload(ctx, payload) + if err != nil { + t.Fatalf("ProcessGiftPayload() error = %v", err) + } + if !resp.Processed { + t.Fatalf("response = %+v, want processed", resp) + } + if _, err := service.ProcessDueLaunches(ctx, now.Add(11*time.Second), 10); err != nil { + t.Fatalf("ProcessDueLaunches() error = %v", err) + } + if _, err := service.ProcessDueInRoomRewards(ctx, now.Add(30*time.Second), 10); err != nil { + t.Fatalf("ProcessDueInRoomRewards() error = %v", err) + } + + var snapshotCount int64 + if err := db.Model(&model.VoiceRoomRocketAudienceSnapshot{}).Count(&snapshotCount).Error; err != nil { + t.Fatalf("count snapshot: %v", err) + } + if snapshotCount != 2 { + t.Fatalf("snapshot count = %d, want 2 active users only", snapshotCount) + } + for _, userID := range staleUsers { + var userSnapshotCount int64 + if err := db.Model(&model.VoiceRoomRocketAudienceSnapshot{}).Where("user_id = ?", userID).Count(&userSnapshotCount).Error; err != nil { + t.Fatalf("count stale snapshot: %v", err) + } + if userSnapshotCount != 0 { + t.Fatalf("stale user %d snapshot count = %d, want 0", userID, userSnapshotCount) + } + var rewardCount int64 + if err := db.Model(&model.VoiceRoomRocketRewardRecord{}).Where("user_id = ? AND reward_scene = ?", userID, rewardSceneInRoom).Count(&rewardCount).Error; err != nil { + t.Fatalf("count stale reward: %v", err) + } + if rewardCount != 0 { + t.Fatalf("stale user %d in-room reward count = %d, want 0", userID, rewardCount) + } + } +} + func TestProcessPendingRewardsGrantsBadgeThroughBadgeGateway(t *testing.T) { gateway := newFakeRocketGateway() service, db := newTestService(t) diff --git a/internal/service/voiceroomrocket/presence.go b/internal/service/voiceroomrocket/presence.go index 946572f..ee76a4f 100644 --- a/internal/service/voiceroomrocket/presence.go +++ b/internal/service/voiceroomrocket/presence.go @@ -33,9 +33,14 @@ func (s *Service) listOnlineRoomUsers(ctx context.Context, sysOrigin string, roo continue } seen[userID] = struct{}{} + seenAt, active := s.loadActiveRoomSeenTime(ctx, sysOrigin, roomID, userID, now) + if !active { + _ = s.repo.Redis.SRem(ctx, onlineRoomKey(sysOrigin, roomID), userID).Err() + continue + } users = append(users, roomAudienceUser{ UserID: userID, - LastSeenTime: s.loadUserSeenTime(ctx, sysOrigin, userID, now), + LastSeenTime: seenAt, }) } sort.Slice(users, func(i, j int) bool { @@ -44,6 +49,43 @@ func (s *Service) listOnlineRoomUsers(ctx context.Context, sysOrigin string, roo return users, nil } +func (s *Service) loadActiveRoomSeenTime(ctx context.Context, sysOrigin string, roomID, userID int64, now time.Time) (time.Time, bool) { + if s.repo.Redis == nil { + return time.Time{}, false + } + rawRoomID, err := s.repo.Redis.Get(ctx, userRoomKey(sysOrigin, userID)).Result() + if err != nil { + return time.Time{}, false + } + currentRoomID, err := strconv.ParseInt(strings.TrimSpace(rawRoomID), 10, 64) + if err != nil || currentRoomID != roomID { + return time.Time{}, false + } + seenAt, ok := s.loadUserSeenTimeStrict(ctx, sysOrigin, userID, now) + if !ok { + return time.Time{}, false + } + if ttl := s.roomPresenceTTL(); ttl > 0 && now.Sub(seenAt) > ttl { + return time.Time{}, false + } + return seenAt, true +} + +func (s *Service) loadUserSeenTimeStrict(ctx context.Context, sysOrigin string, userID int64, fallback time.Time) (time.Time, bool) { + if s.repo.Redis == nil { + return time.Time{}, false + } + raw, err := s.repo.Redis.Get(ctx, userSeenKey(sysOrigin, userID)).Result() + if err != nil { + return time.Time{}, false + } + millis, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil || millis <= 0 { + return time.Time{}, false + } + return resolveMillisTime(millis, fallback), true +} + func (s *Service) loadUserSeenTime(ctx context.Context, sysOrigin string, userID int64, fallback time.Time) time.Time { if s.repo.Redis == nil { return fallback @@ -59,10 +101,22 @@ func (s *Service) loadUserSeenTime(ctx context.Context, sysOrigin string, userID return resolveMillisTime(millis, fallback) } +func (s *Service) roomPresenceTTL() time.Duration { + seconds := s.cfg.VoiceRoomRedPacket.PresenceTTLSeconds + if seconds <= 0 { + seconds = defaultPresenceTTLSeconds + } + return time.Duration(seconds) * time.Second +} + func onlineRoomKey(sysOrigin string, roomID int64) string { return fmt.Sprintf("voice_room:online:%s:%d", strings.ToUpper(strings.TrimSpace(sysOrigin)), roomID) } +func userRoomKey(sysOrigin string, userID int64) string { + return fmt.Sprintf("voice_room:user_room:%s:%d", strings.ToUpper(strings.TrimSpace(sysOrigin)), userID) +} + func userSeenKey(sysOrigin string, userID int64) string { return fmt.Sprintf("voice_room:user_seen:%s:%d", strings.ToUpper(strings.TrimSpace(sysOrigin)), userID) } diff --git a/internal/service/voiceroomrocket/types.go b/internal/service/voiceroomrocket/types.go index 189f692..d616f4f 100644 --- a/internal/service/voiceroomrocket/types.go +++ b/internal/service/voiceroomrocket/types.go @@ -32,6 +32,7 @@ const ( defaultWorkerBatchSize = 100 defaultWorkerInterval = time.Second defaultRoomLockTTL = 10 * time.Second + defaultPresenceTTLSeconds = 60 statusCharging = "CHARGING" statusLaunching = "LAUNCHING" diff --git a/scripts/voice_room_rocket_live_verify.go b/scripts/voice_room_rocket_live_verify.go index 230f752..fad59c1 100644 --- a/scripts/voice_room_rocket_live_verify.go +++ b/scripts/voice_room_rocket_live_verify.go @@ -429,6 +429,7 @@ func seedPresence(ctx context.Context, rdb *redis.Client, roomID int64, users [] key := fmt.Sprintf("voice_room:online:LIKEI:%d", roomID) for _, userID := range users { must(rdb.SAdd(ctx, key, userID).Err(), "seed online room") + must(rdb.Set(ctx, fmt.Sprintf("voice_room:user_room:LIKEI:%d", userID), strconv.FormatInt(roomID, 10), time.Minute).Err(), "seed user room") must(rdb.Set(ctx, fmt.Sprintf("voice_room:user_seen:LIKEI:%d", userID), strconv.FormatInt(time.Now().UnixMilli(), 10), time.Minute).Err(), "seed user seen") } must(rdb.Expire(ctx, key, time.Minute).Err(), "expire online room") diff --git a/scripts/voice_room_rocket_local_verify.go b/scripts/voice_room_rocket_local_verify.go index f34cc8c..20d49c9 100644 --- a/scripts/voice_room_rocket_local_verify.go +++ b/scripts/voice_room_rocket_local_verify.go @@ -109,6 +109,9 @@ type verifySummary struct { Top1PopupBeforeAck int `json:"top1PopupBeforeAck"` Top1PopupAfterAck int `json:"top1PopupAfterAck"` IgnitePopupCount int `json:"ignitePopupCount"` + LeftUserID int64 `json:"leftUserId,string"` + LeftUserSnapshotCount int64 `json:"leftUserSnapshotCount"` + LeftUserRewardCount int64 `json:"leftUserRewardCount"` RewardRecordCount int64 `json:"rewardRecordCount"` SnapshotCount int64 `json:"snapshotCount"` SelectedAudienceCount int64 `json:"selectedAudienceCount"` @@ -162,6 +165,11 @@ func main() { cleanup(ctx, db, rdb, room.RoomID, day) seedConfig(ctx, service, db) seedPresence(ctx, rdb, room.RoomID, append([]int64{room.UserID, igniteUserID}, otherUsers[1:]...)) + leftUserID := otherUsers[2] + must(rdb.Del(ctx, + fmt.Sprintf("voice_room:user_room:LIKEI:%d", leftUserID), + fmt.Sprintf("voice_room:user_seen:LIKEI:%d", leftUserID), + ).Err(), "simulate left user presence") initial, err := service.GetStatus(ctx, user, room.RoomID) must(err, "initial status") @@ -179,7 +187,17 @@ func main() { secondPayload := mqEnvelope(giftPayload("LOCAL_VERIFY_TRACK_2", igniteUserID, room.RoomID, 80, now.Add(time.Second))) second, err := service.ProcessGiftPayload(ctx, secondPayload) must(err, "process second gift") - assert(second.Processed && second.AcceptedEnergy == 40 && second.Launched && second.LaunchNo != "", "second gift result mismatch") + assert(second.Processed && second.AcceptedEnergy == 40 && !second.Launched, "second gift result mismatch") + + launched, err := service.ProcessDueLaunches(ctx, now.Add(11*time.Second), 10) + must(err, "process due launches") + assert(launched == 1, "due launch count mismatch") + var launch model.VoiceRoomRocketLaunch + must(db.WithContext(ctx). + Where("room_id = ? AND day_key = ?", room.RoomID, day). + Order("create_time DESC"). + First(&launch).Error, "load launch") + assert(strings.TrimSpace(launch.LaunchNo) != "", "launch no missing") finalStatus, err := service.GetStatus(ctx, user, room.RoomID) must(err, "final status") @@ -213,7 +231,7 @@ func main() { must(err, "top1 reward records") assert(len(rewardRecords.Records) >= 1, "top1 reward record count mismatch") - dueProcessed, err := service.ProcessDueInRoomRewards(ctx, time.Now().Add(2*time.Second), 10) + dueProcessed, err := service.ProcessDueInRoomRewards(ctx, now.Add(30*time.Second), 10) must(err, "process due in-room rewards") assert(dueProcessed == 1, "due in-room reward count mismatch") @@ -233,10 +251,22 @@ func main() { must(db.WithContext(ctx).Model(&model.VoiceRoomRocketAudienceSnapshot{}). Where("room_id = ?", room.RoomID). Count(&snapshotCount).Error, "count snapshots") + assert(snapshotCount == 3, "snapshot count mismatch") var selectedCount int64 must(db.WithContext(ctx).Model(&model.VoiceRoomRocketAudienceSnapshot{}). Where("room_id = ? AND selected = ?", room.RoomID, true). Count(&selectedCount).Error, "count selected snapshots") + assert(selectedCount == 3, "selected snapshot count mismatch") + var leftSnapshotCount int64 + must(db.WithContext(ctx).Model(&model.VoiceRoomRocketAudienceSnapshot{}). + Where("room_id = ? AND user_id = ?", room.RoomID, leftUserID). + Count(&leftSnapshotCount).Error, "count left user snapshots") + assert(leftSnapshotCount == 0, "left user snapshot mismatch") + var leftRewardCount int64 + must(db.WithContext(ctx).Model(&model.VoiceRoomRocketRewardRecord{}). + Where("room_id = ? AND day_key = ? AND user_id = ?", room.RoomID, day, leftUserID). + Count(&leftRewardCount).Error, "count left user rewards") + assert(leftRewardCount == 0, "left user reward mismatch") var successCount int64 must(db.WithContext(ctx).Model(&model.VoiceRoomRocketRewardRecord{}). Where("room_id = ? AND day_key = ? AND grant_status = ?", room.RoomID, day, "SUCCESS"). @@ -258,13 +288,16 @@ func main() { FirstAcceptedEnergy: first.AcceptedEnergy, SecondRawEnergy: 80, SecondAcceptedEnergy: second.AcceptedEnergy, - LaunchNo: second.LaunchNo, + LaunchNo: launch.LaunchNo, StatusLevel: finalStatus.CurrentLevel, StatusEnergy: finalStatus.CurrentEnergy, KingRecords: len(king.Records), Top1PopupBeforeAck: len(top1Popups.Records), Top1PopupAfterAck: len(top1AfterAck.Records), IgnitePopupCount: len(ignitePopups.Records), + LeftUserID: leftUserID, + LeftUserSnapshotCount: leftSnapshotCount, + LeftUserRewardCount: leftRewardCount, RewardRecordCount: rewardCount, SnapshotCount: snapshotCount, SelectedAudienceCount: selectedCount, @@ -312,6 +345,7 @@ func seedPresence(ctx context.Context, rdb *redis.Client, roomID int64, users [] key := fmt.Sprintf("voice_room:online:LIKEI:%d", roomID) for _, userID := range users { must(rdb.SAdd(ctx, key, userID).Err(), "seed online room") + must(rdb.Set(ctx, fmt.Sprintf("voice_room:user_room:LIKEI:%d", userID), strconv.FormatInt(roomID, 10), time.Minute).Err(), "seed user room") must(rdb.Set(ctx, fmt.Sprintf("voice_room:user_seen:LIKEI:%d", userID), strconv.FormatInt(time.Now().UnixMilli(), 10), time.Minute).Err(), "seed user seen") } must(rdb.Expire(ctx, key, time.Minute).Err(), "expire online room") @@ -373,7 +407,7 @@ func seedConfig(ctx context.Context, service *voiceroomrocket.Service, db *gorm. Rewards: []voiceroomrocket.RewardConfigPayload{ {Level: 1, RewardScene: "TOP1", RewardType: "GOLD", RewardName: "Local Verify Top1 Gold", RewardAmount: 10, Enabled: true, Sort: 1}, {Level: 1, RewardScene: "IGNITE", RewardType: "GOLD", RewardName: "Local Verify Ignite Gold", RewardAmount: 5, Enabled: true, Sort: 1}, - {Level: 1, RewardScene: "IN_ROOM", RewardType: "GOLD", RewardName: "Local Verify In Room Gold", RewardAmount: 1, Enabled: true, Sort: 1}, + {Level: 1, RewardScene: "IN_ROOM", RewardType: "GOLD", RewardName: "Local Verify In Room Gold", RewardAmount: 1, RewardCopies: 3, Enabled: true, Sort: 1}, }, }) must(err, "save rewards")