//go:build ignore package main import ( "context" "encoding/json" "fmt" "log" "os" "strconv" "strings" "time" "chatapp3-golang/internal/common" "chatapp3-golang/internal/config" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "chatapp3-golang/internal/service/tencentim" "chatapp3-golang/internal/service/voiceroomrocket" rmq "github.com/apache/rocketmq-clients/golang/v5" "github.com/apache/rocketmq-clients/golang/v5/credentials" "github.com/redis/go-redis/v9" "gorm.io/driver/mysql" "gorm.io/gorm" ) type liveRoom struct { RoomID int64 UserID int64 } type liveProps struct { ID int64 Type string Name string } type liveRewardRow struct { ID int64 `json:"id,string"` UserID int64 `json:"userId,string"` RewardScene string `json:"rewardScene"` RewardType string `json:"rewardType"` RewardItemID *int64 `json:"rewardItemId,omitempty,string"` RewardName string `json:"rewardName"` RewardAmount int64 `json:"rewardAmount"` GrantStatus string `json:"grantStatus"` GrantEventID string `json:"grantEventId"` ErrorMessage string `json:"errorMessage,omitempty"` } type liveVerifySummary struct { MySQLDSNHost string `json:"mysqlDsnHost"` RedisAddr string `json:"redisAddr"` MQEndpoint string `json:"mqEndpoint"` MQTopic string `json:"mqTopic"` MQTag string `json:"mqTag"` MQConsumerGroup string `json:"mqConsumerGroup"` MQMessageIDs []string `json:"mqMessageIds"` RoomID int64 `json:"roomId,string"` RoomAccount string `json:"roomAccount"` RoomName string `json:"roomName"` Top1UserID int64 `json:"top1UserId,string"` IgniteUserID int64 `json:"igniteUserId,string"` DayKey string `json:"dayKey"` FirstTrackID string `json:"firstTrackId"` SecondTrackID string `json:"secondTrackId"` FirstAcceptedEnergy int64 `json:"firstAcceptedEnergy"` SecondAcceptedEnergy int64 `json:"secondAcceptedEnergy"` LaunchNo string `json:"launchNo"` LaunchLevel int `json:"launchLevel"` RewardRecords []liveRewardRow `json:"rewardRecords"` RewardRecordCount int `json:"rewardRecordCount"` SuccessRewardCount int `json:"successRewardCount"` GoldWalletVerifiedCount int `json:"goldWalletVerifiedCount"` ExpectedGoldByUser map[string]int64 `json:"expectedGoldByUser"` WalletBalanceDeltaByUser map[string]int64 `json:"walletBalanceDeltaByUser"` RidePropsID int64 `json:"ridePropsId,omitempty,string"` RidePropsName string `json:"ridePropsName,omitempty"` PropsRunningWaterCount int64 `json:"propsRunningWaterCount"` AudienceSnapshotCount int64 `json:"audienceSnapshotCount"` SelectedAudienceCount int64 `json:"selectedAudienceCount"` BroadcastStreamCount int64 `json:"broadcastStreamCount"` LaunchBroadcastCount int `json:"launchBroadcastCount"` RewardBroadcastCount int `json:"rewardBroadcastCount"` TencentIMDirectSendOK bool `json:"tencentImDirectSendOk"` Top1PopupBeforeAck int `json:"top1PopupBeforeAck"` Top1PopupAfterAck int `json:"top1PopupAfterAck"` DuplicateReason string `json:"duplicateReason"` ServiceMQInitiallyEnabled bool `json:"serviceMqInitiallyEnabled"` } func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() os.Setenv("rocketmq.client.enableSsl", "false") os.Setenv("mq.consoleAppender.enabled", "true") rmq.EnableSsl = false rmq.ResetLogger() cfg := config.Load() cfg.VoiceRoomRocket.MQ.Enabled = true cfg.VoiceRoomRocket.MQ.ConsumerGroup = "voice-room-rocket-live-verify-" + strconv.FormatInt(time.Now().UnixNano(), 10) cfg.VoiceRoomRocket.MQ.Tag = "voice_room_rocket_live_verify_" + strconv.FormatInt(time.Now().UnixNano(), 10) cfg.VoiceRoomRocket.InRoomRewardScanIntervalSeconds = 1 cfg.VoiceRoomRocket.RewardGrantScanIntervalSeconds = 1 cfg.VoiceRoomRocket.InRoomRewardBatchSize = 100 cfg.VoiceRoomRocket.RewardGrantBatchSize = 100 cfg.VoiceRoomRocket.InRoomRewardUserLimit = 3 db, err := gorm.Open(mysql.Open(cfg.Store.MySQLDSN), &gorm.Config{}) must(err, "connect mysql") sqlDB, err := db.DB() must(err, "unwrap mysql") defer sqlDB.Close() must(sqlDB.PingContext(ctx), "ping mysql") rdb := redis.NewClient(&redis.Options{ Addr: cfg.Store.RedisAddr, Password: cfg.Store.RedisPassword, DB: cfg.Store.RedisDB, }) defer rdb.Close() must(rdb.Ping(ctx).Err(), "ping redis") gateways := integration.NewGateways(cfg) service := voiceroomrocket.NewService(cfg, db, rdb, &gateways) room := pickRoom(ctx, db) if room.RoomID <= 0 || room.UserID <= 0 { log.Fatalf("missing room_member sample: %+v", room) } roomProfile, err := loadRoomProfile(ctx, &gateways, room.RoomID) must(err, "load room profile from java") if strings.TrimSpace(roomProfile.RoomAccount) == "" { log.Fatalf("java room profile missing roomAccount: roomId=%d profile=%+v", room.RoomID, roomProfile) } otherUsers := pickUsers(ctx, db, room.UserID, 5) if len(otherUsers) < 3 { log.Fatalf("missing user sample: room=%+v users=%v", room, otherUsers) } igniteUserID := otherUsers[0] rideProps := pickProps(ctx, db, "RIDE") if rideProps.ID <= 0 { log.Fatalf("missing RIDE props_source_record sample") } imClient := tencentim.NewClient(cfg.TencentIM) if _, err := imClient.CreateAVChatRoom(ctx, roomProfile.RoomAccount, roomProfile.RoomName); err != nil { log.Fatalf("create/check tencent im room %s: %v", roomProfile.RoomAccount, err) } if err := imClient.SendCustomGroupMessage(ctx, roomProfile.RoomAccount, map[string]any{ "type": "VOICE_ROOM_ROCKET_LIVE_VERIFY_DIRECT_IM", "data": map[string]any{ "roomId": strconv.FormatInt(room.RoomID, 10), "at": time.Now().UnixMilli(), }, }); err != nil { log.Fatalf("send tencent im direct verify message: %v", err) } local, err := time.LoadLocation("Asia/Riyadh") must(err, "load timezone") startedAt := time.Now() day := startedAt.In(local).Format("20060102") cleanup(ctx, db, rdb, room.RoomID, day) seedLiveConfig(ctx, service, db, rideProps) seedPresence(ctx, rdb, room.RoomID, append([]int64{room.UserID, igniteUserID}, otherUsers[1:]...)) balanceUsers := uniqueInt64s(append([]int64{room.UserID, igniteUserID}, otherUsers[1:]...)) balancesBefore, err := gateways.MapGoldBalance(ctx, balanceUsers) must(err, "load wallet balances before") propsRunningWaterBefore := countPropsRunningWaterTotal(ctx, db, igniteUserID, rideProps.ID) must(service.Start(ctx), "start rocket service") time.Sleep(4 * time.Second) producer := newProducer(cfg) defer producer.GracefulStop() firstTrack := "LIVE_VERIFY_" + strconv.FormatInt(startedAt.UnixNano(), 10) + "_1" secondTrack := "LIVE_VERIFY_" + strconv.FormatInt(startedAt.UnixNano(), 10) + "_2" firstMsgIDs := sendGift(ctx, producer, cfg, firstTrack, room.UserID, room.RoomID, 60, startedAt) firstLog := waitGiftLog(ctx, db, firstTrack, 30*time.Second) if firstLog.AcceptedEnergy != 60 || firstLog.Status != "SUCCESS" { log.Fatalf("first mq gift mismatch: accepted=%d status=%s reason=%s", firstLog.AcceptedEnergy, firstLog.Status, firstLog.ErrorMessage) } secondMsgIDs := sendGift(ctx, producer, cfg, secondTrack, igniteUserID, room.RoomID, 80, startedAt.Add(time.Second)) secondLog := waitGiftLog(ctx, db, secondTrack, 30*time.Second) if secondLog.AcceptedEnergy != 40 || secondLog.Status != "SUCCESS" { log.Fatalf("second mq gift mismatch: accepted=%d status=%s reason=%s", secondLog.AcceptedEnergy, secondLog.Status, secondLog.ErrorMessage) } launch := waitLaunch(ctx, db, room.RoomID, day, 30*time.Second) if strings.TrimSpace(launch.LaunchNo) == "" { log.Fatalf("launch missing") } waitAudience(ctx, db, launch.ID, 30*time.Second) rewardRows := waitRewardSuccess(ctx, db, launch.ID, 45*time.Second) top1User := common.AuthUser{UserID: room.UserID, SysOrigin: "LIKEI"} popups, err := service.ListRewardPopups(ctx, top1User, room.RoomID) must(err, "list top1 reward popups") ackIDs := make([]voiceroomrocket.FlexibleInt64, 0, len(popups.Records)) for _, record := range popups.Records { ackIDs = append(ackIDs, voiceroomrocket.FlexibleInt64(record.ID)) } if len(ackIDs) > 0 { must(service.AckRewardPopups(ctx, top1User, voiceroomrocket.AckRewardPopupsRequest{RewardRecordIDs: ackIDs}), "ack reward popups") } popupsAfterAck, err := service.ListRewardPopups(ctx, top1User, room.RoomID) must(err, "list top1 reward popups after ack") duplicate, err := service.ProcessGiftPayload(ctx, giftPayload(secondTrack, igniteUserID, room.RoomID, 80, startedAt.Add(time.Second))) must(err, "process duplicate gift") rewardSummaries := summarizeRewardRows(rewardRows) expectedGoldByUser, walletBalanceDeltaByUser, walletVerifiedCount := verifyWalletBalanceDeltas(ctx, &gateways, balancesBefore, rewardRows) propsRunningWater := countPropsRunningWaterTotal(ctx, db, igniteUserID, rideProps.ID) - propsRunningWaterBefore var snapshotCount int64 must(db.WithContext(ctx).Model(&model.VoiceRoomRocketAudienceSnapshot{}).Where("launch_id = ?", launch.ID).Count(&snapshotCount).Error, "count audience snapshots") var selectedCount int64 must(db.WithContext(ctx).Model(&model.VoiceRoomRocketAudienceSnapshot{}).Where("launch_id = ? AND selected = ?", launch.ID, true).Count(&selectedCount).Error, "count selected audience") broadcastCount, err := rdb.XLen(ctx, cfg.VoiceRoomRocket.BroadcastStreamKey).Result() must(err, "count broadcast stream") launchBroadcastCount, rewardBroadcastCount := countBroadcasts(ctx, rdb, cfg.VoiceRoomRocket.BroadcastStreamKey, launch.LaunchNo) successCount := 0 for _, row := range rewardRows { if row.GrantStatus == "SUCCESS" { successCount++ } } summary := liveVerifySummary{ MySQLDSNHost: mysqlHost(cfg.Store.MySQLDSN), RedisAddr: cfg.Store.RedisAddr, MQEndpoint: cfg.VoiceRoomRocket.MQ.Endpoint, MQTopic: cfg.VoiceRoomRocket.MQ.Topic, MQTag: cfg.VoiceRoomRocket.MQ.Tag, MQConsumerGroup: cfg.VoiceRoomRocket.MQ.ConsumerGroup, MQMessageIDs: append(firstMsgIDs, secondMsgIDs...), RoomID: room.RoomID, RoomAccount: roomProfile.RoomAccount, RoomName: roomProfile.RoomName, Top1UserID: room.UserID, IgniteUserID: igniteUserID, DayKey: day, FirstTrackID: firstTrack, SecondTrackID: secondTrack, FirstAcceptedEnergy: firstLog.AcceptedEnergy, SecondAcceptedEnergy: secondLog.AcceptedEnergy, LaunchNo: launch.LaunchNo, LaunchLevel: launch.Level, RewardRecords: rewardSummaries, RewardRecordCount: len(rewardRows), SuccessRewardCount: successCount, GoldWalletVerifiedCount: walletVerifiedCount, ExpectedGoldByUser: expectedGoldByUser, WalletBalanceDeltaByUser: walletBalanceDeltaByUser, RidePropsID: rideProps.ID, RidePropsName: rideProps.Name, PropsRunningWaterCount: propsRunningWater, AudienceSnapshotCount: snapshotCount, SelectedAudienceCount: selectedCount, BroadcastStreamCount: broadcastCount, LaunchBroadcastCount: launchBroadcastCount, RewardBroadcastCount: rewardBroadcastCount, TencentIMDirectSendOK: true, Top1PopupBeforeAck: len(popups.Records), Top1PopupAfterAck: len(popupsAfterAck.Records), DuplicateReason: duplicate.Reason, ServiceMQInitiallyEnabled: true, } encoded, err := json.MarshalIndent(summary, "", " ") must(err, "marshal summary") fmt.Println(string(encoded)) } func pickRoom(ctx context.Context, db *gorm.DB) liveRoom { var row liveRoom _ = db.WithContext(ctx). Table("room_member"). Select("room_id, user_id"). Where("sys_origin = ? AND room_id > 0 AND user_id > 0", "LIKEI"). Order("update_time DESC"). Limit(1). Scan(&row).Error return row } func pickUsers(ctx context.Context, db *gorm.DB, excludeUserID int64, limit int) []int64 { var users []model.UserBaseInfo _ = db.WithContext(ctx). Where("origin_sys = ? AND id <> ?", "LIKEI", excludeUserID). Order("id DESC"). Limit(limit). Find(&users).Error result := make([]int64, 0, len(users)) for _, user := range users { if user.ID > 0 { result = append(result, user.ID) } } return result } func uniqueInt64s(values []int64) []int64 { seen := map[int64]struct{}{} result := make([]int64, 0, len(values)) for _, value := range values { if value <= 0 { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} result = append(result, value) } return result } func pickProps(ctx context.Context, db *gorm.DB, propsType string) liveProps { var row liveProps _ = db.WithContext(ctx). Table("props_source_record"). Select("id, type, name"). Where("sys_origin = ? AND type = ? AND admin_free = ? AND is_del = ?", "LIKEI", propsType, true, false). Order("id ASC"). Limit(1). Scan(&row).Error return row } func loadRoomProfile(ctx context.Context, gateways *integration.Gateways, roomID int64) (integration.RoomProfile, error) { profiles, err := gateways.MapRoomProfiles(ctx, []int64{roomID}) if err != nil { return integration.RoomProfile{}, err } if profile, ok := profiles[roomID]; ok { return profile, nil } for _, profile := range profiles { if int64(profile.ID) == roomID { return profile, nil } } return integration.RoomProfile{}, fmt.Errorf("room profile not found: %d", roomID) } func cleanup(ctx context.Context, db *gorm.DB, rdb *redis.Client, roomID int64, day string) { tables := []string{ "voice_room_rocket_reward_record", "voice_room_rocket_launch", "voice_room_rocket_gift_event_log", "voice_room_rocket_contribution", "voice_room_rocket_level_round", "voice_room_rocket_status", } for _, table := range tables { must(db.WithContext(ctx).Exec("DELETE FROM "+table+" WHERE sys_origin = ? AND room_id = ? AND day_key = ?", "LIKEI", roomID, day).Error, "cleanup "+table) } must(db.WithContext(ctx).Exec("DELETE FROM voice_room_rocket_audience_snapshot WHERE sys_origin = ? AND room_id = ?", "LIKEI", roomID).Error, "cleanup audience") must(db.WithContext(ctx).Exec("DELETE FROM voice_room_rocket_reward_config WHERE sys_origin = ? AND (reward_name LIKE ? OR reward_name LIKE ?)", "LIKEI", "Local Verify%", "Live Verify%").Error, "cleanup reward config") for level := 1; level <= 6; level++ { key := fmt.Sprintf("voice_room:rocket:rank:LIKEI:%d:%s:1:%d", roomID, day, level) _ = rdb.Del(ctx, key).Err() } _ = rdb.Del(ctx, fmt.Sprintf("voice_room:rocket:lock:LIKEI:%d", roomID)).Err() _ = rdb.Del(ctx, fmt.Sprintf("voice_room:online:LIKEI:%d", roomID)).Err() _ = rdb.Del(ctx, "voice_room:rocket:in_room_due").Err() } func seedLiveConfig(ctx context.Context, service *voiceroomrocket.Service, db *gorm.DB, ride liveProps) { _, err := service.SaveAdminConfig(ctx, voiceroomrocket.SaveConfigRequest{ SysOrigin: "LIKEI", Enabled: true, Timezone: "Asia/Riyadh", MaxLevel: 6, RankingTopLimit: 100, RewardRecordKeepDays: 35, BroadcastDurationSeconds: 7, InRoomRewardDelaySeconds: 1, RuleText: json.RawMessage(`{"zh":"live verify rule"}`), }) must(err, "save rocket config") levels := make([]voiceroomrocket.LevelConfigPayload, 0, 6) for i, need := range []int64{100, 200, 300, 400, 500, 600} { levels = append(levels, voiceroomrocket.LevelConfigPayload{ Level: i + 1, NeedEnergy: need, ShakeThresholdPercent: "95.0000", Enabled: true, Sort: i + 1, }) } _, err = service.SaveLevelConfigs(ctx, voiceroomrocket.SaveLevelConfigsRequest{SysOrigin: "LIKEI", Levels: levels}) must(err, "save level config") rideItemID := ride.ID expireDays := 1 _, err = service.SaveRewardConfigs(ctx, voiceroomrocket.SaveRewardConfigsRequest{ SysOrigin: "LIKEI", Rewards: []voiceroomrocket.RewardConfigPayload{ {Level: 1, RewardScene: "TOP1", RewardType: "GOLD", RewardName: "Live Verify Top1 Gold", RewardAmount: 10, Enabled: true, Sort: 1}, {Level: 1, RewardScene: "IGNITE", RewardType: "GOLD", RewardName: "Live Verify Ignite Gold", RewardAmount: 5, Enabled: true, Sort: 1}, {Level: 1, RewardScene: "IGNITE", RewardType: "MOUNT", RewardItemID: &rideItemID, RewardName: "Live Verify Ignite Ride", RewardAmount: 1, ExpireDays: &expireDays, Enabled: true, Sort: 2}, {Level: 1, RewardScene: "IN_ROOM", RewardType: "GOLD", RewardName: "Live Verify In Room Gold", RewardAmount: 1, Enabled: true, Sort: 1}, }, }) must(err, "save reward config") var count int64 must(db.WithContext(ctx).Model(&model.VoiceRoomRocketRewardConfig{}).Where("sys_origin = ? AND reward_name LIKE ? AND enabled = ?", "LIKEI", "Live Verify%", true).Count(&count).Error, "count live reward config") if count != 4 { log.Fatalf("live reward config count=%d, want 4", count) } } func seedPresence(ctx context.Context, rdb *redis.Client, roomID int64, users []int64) { 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") } func newProducer(cfg config.Config) rmq.Producer { producer, err := rmq.NewProducer(&rmq.Config{ Endpoint: cfg.VoiceRoomRocket.MQ.Endpoint, NameSpace: cfg.VoiceRoomRocket.MQ.Namespace, Credentials: &credentials.SessionCredentials{ AccessKey: cfg.VoiceRoomRocket.MQ.AccessKey, AccessSecret: cfg.VoiceRoomRocket.MQ.AccessSecret, SecurityToken: cfg.VoiceRoomRocket.MQ.SecurityToken, }, }, rmq.WithTopics(cfg.VoiceRoomRocket.MQ.Topic)) must(err, "new rocketmq producer") must(producer.Start(), "start rocketmq producer") return producer } func sendGift(ctx context.Context, producer rmq.Producer, cfg config.Config, trackID string, userID int64, roomID int64, gold int64, at time.Time) []string { msg := &rmq.Message{ Topic: cfg.VoiceRoomRocket.MQ.Topic, Body: []byte(giftPayload(trackID, userID, roomID, gold, at)), } msg.SetTag(cfg.VoiceRoomRocket.MQ.Tag) msg.SetKeys(trackID) receipts, err := producer.Send(ctx, msg) must(err, "send rocketmq gift") result := make([]string, 0, len(receipts)) for _, receipt := range receipts { result = append(result, receipt.MessageID) } return result } func giftPayload(trackID string, sendUserID int64, roomID int64, gold int64, at time.Time) string { return fmt.Sprintf(`{ "trackId": %q, "sysOrigin": "LIKEI", "sendUserId": %q, "roomId": %q, "quantity": 1, "giftConfig": {"id": "90001", "giftCandy": %d, "type": "GOLD", "giftTab": "NORMAL"}, "createTime": %d }`, trackID, strconv.FormatInt(sendUserID, 10), strconv.FormatInt(roomID, 10), gold, at.UnixMilli()) } func waitGiftLog(ctx context.Context, db *gorm.DB, trackID string, timeout time.Duration) model.VoiceRoomRocketGiftEventLog { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { var row model.VoiceRoomRocketGiftEventLog err := db.WithContext(ctx).Where("track_id = ?", trackID).First(&row).Error if err == nil { return row } if err != nil && err != gorm.ErrRecordNotFound { log.Fatalf("load gift log %s: %v", trackID, err) } time.Sleep(300 * time.Millisecond) } log.Fatalf("timeout waiting gift log: %s", trackID) return model.VoiceRoomRocketGiftEventLog{} } func waitLaunch(ctx context.Context, db *gorm.DB, roomID int64, day string, timeout time.Duration) model.VoiceRoomRocketLaunch { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { var row model.VoiceRoomRocketLaunch err := db.WithContext(ctx). Where("sys_origin = ? AND room_id = ? AND day_key = ? AND level = ?", "LIKEI", roomID, day, 1). Order("launch_time DESC"). First(&row).Error if err == nil { return row } if err != nil && err != gorm.ErrRecordNotFound { log.Fatalf("load launch: %v", err) } time.Sleep(300 * time.Millisecond) } log.Fatalf("timeout waiting launch") return model.VoiceRoomRocketLaunch{} } func waitAudience(ctx context.Context, db *gorm.DB, launchID int64, timeout time.Duration) { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { var launch model.VoiceRoomRocketLaunch must(db.WithContext(ctx).Where("id = ?", launchID).First(&launch).Error, "load launch audience status") if launch.InRoomSnapshotStatus == "SUCCESS" { return } if launch.InRoomSnapshotStatus == "FAILED" { log.Fatalf("in-room snapshot failed for launch=%d", launchID) } time.Sleep(300 * time.Millisecond) } log.Fatalf("timeout waiting in-room audience snapshot") } func waitRewardSuccess(ctx context.Context, db *gorm.DB, launchID int64, timeout time.Duration) []model.VoiceRoomRocketRewardRecord { deadline := time.Now().Add(timeout) var rows []model.VoiceRoomRocketRewardRecord for time.Now().Before(deadline) { rows = nil must(db.WithContext(ctx).Where("launch_id = ?", launchID).Order("reward_scene ASC, reward_type ASC, user_id ASC").Find(&rows).Error, "load reward rows") if len(rows) >= 6 { allDone := true for _, row := range rows { if row.GrantStatus != "SUCCESS" { allDone = false if row.GrantStatus == "FAILED" { log.Fatalf("reward failed: id=%d type=%s scene=%s err=%s", row.ID, row.RewardType, row.RewardScene, row.ErrorMessage) } } } if allDone { return rows } } time.Sleep(500 * time.Millisecond) } rows = nil _ = db.WithContext(ctx).Where("launch_id = ?", launchID).Order("reward_scene ASC, reward_type ASC, user_id ASC").Find(&rows).Error log.Fatalf("timeout waiting reward success: rows=%+v", rows) return nil } func summarizeRewardRows(rows []model.VoiceRoomRocketRewardRecord) []liveRewardRow { result := make([]liveRewardRow, 0, len(rows)) for _, row := range rows { result = append(result, liveRewardRow{ ID: row.ID, UserID: row.UserID, RewardScene: row.RewardScene, RewardType: row.RewardType, RewardItemID: row.RewardItemID, RewardName: row.RewardName, RewardAmount: row.RewardAmount, GrantStatus: row.GrantStatus, GrantEventID: row.GrantEventID, ErrorMessage: row.ErrorMessage, }) } return result } func verifyWalletBalanceDeltas(ctx context.Context, gateways *integration.Gateways, before map[int64]int64, rows []model.VoiceRoomRocketRewardRecord) (map[string]int64, map[string]int64, int) { expected := map[int64]int64{} for _, row := range rows { if row.RewardType == "GOLD" { expected[row.UserID] += row.RewardAmount } } users := make([]int64, 0, len(expected)) for userID := range expected { users = append(users, userID) } after, err := gateways.MapGoldBalance(ctx, users) must(err, "load wallet balances after") expectedJSON := make(map[string]int64, len(expected)) deltaJSON := make(map[string]int64, len(expected)) verified := 0 for userID, amount := range expected { delta := after[userID] - before[userID] expectedJSON[strconv.FormatInt(userID, 10)] = amount deltaJSON[strconv.FormatInt(userID, 10)] = delta if delta < amount { log.Fatalf("wallet balance delta mismatch: user=%d delta=%d expected>=%d before=%d after=%d", userID, delta, amount, before[userID], after[userID]) } for _, row := range rows { if row.UserID == userID && row.RewardType == "GOLD" { verified++ } } } return expectedJSON, deltaJSON, verified } func countPropsRunningWaterTotal(ctx context.Context, db *gorm.DB, userID int64, propsID int64) int64 { var count int64 must(db.WithContext(ctx). Table("running_water_user_props"). Where("user_id = ? AND props_id = ? AND origin = ?", userID, propsID, "VOICE_ROOM_ROCKET"). Count(&count).Error, "count props running water") return count } func countBroadcasts(ctx context.Context, rdb *redis.Client, streamKey string, launchNo string) (int, int) { entries, err := rdb.XRevRangeN(ctx, streamKey, "+", "-", 100).Result() must(err, "read broadcast stream") launchCount := 0 rewardCount := 0 for _, entry := range entries { if fmt.Sprint(entry.Values["launchNo"]) != launchNo { continue } switch fmt.Sprint(entry.Values["type"]) { case "VOICE_ROOM_ROCKET_LAUNCH_BROADCAST": launchCount++ case "VOICE_ROOM_ROCKET_REWARD_POPUP", "VOICE_ROOM_ROCKET_REWARD_USER": rewardCount++ } } return launchCount, rewardCount } func mysqlHost(dsn string) string { start := strings.Index(dsn, "@tcp(") if start < 0 { return "configured" } start += len("@tcp(") end := strings.Index(dsn[start:], ")") if end < 0 { return "configured" } return dsn[start : start+end] } func must(err error, label string) { if err != nil { log.Fatalf("%s: %v", label, err) } }