From dd9020a542c70cbedc7482deda50a517cf2448c1 Mon Sep 17 00:00:00 2001 From: hy001 Date: Thu, 14 May 2026 13:46:53 +0800 Subject: [PATCH] Add voice room rocket feature --- cmd/api/main.go | 6 + cmd/consumer/main.go | 5 + internal/config/config.go | 90 +++ internal/model/voice_room_rocket_models.go | 212 ++++++ internal/router/router.go | 3 + internal/router/voice_room_rocket_routes.go | 223 ++++++ internal/service/managercenter/service.go | 16 +- .../service/managercenter/service_test.go | 4 +- internal/service/voiceroomrocket/config.go | 421 +++++++++++ internal/service/voiceroomrocket/energy.go | 190 +++++ internal/service/voiceroomrocket/event.go | 513 ++++++++++++++ .../service/voiceroomrocket/event_test.go | 471 +++++++++++++ .../service/voiceroomrocket/in_room_reward.go | 278 ++++++++ internal/service/voiceroomrocket/lifecycle.go | 195 ++++++ internal/service/voiceroomrocket/mapper.go | 308 ++++++++ internal/service/voiceroomrocket/notify.go | 228 ++++++ internal/service/voiceroomrocket/presence.go | 68 ++ internal/service/voiceroomrocket/ranking.go | 142 ++++ internal/service/voiceroomrocket/records.go | 142 ++++ internal/service/voiceroomrocket/reward.go | 342 +++++++++ internal/service/voiceroomrocket/status.go | 159 +++++ internal/service/voiceroomrocket/types.go | 470 +++++++++++++ migrations/032_voice_room_rocket.sql | 195 ++++++ scripts/voice_room_rocket_live_verify.go | 657 ++++++++++++++++++ scripts/voice_room_rocket_local_verify.go | 422 +++++++++++ 25 files changed, 5750 insertions(+), 10 deletions(-) create mode 100644 internal/model/voice_room_rocket_models.go create mode 100644 internal/router/voice_room_rocket_routes.go create mode 100644 internal/service/voiceroomrocket/config.go create mode 100644 internal/service/voiceroomrocket/energy.go create mode 100644 internal/service/voiceroomrocket/event.go create mode 100644 internal/service/voiceroomrocket/event_test.go create mode 100644 internal/service/voiceroomrocket/in_room_reward.go create mode 100644 internal/service/voiceroomrocket/lifecycle.go create mode 100644 internal/service/voiceroomrocket/mapper.go create mode 100644 internal/service/voiceroomrocket/notify.go create mode 100644 internal/service/voiceroomrocket/presence.go create mode 100644 internal/service/voiceroomrocket/ranking.go create mode 100644 internal/service/voiceroomrocket/records.go create mode 100644 internal/service/voiceroomrocket/reward.go create mode 100644 internal/service/voiceroomrocket/status.go create mode 100644 internal/service/voiceroomrocket/types.go create mode 100644 migrations/032_voice_room_rocket.sql create mode 100644 scripts/voice_room_rocket_live_verify.go create mode 100644 scripts/voice_room_rocket_local_verify.go diff --git a/cmd/api/main.go b/cmd/api/main.go index eb8362b..bee22da 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -31,6 +31,7 @@ import ( "chatapp3-golang/internal/service/taskcenter" "chatapp3-golang/internal/service/vip" "chatapp3-golang/internal/service/voiceroomredpacket" + "chatapp3-golang/internal/service/voiceroomrocket" "chatapp3-golang/internal/service/weekstar" ) @@ -59,6 +60,7 @@ func main() { regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways) voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room) voiceRoomRedPacketService.SetRegionResolver(&app.Gateways) + voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways) hostCenterService := hostcenter.NewService(app.Config, &app.Gateways) @@ -79,6 +81,9 @@ func main() { if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil { log.Fatalf("start task center message consumer failed: %v", err) } + if err := voiceRoomRocketService.Start(workerCtx); err != nil { + log.Fatalf("start voice room rocket workers failed: %v", err) + } if err := taskCenterService.StartArchiveWorker(workerCtx); err != nil { log.Fatalf("start task center archive worker failed: %v", err) } @@ -108,6 +113,7 @@ func main() { TaskCenter: taskCenterService, VIP: vipService, VoiceRoomRedPacket: voiceRoomRedPacketService, + VoiceRoomRocket: voiceRoomRocketService, WeekStar: weekStarService, }) server := &http.Server{ diff --git a/cmd/consumer/main.go b/cmd/consumer/main.go index 2ced824..aa6e57f 100644 --- a/cmd/consumer/main.go +++ b/cmd/consumer/main.go @@ -6,6 +6,7 @@ import ( "chatapp3-golang/internal/service/registerreward" "chatapp3-golang/internal/service/taskcenter" "chatapp3-golang/internal/service/voiceroomredpacket" + "chatapp3-golang/internal/service/voiceroomrocket" "chatapp3-golang/internal/service/weekstar" "context" "log" @@ -29,6 +30,7 @@ func main() { regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways) voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room) voiceRoomRedPacketService.SetRegionResolver(&app.Gateways) + voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) workerCtx, workerCancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer workerCancel() @@ -42,6 +44,9 @@ func main() { if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil { log.Fatalf("start task center message consumer failed: %v", err) } + if err := voiceRoomRocketService.Start(workerCtx); err != nil { + log.Fatalf("start voice room rocket workers failed: %v", err) + } if err := taskCenterService.StartArchiveWorker(workerCtx); err != nil { log.Fatalf("start task center archive worker failed: %v", err) } diff --git a/internal/config/config.go b/internal/config/config.go index 510c2a4..f64bab7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,7 @@ type Config struct { WeekStar WeekStarConfig RoomTurnoverReward RoomTurnoverRewardConfig VoiceRoomRedPacket VoiceRoomRedPacketConfig + VoiceRoomRocket VoiceRoomRocketConfig TencentIM TencentIMConfig Baishun BaishunConfig Lingxian LingxianConfig @@ -158,6 +159,35 @@ type VoiceRoomRedPacketConfig struct { ExpireZSetKey string } +// VoiceRoomRocketConfig 保存语音房火箭 Go 侧运行配置。 +type VoiceRoomRocketConfig struct { + DefaultSysOrigin string + Timezone string + RewardRetryStreamKey string + InRoomRewardDueZSetKey string + RoomLockTTLSeconds int + BroadcastStreamKey string + InRoomRewardScanIntervalSeconds int + InRoomRewardBatchSize int + InRoomRewardUserLimit int + RewardGrantScanIntervalSeconds int + RewardGrantBatchSize int + MQ VoiceRoomRocketMQConfig +} + +// VoiceRoomRocketMQConfig 保存语音房火箭送礼 MQ 消费配置。 +type VoiceRoomRocketMQConfig struct { + Enabled bool + Endpoint string + Namespace string + AccessKey string + AccessSecret string + SecurityToken string + ConsumerGroup string + Topic string + Tag string +} + // TencentIMConfig 保存腾讯 IM REST API 配置。 type TencentIMConfig struct { AppID int64 @@ -230,6 +260,9 @@ func Load() Config { taskCenterMQEndpoint := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ENDPOINT", "TASK_CENTER_EVENT_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "") taskCenterMQAccessKey := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_KEY", "TASK_CENTER_EVENT_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "") taskCenterMQAccessSecret := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_SECRET", "TASK_CENTER_EVENT_MQ_SECRET_KEY", "LIKEI_ROCKETMQ_SECRET_KEY"}, "") + voiceRoomRocketMQEndpoint := getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "") + voiceRoomRocketMQAccessKey := getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "") + voiceRoomRocketMQAccessSecret := getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ACCESS_SECRET", "LIKEI_ROCKETMQ_SECRET_KEY"}, "") return Config{ HTTP: HTTPConfig{ @@ -375,6 +408,63 @@ func Load() Config { "voice_room:red_packet:expire_zset", ), }, + VoiceRoomRocket: VoiceRoomRocketConfig{ + DefaultSysOrigin: strings.ToUpper(getEnvAny( + []string{"CHATAPP_VOICE_ROOM_ROCKET_DEFAULT_SYS_ORIGIN", "VOICE_ROOM_ROCKET_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, + "LIKEI", + )), + Timezone: getEnvAny( + []string{"CHATAPP_VOICE_ROOM_ROCKET_TIMEZONE", "VOICE_ROOM_ROCKET_TIMEZONE", "CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"}, + "Asia/Riyadh", + ), + RewardRetryStreamKey: getEnvAny( + []string{"CHATAPP_VOICE_ROOM_ROCKET_REWARD_RETRY_STREAM_KEY", "VOICE_ROOM_ROCKET_REWARD_RETRY_STREAM_KEY"}, + "voice_room:rocket:reward_retry", + ), + InRoomRewardDueZSetKey: getEnvAny( + []string{"CHATAPP_VOICE_ROOM_ROCKET_IN_ROOM_DUE_ZSET_KEY", "VOICE_ROOM_ROCKET_IN_ROOM_DUE_ZSET_KEY"}, + "voice_room:rocket:in_room_due", + ), + RoomLockTTLSeconds: getEnvIntAny( + []string{"CHATAPP_VOICE_ROOM_ROCKET_ROOM_LOCK_TTL_SECONDS", "VOICE_ROOM_ROCKET_ROOM_LOCK_TTL_SECONDS"}, + 10, + ), + BroadcastStreamKey: getEnvAny( + []string{"CHATAPP_VOICE_ROOM_ROCKET_BROADCAST_STREAM_KEY", "VOICE_ROOM_ROCKET_BROADCAST_STREAM_KEY"}, + "voice_room:rocket:broadcast", + ), + InRoomRewardScanIntervalSeconds: getEnvIntAny( + []string{"CHATAPP_VOICE_ROOM_ROCKET_IN_ROOM_SCAN_INTERVAL_SECONDS", "VOICE_ROOM_ROCKET_IN_ROOM_SCAN_INTERVAL_SECONDS"}, + 1, + ), + InRoomRewardBatchSize: getEnvIntAny( + []string{"CHATAPP_VOICE_ROOM_ROCKET_IN_ROOM_BATCH_SIZE", "VOICE_ROOM_ROCKET_IN_ROOM_BATCH_SIZE"}, + 100, + ), + InRoomRewardUserLimit: getEnvIntAny( + []string{"CHATAPP_VOICE_ROOM_ROCKET_IN_ROOM_USER_LIMIT", "VOICE_ROOM_ROCKET_IN_ROOM_USER_LIMIT"}, + 3, + ), + RewardGrantScanIntervalSeconds: getEnvIntAny( + []string{"CHATAPP_VOICE_ROOM_ROCKET_REWARD_GRANT_SCAN_INTERVAL_SECONDS", "VOICE_ROOM_ROCKET_REWARD_GRANT_SCAN_INTERVAL_SECONDS"}, + 1, + ), + RewardGrantBatchSize: getEnvIntAny( + []string{"CHATAPP_VOICE_ROOM_ROCKET_REWARD_GRANT_BATCH_SIZE", "VOICE_ROOM_ROCKET_REWARD_GRANT_BATCH_SIZE"}, + 100, + ), + MQ: VoiceRoomRocketMQConfig{ + Enabled: getEnvBoolAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ENABLED", "VOICE_ROOM_ROCKET_MQ_ENABLED"}, false), + Endpoint: voiceRoomRocketMQEndpoint, + Namespace: getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_NAMESPACE", "VOICE_ROOM_ROCKET_MQ_NAMESPACE"}, ""), + AccessKey: voiceRoomRocketMQAccessKey, + AccessSecret: voiceRoomRocketMQAccessSecret, + SecurityToken: getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_SECURITY_TOKEN", "VOICE_ROOM_ROCKET_MQ_SECURITY_TOKEN"}, ""), + ConsumerGroup: getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_CONSUMER_GROUP", "VOICE_ROOM_ROCKET_MQ_CONSUMER_GROUP"}, "voice-room-rocket"), + Topic: getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_TOPIC", "VOICE_ROOM_ROCKET_MQ_TOPIC"}, "RC_DEFAULT_APP_ORDINARY"), + Tag: getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_TAG", "VOICE_ROOM_ROCKET_MQ_TAG"}, "give_gift_v3"), + }, + }, TencentIM: TencentIMConfig{ AppID: getEnvInt64Any( []string{"CHATAPP_TENCENT_IM_APP_ID", "TENCENT_IM_APP_ID", "LIKEI_IM_APP_ID"}, diff --git a/internal/model/voice_room_rocket_models.go b/internal/model/voice_room_rocket_models.go new file mode 100644 index 0000000..ad6b884 --- /dev/null +++ b/internal/model/voice_room_rocket_models.go @@ -0,0 +1,212 @@ +package model + +import "time" + +// VoiceRoomRocketConfig 保存语音房火箭系统级配置。 +type VoiceRoomRocketConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_voice_room_rocket_config"` + Enabled bool `gorm:"column:enabled"` + Timezone string `gorm:"column:timezone;size:64"` + MaxLevel int `gorm:"column:max_level"` + RankingTopLimit int `gorm:"column:ranking_top_limit"` + RewardRecordKeepDays int `gorm:"column:reward_record_keep_days"` + BroadcastDurationSeconds int `gorm:"column:broadcast_duration_seconds"` + InRoomRewardDelaySeconds int `gorm:"column:in_room_reward_delay_seconds"` + EnergyRuleJSON string `gorm:"column:energy_rule_json;type:json"` + RuleText string `gorm:"column:rule_text;type:json"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (VoiceRoomRocketConfig) TableName() string { return "voice_room_rocket_config" } + +// VoiceRoomRocketLevelConfig 保存每级能量、资源和抖动阈值配置。 +type VoiceRoomRocketLevelConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_voice_room_rocket_level,priority:1"` + Level int `gorm:"column:level;uniqueIndex:uk_voice_room_rocket_level,priority:2"` + NeedEnergy int64 `gorm:"column:need_energy"` + ShakeThresholdPercent string `gorm:"column:shake_threshold_percent;type:decimal(8,4)"` + RocketIconURL string `gorm:"column:rocket_icon_url;size:512"` + RocketAnimationURL string `gorm:"column:rocket_animation_url;size:512"` + ProgressBarURL string `gorm:"column:progress_bar_url;size:512"` + Enabled bool `gorm:"column:enabled"` + Sort int `gorm:"column:sort"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (VoiceRoomRocketLevelConfig) TableName() string { return "voice_room_rocket_level_config" } + +// VoiceRoomRocketRewardConfig 保存 Top1、引爆、在房间三类奖励配置。 +type VoiceRoomRocketRewardConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_voice_room_rocket_reward_scene,priority:1"` + Level int `gorm:"column:level;index:idx_voice_room_rocket_reward_scene,priority:2"` + RewardScene string `gorm:"column:reward_scene;size:32;index:idx_voice_room_rocket_reward_scene,priority:3"` + RewardType string `gorm:"column:reward_type;size:32"` + RewardItemID *int64 `gorm:"column:reward_item_id"` + RewardName string `gorm:"column:reward_name;size:128"` + RewardCover string `gorm:"column:reward_cover;size:512"` + RewardAmount int64 `gorm:"column:reward_amount"` + ExpireDays *int `gorm:"column:expire_days"` + Weight int `gorm:"column:weight"` + Sort int `gorm:"column:sort"` + Enabled bool `gorm:"column:enabled;index:idx_voice_room_rocket_reward_scene,priority:4"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (VoiceRoomRocketRewardConfig) TableName() string { return "voice_room_rocket_reward_config" } + +// VoiceRoomRocketStatus 保存房间当天当前火箭状态。 +type VoiceRoomRocketStatus struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_voice_room_rocket_status,priority:1"` + RoomID int64 `gorm:"column:room_id;uniqueIndex:uk_voice_room_rocket_status,priority:2"` + DayKey string `gorm:"column:day_key;size:16;uniqueIndex:uk_voice_room_rocket_status,priority:3"` + RoundNo int `gorm:"column:round_no"` + CurrentLevel int `gorm:"column:current_level"` + CurrentEnergy int64 `gorm:"column:current_energy"` + NeedEnergy int64 `gorm:"column:need_energy"` + Status string `gorm:"column:status;size:32"` + LevelStartTime time.Time `gorm:"column:level_start_time"` + LastEventTime *time.Time `gorm:"column:last_event_time"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (VoiceRoomRocketStatus) TableName() string { return "voice_room_rocket_status" } + +// VoiceRoomRocketLevelRound 保存每轮每级的充能/发射快照。 +type VoiceRoomRocketLevelRound struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_voice_room_rocket_level_round,priority:1;index:idx_voice_room_rocket_level_round_room,priority:1"` + RoomID int64 `gorm:"column:room_id;uniqueIndex:uk_voice_room_rocket_level_round,priority:2;index:idx_voice_room_rocket_level_round_room,priority:2"` + DayKey string `gorm:"column:day_key;size:16;uniqueIndex:uk_voice_room_rocket_level_round,priority:3;index:idx_voice_room_rocket_level_round_room,priority:3"` + RoundNo int `gorm:"column:round_no;uniqueIndex:uk_voice_room_rocket_level_round,priority:4;index:idx_voice_room_rocket_level_round_room,priority:4"` + Level int `gorm:"column:level;uniqueIndex:uk_voice_room_rocket_level_round,priority:5"` + NeedEnergy int64 `gorm:"column:need_energy"` + FinalEnergy int64 `gorm:"column:final_energy"` + Status string `gorm:"column:status;size:32"` + Top1UserID *int64 `gorm:"column:top1_user_id"` + IgniteUserID *int64 `gorm:"column:ignite_user_id"` + ReachTime *time.Time `gorm:"column:reach_time"` + LaunchTime *time.Time `gorm:"column:launch_time"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (VoiceRoomRocketLevelRound) TableName() string { return "voice_room_rocket_level_round" } + +// VoiceRoomRocketContribution 保存本轮本级用户贡献榜。 +type VoiceRoomRocketContribution struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_voice_room_rocket_contribution_user,priority:1;index:idx_voice_room_rocket_contribution_rank,priority:1"` + RoomID int64 `gorm:"column:room_id;uniqueIndex:uk_voice_room_rocket_contribution_user,priority:2;index:idx_voice_room_rocket_contribution_rank,priority:2"` + DayKey string `gorm:"column:day_key;size:16;uniqueIndex:uk_voice_room_rocket_contribution_user,priority:3;index:idx_voice_room_rocket_contribution_rank,priority:3"` + RoundNo int `gorm:"column:round_no;uniqueIndex:uk_voice_room_rocket_contribution_user,priority:4;index:idx_voice_room_rocket_contribution_rank,priority:4"` + Level int `gorm:"column:level;uniqueIndex:uk_voice_room_rocket_contribution_user,priority:5;index:idx_voice_room_rocket_contribution_rank,priority:5"` + UserID int64 `gorm:"column:user_id;uniqueIndex:uk_voice_room_rocket_contribution_user,priority:6"` + ScoreEnergy int64 `gorm:"column:score_energy;index:idx_voice_room_rocket_contribution_rank,priority:6"` + GiftCount int `gorm:"column:gift_count"` + FirstContributeTime time.Time `gorm:"column:first_contribute_time"` + LastContributeTime time.Time `gorm:"column:last_contribute_time;index:idx_voice_room_rocket_contribution_rank,priority:7"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (VoiceRoomRocketContribution) TableName() string { return "voice_room_rocket_contribution" } + +// VoiceRoomRocketGiftEventLog 保存送礼事件幂等和处理日志。 +type VoiceRoomRocketGiftEventLog struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_voice_room_rocket_event_room,priority:1"` + TrackID string `gorm:"column:track_id;size:128;uniqueIndex:uk_voice_room_rocket_track"` + RoomID int64 `gorm:"column:room_id;index:idx_voice_room_rocket_event_room,priority:2"` + SendUserID int64 `gorm:"column:send_user_id"` + GiftID *int64 `gorm:"column:gift_id"` + GiftType string `gorm:"column:gift_type;size:32"` + GiftTab string `gorm:"column:gift_tab;size:64"` + RawEnergy int64 `gorm:"column:raw_energy"` + AcceptedEnergy int64 `gorm:"column:accepted_energy"` + DayKey string `gorm:"column:day_key;size:16;index:idx_voice_room_rocket_event_room,priority:3"` + RoundNo int `gorm:"column:round_no;index:idx_voice_room_rocket_event_room,priority:4"` + Level int `gorm:"column:level;index:idx_voice_room_rocket_event_room,priority:5"` + Status string `gorm:"column:status;size:32"` + RawPayload string `gorm:"column:raw_payload;type:mediumtext"` + ErrorMessage string `gorm:"column:error_message;size:1024"` + EventTime time.Time `gorm:"column:event_time"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (VoiceRoomRocketGiftEventLog) TableName() string { return "voice_room_rocket_gift_event_log" } + +// VoiceRoomRocketLaunch 保存火箭每次发射记录。 +type VoiceRoomRocketLaunch struct { + ID int64 `gorm:"column:id;primaryKey"` + LaunchNo string `gorm:"column:launch_no;size:64;uniqueIndex:uk_voice_room_rocket_launch_no"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_voice_room_rocket_launch_level,priority:1;index:idx_voice_room_rocket_launch_room,priority:1"` + RoomID int64 `gorm:"column:room_id;uniqueIndex:uk_voice_room_rocket_launch_level,priority:2;index:idx_voice_room_rocket_launch_room,priority:2"` + RoomAccount string `gorm:"column:room_account;size:64"` + DayKey string `gorm:"column:day_key;size:16;uniqueIndex:uk_voice_room_rocket_launch_level,priority:3"` + RoundNo int `gorm:"column:round_no;uniqueIndex:uk_voice_room_rocket_launch_level,priority:4"` + Level int `gorm:"column:level;uniqueIndex:uk_voice_room_rocket_launch_level,priority:5"` + FinalEnergy int64 `gorm:"column:final_energy"` + Top1UserID *int64 `gorm:"column:top1_user_id"` + IgniteUserID *int64 `gorm:"column:ignite_user_id"` + InRoomSnapshotStatus string `gorm:"column:in_room_snapshot_status;size:32"` + LaunchTime time.Time `gorm:"column:launch_time;index:idx_voice_room_rocket_launch_room,priority:3"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (VoiceRoomRocketLaunch) TableName() string { return "voice_room_rocket_launch" } + +// VoiceRoomRocketRewardRecord 保存火箭奖励发放和弹窗状态。 +type VoiceRoomRocketRewardRecord struct { + ID int64 `gorm:"column:id;primaryKey"` + LaunchID int64 `gorm:"column:launch_id;index:idx_voice_room_rocket_reward_launch,priority:1"` + LaunchNo string `gorm:"column:launch_no;size:64"` + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_voice_room_rocket_reward_user,priority:1"` + RoomID int64 `gorm:"column:room_id"` + DayKey string `gorm:"column:day_key;size:16"` + RoundNo int `gorm:"column:round_no"` + Level int `gorm:"column:level"` + UserID int64 `gorm:"column:user_id;index:idx_voice_room_rocket_reward_user,priority:2;index:idx_voice_room_rocket_reward_launch,priority:2"` + RewardScene string `gorm:"column:reward_scene;size:32"` + RewardConfigID int64 `gorm:"column:reward_config_id"` + RewardType string `gorm:"column:reward_type;size:32"` + RewardItemID *int64 `gorm:"column:reward_item_id"` + RewardName string `gorm:"column:reward_name;size:128"` + RewardCover string `gorm:"column:reward_cover;size:512"` + RewardAmount int64 `gorm:"column:reward_amount"` + ExpireDays *int `gorm:"column:expire_days"` + GrantEventID string `gorm:"column:grant_event_id;size:128;uniqueIndex:uk_voice_room_rocket_reward_event"` + GrantStatus string `gorm:"column:grant_status;size:32;index:idx_voice_room_rocket_reward_retry,priority:1"` + PopupStatus string `gorm:"column:popup_status;size:32"` + ErrorMessage string `gorm:"column:error_message;size:1024"` + GrantTime *time.Time `gorm:"column:grant_time"` + CreateTime time.Time `gorm:"column:create_time;index:idx_voice_room_rocket_reward_user,priority:3"` + UpdateTime time.Time `gorm:"column:update_time;index:idx_voice_room_rocket_reward_retry,priority:2"` +} + +func (VoiceRoomRocketRewardRecord) TableName() string { return "voice_room_rocket_reward_record" } + +// VoiceRoomRocketAudienceSnapshot 保存发射后窗口内在线用户快照。 +type VoiceRoomRocketAudienceSnapshot struct { + ID int64 `gorm:"column:id;primaryKey"` + LaunchID int64 `gorm:"column:launch_id;uniqueIndex:uk_voice_room_rocket_audience,priority:1;index:idx_voice_room_rocket_audience_launch,priority:1"` + SysOrigin string `gorm:"column:sys_origin;size:32"` + RoomID int64 `gorm:"column:room_id"` + UserID int64 `gorm:"column:user_id;uniqueIndex:uk_voice_room_rocket_audience,priority:2"` + LastSeenTime time.Time `gorm:"column:last_seen_time"` + Selected bool `gorm:"column:selected;index:idx_voice_room_rocket_audience_launch,priority:2"` + CreateTime time.Time `gorm:"column:create_time"` +} + +func (VoiceRoomRocketAudienceSnapshot) TableName() string { + return "voice_room_rocket_audience_snapshot" +} diff --git a/internal/router/router.go b/internal/router/router.go index a252ecf..3e5476f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -25,6 +25,7 @@ import ( "chatapp3-golang/internal/service/taskcenter" "chatapp3-golang/internal/service/vip" "chatapp3-golang/internal/service/voiceroomredpacket" + "chatapp3-golang/internal/service/voiceroomrocket" "chatapp3-golang/internal/service/weekstar" "github.com/gin-gonic/gin" @@ -51,6 +52,7 @@ type Services struct { TaskCenter *taskcenter.Service VIP *vip.Service VoiceRoomRedPacket *voiceroomredpacket.Service + VoiceRoomRocket *voiceroomrocket.Service WeekStar *weekstar.WeekStarService } @@ -76,6 +78,7 @@ func NewRouter( registerVipRoutes(engine, javaClient, services.VIP) registerRegionIMGroupRoutes(engine, cfg, javaClient, services.RegionIMGroup) registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket) + registerVoiceRoomRocketRoutes(engine, cfg, javaClient, services.VoiceRoomRocket) registerWeekStarRoutes(engine, javaClient, services.WeekStar) registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward) registerHostCenterRoutes(engine, services.HostCenter) diff --git a/internal/router/voice_room_rocket_routes.go b/internal/router/voice_room_rocket_routes.go new file mode 100644 index 0000000..97b0fd5 --- /dev/null +++ b/internal/router/voice_room_rocket_routes.go @@ -0,0 +1,223 @@ +package router + +import ( + "net/http" + "strconv" + "strings" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/service/voiceroomrocket" + + "github.com/gin-gonic/gin" +) + +// registerVoiceRoomRocketRoutes 注册语音房火箭 App、后台和内部调试接口。 +func registerVoiceRoomRocketRoutes( + engine *gin.Engine, + cfg config.Config, + javaClient authGateway, + service *voiceroomrocket.Service, +) { + if service == nil { + return + } + + appGroup := engine.Group("/app/voice-room/rocket") + appGroup.Use(authMiddleware(javaClient)) + appGroup.GET("/config", func(c *gin.Context) { + resp, err := service.GetConfig(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.GET("/status", func(c *gin.Context) { + resp, err := service.GetStatus(c.Request.Context(), mustAuthUser(c), parseInt64(c.Query("roomId"))) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.GET("/king", func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "100"))) + level, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("level", "1"))) + roundNo, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("roundNo", "0"))) + resp, err := service.GetKingRanking(c.Request.Context(), mustAuthUser(c), parseInt64(c.Query("roomId")), level, roundNo, cursor, limit) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.GET("/reward-popups", func(c *gin.Context) { + resp, err := service.ListRewardPopups(c.Request.Context(), mustAuthUser(c), parseInt64(c.Query("roomId"))) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.POST("/reward-popups/ack", func(c *gin.Context) { + var req voiceroomrocket.AckRewardPopupsRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, voiceroomrocket.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + if err := service.AckRewardPopups(c.Request.Context(), mustAuthUser(c), req); err != nil { + writeError(c, err) + return + } + writeOK(c, gin.H{"success": true}) + }) + appGroup.GET("/reward-records", func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + resp, err := service.PageRewardRecords(c.Request.Context(), mustAuthUser(c), parseInt64(c.Query("roomId")), cursor, limit) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.GET("/rule", func(c *gin.Context) { + resp, err := service.GetRule(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + adminGroup := engine.Group("/resident-activity/voice-room-rocket") + adminGroup.Use(consoleAuthMiddleware(javaClient)) + adminGroup.GET("/config", func(c *gin.Context) { + resp, err := service.GetAdminConfig(c.Request.Context(), c.Query("sysOrigin")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + adminGroup.POST("/config/save", func(c *gin.Context) { + var req voiceroomrocket.SaveConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, voiceroomrocket.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.SaveAdminConfig(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + adminGroup.GET("/level-configs", func(c *gin.Context) { + resp, err := service.ListLevelConfigs(c.Request.Context(), c.Query("sysOrigin")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + adminGroup.POST("/level-configs/save", func(c *gin.Context) { + var req voiceroomrocket.SaveLevelConfigsRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, voiceroomrocket.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.SaveLevelConfigs(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + adminGroup.GET("/reward-configs", func(c *gin.Context) { + level, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("level", "0"))) + resp, err := service.ListRewardConfigs(c.Request.Context(), c.Query("sysOrigin"), level) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + adminGroup.POST("/reward-configs/save", func(c *gin.Context) { + var req voiceroomrocket.SaveRewardConfigsRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, voiceroomrocket.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.SaveRewardConfigs(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + adminGroup.GET("/launch-records", func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + resp, err := service.PageAdminLaunchRecords(c.Request.Context(), c.Query("sysOrigin"), parseInt64(c.Query("roomId")), cursor, limit) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + adminGroup.GET("/reward-records", func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + resp, err := service.PageAdminRewardRecords( + c.Request.Context(), + c.Query("sysOrigin"), + parseInt64(c.Query("roomId")), + parseInt64(c.Query("userId")), + c.Query("grantStatus"), + cursor, + limit, + ) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + adminGroup.POST("/reward-records/retry", func(c *gin.Context) { + var req struct { + ID int64 `json:"id,string"` + } + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, voiceroomrocket.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + if err := service.RetryReward(c.Request.Context(), req.ID); err != nil { + writeError(c, err) + return + } + writeOK(c, gin.H{"success": true}) + }) + + internalGroup := engine.Group("/internal/voice-room/rocket") + internalGroup.Use(internalSecretMiddleware(cfg.HTTP.InternalCallbackSecret)) + internalGroup.POST("/gift-event", func(c *gin.Context) { + body, err := c.GetRawData() + if err != nil { + writeError(c, voiceroomrocket.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.ProcessGiftPayload(c.Request.Context(), string(body)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) +} + +func parseInt64(value string) int64 { + parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + return parsed +} diff --git a/internal/service/managercenter/service.go b/internal/service/managercenter/service.go index 4d844bd..f19bcbb 100644 --- a/internal/service/managercenter/service.go +++ b/internal/service/managercenter/service.go @@ -13,14 +13,14 @@ import ( "gorm.io/gorm" ) -type administratorRow struct { - ID int64 `gorm:"column:id;primaryKey"` - UserID int64 `gorm:"column:user_id"` - Status bool `gorm:"column:status"` +type teamManagerInfoRow struct { + ID int64 `gorm:"column:id;primaryKey"` + UserID int64 `gorm:"column:user_id"` + Origin string `gorm:"column:sys_origin"` } -func (administratorRow) TableName() string { - return "sys_administrator" +func (teamManagerInfoRow) TableName() string { + return "team_manager_base_info" } type userBaseInfoRow struct { @@ -320,8 +320,8 @@ func (s *Service) ensureManager(ctx context.Context, user AuthUser) error { var count int64 err := s.db.WithContext(ctx). - Table("sys_administrator"). - Where("user_id = ? AND status = ?", user.UserID, true). + Table("team_manager_base_info"). + Where("user_id = ?", user.UserID). Count(&count).Error if err != nil { return serverError("manager_query_failed", err.Error()) diff --git a/internal/service/managercenter/service_test.go b/internal/service/managercenter/service_test.go index 9726eeb..310bce8 100644 --- a/internal/service/managercenter/service_test.go +++ b/internal/service/managercenter/service_test.go @@ -418,7 +418,7 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) { t.Fatalf("open sqlite: %v", err) } if err := db.AutoMigrate( - &administratorRow{}, + &teamManagerInfoRow{}, &userBaseInfoRow{}, &propsSourceRecordRow{}, &propsNobleVIPAbilityRow{}, @@ -438,7 +438,7 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) { func seedManager(t *testing.T, db *gorm.DB, userID int64) { t.Helper() - if err := db.Create(&administratorRow{ID: userID, UserID: userID, Status: true}).Error; err != nil { + if err := db.Create(&teamManagerInfoRow{ID: userID, UserID: userID, Origin: "LIKEI"}).Error; err != nil { t.Fatalf("seed manager: %v", err) } } diff --git a/internal/service/voiceroomrocket/config.go b/internal/service/voiceroomrocket/config.go new file mode 100644 index 0000000..10883ad --- /dev/null +++ b/internal/service/voiceroomrocket/config.go @@ -0,0 +1,421 @@ +package voiceroomrocket + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" +) + +// GetConfig 返回 App 火箭配置、等级资源和当前等级奖励预览。 +func (s *Service) GetConfig(ctx context.Context, user AuthUser) (*ConfigResponse, error) { + return s.getConfigResponse(ctx, user.SysOrigin, 0) +} + +// GetRule 返回火箭规则说明。 +func (s *Service) GetRule(ctx context.Context, user AuthUser) (json.RawMessage, error) { + snapshot, err := s.loadConfig(ctx, user.SysOrigin) + if err != nil { + return nil, err + } + if strings.TrimSpace(snapshot.RuleText) == "" { + return json.RawMessage(`{}`), nil + } + return json.RawMessage(snapshot.RuleText), nil +} + +// GetAdminConfig 返回后台配置总览。 +func (s *Service) GetAdminConfig(ctx context.Context, sysOrigin string) (*ConfigResponse, error) { + return s.getConfigResponse(ctx, sysOrigin, 0) +} + +// SaveAdminConfig 保存后台系统配置。 +func (s *Service) SaveAdminConfig(ctx context.Context, req SaveConfigRequest) (*ConfigResponse, error) { + sysOrigin := s.normalizeSysOrigin(req.SysOrigin) + timezone := normalizeTimezone(req.Timezone) + if _, err := time.LoadLocation(timezone); err != nil { + return nil, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error()) + } + maxLevel := req.MaxLevel + if maxLevel <= 0 { + maxLevel = defaultMaxLevel + } + rankingLimit := req.RankingTopLimit + if rankingLimit <= 0 || rankingLimit > 100 { + rankingLimit = defaultRankingTopLimit + } + keepDays := req.RewardRecordKeepDays + if keepDays <= 0 { + keepDays = defaultRewardRecordKeepDays + } + broadcastSeconds := req.BroadcastDurationSeconds + if broadcastSeconds <= 0 { + broadcastSeconds = defaultBroadcastDurationSeconds + } + inRoomDelay := req.InRoomRewardDelaySeconds + if inRoomDelay <= 0 { + inRoomDelay = defaultInRoomRewardDelaySeconds + } + energyRule := strings.TrimSpace(string(req.EnergyRuleJSON)) + if energyRule != "" && energyRule != "null" && !json.Valid([]byte(energyRule)) { + return nil, NewAppError(http.StatusBadRequest, "invalid_energy_rule_json", "energyRuleJson must be valid JSON") + } + ruleText := strings.TrimSpace(string(req.RuleText)) + if ruleText != "" && ruleText != "null" && !json.Valid([]byte(ruleText)) { + return nil, NewAppError(http.StatusBadRequest, "invalid_rule_text", "ruleText must be valid JSON") + } + + if err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + now := time.Now() + var row model.VoiceRoomRocketConfig + err := tx.Where("sys_origin = ?", sysOrigin).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + id, idErr := utils.NextID() + if idErr != nil { + return idErr + } + row = model.VoiceRoomRocketConfig{ + ID: id, + SysOrigin: sysOrigin, + CreateTime: now, + } + } else if err != nil { + return err + } + row.Enabled = req.Enabled + row.Timezone = timezone + row.MaxLevel = maxLevel + row.RankingTopLimit = rankingLimit + row.RewardRecordKeepDays = keepDays + row.BroadcastDurationSeconds = broadcastSeconds + row.InRoomRewardDelaySeconds = inRoomDelay + row.EnergyRuleJSON = nullJSONToEmpty(energyRule) + row.RuleText = nullJSONToEmpty(ruleText) + row.UpdateTime = now + if row.CreateTime.IsZero() { + row.CreateTime = now + } + return tx.Save(&row).Error + }); err != nil { + return nil, err + } + return s.GetAdminConfig(ctx, sysOrigin) +} + +// ListLevelConfigs 返回后台等级配置。 +func (s *Service) ListLevelConfigs(ctx context.Context, sysOrigin string) ([]LevelConfigPayload, error) { + rows, err := s.loadLevelConfigs(ctx, s.normalizeSysOrigin(sysOrigin), false) + if err != nil { + return nil, err + } + result := make([]LevelConfigPayload, 0, len(rows)) + for _, row := range rows { + result = append(result, levelPayload(row)) + } + return result, nil +} + +// SaveLevelConfigs 批量保存后台等级配置。 +func (s *Service) SaveLevelConfigs(ctx context.Context, req SaveLevelConfigsRequest) ([]LevelConfigPayload, error) { + sysOrigin := s.normalizeSysOrigin(req.SysOrigin) + if len(req.Levels) == 0 { + return nil, NewAppError(http.StatusBadRequest, "missing_level_configs", "levels is required") + } + seen := map[int]struct{}{} + if err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + now := time.Now() + for _, item := range req.Levels { + if item.Level <= 0 || item.Level > defaultMaxLevel { + return NewAppError(http.StatusBadRequest, "invalid_level", "level must be between 1 and 6") + } + if _, ok := seen[item.Level]; ok { + return NewAppError(http.StatusBadRequest, "duplicate_level", "level must be unique") + } + seen[item.Level] = struct{}{} + if item.NeedEnergy <= 0 { + return NewAppError(http.StatusBadRequest, "invalid_need_energy", "needEnergy must be positive") + } + threshold := strings.TrimSpace(item.ShakeThresholdPercent) + if threshold == "" { + threshold = "0.0000" + } + var row model.VoiceRoomRocketLevelConfig + err := tx.Where("sys_origin = ? AND level = ?", sysOrigin, item.Level).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + id, idErr := utils.NextID() + if idErr != nil { + return idErr + } + row = model.VoiceRoomRocketLevelConfig{ + ID: id, + SysOrigin: sysOrigin, + Level: item.Level, + CreateTime: now, + } + } else if err != nil { + return err + } + row.NeedEnergy = item.NeedEnergy + row.ShakeThresholdPercent = threshold + row.RocketIconURL = strings.TrimSpace(item.RocketIconURL) + row.RocketAnimationURL = strings.TrimSpace(item.RocketAnimationURL) + row.ProgressBarURL = strings.TrimSpace(item.ProgressBarURL) + row.Enabled = item.Enabled + row.Sort = item.Sort + row.UpdateTime = now + if row.CreateTime.IsZero() { + row.CreateTime = now + } + if err := tx.Save(&row).Error; err != nil { + return err + } + } + return nil + }); err != nil { + return nil, err + } + return s.ListLevelConfigs(ctx, sysOrigin) +} + +// ListRewardConfigs 返回后台奖励配置。 +func (s *Service) ListRewardConfigs(ctx context.Context, sysOrigin string, level int) ([]RewardConfigPayload, error) { + query := s.repo.DB.WithContext(ctx). + Where("sys_origin = ?", s.normalizeSysOrigin(sysOrigin)). + Order("level ASC, reward_scene ASC, sort ASC, id ASC") + if level > 0 { + query = query.Where("level = ?", level) + } + var rows []model.VoiceRoomRocketRewardConfig + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + result := make([]RewardConfigPayload, 0, len(rows)) + for _, row := range rows { + result = append(result, rewardPayload(row)) + } + return result, nil +} + +// SaveRewardConfigs 批量保存后台奖励配置。 +func (s *Service) SaveRewardConfigs(ctx context.Context, req SaveRewardConfigsRequest) ([]RewardConfigPayload, error) { + sysOrigin := s.normalizeSysOrigin(req.SysOrigin) + if len(req.Rewards) == 0 { + return nil, NewAppError(http.StatusBadRequest, "missing_reward_configs", "rewards is required") + } + if err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + now := time.Now() + for _, item := range req.Rewards { + scene := strings.ToUpper(strings.TrimSpace(item.RewardScene)) + if scene != rewardSceneTop1 && scene != rewardSceneIgnite && scene != rewardSceneInRoom { + return NewAppError(http.StatusBadRequest, "invalid_reward_scene", "rewardScene is invalid") + } + if item.Level <= 0 || item.Level > defaultMaxLevel { + return NewAppError(http.StatusBadRequest, "invalid_level", "level must be between 1 and 6") + } + rewardType := strings.ToUpper(strings.TrimSpace(item.RewardType)) + if rewardType == "" { + return NewAppError(http.StatusBadRequest, "missing_reward_type", "rewardType is required") + } + if !isSupportedRewardType(rewardType) { + return NewAppError(http.StatusBadRequest, "invalid_reward_type", "rewardType is invalid") + } + rewardName := strings.TrimSpace(item.RewardName) + if rewardName == "" { + return NewAppError(http.StatusBadRequest, "missing_reward_name", "rewardName is required") + } + amount := item.RewardAmount + if amount <= 0 { + amount = 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 { + return err + } + } else { + id, idErr := utils.NextID() + if idErr != nil { + return idErr + } + row = model.VoiceRoomRocketRewardConfig{ + ID: id, + SysOrigin: sysOrigin, + CreateTime: now, + } + } + row.Level = item.Level + row.RewardScene = scene + row.RewardType = rewardType + row.RewardItemID = item.RewardItemID + row.RewardName = rewardName + row.RewardCover = strings.TrimSpace(item.RewardCover) + row.RewardAmount = amount + row.ExpireDays = item.ExpireDays + row.Weight = item.Weight + row.Sort = item.Sort + row.Enabled = item.Enabled + row.UpdateTime = now + if row.CreateTime.IsZero() { + row.CreateTime = now + } + if err := tx.Save(&row).Error; err != nil { + return err + } + } + return nil + }); err != nil { + return nil, err + } + return s.ListRewardConfigs(ctx, sysOrigin, 0) +} + +func (s *Service) getConfigResponse(ctx context.Context, sysOrigin string, currentLevel int) (*ConfigResponse, error) { + snapshot, err := s.loadConfig(ctx, sysOrigin) + if err != nil { + return nil, err + } + levels, err := s.loadLevelConfigs(ctx, snapshot.SysOrigin, true) + if err != nil { + return nil, err + } + rewardLevel := currentLevel + if rewardLevel <= 0 { + rewardLevel = 1 + } + rewards, err := s.loadRewardConfigs(ctx, snapshot.SysOrigin, rewardLevel) + if err != nil { + return nil, err + } + return configResponse(snapshot, levels, rewards), nil +} + +func (s *Service) loadConfig(ctx context.Context, sysOrigin string) (configSnapshot, error) { + sysOrigin = s.normalizeSysOrigin(sysOrigin) + var row model.VoiceRoomRocketConfig + err := s.repo.DB.WithContext(ctx).Where("sys_origin = ?", sysOrigin).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return configSnapshot{ + SysOrigin: sysOrigin, + Enabled: false, + Timezone: normalizeTimezone(s.cfg.VoiceRoomRocket.Timezone), + MaxLevel: defaultMaxLevel, + RankingTopLimit: defaultRankingTopLimit, + RewardRecordKeepDays: defaultRewardRecordKeepDays, + BroadcastDurationSeconds: defaultBroadcastDurationSeconds, + InRoomRewardDelaySeconds: defaultInRoomRewardDelaySeconds, + }, nil + } + if err != nil { + return configSnapshot{}, err + } + return configSnapshot{ + Configured: true, + ID: row.ID, + SysOrigin: row.SysOrigin, + Enabled: row.Enabled, + Timezone: normalizeTimezone(row.Timezone), + MaxLevel: positiveOrDefault(row.MaxLevel, defaultMaxLevel), + RankingTopLimit: clampPositive(row.RankingTopLimit, defaultRankingTopLimit, 100), + RewardRecordKeepDays: positiveOrDefault(row.RewardRecordKeepDays, defaultRewardRecordKeepDays), + BroadcastDurationSeconds: positiveOrDefault(row.BroadcastDurationSeconds, defaultBroadcastDurationSeconds), + InRoomRewardDelaySeconds: positiveOrDefault(row.InRoomRewardDelaySeconds, defaultInRoomRewardDelaySeconds), + EnergyRuleJSON: row.EnergyRuleJSON, + RuleText: row.RuleText, + UpdateTime: row.UpdateTime, + }, nil +} + +func (s *Service) loadLevelConfigs(ctx context.Context, sysOrigin string, enabledOnly bool) ([]model.VoiceRoomRocketLevelConfig, error) { + query := s.repo.DB.WithContext(ctx). + Where("sys_origin = ?", s.normalizeSysOrigin(sysOrigin)). + Order("sort ASC, level ASC") + if enabledOnly { + query = query.Where("enabled = ?", true) + } + var rows []model.VoiceRoomRocketLevelConfig + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + if len(rows) == 0 && enabledOnly { + return defaultLevelRows(s.normalizeSysOrigin(sysOrigin)), nil + } + return rows, nil +} + +func (s *Service) loadLevelConfig(ctx context.Context, sysOrigin string, level int) (model.VoiceRoomRocketLevelConfig, error) { + var row model.VoiceRoomRocketLevelConfig + err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND level = ? AND enabled = ?", s.normalizeSysOrigin(sysOrigin), level, true). + First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + defaults := defaultLevelRows(s.normalizeSysOrigin(sysOrigin)) + for _, item := range defaults { + if item.Level == level { + return item, nil + } + } + return model.VoiceRoomRocketLevelConfig{}, NewAppError(http.StatusBadRequest, "rocket_level_not_configured", "rocket level is not configured") + } + return row, err +} + +func (s *Service) loadRewardConfigs(ctx context.Context, sysOrigin string, level int) ([]model.VoiceRoomRocketRewardConfig, error) { + query := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND enabled = ?", s.normalizeSysOrigin(sysOrigin), true). + Order("reward_scene ASC, sort ASC, id ASC") + if level > 0 { + query = query.Where("level = ?", level) + } + var rows []model.VoiceRoomRocketRewardConfig + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +func defaultLevelRows(sysOrigin string) []model.VoiceRoomRocketLevelConfig { + rows := make([]model.VoiceRoomRocketLevelConfig, 0, defaultMaxLevel) + for i := 1; i <= defaultMaxLevel; i++ { + rows = append(rows, model.VoiceRoomRocketLevelConfig{ + SysOrigin: sysOrigin, + Level: i, + NeedEnergy: int64(i * 100), + ShakeThresholdPercent: "95.0000", + Enabled: true, + Sort: i, + }) + } + return rows +} + +func positiveOrDefault(value int, fallback int) int { + if value <= 0 { + return fallback + } + return value +} + +func clampPositive(value int, fallback int, maxValue int) int { + if value <= 0 { + value = fallback + } + if maxValue > 0 && value > maxValue { + return maxValue + } + return value +} + +func nullJSONToEmpty(value string) string { + value = strings.TrimSpace(value) + if value == "" || value == "null" { + return "{}" + } + return value +} diff --git a/internal/service/voiceroomrocket/energy.go b/internal/service/voiceroomrocket/energy.go new file mode 100644 index 0000000..3fcfc7b --- /dev/null +++ b/internal/service/voiceroomrocket/energy.go @@ -0,0 +1,190 @@ +package voiceroomrocket + +import ( + "encoding/json" + "math" + "strings" +) + +type energyRuleConfig struct { + GoldRatio float64 `json:"goldRatio"` + AllowedGiftTypes []string `json:"allowedGiftTypes"` + AllowedGiftTabs []string `json:"allowedGiftTabs"` + BlockedGiftTypes []string `json:"blockedGiftTypes"` + BlockedGiftTabs []string `json:"blockedGiftTabs"` + GiftRules []giftEnergyRule `json:"giftRules"` +} + +type giftEnergyRule struct { + GiftID flexibleInt64 `json:"giftId"` + GiftIDs []flexibleInt64 `json:"giftIds"` + Enabled *bool `json:"enabled"` + Allow *bool `json:"allow"` + Energy int64 `json:"energy"` + EnergyPerUnit int64 `json:"energyPerUnit"` + Ratio float64 `json:"ratio"` +} + +func resolveGiftEnergy(event giftEvent, snapshot configSnapshot) (int64, string) { + rule, err := parseEnergyRule(snapshot.EnergyRuleJSON) + if err != nil { + return 0, "invalid_energy_rule" + } + base := calculateBaseGiftEnergy(event) + if base <= 0 { + return 0, "zero_energy" + } + if custom, ok, reason := applyGiftIDRule(event, base, rule); ok || reason != "" { + return custom, reason + } + if !isAllowedByGiftTypeAndTab(event, rule) { + return 0, "unsupported_gift" + } + ratio := rule.GoldRatio + if ratio <= 0 { + ratio = 1 + } + energy := int64(math.Floor(float64(base) * ratio)) + if energy <= 0 { + return 0, "zero_energy" + } + return energy, "" +} + +func parseEnergyRule(raw string) (energyRuleConfig, error) { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "null" || raw == "{}" { + return energyRuleConfig{}, nil + } + var rule energyRuleConfig + if err := json.Unmarshal([]byte(raw), &rule); err != nil { + return energyRuleConfig{}, err + } + return rule, nil +} + +func applyGiftIDRule(event giftEvent, base int64, rule energyRuleConfig) (int64, bool, string) { + giftID := event.GiftConfig.ID.Int64() + if giftID <= 0 || len(rule.GiftRules) == 0 { + return 0, false, "" + } + for _, item := range rule.GiftRules { + if !giftRuleMatches(item, giftID) { + continue + } + if item.Enabled != nil && !*item.Enabled { + return 0, true, "gift_rule_disabled" + } + if item.Allow != nil && !*item.Allow { + return 0, true, "gift_rule_blocked" + } + quantity := normalizedGiftQuantity(event) + acceptUserSize := normalizedAcceptUserSize(event) + if item.Energy > 0 { + return item.Energy * quantity * acceptUserSize, true, "" + } + if item.EnergyPerUnit > 0 { + return item.EnergyPerUnit * quantity * acceptUserSize, true, "" + } + if item.Ratio > 0 { + energy := int64(math.Floor(float64(base) * item.Ratio)) + if energy <= 0 { + return 0, true, "zero_energy" + } + return energy, true, "" + } + return base, true, "" + } + return 0, false, "" +} + +func giftRuleMatches(rule giftEnergyRule, giftID int64) bool { + if rule.GiftID.Int64() == giftID { + return true + } + for _, id := range rule.GiftIDs { + if id.Int64() == giftID { + return true + } + } + return false +} + +func isAllowedByGiftTypeAndTab(event giftEvent, rule energyRuleConfig) bool { + giftType := normalizeGiftToken(event.GiftConfig.Type) + giftTab := normalizeGiftToken(event.GiftConfig.GiftTab) + if containsGiftToken(rule.BlockedGiftTypes, giftType) || containsGiftToken(rule.BlockedGiftTabs, giftTab) { + return false + } + if len(rule.AllowedGiftTypes) > 0 && !containsGiftToken(rule.AllowedGiftTypes, giftType) { + return false + } + if len(rule.AllowedGiftTabs) > 0 && !containsGiftToken(rule.AllowedGiftTabs, giftTab) { + return false + } + if isDefaultBlockedGiftToken(giftType) || isDefaultBlockedGiftToken(giftTab) { + return false + } + if giftType == "" { + return false + } + return giftType == rewardTypeGold +} + +func containsGiftToken(list []string, token string) bool { + token = normalizeGiftToken(token) + if token == "" { + return false + } + for _, item := range list { + if normalizeGiftToken(item) == token { + return true + } + } + return false +} + +func isDefaultBlockedGiftToken(token string) bool { + switch normalizeGiftToken(token) { + case "BACKPACK", "BAG", "DIAMOND", "LUCKY", "ACTIVITY", "CP", "MAGIC", "FREE": + return true + default: + return false + } +} + +func normalizeGiftToken(value string) string { + return strings.ToUpper(strings.TrimSpace(value)) +} + +func calculateBaseGiftEnergy(event giftEvent) int64 { + if actual := event.GiftValue.ActualAmount.Int64(); actual > 0 { + return actual + } + quantity := normalizedGiftQuantity(event) + giftCandy := event.GiftConfig.GiftCandy.Int64() + if giftCandy <= 0 { + return 0 + } + return giftCandy * quantity * normalizedAcceptUserSize(event) +} + +func calculateRawEnergy(event giftEvent) int64 { + return calculateBaseGiftEnergy(event) +} + +func normalizedGiftQuantity(event giftEvent) int64 { + quantity := event.Quantity.Int64() + if quantity <= 0 { + return 1 + } + return quantity +} + +func normalizedAcceptUserSize(event giftEvent) int64 { + acceptUserSize := int64(countDistinctAcceptUsers(event.Accepts)) + if acceptUserSize <= 0 { + return 1 + } + return acceptUserSize +} diff --git a/internal/service/voiceroomrocket/event.go b/internal/service/voiceroomrocket/event.go new file mode 100644 index 0000000..3b3c972 --- /dev/null +++ b/internal/service/voiceroomrocket/event.go @@ -0,0 +1,513 @@ +package voiceroomrocket + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +type launchResult struct { + Launched bool + Launch model.VoiceRoomRocketLaunch +} + +// ProcessGiftPayload 解析送礼消息原文并按火箭规则加能量。 +func (s *Service) ProcessGiftPayload(ctx context.Context, payload string) (*ProcessGiftResponse, error) { + payload = strings.TrimSpace(payload) + if payload == "" { + return &ProcessGiftResponse{Processed: false, Reason: "empty_payload"}, nil + } + event, err := decodeGiftEventPayload(payload, s.cfg.VoiceRoomRocket.MQ.Tag) + if err != nil { + return nil, err + } + return s.ProcessGiftEvent(ctx, event, payload) +} + +// ProcessGiftEvent 处理一条已经解析的送礼事件。 +func (s *Service) ProcessGiftEvent(ctx context.Context, event giftEvent, rawPayload string) (*ProcessGiftResponse, error) { + trackID := strings.TrimSpace(event.TrackID.String()) + sysOrigin := s.normalizeSysOrigin(event.SysOrigin) + roomID := event.RoomID.Int64() + sendUserID := event.SendUserID.Int64() + if trackID == "" || roomID <= 0 || sendUserID <= 0 { + return &ProcessGiftResponse{Processed: false, Reason: "missing_required_fields"}, nil + } + eventTime := resolveMillisTime(event.CreateTime.Int64(), time.Now()) + snapshot, err := s.loadConfig(ctx, sysOrigin) + if err != nil { + return nil, err + } + if !snapshot.Enabled { + return &ProcessGiftResponse{Processed: false, Reason: "rocket_disabled"}, nil + } + rawEnergy, ignoreReason := resolveGiftEnergy(event, snapshot) + if rawEnergy <= 0 { + if ignoreReason == "" { + ignoreReason = "zero_energy" + } + if err := s.createIgnoredGiftLog(ctx, sysOrigin, trackID, roomID, sendUserID, event, rawPayload, eventTime, ignoreReason); err != nil { + return nil, err + } + return &ProcessGiftResponse{Processed: false, Reason: ignoreReason}, nil + } + + unlock, err := s.acquireRoomLock(ctx, sysOrigin, roomID) + if err != nil { + return nil, err + } + if unlock != nil { + defer unlock() + } + + local := resolveLocation(snapshot.Timezone) + day := dayKey(eventTime, local) + resp := &ProcessGiftResponse{ + Processed: false, + RoomID: roomID, + DayKey: day, + } + var result launchResult + err = s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + statusRow, err := s.loadOrInitStatus(ctx, tx, sysOrigin, roomID, day, eventTime.In(local)) + if err != nil { + return err + } + resp.RoundNo = statusRow.RoundNo + resp.Level = statusRow.CurrentLevel + + logID, err := utils.NextID() + if err != nil { + return err + } + giftID := event.GiftConfig.ID.Int64() + var giftIDPtr *int64 + if giftID > 0 { + giftIDPtr = &giftID + } + remain := statusRow.NeedEnergy - statusRow.CurrentEnergy + if remain < 0 { + remain = 0 + } + accepted := rawEnergy + if accepted > remain { + accepted = remain + } + if accepted <= 0 { + record := model.VoiceRoomRocketGiftEventLog{ + ID: logID, + SysOrigin: sysOrigin, + TrackID: trackID, + RoomID: roomID, + SendUserID: sendUserID, + GiftID: giftIDPtr, + GiftType: strings.TrimSpace(event.GiftConfig.Type), + GiftTab: strings.TrimSpace(event.GiftConfig.GiftTab), + RawEnergy: rawEnergy, + AcceptedEnergy: 0, + DayKey: statusRow.DayKey, + RoundNo: statusRow.RoundNo, + Level: statusRow.CurrentLevel, + Status: eventStatusIgnored, + RawPayload: truncateString(rawPayload, 16*1024*1024), + ErrorMessage: "level_already_full", + EventTime: eventTime, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + if err := tx.Create(&record).Error; err != nil { + if isDuplicateKeyErr(err) { + resp.Reason = "duplicate_event" + return nil + } + return err + } + resp.Reason = "level_already_full" + return nil + } + + now := time.Now() + record := model.VoiceRoomRocketGiftEventLog{ + ID: logID, + SysOrigin: sysOrigin, + TrackID: trackID, + RoomID: roomID, + SendUserID: sendUserID, + GiftID: giftIDPtr, + GiftType: strings.TrimSpace(event.GiftConfig.Type), + GiftTab: strings.TrimSpace(event.GiftConfig.GiftTab), + RawEnergy: rawEnergy, + AcceptedEnergy: accepted, + DayKey: statusRow.DayKey, + RoundNo: statusRow.RoundNo, + Level: statusRow.CurrentLevel, + Status: eventStatusSuccess, + RawPayload: truncateString(rawPayload, 16*1024*1024), + EventTime: eventTime, + CreateTime: now, + UpdateTime: now, + } + if err := tx.Create(&record).Error; err != nil { + if isDuplicateKeyErr(err) { + resp.Reason = "duplicate_event" + return nil + } + return err + } + + if err := s.addContribution(ctx, tx, statusRow, sendUserID, accepted, eventTime); err != nil { + return err + } + + newEnergy := statusRow.CurrentEnergy + accepted + statusUpdates := map[string]any{ + "current_energy": newEnergy, + "last_event_time": eventTime, + "update_time": now, + } + if newEnergy >= statusRow.NeedEnergy { + statusUpdates["status"] = statusLaunching + } + if err := tx.Model(&model.VoiceRoomRocketStatus{}). + Where("id = ?", statusRow.ID). + Updates(statusUpdates).Error; err != nil { + return err + } + statusRow.CurrentEnergy = newEnergy + statusRow.LastEventTime = &eventTime + resp.Processed = true + resp.AcceptedEnergy = accepted + + if err := s.updateRedisRank(ctx, statusRow, sendUserID, accepted); err != nil { + return err + } + if newEnergy >= statusRow.NeedEnergy { + launch, err := s.launchLocked(ctx, tx, statusRow, sendUserID, now) + if err != nil { + return err + } + result = launch + resp.Launched = launch.Launched + resp.LaunchNo = launch.Launch.LaunchNo + } + return nil + }) + if err != nil { + return nil, err + } + if result.Launched { + _ = s.notifyLaunch(context.Background(), result.Launch) + } else if resp.Processed { + _ = s.notifyStatusUpdate(context.Background(), sysOrigin, roomID) + } + return resp, nil +} + +func (s *Service) createIgnoredGiftLog(ctx context.Context, sysOrigin, trackID string, roomID, sendUserID int64, event giftEvent, rawPayload string, eventTime time.Time, reason string) error { + id, err := utils.NextID() + if err != nil { + return err + } + giftID := event.GiftConfig.ID.Int64() + var giftIDPtr *int64 + if giftID > 0 { + giftIDPtr = &giftID + } + now := time.Now() + record := model.VoiceRoomRocketGiftEventLog{ + ID: id, + SysOrigin: sysOrigin, + TrackID: trackID, + RoomID: roomID, + SendUserID: sendUserID, + GiftID: giftIDPtr, + GiftType: strings.TrimSpace(event.GiftConfig.Type), + GiftTab: strings.TrimSpace(event.GiftConfig.GiftTab), + Status: eventStatusIgnored, + RawPayload: truncateString(rawPayload, 16*1024*1024), + ErrorMessage: reason, + EventTime: eventTime, + CreateTime: now, + UpdateTime: now, + } + err = s.repo.DB.WithContext(ctx).Create(&record).Error + if err != nil && !isDuplicateKeyErr(err) { + return err + } + return nil +} + +func (s *Service) addContribution(ctx context.Context, tx *gorm.DB, statusRow model.VoiceRoomRocketStatus, userID int64, accepted int64, eventTime time.Time) error { + var row model.VoiceRoomRocketContribution + err := tx.WithContext(ctx). + Where( + "sys_origin = ? AND room_id = ? AND day_key = ? AND round_no = ? AND level = ? AND user_id = ?", + statusRow.SysOrigin, statusRow.RoomID, statusRow.DayKey, statusRow.RoundNo, statusRow.CurrentLevel, userID, + ). + First(&row).Error + now := time.Now() + if errors.Is(err, gorm.ErrRecordNotFound) { + id, idErr := utils.NextID() + if idErr != nil { + return idErr + } + row = model.VoiceRoomRocketContribution{ + ID: id, + SysOrigin: statusRow.SysOrigin, + RoomID: statusRow.RoomID, + DayKey: statusRow.DayKey, + RoundNo: statusRow.RoundNo, + Level: statusRow.CurrentLevel, + UserID: userID, + ScoreEnergy: accepted, + GiftCount: 1, + FirstContributeTime: eventTime, + LastContributeTime: eventTime, + CreateTime: now, + UpdateTime: now, + } + return tx.Create(&row).Error + } + if err != nil { + return err + } + return tx.Model(&model.VoiceRoomRocketContribution{}). + Where("id = ?", row.ID). + Updates(map[string]any{ + "score_energy": gorm.Expr("score_energy + ?", accepted), + "gift_count": gorm.Expr("gift_count + 1"), + "last_contribute_time": eventTime, + "update_time": now, + }).Error +} + +func (s *Service) launchLocked(ctx context.Context, tx *gorm.DB, statusRow model.VoiceRoomRocketStatus, igniteUserID int64, now time.Time) (launchResult, error) { + top1UserID, err := s.resolveTop1UserID(ctx, tx, statusRow) + if err != nil { + return launchResult{}, err + } + launchID, err := utils.NextID() + if err != nil { + return launchResult{}, err + } + launchNo := fmt.Sprintf("VRR%d", launchID) + roomAccount := s.resolveRoomAccount(ctx, statusRow.RoomID) + launch := model.VoiceRoomRocketLaunch{ + ID: launchID, + LaunchNo: launchNo, + SysOrigin: statusRow.SysOrigin, + RoomID: statusRow.RoomID, + RoomAccount: roomAccount, + DayKey: statusRow.DayKey, + RoundNo: statusRow.RoundNo, + Level: statusRow.CurrentLevel, + FinalEnergy: statusRow.NeedEnergy, + Top1UserID: top1UserID, + IgniteUserID: &igniteUserID, + InRoomSnapshotStatus: inRoomSnapshotPending, + LaunchTime: now, + CreateTime: now, + UpdateTime: now, + } + if err := tx.Create(&launch).Error; err != nil { + if isDuplicateKeyErr(err) { + return launchResult{Launched: false}, nil + } + return launchResult{}, err + } + reachTime := now + if err := tx.Model(&model.VoiceRoomRocketLevelRound{}). + Where( + "sys_origin = ? AND room_id = ? AND day_key = ? AND round_no = ? AND level = ?", + statusRow.SysOrigin, statusRow.RoomID, statusRow.DayKey, statusRow.RoundNo, statusRow.CurrentLevel, + ). + Updates(map[string]any{ + "final_energy": statusRow.NeedEnergy, + "status": levelRoundStatusLaunched, + "top1_user_id": top1UserID, + "ignite_user_id": igniteUserID, + "reach_time": reachTime, + "launch_time": now, + "update_time": now, + }).Error; err != nil { + return launchResult{}, err + } + if err := s.createImmediateRewardRecords(ctx, tx, launch, top1UserID, igniteUserID, now); err != nil { + return launchResult{}, err + } + if err := s.enqueueInRoomReward(ctx, launch, now); err != nil { + return launchResult{}, err + } + + nextRound := statusRow.RoundNo + nextLevel := statusRow.CurrentLevel + 1 + if nextLevel > defaultMaxLevel { + nextRound++ + nextLevel = 1 + } + nextLevelConfig, err := s.loadLevelConfig(ctx, statusRow.SysOrigin, nextLevel) + if err != nil { + return launchResult{}, err + } + if err := tx.Model(&model.VoiceRoomRocketStatus{}). + Where("id = ?", statusRow.ID). + Updates(map[string]any{ + "round_no": nextRound, + "current_level": nextLevel, + "current_energy": 0, + "need_energy": nextLevelConfig.NeedEnergy, + "status": statusCharging, + "level_start_time": now, + "update_time": now, + }).Error; err != nil { + return launchResult{}, err + } + if err := s.ensureLevelRound(ctx, tx, statusRow.SysOrigin, statusRow.RoomID, statusRow.DayKey, nextRound, nextLevel, nextLevelConfig.NeedEnergy, now); err != nil { + return launchResult{}, err + } + return launchResult{Launched: true, Launch: launch}, nil +} + +func (s *Service) resolveTop1UserID(ctx context.Context, tx *gorm.DB, statusRow model.VoiceRoomRocketStatus) (*int64, error) { + var row model.VoiceRoomRocketContribution + err := tx.WithContext(ctx). + Where( + "sys_origin = ? AND room_id = ? AND day_key = ? AND round_no = ? AND level = ?", + statusRow.SysOrigin, statusRow.RoomID, statusRow.DayKey, statusRow.RoundNo, statusRow.CurrentLevel, + ). + Order("score_energy DESC, last_contribute_time ASC, user_id ASC"). + First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row.UserID, nil +} + +func (s *Service) acquireRoomLock(ctx context.Context, sysOrigin string, roomID int64) (func(), error) { + if s.repo.Redis == nil { + return nil, nil + } + ttl := time.Duration(s.cfg.VoiceRoomRocket.RoomLockTTLSeconds) * time.Second + if ttl <= 0 { + ttl = defaultRoomLockTTL + } + key := roomLockKey(sysOrigin, roomID) + token := strconv.FormatInt(time.Now().UnixNano(), 10) + for i := 0; i < 20; i++ { + ok, err := s.repo.Redis.SetNX(ctx, key, token, ttl).Result() + if err != nil { + return nil, NewAppError(http.StatusServiceUnavailable, "rocket_lock_failed", err.Error()) + } + if ok { + return func() { _ = s.releaseRoomLock(context.Background(), key, token) }, nil + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(50 * time.Millisecond): + } + } + return nil, NewAppError(http.StatusConflict, "rocket_room_busy", "room rocket is busy") +} + +func (s *Service) releaseRoomLock(ctx context.Context, key string, token string) error { + if s.repo.Redis == nil { + return nil + } + const script = ` +if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) +end +return 0` + return s.repo.Redis.Eval(ctx, script, []string{key}, token).Err() +} + +func (s *Service) updateRedisRank(ctx context.Context, statusRow model.VoiceRoomRocketStatus, userID int64, accepted int64) error { + if s.repo.Redis == nil || accepted <= 0 { + return nil + } + return s.repo.Redis.ZIncrBy(ctx, rankRedisKey(statusRow.SysOrigin, statusRow.RoomID, statusRow.DayKey, statusRow.RoundNo, statusRow.CurrentLevel), float64(accepted), strconv.FormatInt(userID, 10)).Err() +} + +func (s *Service) enqueueInRoomReward(ctx context.Context, launch model.VoiceRoomRocketLaunch, now time.Time) error { + if s.repo.Redis == nil { + return nil + } + delaySeconds := defaultInRoomRewardDelaySeconds + if snapshot, err := s.loadConfig(ctx, launch.SysOrigin); err == nil && snapshot.InRoomRewardDelaySeconds > 0 { + delaySeconds = snapshot.InRoomRewardDelaySeconds + } + due := now.Add(time.Duration(delaySeconds) * time.Second) + key := strings.TrimSpace(s.cfg.VoiceRoomRocket.InRoomRewardDueZSetKey) + if key == "" { + key = "voice_room:rocket:in_room_due" + } + return s.repo.Redis.ZAdd(ctx, key, redis.Z{ + Score: float64(due.Unix()), + Member: launch.LaunchNo, + }).Err() +} + +func decodeGiftEventPayload(payload string, expectedTag string) (giftEvent, error) { + var event giftEvent + if err := json.Unmarshal([]byte(payload), &event); err != nil { + return event, err + } + if event.hasBusinessFields() { + return event, nil + } + var envelope messageEventEnvelope + if err := json.Unmarshal([]byte(payload), &envelope); err != nil { + return event, err + } + if strings.TrimSpace(expectedTag) != "" && + strings.TrimSpace(envelope.Tag) != "" && + strings.TrimSpace(expectedTag) != strings.TrimSpace(envelope.Tag) { + return giftEvent{}, nil + } + bodyPayload, ok, err := envelope.bodyPayload() + if err != nil || !ok { + return event, err + } + if err := json.Unmarshal([]byte(bodyPayload), &event); err != nil { + return event, err + } + return event, nil +} + +func (e messageEventEnvelope) bodyPayload() (string, bool, error) { + raw := strings.TrimSpace(string(e.Body)) + if raw == "" || raw == "null" { + return "", false, nil + } + if strings.HasPrefix(raw, "\"") { + var body string + if err := json.Unmarshal(e.Body, &body); err != nil { + return "", false, err + } + body = strings.TrimSpace(body) + return body, body != "", nil + } + return raw, true, nil +} + +func (e giftEvent) hasBusinessFields() bool { + return strings.TrimSpace(e.TrackID.String()) != "" || + e.SendUserID.Int64() > 0 || + e.RoomID.Int64() > 0 || + e.GiftConfig.ID.Int64() > 0 +} diff --git a/internal/service/voiceroomrocket/event_test.go b/internal/service/voiceroomrocket/event_test.go new file mode 100644 index 0000000..237edc1 --- /dev/null +++ b/internal/service/voiceroomrocket/event_test.go @@ -0,0 +1,471 @@ +package voiceroomrocket + +import ( + "context" + "strconv" + "testing" + "time" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestProcessGiftPayloadDropsOverflowAndLaunchesOnce(t *testing.T) { + service, db := newTestService(t) + ctx := context.Background() + now := time.Now() + seedRocketConfig(t, db) + seedLevelConfigs(t, db, []int64{100, 200, 300, 400, 500, 600}) + seedRewardConfig(t, db, 1, rewardSceneTop1, "GOLD", 10) + seedRewardConfig(t, db, 1, rewardSceneIgnite, "GOLD", 5) + + firstPayload := `{ + "trackId":"track-1", + "sysOrigin":"LIKEI", + "sendUserId":"1001", + "roomId":"9001", + "quantity":1, + "giftConfig":{"id":"11","giftCandy":99,"type":"GOLD","giftTab":"NORMAL"}, + "createTime":` + strconvFormatMillis(now) + ` + }` + first, err := service.ProcessGiftPayload(ctx, firstPayload) + if err != nil { + t.Fatalf("ProcessGiftPayload(first) error = %v", err) + } + if !first.Processed || first.AcceptedEnergy != 99 || first.Launched { + t.Fatalf("first response = %+v", first) + } + + secondPayload := `{ + "trackId":"track-2", + "sysOrigin":"LIKEI", + "sendUserId":"1002", + "roomId":"9001", + "quantity":1, + "giftConfig":{"id":"12","giftCandy":101,"type":"GOLD","giftTab":"NORMAL"}, + "createTime":` + strconvFormatMillis(now.Add(time.Second)) + ` + }` + second, err := service.ProcessGiftPayload(ctx, secondPayload) + if err != nil { + t.Fatalf("ProcessGiftPayload(second) error = %v", err) + } + if !second.Processed || second.AcceptedEnergy != 1 || !second.Launched { + t.Fatalf("second response = %+v", second) + } + + var status model.VoiceRoomRocketStatus + if err := db.Where("sys_origin = ? AND room_id = ?", "LIKEI", int64(9001)).First(&status).Error; err != nil { + t.Fatalf("load status: %v", err) + } + if status.RoundNo != 1 || status.CurrentLevel != 2 || status.CurrentEnergy != 0 || status.NeedEnergy != 200 { + t.Fatalf("status = %+v, want round 1 level 2 energy 0 need 200", status) + } + + var eventLog model.VoiceRoomRocketGiftEventLog + if err := db.Where("track_id = ?", "track-2").First(&eventLog).Error; err != nil { + t.Fatalf("load event log: %v", err) + } + if eventLog.RawEnergy != 101 || eventLog.AcceptedEnergy != 1 { + t.Fatalf("eventLog energy raw=%d accepted=%d, want 101/1", eventLog.RawEnergy, eventLog.AcceptedEnergy) + } + + var launch model.VoiceRoomRocketLaunch + if err := db.Where("room_id = ? AND round_no = ? AND level = ?", int64(9001), 1, 1).First(&launch).Error; err != nil { + t.Fatalf("load launch: %v", err) + } + if launch.Top1UserID == nil || *launch.Top1UserID != 1001 || launch.IgniteUserID == nil || *launch.IgniteUserID != 1002 { + t.Fatalf("launch users top1=%v ignite=%v, want 1001/1002", launch.Top1UserID, launch.IgniteUserID) + } + + var rewardCount int64 + if err := db.Model(&model.VoiceRoomRocketRewardRecord{}).Where("launch_id = ?", launch.ID).Count(&rewardCount).Error; err != nil { + t.Fatalf("count reward records: %v", err) + } + if rewardCount != 2 { + t.Fatalf("reward count = %d, want 2", rewardCount) + } + + duplicate, err := service.ProcessGiftPayload(ctx, secondPayload) + if err != nil { + t.Fatalf("ProcessGiftPayload(duplicate) error = %v", err) + } + if duplicate.Reason != "duplicate_event" { + t.Fatalf("duplicate response = %+v, want duplicate_event", duplicate) + } + var launchCount int64 + if err := db.Model(&model.VoiceRoomRocketLaunch{}).Count(&launchCount).Error; err != nil { + t.Fatalf("count launches: %v", err) + } + if launchCount != 1 { + t.Fatalf("launch count = %d, want 1", launchCount) + } +} + +func TestDecodeGiftEventPayloadEnvelope(t *testing.T) { + payload := `{"tag":"give_gift_v3","body":"{\"trackId\":\"t1\",\"sendUserId\":\"10\",\"roomId\":\"20\",\"giftValue\":{\"actualAmount\":\"30\"}}"}` + event, err := decodeGiftEventPayload(payload, "give_gift_v3") + if err != nil { + t.Fatalf("decodeGiftEventPayload() error = %v", err) + } + if event.TrackID.String() != "t1" || event.SendUserID.Int64() != 10 || event.RoomID.Int64() != 20 || calculateRawEnergy(event) != 30 { + t.Fatalf("event = %+v", event) + } +} + +func TestGiftEnergyRulesRejectUnsupportedAndAllowConfiguredGift(t *testing.T) { + service, db := newTestService(t) + ctx := context.Background() + seedRocketConfig(t, db) + seedLevelConfigs(t, db, []int64{100, 200, 300, 400, 500, 600}) + + backpackPayload := `{ + "trackId":"track-backpack", + "sysOrigin":"LIKEI", + "sendUserId":"1001", + "roomId":"9001", + "quantity":1, + "giftConfig":{"id":"11","giftCandy":99,"type":"GOLD","giftTab":"BACKPACK"}, + "createTime":` + strconvFormatMillis(time.Now()) + ` + }` + ignored, err := service.ProcessGiftPayload(ctx, backpackPayload) + if err != nil { + t.Fatalf("ProcessGiftPayload(backpack) error = %v", err) + } + if ignored.Processed || ignored.Reason != "unsupported_gift" { + t.Fatalf("ignored = %+v, want unsupported_gift", ignored) + } + + if err := db.Model(&model.VoiceRoomRocketConfig{}). + Where("sys_origin = ?", "LIKEI"). + Update("energy_rule_json", `{"giftRules":[{"giftId":"22","energy":7,"enabled":true}]}`).Error; err != nil { + t.Fatalf("update energy rule: %v", err) + } + customPayload := `{ + "trackId":"track-custom", + "sysOrigin":"LIKEI", + "sendUserId":"1001", + "roomId":"9001", + "quantity":2, + "giftConfig":{"id":"22","giftCandy":99,"type":"MAGIC","giftTab":"MAGIC"}, + "createTime":` + strconvFormatMillis(time.Now()) + ` + }` + accepted, err := service.ProcessGiftPayload(ctx, customPayload) + if err != nil { + t.Fatalf("ProcessGiftPayload(custom) error = %v", err) + } + if !accepted.Processed || accepted.AcceptedEnergy != 14 { + t.Fatalf("accepted = %+v, want 14 energy", accepted) + } +} + +func TestInRoomRewardWorkerSnapshotsSelectsAndGrants(t *testing.T) { + gateway := newFakeRocketGateway() + service, db, rdb, mr := newTestServiceWithRedis(t, gateway) + defer mr.Close() + + ctx := context.Background() + 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) + seedRewardConfig(t, db, 1, rewardSceneInRoom, rewardTypeGold, 1) + + roomID := int64(9001) + for _, userID := range []int64{1001, 1002, 1003, 1004} { + if err := rdb.SAdd(ctx, onlineRoomKey("LIKEI", roomID), userID).Err(); err != nil { + t.Fatalf("sadd online: %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) + } + } + + payload := `{ + "trackId":"track-launch-in-room", + "sysOrigin":"LIKEI", + "sendUserId":"1001", + "roomId":"9001", + "quantity":1, + "giftConfig":{"id":"11","giftCandy":100,"type":"GOLD","giftTab":"NORMAL"}, + "createTime":` + strconvFormatMillis(time.Now()) + ` + }` + resp, err := service.ProcessGiftPayload(ctx, payload) + if err != nil { + t.Fatalf("ProcessGiftPayload() error = %v", err) + } + if !resp.Launched || resp.LaunchNo == "" { + t.Fatalf("launch response = %+v", resp) + } + + processed, err := service.ProcessDueInRoomRewards(ctx, time.Now().Add(20*time.Second), 10) + if err != nil { + t.Fatalf("ProcessDueInRoomRewards() error = %v", err) + } + if processed != 1 { + t.Fatalf("processed due = %d, want 1", processed) + } + + var snapshotCount int64 + if err := db.Model(&model.VoiceRoomRocketAudienceSnapshot{}).Count(&snapshotCount).Error; err != nil { + t.Fatalf("count snapshot: %v", err) + } + if snapshotCount != 4 { + t.Fatalf("snapshot count = %d, want 4", snapshotCount) + } + var selectedCount int64 + if err := db.Model(&model.VoiceRoomRocketAudienceSnapshot{}).Where("selected = ?", true).Count(&selectedCount).Error; err != nil { + t.Fatalf("count selected: %v", err) + } + if selectedCount != 3 { + t.Fatalf("selected count = %d, want 3", selectedCount) + } + + granted, err := service.ProcessPendingRewards(ctx, 20) + if err != nil { + t.Fatalf("ProcessPendingRewards() error = %v", err) + } + if granted != 5 { + t.Fatalf("granted = %d, want 5", granted) + } + var pending int64 + if err := db.Model(&model.VoiceRoomRocketRewardRecord{}).Where("grant_status <> ?", rewardStatusSuccess).Count(&pending).Error; err != nil { + t.Fatalf("count pending: %v", err) + } + if pending != 0 { + t.Fatalf("pending rewards = %d, want 0", pending) + } + if len(gateway.goldEvents) != 5 { + t.Fatalf("gold grant count = %d, want 5", len(gateway.goldEvents)) + } +} + +func TestNotifyLaunchPublishesIMAndRedisStream(t *testing.T) { + gateway := newFakeRocketGateway() + service, db, rdb, mr := newTestServiceWithRedis(t, gateway) + defer mr.Close() + seedRocketConfig(t, db) + fakeIM := &fakeRocketIM{} + service.SetIMGateway(fakeIM) + + ignite := int64(1001) + launch := model.VoiceRoomRocketLaunch{ + LaunchNo: "VRR-test", + SysOrigin: "LIKEI", + RoomID: 9001, + RoomAccount: "room-9001", + RoundNo: 1, + Level: 1, + IgniteUserID: &ignite, + } + if err := service.notifyLaunch(context.Background(), launch); err != nil { + t.Fatalf("notifyLaunch() error = %v", err) + } + if len(fakeIM.messages) != 1 || fakeIM.messages[0].groupID != "room-9001" { + t.Fatalf("im messages = %+v", fakeIM.messages) + } + count, err := rdb.XLen(context.Background(), "voice_room:rocket:broadcast").Result() + if err != nil { + t.Fatalf("xlen broadcast: %v", err) + } + if count != 1 { + t.Fatalf("broadcast stream count = %d, want 1", count) + } +} + +func TestDayKeyUsesUTCPlus3BusinessDay(t *testing.T) { + location := resolveLocation("Asia/Riyadh") + before := time.Date(2026, 5, 13, 20, 59, 59, 0, time.UTC) + after := time.Date(2026, 5, 13, 21, 0, 0, 0, time.UTC) + if got := dayKey(before, location); got != "20260513" { + t.Fatalf("dayKey(before) = %s, want 20260513", got) + } + if got := dayKey(after, location); got != "20260514" { + t.Fatalf("dayKey(after) = %s, want 20260514", got) + } +} + +func newTestService(t *testing.T) (*Service, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate( + &model.VoiceRoomRocketConfig{}, + &model.VoiceRoomRocketLevelConfig{}, + &model.VoiceRoomRocketRewardConfig{}, + &model.VoiceRoomRocketStatus{}, + &model.VoiceRoomRocketLevelRound{}, + &model.VoiceRoomRocketContribution{}, + &model.VoiceRoomRocketGiftEventLog{}, + &model.VoiceRoomRocketLaunch{}, + &model.VoiceRoomRocketRewardRecord{}, + &model.VoiceRoomRocketAudienceSnapshot{}, + ); err != nil { + t.Fatalf("migrate: %v", err) + } + service := NewService(config.Config{ + VoiceRoomRocket: config.VoiceRoomRocketConfig{ + DefaultSysOrigin: "LIKEI", + Timezone: "Asia/Riyadh", + }, + }, db, nil, nil) + return service, db +} + +func newTestServiceWithRedis(t *testing.T, gateway *fakeRocketGateway) (*Service, *gorm.DB, *redis.Client, *miniredis.Miniredis) { + t.Helper() + service, db := newTestService(t) + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + service.repo.Redis = rdb + service.gateways = gateway + service.roomProfiles = gateway + service.userProfiles = gateway + service.wallet = gateway + service.rewardGateway = gateway + service.cfg.VoiceRoomRocket.InRoomRewardUserLimit = 3 + service.cfg.VoiceRoomRocket.InRoomRewardDueZSetKey = "voice_room:rocket:in_room_due" + return service, db, rdb, mr +} + +type fakeRocketGateway struct { + goldEvents map[string]integration.GoldReceiptCommand + props []integration.GivePropsBackpackRequest + badges []int64 +} + +type fakeRocketIM struct { + messages []fakeRocketIMMessage +} + +type fakeRocketIMMessage struct { + groupID string + body any +} + +func newFakeRocketGateway() *fakeRocketGateway { + return &fakeRocketGateway{goldEvents: map[string]integration.GoldReceiptCommand{}} +} + +func (g *fakeRocketGateway) MapRoomProfiles(ctx context.Context, roomIDs []int64) (map[int64]integration.RoomProfile, error) { + result := make(map[int64]integration.RoomProfile, len(roomIDs)) + for _, roomID := range roomIDs { + result[roomID] = integration.RoomProfile{ID: integration.Int64Value(roomID), RoomAccount: strconv.FormatInt(roomID, 10), RoomName: "room"} + } + return result, nil +} + +func (g *fakeRocketGateway) GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error) { + return integration.UserProfile{ID: integration.Int64Value(userID), Account: strconv.FormatInt(userID, 10), UserNickname: "user"}, nil +} + +func (g *fakeRocketGateway) ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error { + g.goldEvents[cmd.EventID] = cmd + return nil +} + +func (g *fakeRocketGateway) ExistsGoldEvent(ctx context.Context, eventID string) (bool, error) { + _, ok := g.goldEvents[eventID] + return ok, nil +} + +func (g *fakeRocketGateway) GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error { + g.props = append(g.props, req) + return nil +} + +func (g *fakeRocketGateway) GetNobleVIPAbility(ctx context.Context, sourceID int64) (integration.PropsNobleVIPAbility, error) { + return integration.PropsNobleVIPAbility{}, nil +} + +func (g *fakeRocketGateway) SwitchUseProps(ctx context.Context, userID int64, propsID int64) error { + return nil +} + +func (g *fakeRocketGateway) ActivateTemporaryBadge(ctx context.Context, userID int64, badgeID int64, days int) error { + g.badges = append(g.badges, badgeID) + return nil +} + +func (g *fakeRocketGateway) RemoveUserProfileCacheAll(ctx context.Context, userID int64) error { + return nil +} + +func (g *fakeRocketIM) SendCustomGroupMessage(ctx context.Context, groupID string, body any) error { + g.messages = append(g.messages, fakeRocketIMMessage{groupID: groupID, body: body}) + return nil +} + +func seedRocketConfig(t *testing.T, db *gorm.DB) { + t.Helper() + now := time.Now() + row := model.VoiceRoomRocketConfig{ + ID: 1, + SysOrigin: "LIKEI", + Enabled: true, + Timezone: "Asia/Riyadh", + MaxLevel: 6, + RankingTopLimit: 100, + RewardRecordKeepDays: 35, + BroadcastDurationSeconds: 7, + InRoomRewardDelaySeconds: 10, + CreateTime: now, + UpdateTime: now, + } + if err := db.Create(&row).Error; err != nil { + t.Fatalf("seed config: %v", err) + } +} + +func seedLevelConfigs(t *testing.T, db *gorm.DB, energy []int64) { + t.Helper() + now := time.Now() + for index, need := range energy { + row := model.VoiceRoomRocketLevelConfig{ + ID: int64(100 + index), + SysOrigin: "LIKEI", + Level: index + 1, + NeedEnergy: need, + ShakeThresholdPercent: "95.0000", + Enabled: true, + Sort: index + 1, + CreateTime: now, + UpdateTime: now, + } + if err := db.Create(&row).Error; err != nil { + t.Fatalf("seed level: %v", err) + } + } +} + +func seedRewardConfig(t *testing.T, db *gorm.DB, level int, scene string, rewardType string, amount int64) { + t.Helper() + now := time.Now() + row := model.VoiceRoomRocketRewardConfig{ + ID: int64(1000 + len(scene) + level + int(amount)), + SysOrigin: "LIKEI", + Level: level, + RewardScene: scene, + RewardType: rewardType, + RewardName: scene + " reward", + RewardAmount: amount, + Enabled: true, + CreateTime: now, + UpdateTime: now, + } + if err := db.Create(&row).Error; err != nil { + t.Fatalf("seed reward: %v", err) + } +} + +func strconvFormatMillis(t time.Time) string { + return strconv.FormatInt(t.UnixMilli(), 10) +} diff --git a/internal/service/voiceroomrocket/in_room_reward.go b/internal/service/voiceroomrocket/in_room_reward.go new file mode 100644 index 0000000..8f79b5b --- /dev/null +++ b/internal/service/voiceroomrocket/in_room_reward.go @@ -0,0 +1,278 @@ +package voiceroomrocket + +import ( + "context" + "errors" + "hash/fnv" + "math/rand" + "sort" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// ProcessDueInRoomRewards 扫描到期的在房间奖励任务,写入在线用户快照并生成中奖记录。 +func (s *Service) ProcessDueInRoomRewards(ctx context.Context, now time.Time, limit int64) (int, error) { + if s.repo.Redis == nil { + return 0, nil + } + if limit <= 0 { + limit = int64(defaultWorkerBatchSize) + } + key := s.inRoomDueZSetKey() + launchNos, err := s.repo.Redis.ZRangeByScore(ctx, key, &redis.ZRangeBy{ + Min: "-inf", + Max: strconv.FormatInt(now.Unix(), 10), + Count: limit, + }).Result() + if err != nil { + return 0, err + } + processed := 0 + for _, launchNo := range launchNos { + launchNo = strings.TrimSpace(launchNo) + if launchNo == "" { + continue + } + done, err := s.processInRoomRewardLaunch(ctx, launchNo, now) + if err != nil { + return processed, err + } + if done { + if err := s.repo.Redis.ZRem(context.Background(), key, launchNo).Err(); err != nil { + return processed, err + } + processed++ + } + } + return processed, nil +} + +func (s *Service) processInRoomRewardLaunch(ctx context.Context, launchNo string, now time.Time) (bool, error) { + var launch model.VoiceRoomRocketLaunch + var selectedRecords []model.VoiceRoomRocketRewardRecord + done := false + err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("launch_no = ?", launchNo). + First(&launch).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + done = true + return nil + } + return err + } + switch launch.InRoomSnapshotStatus { + case inRoomSnapshotSuccess, inRoomSnapshotEmpty: + done = true + return nil + } + if err := tx.Model(&model.VoiceRoomRocketLaunch{}). + Where("id = ?", launch.ID). + Updates(map[string]any{ + "in_room_snapshot_status": inRoomSnapshotProcessing, + "update_time": now, + }).Error; err != nil { + return err + } + + audience, err := s.listOnlineRoomUsers(ctx, launch.SysOrigin, launch.RoomID, now) + if err != nil { + return err + } + selected := s.selectInRoomRewardUsers(launch, audience) + if err := s.saveAudienceSnapshot(ctx, tx, launch, audience, selected, now); err != nil { + return err + } + if len(selected) == 0 { + if err := s.markLaunchInRoomStatus(ctx, tx, launch.ID, inRoomSnapshotEmpty, now); err != nil { + return err + } + done = true + return nil + } + + rewards, err := s.loadInRoomRewardConfigs(ctx, launch.SysOrigin, launch.Level) + if err != nil { + return err + } + for _, userID := range selected { + for _, reward := range s.pickInRoomRewardsForUser(launch, rewards, userID) { + record, err := s.createRewardRecord(ctx, tx, launch, reward, userID, now) + if err != nil { + return err + } + if record.ID > 0 { + selectedRecords = append(selectedRecords, record) + } + } + } + if err := s.markLaunchInRoomStatus(ctx, tx, launch.ID, inRoomSnapshotSuccess, now); err != nil { + return err + } + done = true + return nil + }) + if err != nil { + _ = s.repo.DB.WithContext(context.Background()). + Model(&model.VoiceRoomRocketLaunch{}). + Where("launch_no = ?", launchNo). + Updates(map[string]any{ + "in_room_snapshot_status": inRoomSnapshotFailed, + "update_time": time.Now(), + }).Error + return false, err + } + if len(selectedRecords) > 0 { + _ = s.notifyRewardRecords(context.Background(), launch, selectedRecords) + } + return done, nil +} + +func (s *Service) saveAudienceSnapshot( + ctx context.Context, + tx *gorm.DB, + launch model.VoiceRoomRocketLaunch, + audience []roomAudienceUser, + selected []int64, + now time.Time, +) error { + selectedSet := make(map[int64]struct{}, len(selected)) + for _, userID := range selected { + selectedSet[userID] = struct{}{} + } + rows := make([]model.VoiceRoomRocketAudienceSnapshot, 0, len(audience)) + for _, user := range audience { + id, err := utils.NextID() + if err != nil { + return err + } + _, isSelected := selectedSet[user.UserID] + rows = append(rows, model.VoiceRoomRocketAudienceSnapshot{ + ID: id, + LaunchID: launch.ID, + SysOrigin: launch.SysOrigin, + RoomID: launch.RoomID, + UserID: user.UserID, + LastSeenTime: user.LastSeenTime, + Selected: isSelected, + CreateTime: now, + }) + } + if len(rows) == 0 { + return nil + } + return tx.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "launch_id"}, {Name: "user_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"last_seen_time", "selected"}), + }). + Create(&rows).Error +} + +func (s *Service) markLaunchInRoomStatus(ctx context.Context, tx *gorm.DB, launchID int64, status string, now time.Time) error { + return tx.WithContext(ctx).Model(&model.VoiceRoomRocketLaunch{}). + Where("id = ?", launchID). + Updates(map[string]any{ + "in_room_snapshot_status": status, + "update_time": now, + }).Error +} + +func (s *Service) loadInRoomRewardConfigs(ctx context.Context, sysOrigin string, level int) ([]model.VoiceRoomRocketRewardConfig, error) { + rewards, err := s.loadRewardConfigs(ctx, sysOrigin, level) + if err != nil { + return nil, err + } + result := make([]model.VoiceRoomRocketRewardConfig, 0, len(rewards)) + for _, reward := range rewards { + if strings.EqualFold(strings.TrimSpace(reward.RewardScene), rewardSceneInRoom) { + result = append(result, reward) + } + } + return result, nil +} + +func (s *Service) selectInRoomRewardUsers(launch model.VoiceRoomRocketLaunch, audience []roomAudienceUser) []int64 { + limit := s.cfg.VoiceRoomRocket.InRoomRewardUserLimit + if limit <= 0 { + limit = defaultInRoomRewardUserLimit + } + if len(audience) == 0 { + return nil + } + userIDs := make([]int64, 0, len(audience)) + for _, user := range audience { + if user.UserID > 0 { + userIDs = append(userIDs, user.UserID) + } + } + sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] }) + if len(userIDs) <= limit { + return userIDs + } + rng := rand.New(rand.NewSource(seedFromStrings(launch.LaunchNo, "audience"))) + rng.Shuffle(len(userIDs), func(i, j int) { + userIDs[i], userIDs[j] = userIDs[j], userIDs[i] + }) + selected := append([]int64(nil), userIDs[:limit]...) + sort.Slice(selected, func(i, j int) bool { return selected[i] < selected[j] }) + return selected +} + +func (s *Service) pickInRoomRewardsForUser(launch model.VoiceRoomRocketLaunch, rewards []model.VoiceRoomRocketRewardConfig, userID int64) []model.VoiceRoomRocketRewardConfig { + if len(rewards) == 0 { + return nil + } + fixed := make([]model.VoiceRoomRocketRewardConfig, 0, len(rewards)) + weighted := make([]model.VoiceRoomRocketRewardConfig, 0, len(rewards)) + totalWeight := 0 + for _, reward := range rewards { + if reward.Weight <= 0 { + fixed = append(fixed, reward) + continue + } + weighted = append(weighted, reward) + totalWeight += reward.Weight + } + result := append([]model.VoiceRoomRocketRewardConfig(nil), fixed...) + if totalWeight <= 0 { + return result + } + 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 { + result = append(result, reward) + break + } + } + return result +} + +func seedFromStrings(parts ...string) int64 { + hash := fnv.New64a() + for _, part := range parts { + _, _ = hash.Write([]byte(part)) + _, _ = hash.Write([]byte{0}) + } + return int64(hash.Sum64() & 0x7fffffffffffffff) +} + +func (s *Service) inRoomDueZSetKey() string { + key := strings.TrimSpace(s.cfg.VoiceRoomRocket.InRoomRewardDueZSetKey) + if key == "" { + return "voice_room:rocket:in_room_due" + } + return key +} diff --git a/internal/service/voiceroomrocket/lifecycle.go b/internal/service/voiceroomrocket/lifecycle.go new file mode 100644 index 0000000..d29897e --- /dev/null +++ b/internal/service/voiceroomrocket/lifecycle.go @@ -0,0 +1,195 @@ +package voiceroomrocket + +import ( + "context" + "encoding/json" + "errors" + "log" + "strings" + "time" + + rmq "github.com/apache/rocketmq-clients/golang/v5" + "github.com/apache/rocketmq-clients/golang/v5/credentials" +) + +// Start 启动语音房火箭完整后台任务:MQ 消费、在房奖励延迟扫描、奖励自动发放。 +func (s *Service) Start(ctx context.Context) error { + if err := s.StartMessageConsumer(ctx); err != nil { + return err + } + s.StartInRoomRewardWorker(ctx) + s.StartRewardGrantWorker(ctx) + return nil +} + +// StartMessageConsumer 启动语音房火箭送礼消息消费。 +func (s *Service) StartMessageConsumer(ctx context.Context) error { + if !s.cfg.VoiceRoomRocket.MQ.Enabled { + log.Printf("voice room rocket rocketmq consumer disabled") + return nil + } + if strings.TrimSpace(s.cfg.VoiceRoomRocket.MQ.Endpoint) == "" || + strings.TrimSpace(s.cfg.VoiceRoomRocket.MQ.AccessKey) == "" || + strings.TrimSpace(s.cfg.VoiceRoomRocket.MQ.AccessSecret) == "" { + log.Printf("voice room rocket rocketmq consumer skipped: endpoint or credentials missing") + return nil + } + go s.startRocketMQConsumerWithRetry(ctx) + return nil +} + +// StartInRoomRewardWorker 启动在房奖励到期扫描。 +func (s *Service) StartInRoomRewardWorker(ctx context.Context) { + if s.repo.Redis == nil { + log.Printf("voice room rocket in-room reward worker skipped: redis is nil") + return + } + interval := time.Duration(s.cfg.VoiceRoomRocket.InRoomRewardScanIntervalSeconds) * time.Second + if interval <= 0 { + interval = defaultWorkerInterval + } + batchSize := int64(s.cfg.VoiceRoomRocket.InRoomRewardBatchSize) + if batchSize <= 0 { + batchSize = defaultWorkerBatchSize + } + log.Printf("voice room rocket in-room reward worker started. interval=%s batch=%d", interval, batchSize) + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + if _, err := s.ProcessDueInRoomRewards(ctx, time.Now(), batchSize); err != nil && ctx.Err() == nil { + log.Printf("voice room rocket in-room reward scan failed: %v", err) + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }() +} + +// StartRewardGrantWorker 启动奖励自动发放扫描。 +func (s *Service) StartRewardGrantWorker(ctx context.Context) { + interval := time.Duration(s.cfg.VoiceRoomRocket.RewardGrantScanIntervalSeconds) * time.Second + if interval <= 0 { + interval = defaultWorkerInterval + } + batchSize := s.cfg.VoiceRoomRocket.RewardGrantBatchSize + if batchSize <= 0 { + batchSize = defaultWorkerBatchSize + } + log.Printf("voice room rocket reward grant worker started. interval=%s batch=%d", interval, batchSize) + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + if _, err := s.ProcessPendingRewards(ctx, batchSize); err != nil && ctx.Err() == nil { + log.Printf("voice room rocket reward grant scan failed: %v", err) + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }() +} + +func (s *Service) startRocketMQConsumerWithRetry(ctx context.Context) { + retryDelay := 5 * time.Second + for { + if ctx.Err() != nil { + return + } + if err := s.startRocketMQConsumer(ctx); err != nil { + log.Printf("start voice room rocket rocketmq consumer failed: %v; retry in %s", err, retryDelay) + timer := time.NewTimer(retryDelay) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + if retryDelay < time.Minute { + retryDelay *= 2 + if retryDelay > time.Minute { + retryDelay = time.Minute + } + } + continue + } + return + } +} + +func (s *Service) startRocketMQConsumer(ctx context.Context) error { + filter := rmq.SUB_ALL + if tag := strings.TrimSpace(s.cfg.VoiceRoomRocket.MQ.Tag); tag != "" { + filter = rmq.NewFilterExpression(tag) + } + consumer, err := rmq.NewPushConsumer(&rmq.Config{ + Endpoint: s.cfg.VoiceRoomRocket.MQ.Endpoint, + NameSpace: s.cfg.VoiceRoomRocket.MQ.Namespace, + ConsumerGroup: s.cfg.VoiceRoomRocket.MQ.ConsumerGroup, + Credentials: &credentials.SessionCredentials{ + AccessKey: s.cfg.VoiceRoomRocket.MQ.AccessKey, + AccessSecret: s.cfg.VoiceRoomRocket.MQ.AccessSecret, + SecurityToken: s.cfg.VoiceRoomRocket.MQ.SecurityToken, + }, + }, + rmq.WithPushSubscriptionExpressions(map[string]*rmq.FilterExpression{ + s.cfg.VoiceRoomRocket.MQ.Topic: filter, + }), + rmq.WithPushConsumptionThreadCount(8), + rmq.WithPushMaxCacheMessageCount(256), + rmq.WithPushMessageListener(&rmq.FuncMessageListener{ + Consume: func(messageView *rmq.MessageView) rmq.ConsumerResult { + if messageView == nil { + return rmq.SUCCESS + } + if err := s.processRocketMQMessage(context.Background(), messageView); err != nil { + log.Printf("voice room rocket mq process failed. messageId=%s err=%v", messageView.GetMessageId(), err) + return rmq.FAILURE + } + return rmq.SUCCESS + }, + }), + ) + if err != nil { + return err + } + if err := consumer.Start(); err != nil { + return err + } + s.rocketConsumer = consumer + log.Printf("voice room rocket rocketmq consumer started. topic=%s group=%s tag=%s", + s.cfg.VoiceRoomRocket.MQ.Topic, + s.cfg.VoiceRoomRocket.MQ.ConsumerGroup, + s.cfg.VoiceRoomRocket.MQ.Tag, + ) + go func() { + <-ctx.Done() + if s.rocketConsumer != nil { + _ = s.rocketConsumer.GracefulStop() + } + }() + return nil +} + +func (s *Service) processRocketMQMessage(ctx context.Context, messageView *rmq.MessageView) error { + payload := strings.TrimSpace(string(messageView.GetBody())) + if payload == "" { + return nil + } + if _, err := s.ProcessGiftPayload(ctx, payload); err != nil { + var syntaxErr *json.SyntaxError + var typeErr *json.UnmarshalTypeError + if errors.As(err, &syntaxErr) || errors.As(err, &typeErr) { + log.Printf("drop malformed voice room rocket message. messageId=%s err=%v", messageView.GetMessageId(), err) + return nil + } + return err + } + return nil +} diff --git a/internal/service/voiceroomrocket/mapper.go b/internal/service/voiceroomrocket/mapper.go new file mode 100644 index 0000000..681a17c --- /dev/null +++ b/internal/service/voiceroomrocket/mapper.go @@ -0,0 +1,308 @@ +package voiceroomrocket + +import ( + "encoding/json" + "fmt" + "math" + "sort" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" +) + +func (s *Service) normalizeSysOrigin(sysOrigin string) string { + if strings.TrimSpace(sysOrigin) != "" { + return strings.ToUpper(strings.TrimSpace(sysOrigin)) + } + if strings.TrimSpace(s.cfg.VoiceRoomRocket.DefaultSysOrigin) != "" { + return strings.ToUpper(strings.TrimSpace(s.cfg.VoiceRoomRocket.DefaultSysOrigin)) + } + return defaultSysOrigin +} + +func normalizeTimezone(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return defaultTimezone + } + return value +} + +func resolveLocation(value string) *time.Location { + location, err := time.LoadLocation(normalizeTimezone(value)) + if err != nil { + return time.UTC + } + return location +} + +func dayKey(t time.Time, location *time.Location) string { + if location == nil { + location = time.UTC + } + return t.In(location).Format("20060102") +} + +func normalizePage(cursor, limit int) (int, int) { + if cursor <= 0 { + cursor = 1 + } + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + return cursor, limit +} + +func formatDateTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04:05") +} + +func pointerTimeValue(value *time.Time) time.Time { + if value == nil { + return time.Time{} + } + return *value +} + +func decimalPercent(current, need int64) (string, int) { + if need <= 0 || current <= 0 { + return "0.0000", 0 + } + percent := float64(current) * 100 / float64(need) + display := int(math.Floor(percent)) + if display > 100 { + display = 100 + } + return fmt.Sprintf("%.4f", percent), display +} + +func parsePercent(value string) float64 { + parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil { + return 0 + } + return parsed +} + +func countDistinctAcceptUsers(users []giftEventUser) int { + if len(users) == 0 { + return 0 + } + set := make(map[int64]struct{}, len(users)) + for _, user := range users { + if user.AcceptUserID.Int64() <= 0 { + continue + } + set[user.AcceptUserID.Int64()] = struct{}{} + } + return len(set) +} + +func resolveMillisTime(raw int64, fallback time.Time) time.Time { + if raw <= 0 { + return fallback + } + if raw < 1_000_000_000_000 { + return time.Unix(raw, 0) + } + return time.UnixMilli(raw) +} + +func truncateString(value string, limit int) string { + if limit <= 0 || len(value) <= limit { + return value + } + return value[:limit] +} + +func isDuplicateKeyErr(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "duplicate entry") || + strings.Contains(message, "duplicate key") || + strings.Contains(message, "unique constraint") +} + +func levelPayload(row model.VoiceRoomRocketLevelConfig) LevelConfigPayload { + return LevelConfigPayload{ + ID: row.ID, + Level: row.Level, + NeedEnergy: row.NeedEnergy, + ShakeThresholdPercent: row.ShakeThresholdPercent, + RocketIconURL: row.RocketIconURL, + RocketAnimationURL: row.RocketAnimationURL, + ProgressBarURL: row.ProgressBarURL, + Enabled: row.Enabled, + Sort: row.Sort, + } +} + +func rewardPayload(row model.VoiceRoomRocketRewardConfig) RewardConfigPayload { + return RewardConfigPayload{ + ID: row.ID, + Level: row.Level, + RewardScene: row.RewardScene, + RewardType: row.RewardType, + RewardItemID: row.RewardItemID, + RewardName: row.RewardName, + RewardCover: row.RewardCover, + RewardAmount: row.RewardAmount, + ExpireDays: row.ExpireDays, + Weight: row.Weight, + Sort: row.Sort, + Enabled: row.Enabled, + } +} + +func rewardPreviewPayload(rows []model.VoiceRoomRocketRewardConfig) RewardPreviewResponse { + sort.Slice(rows, func(i, j int) bool { + if rows[i].RewardScene != rows[j].RewardScene { + return rewardSceneOrder(rows[i].RewardScene) < rewardSceneOrder(rows[j].RewardScene) + } + if rows[i].Sort != rows[j].Sort { + return rows[i].Sort < rows[j].Sort + } + return rows[i].ID < rows[j].ID + }) + resp := RewardPreviewResponse{} + for _, row := range rows { + payload := rewardPayload(row) + switch strings.ToUpper(strings.TrimSpace(row.RewardScene)) { + case rewardSceneTop1: + resp.Top1 = append(resp.Top1, payload) + case rewardSceneIgnite: + resp.Ignite = append(resp.Ignite, payload) + case rewardSceneInRoom: + resp.InRoom = append(resp.InRoom, payload) + } + } + return resp +} + +func rewardSceneOrder(scene string) int { + switch strings.ToUpper(strings.TrimSpace(scene)) { + case rewardSceneTop1: + return 1 + case rewardSceneIgnite: + return 2 + case rewardSceneInRoom: + return 3 + default: + return 100 + } +} + +func isSupportedRewardType(rewardType string) bool { + switch strings.ToUpper(strings.TrimSpace(rewardType)) { + case rewardTypeGold, + rewardTypeMount, + rewardTypeAvatarFrame, + rewardTypeChatBubble, + rewardTypeVIPCard, + rewardTypeGift, + rewardTypeBadge: + return true + default: + return false + } +} + +func configResponse(snapshot configSnapshot, levels []model.VoiceRoomRocketLevelConfig, rewards []model.VoiceRoomRocketRewardConfig) *ConfigResponse { + levelPayloads := make([]LevelConfigPayload, 0, len(levels)) + sort.Slice(levels, func(i, j int) bool { + if levels[i].Sort != levels[j].Sort { + return levels[i].Sort < levels[j].Sort + } + return levels[i].Level < levels[j].Level + }) + for _, row := range levels { + levelPayloads = append(levelPayloads, levelPayload(row)) + } + + var ruleText json.RawMessage + if strings.TrimSpace(snapshot.RuleText) != "" { + ruleText = json.RawMessage(snapshot.RuleText) + } + return &ConfigResponse{ + Configured: snapshot.Configured, + ID: snapshot.ID, + SysOrigin: snapshot.SysOrigin, + Enabled: snapshot.Enabled, + Timezone: snapshot.Timezone, + MaxLevel: snapshot.MaxLevel, + RankingTopLimit: snapshot.RankingTopLimit, + RewardRecordKeepDays: snapshot.RewardRecordKeepDays, + BroadcastDurationSeconds: snapshot.BroadcastDurationSeconds, + InRoomRewardDelaySeconds: snapshot.InRoomRewardDelaySeconds, + RuleText: ruleText, + Levels: levelPayloads, + RewardPreview: rewardPreviewPayload(rewards), + UpdateTime: formatDateTime(snapshot.UpdateTime), + } +} + +func toRewardRecordView(row model.VoiceRoomRocketRewardRecord) RewardRecordView { + return RewardRecordView{ + ID: row.ID, + LaunchNo: row.LaunchNo, + RoomID: row.RoomID, + DayKey: row.DayKey, + RoundNo: row.RoundNo, + Level: row.Level, + UserID: row.UserID, + RewardScene: row.RewardScene, + RewardType: row.RewardType, + RewardItemID: row.RewardItemID, + RewardName: row.RewardName, + RewardCover: row.RewardCover, + RewardAmount: row.RewardAmount, + ExpireDays: row.ExpireDays, + GrantStatus: row.GrantStatus, + PopupStatus: row.PopupStatus, + ErrorMessage: row.ErrorMessage, + GrantTime: formatDateTime(pointerTimeValue(row.GrantTime)), + CreateTime: formatDateTime(row.CreateTime), + } +} + +func toLaunchRecordView(row model.VoiceRoomRocketLaunch) LaunchRecordView { + return LaunchRecordView{ + ID: row.ID, + LaunchNo: row.LaunchNo, + SysOrigin: row.SysOrigin, + RoomID: row.RoomID, + RoomAccount: row.RoomAccount, + DayKey: row.DayKey, + RoundNo: row.RoundNo, + Level: row.Level, + FinalEnergy: row.FinalEnergy, + Top1UserID: row.Top1UserID, + IgniteUserID: row.IgniteUserID, + InRoomSnapshotStatus: row.InRoomSnapshotStatus, + LaunchTime: formatDateTime(row.LaunchTime), + } +} + +func userProfileToKingRecord(userID int64, rank int, score int64, profile integration.UserProfile) KingRecord { + return KingRecord{ + Rank: rank, + UserID: userID, + UserAvatar: profile.UserAvatar, + UserNickname: profile.UserNickname, + Account: profile.Account, + CountryCode: profile.CountryCode, + CountryName: profile.CountryName, + ScoreEnergy: score, + } +} diff --git a/internal/service/voiceroomrocket/notify.go b/internal/service/voiceroomrocket/notify.go new file mode 100644 index 0000000..48966fa --- /dev/null +++ b/internal/service/voiceroomrocket/notify.go @@ -0,0 +1,228 @@ +package voiceroomrocket + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "strings" + + "chatapp3-golang/internal/model" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +func (s *Service) notifyStatusUpdate(ctx context.Context, sysOrigin string, roomID int64) error { + if s.repo.Redis != nil { + body, _ := json.Marshal(map[string]any{ + "type": imTypeStatusUpdate, + "sysOrigin": sysOrigin, + "roomId": strconv.FormatInt(roomID, 10), + }) + streamKey := strings.TrimSpace(s.cfg.VoiceRoomRocket.BroadcastStreamKey) + if streamKey == "" { + streamKey = "voice_room:rocket:broadcast" + } + _ = s.repo.Redis.XAdd(ctx, &redis.XAddArgs{ + Stream: streamKey, + Values: map[string]any{ + "type": imTypeStatusUpdate, + "roomId": strconv.FormatInt(roomID, 10), + "body": string(body), + }, + }).Err() + } + if s.im == nil { + return nil + } + groupID := s.resolveRoomAccount(ctx, roomID) + return s.im.SendCustomGroupMessage(ctx, groupID, map[string]any{ + "type": imTypeStatusUpdate, + "data": map[string]any{ + "roomId": strconv.FormatInt(roomID, 10), + }, + }) +} + +func (s *Service) notifyLaunch(ctx context.Context, launch model.VoiceRoomRocketLaunch) error { + triggerUser := map[string]any{} + if launch.IgniteUserID != nil && s.userProfiles != nil { + if profile, err := s.userProfiles.GetUserProfile(ctx, *launch.IgniteUserID); err == nil { + triggerUser = map[string]any{ + "userId": strconv.FormatInt(*launch.IgniteUserID, 10), + "nickname": profile.UserNickname, + "avatar": profile.UserAvatar, + "account": profile.Account, + "countryCode": profile.CountryCode, + "countryName": profile.CountryName, + } + } + } + roomName := "" + if s.roomProfiles != nil { + if profiles, err := s.roomProfiles.MapRoomProfiles(ctx, []int64{launch.RoomID}); err == nil { + if profile, ok := profiles[launch.RoomID]; ok { + roomName = profile.RoomName + if strings.TrimSpace(launch.RoomAccount) == "" && strings.TrimSpace(profile.RoomAccount) != "" { + launch.RoomAccount = strings.TrimSpace(profile.RoomAccount) + } + } + } + } + durationSeconds := defaultBroadcastDurationSeconds + if snapshot, err := s.loadConfig(ctx, launch.SysOrigin); err == nil && snapshot.BroadcastDurationSeconds > 0 { + durationSeconds = snapshot.BroadcastDurationSeconds + } + triggerUserID := "" + if launch.IgniteUserID != nil { + triggerUserID = strconv.FormatInt(*launch.IgniteUserID, 10) + } + body := map[string]any{ + "type": imTypeLaunchBroadcast, + "data": map[string]any{ + "roomId": strconv.FormatInt(launch.RoomID, 10), + "roomAccount": launch.RoomAccount, + "roomName": roomName, + "roundNo": launch.RoundNo, + "level": launch.Level, + "durationSeconds": durationSeconds, + "triggerUser": triggerUser, + "triggerUserId": triggerUserID, + "launchNo": launch.LaunchNo, + }, + } + if s.repo.Redis != nil { + payload, _ := json.Marshal(body) + streamKey := strings.TrimSpace(s.cfg.VoiceRoomRocket.BroadcastStreamKey) + if streamKey == "" { + streamKey = "voice_room:rocket:broadcast" + } + _ = s.repo.Redis.XAdd(ctx, &redis.XAddArgs{ + Stream: streamKey, + Values: map[string]any{ + "type": imTypeLaunchBroadcast, + "roomId": strconv.FormatInt(launch.RoomID, 10), + "launchNo": launch.LaunchNo, + "body": string(payload), + }, + }).Err() + } + if s.im == nil { + return nil + } + groupID := launch.RoomAccount + if strings.TrimSpace(groupID) == "" { + groupID = s.resolveRoomAccount(ctx, launch.RoomID) + } + return s.im.SendCustomGroupMessage(ctx, groupID, body) +} + +func (s *Service) notifyRewardRecords(ctx context.Context, launch model.VoiceRoomRocketLaunch, records []model.VoiceRoomRocketRewardRecord) error { + if len(records) == 0 { + return nil + } + users := make([]string, 0, len(records)) + seen := map[int64]struct{}{} + for _, record := range records { + if record.UserID <= 0 { + continue + } + if _, exists := seen[record.UserID]; exists { + continue + } + seen[record.UserID] = struct{}{} + users = append(users, strconv.FormatInt(record.UserID, 10)) + } + if len(users) == 0 { + return nil + } + body := map[string]any{ + "type": imTypeRewardPopup, + "data": map[string]any{ + "roomId": strconv.FormatInt(launch.RoomID, 10), + "launchNo": launch.LaunchNo, + "level": launch.Level, + "userIds": users, + }, + } + s.publishRewardMessage(ctx, launch.RoomID, launch.LaunchNo, body) + if s.im == nil { + return nil + } + groupID := launch.RoomAccount + if strings.TrimSpace(groupID) == "" { + groupID = s.resolveRoomAccount(ctx, launch.RoomID) + } + return s.im.SendCustomGroupMessage(ctx, groupID, body) +} + +func (s *Service) notifyRewardRecord(ctx context.Context, record model.VoiceRoomRocketRewardRecord) error { + body := map[string]any{ + "type": imTypeRewardUser, + "data": map[string]any{ + "roomId": strconv.FormatInt(record.RoomID, 10), + "launchNo": record.LaunchNo, + "level": record.Level, + "userId": strconv.FormatInt(record.UserID, 10), + "rewardScene": record.RewardScene, + "rewardType": record.RewardType, + "rewardName": record.RewardName, + "rewardAmount": record.RewardAmount, + "rewardRecordId": strconv.FormatInt(record.ID, 10), + }, + } + s.publishRewardMessage(ctx, record.RoomID, record.LaunchNo, body) + return nil +} + +func (s *Service) publishRewardMessage(ctx context.Context, roomID int64, launchNo string, body map[string]any) { + if s.repo.Redis == nil { + return + } + payload, _ := json.Marshal(body) + streamKey := strings.TrimSpace(s.cfg.VoiceRoomRocket.BroadcastStreamKey) + if streamKey == "" { + streamKey = "voice_room:rocket:broadcast" + } + _ = s.repo.Redis.XAdd(ctx, &redis.XAddArgs{ + Stream: streamKey, + Values: map[string]any{ + "type": body["type"], + "roomId": strconv.FormatInt(roomID, 10), + "launchNo": launchNo, + "body": string(payload), + }, + }).Err() +} + +func (s *Service) resolveRoomAccount(ctx context.Context, roomID int64) string { + if roomID <= 0 { + return "" + } + if s.roomProfiles != nil { + profiles, err := s.roomProfiles.MapRoomProfiles(ctx, []int64{roomID}) + if err == nil { + if profile, ok := profiles[roomID]; ok && strings.TrimSpace(profile.RoomAccount) != "" { + return strings.TrimSpace(profile.RoomAccount) + } + for _, profile := range profiles { + if int64(profile.ID) == roomID && strings.TrimSpace(profile.RoomAccount) != "" { + return strings.TrimSpace(profile.RoomAccount) + } + } + } + } + var room model.RoomLiveBroadcastVIP + err := s.repo.DB.WithContext(ctx). + Where("room_id = ? OR id = ?", roomID, roomID). + Order("update_time DESC"). + First(&room).Error + if err == nil && room.RoomAccount > 0 { + return strconv.FormatInt(room.RoomAccount, 10) + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return strconv.FormatInt(roomID, 10) + } + return strconv.FormatInt(roomID, 10) +} diff --git a/internal/service/voiceroomrocket/presence.go b/internal/service/voiceroomrocket/presence.go new file mode 100644 index 0000000..946572f --- /dev/null +++ b/internal/service/voiceroomrocket/presence.go @@ -0,0 +1,68 @@ +package voiceroomrocket + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + "time" +) + +type roomAudienceUser struct { + UserID int64 + LastSeenTime time.Time +} + +func (s *Service) listOnlineRoomUsers(ctx context.Context, sysOrigin string, roomID int64, now time.Time) ([]roomAudienceUser, error) { + if s.repo.Redis == nil { + return nil, nil + } + members, err := s.repo.Redis.SMembers(ctx, onlineRoomKey(sysOrigin, roomID)).Result() + if err != nil { + return nil, err + } + users := make([]roomAudienceUser, 0, len(members)) + seen := make(map[int64]struct{}, len(members)) + for _, raw := range members { + userID, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil || userID <= 0 { + continue + } + if _, exists := seen[userID]; exists { + continue + } + seen[userID] = struct{}{} + users = append(users, roomAudienceUser{ + UserID: userID, + LastSeenTime: s.loadUserSeenTime(ctx, sysOrigin, userID, now), + }) + } + sort.Slice(users, func(i, j int) bool { + return users[i].UserID < users[j].UserID + }) + return users, nil +} + +func (s *Service) loadUserSeenTime(ctx context.Context, sysOrigin string, userID int64, fallback time.Time) time.Time { + if s.repo.Redis == nil { + return fallback + } + raw, err := s.repo.Redis.Get(ctx, userSeenKey(sysOrigin, userID)).Result() + if err != nil { + return fallback + } + millis, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil || millis <= 0 { + return fallback + } + return resolveMillisTime(millis, fallback) +} + +func onlineRoomKey(sysOrigin string, roomID int64) string { + return fmt.Sprintf("voice_room:online:%s:%d", strings.ToUpper(strings.TrimSpace(sysOrigin)), roomID) +} + +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/ranking.go b/internal/service/voiceroomrocket/ranking.go new file mode 100644 index 0000000..48d809e --- /dev/null +++ b/internal/service/voiceroomrocket/ranking.go @@ -0,0 +1,142 @@ +package voiceroomrocket + +import ( + "context" + "net/http" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" +) + +// GetKingRanking 查询火箭王榜单。 +func (s *Service) GetKingRanking(ctx context.Context, user AuthUser, roomID int64, level int, roundNo int, cursor int, limit int) (*KingResponse, error) { + if roomID <= 0 { + return nil, NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required") + } + if level <= 0 || level > defaultMaxLevel { + return nil, NewAppError(http.StatusBadRequest, "invalid_level", "level must be between 1 and 6") + } + cursor, limit = normalizePage(cursor, limit) + snapshot, err := s.loadConfig(ctx, user.SysOrigin) + if err != nil { + return nil, err + } + local := resolveLocation(snapshot.Timezone) + now := time.Now().In(local) + statusRow, err := s.loadOrInitStatus(ctx, nil, snapshot.SysOrigin, roomID, dayKey(now, local), now) + if err != nil { + return nil, err + } + if roundNo <= 0 { + roundNo = statusRow.RoundNo + } + sourceRoundNo := roundNo + records, err := s.rankRecords(ctx, snapshot.SysOrigin, roomID, statusRow.DayKey, sourceRoundNo, level, cursor, limit) + if err != nil { + return nil, err + } + if len(records) == 0 && roundNo == statusRow.RoundNo && level > statusRow.CurrentLevel && roundNo > 1 { + sourceRoundNo = roundNo - 1 + records, err = s.rankRecords(ctx, snapshot.SysOrigin, roomID, statusRow.DayKey, sourceRoundNo, level, cursor, limit) + if err != nil { + return nil, err + } + } + resp := &KingResponse{ + RoomID: roomID, + DayKey: statusRow.DayKey, + RoundNo: roundNo, + SourceRoundNo: sourceRoundNo, + Level: level, + Records: records, + } + if len(records) > 3 { + resp.Podium = append([]KingRecord(nil), records[:3]...) + } else { + resp.Podium = append([]KingRecord(nil), records...) + } + if len(records) == 0 { + resp.EmptyReason = "未达到火箭等级,没有火箭王" + } + return resp, nil +} + +func (s *Service) topKingRecords(ctx context.Context, sysOrigin string, roomID int64, day string, roundNo int, level int, limit int) ([]KingRecord, error) { + if limit <= 0 { + limit = 3 + } + return s.rankRecords(ctx, sysOrigin, roomID, day, roundNo, level, 1, limit) +} + +func (s *Service) rankRecords(ctx context.Context, sysOrigin string, roomID int64, day string, roundNo int, level int, cursor int, limit int) ([]KingRecord, error) { + cursor, limit = normalizePage(cursor, limit) + var rows []model.VoiceRoomRocketContribution + if err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND room_id = ? AND day_key = ? AND round_no = ? AND level = ?", sysOrigin, roomID, day, roundNo, level). + Order("score_energy DESC, last_contribute_time ASC, user_id ASC"). + Offset((cursor - 1) * limit). + Limit(limit). + Find(&rows).Error; err != nil { + return nil, err + } + profiles := s.mapUserProfiles(ctx, contributionUserIDs(rows)) + records := make([]KingRecord, 0, len(rows)) + baseRank := (cursor-1)*limit + 1 + for index, row := range rows { + profile := profiles[row.UserID] + record := userProfileToKingRecord(row.UserID, baseRank+index, row.ScoreEnergy, profile) + if strings.TrimSpace(record.Account) == "" { + record.Account = strconv.FormatInt(row.UserID, 10) + } + if strings.TrimSpace(record.UserNickname) == "" { + record.UserNickname = record.Account + } + records = append(records, record) + } + return records, nil +} + +func contributionUserIDs(rows []model.VoiceRoomRocketContribution) []int64 { + result := make([]int64, 0, len(rows)) + seen := map[int64]struct{}{} + for _, row := range rows { + if row.UserID <= 0 { + continue + } + if _, ok := seen[row.UserID]; ok { + continue + } + seen[row.UserID] = struct{}{} + result = append(result, row.UserID) + } + return result +} + +func (s *Service) mapUserProfiles(ctx context.Context, userIDs []int64) map[int64]integration.UserProfile { + result := make(map[int64]integration.UserProfile, len(userIDs)) + if s.userProfiles == nil { + return result + } + for _, userID := range userIDs { + profile, err := s.userProfiles.GetUserProfile(ctx, userID) + if err == nil { + result[userID] = profile + } + } + return result +} + +func rankRedisKey(sysOrigin string, roomID int64, day string, roundNo int, level int) string { + return "voice_room:rocket:rank:" + strings.ToUpper(strings.TrimSpace(sysOrigin)) + + ":" + strconv.FormatInt(roomID, 10) + + ":" + day + + ":" + strconv.Itoa(roundNo) + + ":" + strconv.Itoa(level) +} + +func roomLockKey(sysOrigin string, roomID int64) string { + return "voice_room:rocket:lock:" + strings.ToUpper(strings.TrimSpace(sysOrigin)) + ":" + strconv.FormatInt(roomID, 10) +} diff --git a/internal/service/voiceroomrocket/records.go b/internal/service/voiceroomrocket/records.go new file mode 100644 index 0000000..e903308 --- /dev/null +++ b/internal/service/voiceroomrocket/records.go @@ -0,0 +1,142 @@ +package voiceroomrocket + +import ( + "context" + "net/http" + "time" + + "chatapp3-golang/internal/model" +) + +// ListRewardPopups 返回当前用户未读中奖弹窗记录。 +func (s *Service) ListRewardPopups(ctx context.Context, user AuthUser, roomID int64) (*RewardPopupResponse, error) { + sysOrigin := s.normalizeSysOrigin(user.SysOrigin) + query := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND user_id = ? AND popup_status = ?", sysOrigin, user.UserID, popupStatusUnread). + Order("create_time DESC, id DESC"). + Limit(100) + if roomID > 0 { + query = query.Where("room_id = ?", roomID) + } + var rows []model.VoiceRoomRocketRewardRecord + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + records := make([]RewardRecordView, 0, len(rows)) + for _, row := range rows { + records = append(records, toRewardRecordView(row)) + } + return &RewardPopupResponse{Records: records}, nil +} + +// AckRewardPopups 标记中奖弹窗为已读。 +func (s *Service) AckRewardPopups(ctx context.Context, user AuthUser, req AckRewardPopupsRequest) error { + if len(req.RewardRecordIDs) == 0 { + return NewAppError(http.StatusBadRequest, "missing_reward_record_ids", "rewardRecordIds is required") + } + ids := make([]int64, 0, len(req.RewardRecordIDs)) + for _, id := range req.RewardRecordIDs { + if id.Int64() > 0 { + ids = append(ids, id.Int64()) + } + } + if len(ids) == 0 { + return NewAppError(http.StatusBadRequest, "missing_reward_record_ids", "rewardRecordIds is required") + } + return s.repo.DB.WithContext(ctx). + Model(&model.VoiceRoomRocketRewardRecord{}). + Where("sys_origin = ? AND user_id = ? AND id IN ?", s.normalizeSysOrigin(user.SysOrigin), user.UserID, ids). + Updates(map[string]any{ + "popup_status": popupStatusRead, + "update_time": time.Now(), + }).Error +} + +// PageRewardRecords 返回用户最近中奖记录。 +func (s *Service) PageRewardRecords(ctx context.Context, user AuthUser, roomID int64, cursor int, limit int) (*RewardRecordPageResponse, error) { + cursor, limit = normalizePage(cursor, limit) + snapshot, err := s.loadConfig(ctx, user.SysOrigin) + if err != nil { + return nil, err + } + keepDays := snapshot.RewardRecordKeepDays + if keepDays <= 0 { + keepDays = defaultRewardRecordKeepDays + } + startAt := time.Now().AddDate(0, 0, -keepDays) + query := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND user_id = ? AND create_time >= ?", snapshot.SysOrigin, user.UserID, startAt). + Order("create_time DESC, id DESC"). + Offset((cursor - 1) * limit). + Limit(limit) + if roomID > 0 { + query = query.Where("room_id = ?", roomID) + } + var rows []model.VoiceRoomRocketRewardRecord + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + records := make([]RewardRecordView, 0, len(rows)) + for _, row := range rows { + records = append(records, toRewardRecordView(row)) + } + resp := &RewardRecordPageResponse{ + Records: records, + Cursor: cursor, + Limit: limit, + } + if len(records) == 0 { + resp.EmptyReason = "仅展示最近 35 天的记录" + } + return resp, nil +} + +// PageAdminLaunchRecords 返回后台发射记录。 +func (s *Service) PageAdminLaunchRecords(ctx context.Context, sysOrigin string, roomID int64, cursor int, limit int) (*LaunchRecordPageResponse, error) { + cursor, limit = normalizePage(cursor, limit) + query := s.repo.DB.WithContext(ctx). + Where("sys_origin = ?", s.normalizeSysOrigin(sysOrigin)). + Order("launch_time DESC, id DESC"). + Offset((cursor - 1) * limit). + Limit(limit) + if roomID > 0 { + query = query.Where("room_id = ?", roomID) + } + var rows []model.VoiceRoomRocketLaunch + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + records := make([]LaunchRecordView, 0, len(rows)) + for _, row := range rows { + records = append(records, toLaunchRecordView(row)) + } + return &LaunchRecordPageResponse{Records: records, Cursor: cursor, Limit: limit}, nil +} + +// PageAdminRewardRecords 返回后台奖励记录。 +func (s *Service) PageAdminRewardRecords(ctx context.Context, sysOrigin string, roomID int64, userID int64, grantStatus string, cursor int, limit int) (*RewardRecordPageResponse, error) { + cursor, limit = normalizePage(cursor, limit) + query := s.repo.DB.WithContext(ctx). + Where("sys_origin = ?", s.normalizeSysOrigin(sysOrigin)). + Order("create_time DESC, id DESC"). + Offset((cursor - 1) * limit). + Limit(limit) + if roomID > 0 { + query = query.Where("room_id = ?", roomID) + } + if userID > 0 { + query = query.Where("user_id = ?", userID) + } + if grantStatus != "" { + query = query.Where("grant_status = ?", grantStatus) + } + var rows []model.VoiceRoomRocketRewardRecord + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + records := make([]RewardRecordView, 0, len(rows)) + for _, row := range rows { + records = append(records, toRewardRecordView(row)) + } + return &RewardRecordPageResponse{Records: records, Cursor: cursor, Limit: limit}, nil +} diff --git a/internal/service/voiceroomrocket/reward.go b/internal/service/voiceroomrocket/reward.go new file mode 100644 index 0000000..c9f88ae --- /dev/null +++ b/internal/service/voiceroomrocket/reward.go @@ -0,0 +1,342 @@ +package voiceroomrocket + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" +) + +var errRewardLocked = errors.New("reward record is locked") + +func (s *Service) createImmediateRewardRecords(ctx context.Context, tx *gorm.DB, launch model.VoiceRoomRocketLaunch, top1UserID *int64, igniteUserID int64, now time.Time) error { + rewards, err := s.loadRewardConfigs(ctx, launch.SysOrigin, launch.Level) + if err != nil { + return err + } + for _, reward := range rewards { + scene := strings.ToUpper(strings.TrimSpace(reward.RewardScene)) + switch scene { + case rewardSceneTop1: + if top1UserID == nil || *top1UserID <= 0 { + continue + } + if _, err := s.createRewardRecord(ctx, tx, launch, reward, *top1UserID, now); err != nil { + return err + } + case rewardSceneIgnite: + if igniteUserID <= 0 { + continue + } + if _, err := s.createRewardRecord(ctx, tx, launch, reward, igniteUserID, now); err != nil { + return err + } + } + } + return nil +} + +func (s *Service) createRewardRecord(ctx context.Context, tx *gorm.DB, launch model.VoiceRoomRocketLaunch, reward model.VoiceRoomRocketRewardConfig, userID int64, now time.Time) (model.VoiceRoomRocketRewardRecord, error) { + id, err := utils.NextID() + if err != nil { + return model.VoiceRoomRocketRewardRecord{}, err + } + eventID := rewardGrantEventID(launch.LaunchNo, reward.RewardScene, userID, reward.ID) + record := model.VoiceRoomRocketRewardRecord{ + ID: id, + LaunchID: launch.ID, + LaunchNo: launch.LaunchNo, + SysOrigin: launch.SysOrigin, + RoomID: launch.RoomID, + DayKey: launch.DayKey, + RoundNo: launch.RoundNo, + Level: launch.Level, + UserID: userID, + RewardScene: reward.RewardScene, + RewardConfigID: reward.ID, + RewardType: reward.RewardType, + RewardItemID: reward.RewardItemID, + RewardName: reward.RewardName, + RewardCover: reward.RewardCover, + RewardAmount: reward.RewardAmount, + ExpireDays: reward.ExpireDays, + GrantEventID: eventID, + GrantStatus: rewardStatusPending, + PopupStatus: popupStatusUnread, + CreateTime: now, + UpdateTime: now, + } + if err := tx.WithContext(ctx).Create(&record).Error; err != nil && !isDuplicateKeyErr(err) { + return model.VoiceRoomRocketRewardRecord{}, err + } + if err != nil && isDuplicateKeyErr(err) { + _ = tx.WithContext(ctx).Where("grant_event_id = ?", eventID).First(&record).Error + } + return record, nil +} + +// RetryReward 尝试补发一条失败或待发奖励。当前先接金币,其他权益保留 PENDING 由后续 gateway 适配。 +func (s *Service) RetryReward(ctx context.Context, rewardRecordID int64) error { + if rewardRecordID <= 0 { + return nil + } + var row model.VoiceRoomRocketRewardRecord + if err := s.repo.DB.WithContext(ctx).Where("id = ?", rewardRecordID).First(&row).Error; err != nil { + return err + } + return s.processRewardRecord(ctx, row) +} + +// ProcessPendingRewards 自动扫描 PENDING 奖励记录并发放。 +func (s *Service) ProcessPendingRewards(ctx context.Context, limit int) (int, error) { + if limit <= 0 { + limit = defaultWorkerBatchSize + } + var rows []model.VoiceRoomRocketRewardRecord + if err := s.repo.DB.WithContext(ctx). + Where("grant_status = ?", rewardStatusPending). + Order("create_time ASC, id ASC"). + Limit(limit). + Find(&rows).Error; err != nil { + return 0, err + } + processed := 0 + for _, row := range rows { + if err := s.processRewardRecord(ctx, row); err != nil { + return processed, err + } + processed++ + } + return processed, nil +} + +func (s *Service) processRewardRecord(ctx context.Context, row model.VoiceRoomRocketRewardRecord) error { + if row.ID <= 0 { + return nil + } + unlock, err := s.acquireRewardLock(ctx, row.ID) + if err != nil { + if errors.Is(err, errRewardLocked) { + return nil + } + return err + } + if unlock != nil { + defer unlock() + } + var latest model.VoiceRoomRocketRewardRecord + if err := s.repo.DB.WithContext(ctx).Where("id = ?", row.ID).First(&latest).Error; err != nil { + return err + } + if latest.GrantStatus == rewardStatusSuccess { + return nil + } + return s.grantReward(ctx, latest) +} + +func (s *Service) grantReward(ctx context.Context, row model.VoiceRoomRocketRewardRecord) error { + var err error + switch strings.ToUpper(strings.TrimSpace(row.RewardType)) { + case rewardTypeGold: + err = s.grantGoldReward(ctx, row) + case rewardTypeMount, rewardTypeAvatarFrame, rewardTypeChatBubble, rewardTypeVIPCard, rewardTypeGift, rewardTypeBadge: + err = s.grantPropsReward(ctx, row) + default: + err = NewAppError(http.StatusBadRequest, "unsupported_reward_type", "unsupported reward type: "+row.RewardType) + } + now := time.Now() + if err != nil { + _ = s.repo.DB.WithContext(context.Background()). + Model(&model.VoiceRoomRocketRewardRecord{}). + Where("id = ?", row.ID). + Updates(map[string]any{ + "grant_status": rewardStatusFailed, + "error_message": truncateString(err.Error(), 1024), + "update_time": now, + }).Error + return err + } + if err := s.repo.DB.WithContext(ctx). + Model(&model.VoiceRoomRocketRewardRecord{}). + Where("id = ?", row.ID). + Updates(map[string]any{ + "grant_status": rewardStatusSuccess, + "error_message": "", + "grant_time": now, + "update_time": now, + }).Error; err != nil { + return err + } + _ = s.notifyRewardRecord(context.Background(), row) + return nil +} + +func (s *Service) grantGoldReward(ctx context.Context, row model.VoiceRoomRocketRewardRecord) error { + if s.wallet == nil { + return NewAppError(http.StatusServiceUnavailable, "wallet_unavailable", "wallet gateway is unavailable") + } + exists, err := s.wallet.ExistsGoldEvent(ctx, row.GrantEventID) + if err != nil { + return err + } + if !exists { + if err := s.wallet.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ + ReceiptType: "INCOME", + UserID: row.UserID, + SysOrigin: row.SysOrigin, + EventID: row.GrantEventID, + Remark: fmt.Sprintf("voice room rocket %s level %d", row.RewardScene, row.Level), + Amount: integration.NewPennyAmountPayloadFromDollar(row.RewardAmount), + CloseDelayAsset: true, + OpUserType: "BACK", + CustomizeOrigin: "VOICE_ROOM_ROCKET", + CustomizeOriginDesc: "Voice Room Rocket Reward", + }); err != nil { + return err + } + } + return nil +} + +func (s *Service) grantPropsReward(ctx context.Context, row model.VoiceRoomRocketRewardRecord) error { + if s.rewardGateway == nil { + return NewAppError(http.StatusServiceUnavailable, "reward_gateway_unavailable", "reward gateway is unavailable") + } + if row.RewardItemID == nil || *row.RewardItemID <= 0 { + return NewAppError(http.StatusBadRequest, "missing_reward_item", "rewardItemId is required for "+row.RewardType) + } + itemID := *row.RewardItemID + rewardType := strings.ToUpper(strings.TrimSpace(row.RewardType)) + days := rewardDays(row) + switch rewardType { + case rewardTypeBadge: + return s.rewardGateway.ActivateTemporaryBadge(ctx, row.UserID, itemID, days) + case rewardTypeVIPCard: + if err := s.giveProps(ctx, row, itemID, propsTypeNobleVIP, days, true); err != nil { + return err + } + if err := s.grantVIPAccessories(ctx, row, itemID, days); err != nil { + return err + } + _ = s.rewardGateway.RemoveUserProfileCacheAll(ctx, row.UserID) + return nil + default: + propsType, err := propsTypeForRewardType(rewardType) + if err != nil { + return err + } + useProps := rewardType != rewardTypeGift + if err := s.giveProps(ctx, row, itemID, propsType, days, useProps); err != nil { + return err + } + if useProps { + _ = s.rewardGateway.RemoveUserProfileCacheAll(ctx, row.UserID) + } + return nil + } +} + +func (s *Service) giveProps(ctx context.Context, row model.VoiceRoomRocketRewardRecord, propsID int64, propsType string, days int, useProps bool) error { + if err := s.rewardGateway.GivePropsBackpack(ctx, integration.GivePropsBackpackRequest{ + AcceptUserID: row.UserID, + PropsID: propsID, + Type: propsType, + Origin: "VOICE_ROOM_ROCKET", + OriginDesc: "Voice Room Rocket Reward", + Days: days, + UseProps: useProps, + AllowGive: false, + }); err != nil { + return err + } + if useProps { + if err := s.rewardGateway.SwitchUseProps(ctx, row.UserID, propsID); err != nil { + return err + } + } + return nil +} + +func (s *Service) grantVIPAccessories(ctx context.Context, row model.VoiceRoomRocketRewardRecord, sourceID int64, days int) error { + ability, err := s.rewardGateway.GetNobleVIPAbility(ctx, sourceID) + if err != nil { + return nil + } + grants := []struct { + id int64 + typ string + }{ + {int64(ability.CarID), propsTypeRide}, + {int64(ability.AvatarFrameID), propsTypeAvatarFrame}, + {int64(ability.ChatBubbleID), propsTypeChatBubble}, + } + for _, grant := range grants { + if grant.id <= 0 { + continue + } + if err := s.giveProps(ctx, row, grant.id, grant.typ, days, true); err != nil { + return err + } + } + return nil +} + +func propsTypeForRewardType(rewardType string) (string, error) { + switch strings.ToUpper(strings.TrimSpace(rewardType)) { + case rewardTypeMount: + return propsTypeRide, nil + case rewardTypeAvatarFrame: + return propsTypeAvatarFrame, nil + case rewardTypeChatBubble: + return propsTypeChatBubble, nil + case rewardTypeGift: + return propsTypeGift, nil + default: + return "", NewAppError(http.StatusBadRequest, "unsupported_reward_type", "unsupported reward type: "+rewardType) + } +} + +func rewardDays(row model.VoiceRoomRocketRewardRecord) int { + if row.ExpireDays != nil && *row.ExpireDays > 0 { + return *row.ExpireDays + } + if row.RewardAmount > 0 && strings.ToUpper(strings.TrimSpace(row.RewardType)) != rewardTypeGold { + if row.RewardAmount > int64(^uint(0)>>1) { + return 1 + } + return int(row.RewardAmount) + } + if strings.EqualFold(row.RewardType, rewardTypeVIPCard) { + return 30 + } + return 7 +} + +func (s *Service) acquireRewardLock(ctx context.Context, rewardRecordID int64) (func(), error) { + if s.repo.Redis == nil { + return nil, nil + } + key := "voice_room:rocket:reward_lock:" + strconv.FormatInt(rewardRecordID, 10) + token := strconv.FormatInt(time.Now().UnixNano(), 10) + ok, err := s.repo.Redis.SetNX(ctx, key, token, 30*time.Second).Result() + if err != nil { + return nil, err + } + if !ok { + return nil, errRewardLocked + } + return func() { _ = s.releaseRoomLock(context.Background(), key, token) }, nil +} + +func rewardGrantEventID(launchNo, scene string, userID int64, rewardConfigID int64) string { + return fmt.Sprintf("VOICE_ROOM_ROCKET:%s:%s:%d:%d", launchNo, strings.ToUpper(strings.TrimSpace(scene)), userID, rewardConfigID) +} diff --git a/internal/service/voiceroomrocket/status.go b/internal/service/voiceroomrocket/status.go new file mode 100644 index 0000000..7cc0c42 --- /dev/null +++ b/internal/service/voiceroomrocket/status.go @@ -0,0 +1,159 @@ +package voiceroomrocket + +import ( + "context" + "errors" + "net/http" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" +) + +// GetStatus 查询房间当前火箭状态,懒初始化当天状态。 +func (s *Service) GetStatus(ctx context.Context, user AuthUser, roomID int64) (*StatusResponse, error) { + if roomID <= 0 { + return nil, NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required") + } + snapshot, err := s.loadConfig(ctx, user.SysOrigin) + if err != nil { + return nil, err + } + local := resolveLocation(snapshot.Timezone) + now := time.Now().In(local) + status, err := s.loadOrInitStatus(ctx, nil, snapshot.SysOrigin, roomID, dayKey(now, local), now) + if err != nil { + return nil, err + } + levels, err := s.loadLevelConfigs(ctx, snapshot.SysOrigin, true) + if err != nil { + return nil, err + } + currentLevelConfig, err := s.loadLevelConfig(ctx, snapshot.SysOrigin, status.CurrentLevel) + if err != nil { + return nil, err + } + rewards, err := s.loadRewardConfigs(ctx, snapshot.SysOrigin, status.CurrentLevel) + if err != nil { + return nil, err + } + percent, displayPercent := decimalPercent(status.CurrentEnergy, status.NeedEnergy) + shake := status.NeedEnergy > 0 && + float64(status.CurrentEnergy)*100/float64(status.NeedEnergy) >= parsePercent(currentLevelConfig.ShakeThresholdPercent) + kings, _ := s.topKingRecords(ctx, snapshot.SysOrigin, roomID, status.DayKey, status.RoundNo, status.CurrentLevel, 3) + return &StatusResponse{ + RoomID: roomID, + DayKey: status.DayKey, + RoundNo: status.RoundNo, + DisplayRound: status.RoundNo > 1, + CurrentLevel: status.CurrentLevel, + CurrentEnergy: status.CurrentEnergy, + NeedEnergy: status.NeedEnergy, + EnergyPercent: percent, + DisplayPercent: displayPercent, + Shake: shake, + Levels: buildLevelPayloads(levels), + RewardPreview: rewardPreviewPayload(rewards), + RocketKings: kings, + }, nil +} + +func (s *Service) loadOrInitStatus(ctx context.Context, tx *gorm.DB, sysOrigin string, roomID int64, day string, now time.Time) (model.VoiceRoomRocketStatus, error) { + db := s.repo.DB.WithContext(ctx) + if tx != nil { + db = tx.WithContext(ctx) + } + var row model.VoiceRoomRocketStatus + err := db.Where("sys_origin = ? AND room_id = ? AND day_key = ?", sysOrigin, roomID, day).First(&row).Error + if err == nil { + return row, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return model.VoiceRoomRocketStatus{}, err + } + levelConfig, err := s.loadLevelConfig(ctx, sysOrigin, 1) + if err != nil { + return model.VoiceRoomRocketStatus{}, err + } + id, err := utils.NextID() + if err != nil { + return model.VoiceRoomRocketStatus{}, err + } + row = model.VoiceRoomRocketStatus{ + ID: id, + SysOrigin: sysOrigin, + RoomID: roomID, + DayKey: day, + RoundNo: 1, + CurrentLevel: 1, + CurrentEnergy: 0, + NeedEnergy: levelConfig.NeedEnergy, + Status: statusCharging, + LevelStartTime: now, + CreateTime: now, + UpdateTime: now, + } + if err := db.Create(&row).Error; err != nil { + if isDuplicateKeyErr(err) { + var existing model.VoiceRoomRocketStatus + if loadErr := db.Where("sys_origin = ? AND room_id = ? AND day_key = ?", sysOrigin, roomID, day).First(&existing).Error; loadErr != nil { + return model.VoiceRoomRocketStatus{}, loadErr + } + return existing, nil + } + return model.VoiceRoomRocketStatus{}, err + } + if err := s.ensureLevelRound(ctx, tx, sysOrigin, roomID, day, 1, 1, levelConfig.NeedEnergy, now); err != nil { + return model.VoiceRoomRocketStatus{}, err + } + return row, nil +} + +func (s *Service) ensureLevelRound(ctx context.Context, tx *gorm.DB, sysOrigin string, roomID int64, day string, roundNo int, level int, needEnergy int64, now time.Time) error { + db := s.repo.DB.WithContext(ctx) + if tx != nil { + db = tx.WithContext(ctx) + } + var row model.VoiceRoomRocketLevelRound + err := db.Where( + "sys_origin = ? AND room_id = ? AND day_key = ? AND round_no = ? AND level = ?", + sysOrigin, roomID, day, roundNo, level, + ).First(&row).Error + if err == nil { + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + id, err := utils.NextID() + if err != nil { + return err + } + row = model.VoiceRoomRocketLevelRound{ + ID: id, + SysOrigin: sysOrigin, + RoomID: roomID, + DayKey: day, + RoundNo: roundNo, + Level: level, + NeedEnergy: needEnergy, + FinalEnergy: 0, + Status: levelRoundStatusCharging, + CreateTime: now, + UpdateTime: now, + } + if err := db.Create(&row).Error; err != nil && !isDuplicateKeyErr(err) { + return err + } + return nil +} + +func buildLevelPayloads(rows []model.VoiceRoomRocketLevelConfig) []LevelConfigPayload { + result := make([]LevelConfigPayload, 0, len(rows)) + for _, row := range rows { + result = append(result, levelPayload(row)) + } + return result +} diff --git a/internal/service/voiceroomrocket/types.go b/internal/service/voiceroomrocket/types.go new file mode 100644 index 0000000..a4654ee --- /dev/null +++ b/internal/service/voiceroomrocket/types.go @@ -0,0 +1,470 @@ +package voiceroomrocket + +import ( + "context" + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + "time" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/service/tencentim" + + rmq "github.com/apache/rocketmq-clients/golang/v5" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +const ( + defaultSysOrigin = "LIKEI" + defaultTimezone = "Asia/Riyadh" + defaultMaxLevel = 6 + defaultRankingTopLimit = 100 + defaultRewardRecordKeepDays = 35 + defaultBroadcastDurationSeconds = 7 + defaultInRoomRewardDelaySeconds = 10 + defaultInRoomRewardUserLimit = 3 + defaultWorkerBatchSize = 100 + defaultWorkerInterval = time.Second + defaultRoomLockTTL = 10 * time.Second + + statusCharging = "CHARGING" + statusLaunching = "LAUNCHING" + + levelRoundStatusCharging = "CHARGING" + levelRoundStatusLaunched = "LAUNCHED" + + eventStatusIgnored = "IGNORED" + eventStatusSuccess = "SUCCESS" + eventStatusFailed = "FAILED" + + rewardSceneTop1 = "TOP1" + rewardSceneIgnite = "IGNITE" + rewardSceneInRoom = "IN_ROOM" + + rewardStatusPending = "PENDING" + rewardStatusSuccess = "SUCCESS" + rewardStatusFailed = "FAILED" + + popupStatusUnread = "UNREAD" + popupStatusRead = "READ" + + inRoomSnapshotPending = "PENDING" + inRoomSnapshotProcessing = "PROCESSING" + inRoomSnapshotSuccess = "SUCCESS" + inRoomSnapshotEmpty = "EMPTY" + inRoomSnapshotFailed = "FAILED" + + rewardTypeGold = "GOLD" + rewardTypeMount = "MOUNT" + rewardTypeAvatarFrame = "AVATAR_FRAME" + rewardTypeChatBubble = "CHAT_BUBBLE" + rewardTypeVIPCard = "VIP_CARD" + rewardTypeGift = "GIFT" + rewardTypeBadge = "BADGE" + + propsTypeNobleVIP = "NOBLE_VIP" + propsTypeRide = "RIDE" + propsTypeAvatarFrame = "AVATAR_FRAME" + propsTypeChatBubble = "CHAT_BUBBLE" + propsTypeGift = "GIFT" + propsTypeBadge = "BADGE" + + imTypeStatusUpdate = "VOICE_ROOM_ROCKET_STATUS_UPDATE" + imTypeLaunchBroadcast = "VOICE_ROOM_ROCKET_LAUNCH_BROADCAST" + imTypeRewardPopup = "VOICE_ROOM_ROCKET_REWARD_POPUP" + imTypeRewardUser = "VOICE_ROOM_ROCKET_REWARD_USER" +) + +type AppError = common.AppError +type AuthUser = common.AuthUser + +var NewAppError = common.NewAppError + +type voiceRoomRocketDB interface { + WithContext(ctx context.Context) *gorm.DB +} + +type imGateway interface { + SendCustomGroupMessage(ctx context.Context, groupID string, body any) error +} + +type roomProfileGateway interface { + MapRoomProfiles(ctx context.Context, roomIDs []int64) (map[int64]integration.RoomProfile, error) +} + +type userProfileGateway interface { + GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error) +} + +type walletGateway interface { + ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error + ExistsGoldEvent(ctx context.Context, eventID string) (bool, error) +} + +type rewardGateway interface { + walletGateway + GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error + GetNobleVIPAbility(ctx context.Context, sourceID int64) (integration.PropsNobleVIPAbility, error) + SwitchUseProps(ctx context.Context, userID int64, propsID int64) error + ActivateTemporaryBadge(ctx context.Context, userID int64, badgeID int64, days int) error + RemoveUserProfileCacheAll(ctx context.Context, userID int64) error +} + +type gatewayBundle interface { + roomProfileGateway + userProfileGateway + rewardGateway +} + +type ports struct { + DB voiceRoomRocketDB + Redis redis.Cmdable +} + +// Service 承接语音房火箭配置、状态、送礼加能量、发射、榜单和奖励记录。 +type Service struct { + cfg config.Config + repo ports + gateways gatewayBundle + im imGateway + roomProfiles roomProfileGateway + userProfiles userProfileGateway + wallet walletGateway + rewardGateway rewardGateway + location *time.Location + rocketConsumer rmq.PushConsumer +} + +// NewService 创建语音房火箭服务。 +func NewService(cfg config.Config, db voiceRoomRocketDB, cache redis.Cmdable, gateways gatewayBundle) *Service { + timezone := strings.TrimSpace(cfg.VoiceRoomRocket.Timezone) + if timezone == "" { + timezone = defaultTimezone + } + location, err := time.LoadLocation(timezone) + if err != nil { + location = time.UTC + } + service := &Service{ + cfg: cfg, + repo: ports{DB: db, Redis: cache}, + gateways: gateways, + im: tencentim.NewClient(cfg.TencentIM), + location: location, + } + if gateways != nil { + service.roomProfiles = gateways + service.userProfiles = gateways + service.wallet = gateways + service.rewardGateway = gateways + } + return service +} + +// SetIMGateway 允许测试或后续装配替换 IM 发送实现。 +func (s *Service) SetIMGateway(gateway imGateway) { + if s != nil { + s.im = gateway + } +} + +type flexibleInt64 int64 + +// FlexibleInt64 暴露给本地验证和路由测试,兼容 JSON 字符串形式的 Snowflake ID。 +type FlexibleInt64 = flexibleInt64 + +func (v *flexibleInt64) UnmarshalJSON(data []byte) error { + raw := strings.TrimSpace(string(data)) + if raw == "" || raw == "null" || raw == `""` { + *v = 0 + return nil + } + if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") { + unquoted, err := strconv.Unquote(raw) + if err != nil { + return err + } + raw = strings.TrimSpace(unquoted) + } + if raw == "" { + *v = 0 + return nil + } + if parsed, err := strconv.ParseInt(raw, 10, 64); err == nil { + *v = flexibleInt64(parsed) + return nil + } + parsed, err := strconv.ParseFloat(raw, 64) + if err != nil { + return fmt.Errorf("invalid int64 value: %w", err) + } + *v = flexibleInt64(int64(math.Round(parsed))) + return nil +} + +func (v flexibleInt64) Int64() int64 { return int64(v) } + +func (v flexibleInt64) MarshalJSON() ([]byte, error) { + return json.Marshal(strconv.FormatInt(int64(v), 10)) +} + +type flexibleString string + +func (v *flexibleString) UnmarshalJSON(data []byte) error { + raw := strings.TrimSpace(string(data)) + if raw == "" || raw == "null" { + *v = "" + return nil + } + if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") { + var parsed string + if err := json.Unmarshal(data, &parsed); err != nil { + return err + } + *v = flexibleString(strings.TrimSpace(parsed)) + return nil + } + *v = flexibleString(raw) + return nil +} + +func (v flexibleString) String() string { return string(v) } + +type giftEvent struct { + TrackID flexibleString `json:"trackId"` + SysOrigin string `json:"sysOrigin"` + SendUserID flexibleInt64 `json:"sendUserId"` + RoomID flexibleInt64 `json:"roomId"` + Quantity flexibleInt64 `json:"quantity"` + CreateTime flexibleInt64 `json:"createTime"` + Accepts []giftEventUser `json:"accepts"` + GiftConfig giftEventGift `json:"giftConfig"` + GiftValue giftEventValue `json:"giftValue"` + Extra map[string]any `json:"extra"` +} + +type giftEventUser struct { + AcceptUserID flexibleInt64 `json:"acceptUserId"` +} + +type giftEventGift struct { + ID flexibleInt64 `json:"id"` + GiftCandy flexibleInt64 `json:"giftCandy"` + GiftIntegral flexibleInt64 `json:"giftIntegral"` + GiftName string `json:"giftName"` + GiftPhoto string `json:"giftPhoto"` + Type string `json:"type"` + GiftTab string `json:"giftTab"` +} + +type giftEventValue struct { + ActualAmount flexibleInt64 `json:"actualAmount"` +} + +type messageEventEnvelope struct { + Tag string `json:"tag"` + Body json.RawMessage `json:"body"` +} + +type configSnapshot struct { + Configured bool + ID int64 + SysOrigin string + Enabled bool + Timezone string + MaxLevel int + RankingTopLimit int + RewardRecordKeepDays int + BroadcastDurationSeconds int + InRoomRewardDelaySeconds int + EnergyRuleJSON string + RuleText string + UpdateTime time.Time +} + +type LevelConfigPayload struct { + ID int64 `json:"id,string,omitempty"` + Level int `json:"level"` + NeedEnergy int64 `json:"needEnergy"` + ShakeThresholdPercent string `json:"shakeThresholdPercent"` + RocketIconURL string `json:"rocketIconUrl,omitempty"` + RocketAnimationURL string `json:"rocketAnimationUrl,omitempty"` + ProgressBarURL string `json:"progressBarUrl,omitempty"` + Enabled bool `json:"enabled"` + Sort int `json:"sort"` +} + +type RewardConfigPayload struct { + ID int64 `json:"id,string,omitempty"` + Level int `json:"level"` + RewardScene string `json:"rewardScene"` + RewardType string `json:"rewardType"` + RewardItemID *int64 `json:"rewardItemId,string,omitempty"` + RewardName string `json:"rewardName"` + RewardCover string `json:"rewardCover,omitempty"` + RewardAmount int64 `json:"rewardAmount"` + ExpireDays *int `json:"expireDays,omitempty"` + Weight int `json:"weight"` + Sort int `json:"sort"` + Enabled bool `json:"enabled"` +} + +type RewardPreviewResponse struct { + Top1 []RewardConfigPayload `json:"top1"` + Ignite []RewardConfigPayload `json:"ignite"` + InRoom []RewardConfigPayload `json:"inRoom"` +} + +type ConfigResponse struct { + Configured bool `json:"configured"` + ID int64 `json:"id,string,omitempty"` + SysOrigin string `json:"sysOrigin"` + Enabled bool `json:"enabled"` + Timezone string `json:"timezone"` + MaxLevel int `json:"maxLevel"` + RankingTopLimit int `json:"rankingTopLimit"` + RewardRecordKeepDays int `json:"rewardRecordKeepDays"` + BroadcastDurationSeconds int `json:"broadcastDurationSeconds"` + InRoomRewardDelaySeconds int `json:"inRoomRewardDelaySeconds"` + RuleText json.RawMessage `json:"ruleText,omitempty"` + Levels []LevelConfigPayload `json:"levels"` + RewardPreview RewardPreviewResponse `json:"rewardPreview"` + UpdateTime string `json:"updateTime,omitempty"` +} + +type SaveConfigRequest struct { + SysOrigin string `json:"sysOrigin"` + Enabled bool `json:"enabled"` + Timezone string `json:"timezone"` + MaxLevel int `json:"maxLevel"` + RankingTopLimit int `json:"rankingTopLimit"` + RewardRecordKeepDays int `json:"rewardRecordKeepDays"` + BroadcastDurationSeconds int `json:"broadcastDurationSeconds"` + InRoomRewardDelaySeconds int `json:"inRoomRewardDelaySeconds"` + EnergyRuleJSON json.RawMessage `json:"energyRuleJson"` + RuleText json.RawMessage `json:"ruleText"` +} + +type SaveLevelConfigsRequest struct { + SysOrigin string `json:"sysOrigin"` + Levels []LevelConfigPayload `json:"levels"` +} + +type SaveRewardConfigsRequest struct { + SysOrigin string `json:"sysOrigin"` + Rewards []RewardConfigPayload `json:"rewards"` +} + +type StatusResponse struct { + RoomID int64 `json:"roomId,string"` + DayKey string `json:"dayKey"` + RoundNo int `json:"roundNo"` + DisplayRound bool `json:"displayRound"` + CurrentLevel int `json:"currentLevel"` + CurrentEnergy int64 `json:"currentEnergy"` + NeedEnergy int64 `json:"needEnergy"` + EnergyPercent string `json:"energyPercent"` + DisplayPercent int `json:"displayPercent"` + Shake bool `json:"shake"` + Levels []LevelConfigPayload `json:"levels"` + RewardPreview RewardPreviewResponse `json:"rewardPreview"` + RocketKings []KingRecord `json:"rocketKings"` +} + +type KingResponse struct { + RoomID int64 `json:"roomId,string"` + DayKey string `json:"dayKey"` + RoundNo int `json:"roundNo"` + SourceRoundNo int `json:"sourceRoundNo"` + Level int `json:"level"` + Podium []KingRecord `json:"podium"` + Records []KingRecord `json:"records"` + EmptyReason string `json:"emptyReason,omitempty"` +} + +type KingRecord struct { + Rank int `json:"rank"` + UserID int64 `json:"userId,string"` + UserAvatar string `json:"userAvatar,omitempty"` + UserNickname string `json:"userNickname,omitempty"` + Account string `json:"account,omitempty"` + CountryCode string `json:"countryCode,omitempty"` + CountryName string `json:"countryName,omitempty"` + ScoreEnergy int64 `json:"scoreEnergy"` +} + +type RewardRecordView struct { + ID int64 `json:"id,string"` + LaunchNo string `json:"launchNo"` + RoomID int64 `json:"roomId,string"` + DayKey string `json:"dayKey"` + RoundNo int `json:"roundNo"` + Level int `json:"level"` + UserID int64 `json:"userId,string"` + RewardScene string `json:"rewardScene"` + RewardType string `json:"rewardType"` + RewardItemID *int64 `json:"rewardItemId,string,omitempty"` + RewardName string `json:"rewardName"` + RewardCover string `json:"rewardCover,omitempty"` + RewardAmount int64 `json:"rewardAmount"` + ExpireDays *int `json:"expireDays,omitempty"` + GrantStatus string `json:"grantStatus"` + PopupStatus string `json:"popupStatus"` + ErrorMessage string `json:"errorMessage,omitempty"` + GrantTime string `json:"grantTime,omitempty"` + CreateTime string `json:"createTime"` +} + +type RewardRecordPageResponse struct { + Records []RewardRecordView `json:"records"` + EmptyReason string `json:"emptyReason,omitempty"` + Cursor int `json:"cursor"` + Limit int `json:"limit"` +} + +type RewardPopupResponse struct { + Records []RewardRecordView `json:"records"` +} + +type AckRewardPopupsRequest struct { + RewardRecordIDs []flexibleInt64 `json:"rewardRecordIds"` +} + +type LaunchRecordView struct { + ID int64 `json:"id,string"` + LaunchNo string `json:"launchNo"` + SysOrigin string `json:"sysOrigin"` + RoomID int64 `json:"roomId,string"` + RoomAccount string `json:"roomAccount,omitempty"` + DayKey string `json:"dayKey"` + RoundNo int `json:"roundNo"` + Level int `json:"level"` + FinalEnergy int64 `json:"finalEnergy"` + Top1UserID *int64 `json:"top1UserId,string,omitempty"` + IgniteUserID *int64 `json:"igniteUserId,string,omitempty"` + InRoomSnapshotStatus string `json:"inRoomSnapshotStatus"` + LaunchTime string `json:"launchTime"` +} + +type LaunchRecordPageResponse struct { + Records []LaunchRecordView `json:"records"` + Cursor int `json:"cursor"` + Limit int `json:"limit"` +} + +type ProcessGiftResponse struct { + Processed bool `json:"processed"` + Reason string `json:"reason,omitempty"` + RoomID int64 `json:"roomId,string,omitempty"` + DayKey string `json:"dayKey,omitempty"` + RoundNo int `json:"roundNo,omitempty"` + Level int `json:"level,omitempty"` + AcceptedEnergy int64 `json:"acceptedEnergy,omitempty"` + Launched bool `json:"launched"` + LaunchNo string `json:"launchNo,omitempty"` +} diff --git a/migrations/032_voice_room_rocket.sql b/migrations/032_voice_room_rocket.sql new file mode 100644 index 0000000..c063383 --- /dev/null +++ b/migrations/032_voice_room_rocket.sql @@ -0,0 +1,195 @@ +CREATE TABLE IF NOT EXISTS voice_room_rocket_config ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL COMMENT '系统来源', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Riyadh' COMMENT '业务时区', + max_level INT NOT NULL DEFAULT 6 COMMENT '最大等级', + ranking_top_limit INT NOT NULL DEFAULT 100 COMMENT '火箭王榜单展示人数', + reward_record_keep_days INT NOT NULL DEFAULT 35 COMMENT '用户奖励记录展示天数', + broadcast_duration_seconds INT NOT NULL DEFAULT 7 COMMENT '发射广播展示秒数', + in_room_reward_delay_seconds INT NOT NULL DEFAULT 10 COMMENT '在房间奖励延迟窗口秒数', + energy_rule_json JSON DEFAULT NULL COMMENT '礼物折算规则', + rule_text JSON DEFAULT NULL COMMENT '规则说明多语言文案', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_voice_room_rocket_config (sys_origin) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房火箭系统配置'; + +CREATE TABLE IF NOT EXISTS voice_room_rocket_level_config ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + level INT NOT NULL COMMENT 'Lv.1-Lv.6', + need_energy BIGINT NOT NULL COMMENT '本级发射所需能量', + shake_threshold_percent DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '抖动阈值百分比', + rocket_icon_url VARCHAR(512) DEFAULT NULL, + rocket_animation_url VARCHAR(512) DEFAULT NULL, + progress_bar_url VARCHAR(512) DEFAULT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + sort INT NOT NULL DEFAULT 0, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_voice_room_rocket_level (sys_origin, level) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房火箭等级配置'; + +CREATE TABLE IF NOT EXISTS voice_room_rocket_reward_config ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + level INT NOT NULL COMMENT '适用等级', + reward_scene VARCHAR(32) NOT NULL COMMENT 'TOP1/IGNITE/IN_ROOM', + reward_type VARCHAR(32) NOT NULL COMMENT 'GOLD/MOUNT/AVATAR_FRAME/CHAT_BUBBLE/VIP_CARD/GIFT/BADGE', + reward_item_id BIGINT DEFAULT NULL COMMENT '道具、礼物、权益等资源ID', + reward_name VARCHAR(128) NOT NULL, + reward_cover VARCHAR(512) DEFAULT NULL, + reward_amount BIGINT NOT NULL DEFAULT 1 COMMENT '金币数量或道具数量/天数', + expire_days INT DEFAULT NULL COMMENT '时效类奖励有效天数', + weight INT NOT NULL DEFAULT 0 COMMENT 'IN_ROOM 随机权重;0 表示固定发放', + sort INT NOT NULL DEFAULT 0, + enabled TINYINT(1) NOT NULL DEFAULT 1, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_voice_room_rocket_reward_scene (sys_origin, level, reward_scene, enabled) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房火箭奖励配置'; + +CREATE TABLE IF NOT EXISTS voice_room_rocket_status ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + room_id BIGINT NOT NULL, + day_key VARCHAR(16) NOT NULL COMMENT 'UTC+3 业务日 yyyyMMdd', + round_no INT NOT NULL DEFAULT 1, + current_level INT NOT NULL DEFAULT 1, + current_energy BIGINT NOT NULL DEFAULT 0, + need_energy BIGINT NOT NULL, + status VARCHAR(32) NOT NULL COMMENT 'CHARGING/LAUNCHING', + level_start_time DATETIME NOT NULL, + last_event_time DATETIME DEFAULT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_voice_room_rocket_status (sys_origin, room_id, day_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房火箭当前状态'; + +CREATE TABLE IF NOT EXISTS voice_room_rocket_level_round ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + room_id BIGINT NOT NULL, + day_key VARCHAR(16) NOT NULL, + round_no INT NOT NULL, + level INT NOT NULL, + need_energy BIGINT NOT NULL, + final_energy BIGINT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL COMMENT 'CHARGING/LAUNCHED', + top1_user_id BIGINT DEFAULT NULL, + ignite_user_id BIGINT DEFAULT NULL, + reach_time DATETIME DEFAULT NULL COMMENT '能量达标时间', + launch_time DATETIME DEFAULT NULL COMMENT '发射时间', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_voice_room_rocket_level_round (sys_origin, room_id, day_key, round_no, level), + KEY idx_voice_room_rocket_level_round_room (sys_origin, room_id, day_key, round_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房火箭每轮每级快照'; + +CREATE TABLE IF NOT EXISTS voice_room_rocket_contribution ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + room_id BIGINT NOT NULL, + day_key VARCHAR(16) NOT NULL, + round_no INT NOT NULL, + level INT NOT NULL, + user_id BIGINT NOT NULL, + score_energy BIGINT NOT NULL DEFAULT 0, + gift_count INT NOT NULL DEFAULT 0, + first_contribute_time DATETIME NOT NULL, + last_contribute_time DATETIME NOT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_voice_room_rocket_contribution_user (sys_origin, room_id, day_key, round_no, level, user_id), + KEY idx_voice_room_rocket_contribution_rank (sys_origin, room_id, day_key, round_no, level, score_energy, last_contribute_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房火箭贡献榜'; + +CREATE TABLE IF NOT EXISTS voice_room_rocket_gift_event_log ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + track_id VARCHAR(128) NOT NULL, + room_id BIGINT NOT NULL, + send_user_id BIGINT NOT NULL, + gift_id BIGINT DEFAULT NULL, + gift_type VARCHAR(32) DEFAULT NULL, + gift_tab VARCHAR(64) DEFAULT NULL, + raw_energy BIGINT NOT NULL DEFAULT 0, + accepted_energy BIGINT NOT NULL DEFAULT 0, + day_key VARCHAR(16) NOT NULL, + round_no INT NOT NULL, + level INT NOT NULL, + status VARCHAR(32) NOT NULL COMMENT 'IGNORED/SUCCESS/FAILED', + raw_payload MEDIUMTEXT, + error_message VARCHAR(1024) DEFAULT NULL, + event_time DATETIME NOT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_voice_room_rocket_track (track_id), + KEY idx_voice_room_rocket_event_room (sys_origin, room_id, day_key, round_no, level) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房火箭送礼事件日志'; + +CREATE TABLE IF NOT EXISTS voice_room_rocket_launch ( + id BIGINT NOT NULL PRIMARY KEY, + launch_no VARCHAR(64) NOT NULL, + sys_origin VARCHAR(32) NOT NULL, + room_id BIGINT NOT NULL, + room_account VARCHAR(64) DEFAULT NULL, + day_key VARCHAR(16) NOT NULL, + round_no INT NOT NULL, + level INT NOT NULL, + final_energy BIGINT NOT NULL, + top1_user_id BIGINT DEFAULT NULL, + ignite_user_id BIGINT DEFAULT NULL, + in_room_snapshot_status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + launch_time DATETIME NOT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_voice_room_rocket_launch_no (launch_no), + UNIQUE KEY uk_voice_room_rocket_launch_level (sys_origin, room_id, day_key, round_no, level), + KEY idx_voice_room_rocket_launch_room (sys_origin, room_id, launch_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房火箭发射记录'; + +CREATE TABLE IF NOT EXISTS voice_room_rocket_reward_record ( + id BIGINT NOT NULL PRIMARY KEY, + launch_id BIGINT NOT NULL, + launch_no VARCHAR(64) NOT NULL, + sys_origin VARCHAR(32) NOT NULL, + room_id BIGINT NOT NULL, + day_key VARCHAR(16) NOT NULL, + round_no INT NOT NULL, + level INT NOT NULL, + user_id BIGINT NOT NULL, + reward_scene VARCHAR(32) NOT NULL, + reward_config_id BIGINT NOT NULL, + reward_type VARCHAR(32) NOT NULL, + reward_item_id BIGINT DEFAULT NULL, + reward_name VARCHAR(128) NOT NULL, + reward_cover VARCHAR(512) DEFAULT NULL, + reward_amount BIGINT NOT NULL DEFAULT 1, + expire_days INT DEFAULT NULL, + grant_event_id VARCHAR(128) NOT NULL, + grant_status VARCHAR(32) NOT NULL COMMENT 'PENDING/SUCCESS/FAILED', + popup_status VARCHAR(32) NOT NULL DEFAULT 'UNREAD' COMMENT 'UNREAD/READ', + error_message VARCHAR(1024) DEFAULT NULL, + grant_time DATETIME DEFAULT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_voice_room_rocket_reward_event (grant_event_id), + KEY idx_voice_room_rocket_reward_user (sys_origin, user_id, create_time), + KEY idx_voice_room_rocket_reward_launch (launch_id, user_id), + KEY idx_voice_room_rocket_reward_retry (grant_status, update_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房火箭奖励记录'; + +CREATE TABLE IF NOT EXISTS voice_room_rocket_audience_snapshot ( + id BIGINT NOT NULL PRIMARY KEY, + launch_id BIGINT NOT NULL, + sys_origin VARCHAR(32) NOT NULL, + room_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + last_seen_time DATETIME NOT NULL, + selected TINYINT(1) NOT NULL DEFAULT 0, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_voice_room_rocket_audience (launch_id, user_id), + KEY idx_voice_room_rocket_audience_launch (launch_id, selected) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='语音房火箭在房间用户快照'; diff --git a/scripts/voice_room_rocket_live_verify.go b/scripts/voice_room_rocket_live_verify.go new file mode 100644 index 0000000..230f752 --- /dev/null +++ b/scripts/voice_room_rocket_live_verify.go @@ -0,0 +1,657 @@ +//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_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) + } +} diff --git a/scripts/voice_room_rocket_local_verify.go b/scripts/voice_room_rocket_local_verify.go new file mode 100644 index 0000000..f34cc8c --- /dev/null +++ b/scripts/voice_room_rocket_local_verify.go @@ -0,0 +1,422 @@ +//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/voiceroomrocket" + + "github.com/redis/go-redis/v9" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +type localGateway struct { + db *gorm.DB + goldEvents map[string]integration.GoldReceiptCommand +} + +func (g localGateway) MapRoomProfiles(ctx context.Context, roomIDs []int64) (map[int64]integration.RoomProfile, error) { + result := make(map[int64]integration.RoomProfile, len(roomIDs)) + for _, roomID := range roomIDs { + result[roomID] = integration.RoomProfile{ + ID: integration.Int64Value(roomID), + RoomAccount: strconv.FormatInt(roomID, 10), + RoomName: "local-room-" + strconv.FormatInt(roomID, 10), + SysOrigin: "LIKEI", + } + } + return result, nil +} + +func (g localGateway) GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error) { + var user model.UserBaseInfo + if err := g.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil { + return integration.UserProfile{}, err + } + return integration.UserProfile{ + ID: integration.Int64Value(user.ID), + Account: user.Account, + UserAvatar: user.UserAvatar, + UserNickname: user.UserNickname, + CountryCode: user.CountryCode, + CountryName: user.CountryName, + OriginSys: user.OriginSys, + }, nil +} + +func (g localGateway) ExistsGoldEvent(ctx context.Context, eventID string) (bool, error) { + _, ok := g.goldEvents[eventID] + return ok, nil +} + +func (g localGateway) ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error { + g.goldEvents[cmd.EventID] = cmd + return nil +} + +func (g localGateway) GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error { + return nil +} + +func (g localGateway) GetNobleVIPAbility(ctx context.Context, sourceID int64) (integration.PropsNobleVIPAbility, error) { + return integration.PropsNobleVIPAbility{}, nil +} + +func (g localGateway) SwitchUseProps(ctx context.Context, userID int64, propsID int64) error { + return nil +} + +func (g localGateway) ActivateTemporaryBadge(ctx context.Context, userID int64, badgeID int64, days int) error { + return nil +} + +func (g localGateway) RemoveUserProfileCacheAll(ctx context.Context, userID int64) error { + return nil +} + +type sampleRoom struct { + RoomID int64 + UserID int64 +} + +type verifySummary struct { + DSNHost string `json:"dsnHost"` + RedisAddr string `json:"redisAddr"` + RoomID int64 `json:"roomId,string"` + Top1UserID int64 `json:"top1UserId,string"` + IgniteUserID int64 `json:"igniteUserId,string"` + DayKey string `json:"dayKey"` + FirstAcceptedEnergy int64 `json:"firstAcceptedEnergy"` + SecondRawEnergy int64 `json:"secondRawEnergy"` + SecondAcceptedEnergy int64 `json:"secondAcceptedEnergy"` + LaunchNo string `json:"launchNo"` + StatusLevel int `json:"statusLevel"` + StatusEnergy int64 `json:"statusEnergy"` + KingRecords int `json:"kingRecords"` + Top1PopupBeforeAck int `json:"top1PopupBeforeAck"` + Top1PopupAfterAck int `json:"top1PopupAfterAck"` + IgnitePopupCount int `json:"ignitePopupCount"` + RewardRecordCount int64 `json:"rewardRecordCount"` + SnapshotCount int64 `json:"snapshotCount"` + SelectedAudienceCount int64 `json:"selectedAudienceCount"` + GrantedRewardCount int64 `json:"grantedRewardCount"` + PendingRewardCount int64 `json:"pendingRewardCount"` + GoldGrantCount int `json:"goldGrantCount"` + BroadcastEventCount int64 `json:"broadcastEventCount"` + MQEnvelopeVerified bool `json:"mqEnvelopeVerified"` + DuplicateReason string `json:"duplicateReason"` +} + +func main() { + ctx := context.Background() + dsn := env("CHATAPP_STORE_MYSQL_DSN", "root:123456@tcp(127.0.0.1:13306)/likei?charset=utf8mb4&parseTime=True&loc=Asia%2FRiyadh") + redisAddr := env("CHATAPP_STORE_REDIS_ADDR", "127.0.0.1:16379") + + db, err := gorm.Open(mysql.Open(dsn), &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: redisAddr}) + defer rdb.Close() + must(rdb.Ping(ctx).Err(), "ping redis") + + room := pickRoom(ctx, db) + otherUsers := pickUsers(ctx, db, room.UserID, 3) + if room.RoomID <= 0 || room.UserID <= 0 || len(otherUsers) < 3 { + log.Fatalf("missing real room/user sample: room=%+v otherUsers=%v", room, otherUsers) + } + igniteUserID := otherUsers[0] + + cfg := config.Config{ + VoiceRoomRocket: config.VoiceRoomRocketConfig{ + DefaultSysOrigin: "LIKEI", + Timezone: "Asia/Riyadh", + InRoomRewardDueZSetKey: "voice_room:rocket:in_room_due", + BroadcastStreamKey: "voice_room:rocket:broadcast", + RoomLockTTLSeconds: 10, + }, + } + gateway := localGateway{db: db, goldEvents: map[string]integration.GoldReceiptCommand{}} + service := voiceroomrocket.NewService(cfg, db, rdb, gateway) + user := common.AuthUser{UserID: room.UserID, SysOrigin: "LIKEI"} + igniteUser := common.AuthUser{UserID: igniteUserID, SysOrigin: "LIKEI"} + + now := time.Now() + day := now.In(time.FixedZone("UTC+3", 3*60*60)).Format("20060102") + cleanup(ctx, db, rdb, room.RoomID, day) + seedConfig(ctx, service, db) + seedPresence(ctx, rdb, room.RoomID, append([]int64{room.UserID, igniteUserID}, otherUsers[1:]...)) + + initial, err := service.GetStatus(ctx, user, room.RoomID) + must(err, "initial status") + assert(initial.CurrentLevel == 1 && initial.CurrentEnergy == 0 && initial.NeedEnergy == 100, "initial status mismatch") + + firstPayload := giftPayload("LOCAL_VERIFY_TRACK_1", room.UserID, room.RoomID, 60, now) + first, err := service.ProcessGiftPayload(ctx, firstPayload) + must(err, "process first gift") + assert(first.Processed && first.AcceptedEnergy == 60 && !first.Launched, "first gift result mismatch") + + mid, err := service.GetStatus(ctx, user, room.RoomID) + must(err, "mid status") + assert(mid.CurrentLevel == 1 && mid.CurrentEnergy == 60 && mid.DisplayPercent == 60, "mid status mismatch") + + 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") + + finalStatus, err := service.GetStatus(ctx, user, room.RoomID) + must(err, "final status") + assert(finalStatus.CurrentLevel == 2 && finalStatus.CurrentEnergy == 0 && finalStatus.NeedEnergy == 200, "final status mismatch") + + king, err := service.GetKingRanking(ctx, user, room.RoomID, 1, 1, 1, 100) + must(err, "king ranking") + assert(len(king.Records) >= 2, "king records missing") + assert(king.Records[0].UserID == room.UserID && king.Records[0].ScoreEnergy == 60, "top1 ranking mismatch") + assert(king.Records[1].UserID == igniteUserID && king.Records[1].ScoreEnergy == 40, "ignite ranking mismatch") + + top1Popups, err := service.ListRewardPopups(ctx, user, room.RoomID) + must(err, "top1 popups") + assert(len(top1Popups.Records) >= 1, "top1 popup count mismatch") + ignitePopups, err := service.ListRewardPopups(ctx, igniteUser, room.RoomID) + must(err, "ignite popups") + assert(len(ignitePopups.Records) >= 1, "ignite popup count mismatch") + + ackIDs := make([]voiceroomrocket.FlexibleInt64, 0, len(top1Popups.Records)) + for _, record := range top1Popups.Records { + ackIDs = append(ackIDs, voiceroomrocket.FlexibleInt64(record.ID)) + } + must(service.AckRewardPopups(ctx, user, voiceroomrocket.AckRewardPopupsRequest{ + RewardRecordIDs: ackIDs, + }), "ack top1 popup") + top1AfterAck, err := service.ListRewardPopups(ctx, user, room.RoomID) + must(err, "top1 popups after ack") + assert(len(top1AfterAck.Records) == 0, "top1 popup ack mismatch") + + rewardRecords, err := service.PageRewardRecords(ctx, user, room.RoomID, 1, 20) + 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) + must(err, "process due in-room rewards") + assert(dueProcessed == 1, "due in-room reward count mismatch") + + granted, err := service.ProcessPendingRewards(ctx, 100) + must(err, "process pending rewards") + assert(granted >= 5, "granted reward count mismatch") + + duplicate, err := service.ProcessGiftPayload(ctx, secondPayload) + must(err, "process duplicate gift") + assert(duplicate.Reason == "duplicate_event", "duplicate reason mismatch") + + var rewardCount int64 + must(db.WithContext(ctx).Model(&model.VoiceRoomRocketRewardRecord{}). + Where("room_id = ? AND day_key = ?", room.RoomID, day). + Count(&rewardCount).Error, "count rewards") + var snapshotCount int64 + must(db.WithContext(ctx).Model(&model.VoiceRoomRocketAudienceSnapshot{}). + Where("room_id = ?", room.RoomID). + Count(&snapshotCount).Error, "count snapshots") + var selectedCount int64 + must(db.WithContext(ctx).Model(&model.VoiceRoomRocketAudienceSnapshot{}). + Where("room_id = ? AND selected = ?", room.RoomID, true). + Count(&selectedCount).Error, "count selected snapshots") + var successCount int64 + must(db.WithContext(ctx).Model(&model.VoiceRoomRocketRewardRecord{}). + Where("room_id = ? AND day_key = ? AND grant_status = ?", room.RoomID, day, "SUCCESS"). + Count(&successCount).Error, "count granted rewards") + var pendingCount int64 + must(db.WithContext(ctx).Model(&model.VoiceRoomRocketRewardRecord{}). + Where("room_id = ? AND day_key = ? AND grant_status <> ?", room.RoomID, day, "SUCCESS"). + Count(&pendingCount).Error, "count pending rewards") + broadcastCount, err := rdb.XLen(ctx, "voice_room:rocket:broadcast").Result() + must(err, "count broadcast stream") + + summary := verifySummary{ + DSNHost: "127.0.0.1:13306/likei", + RedisAddr: redisAddr, + RoomID: room.RoomID, + Top1UserID: room.UserID, + IgniteUserID: igniteUserID, + DayKey: day, + FirstAcceptedEnergy: first.AcceptedEnergy, + SecondRawEnergy: 80, + SecondAcceptedEnergy: second.AcceptedEnergy, + LaunchNo: second.LaunchNo, + StatusLevel: finalStatus.CurrentLevel, + StatusEnergy: finalStatus.CurrentEnergy, + KingRecords: len(king.Records), + Top1PopupBeforeAck: len(top1Popups.Records), + Top1PopupAfterAck: len(top1AfterAck.Records), + IgnitePopupCount: len(ignitePopups.Records), + RewardRecordCount: rewardCount, + SnapshotCount: snapshotCount, + SelectedAudienceCount: selectedCount, + GrantedRewardCount: successCount, + PendingRewardCount: pendingCount, + GoldGrantCount: len(gateway.goldEvents), + BroadcastEventCount: broadcastCount, + MQEnvelopeVerified: true, + DuplicateReason: duplicate.Reason, + } + encoded, err := json.MarshalIndent(summary, "", " ") + must(err, "marshal summary") + fmt.Println(string(encoded)) +} + +func pickRoom(ctx context.Context, db *gorm.DB) sampleRoom { + var row sampleRoom + _ = 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 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_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 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 voice_room_rocket_audience_snapshot") + must(db.WithContext(ctx).Exec("DELETE FROM voice_room_rocket_reward_config WHERE sys_origin = ? AND reward_name LIKE ?", "LIKEI", "Local 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() + _ = rdb.Del(ctx, "voice_room:rocket:broadcast").Err() +} + +func seedConfig(ctx context.Context, service *voiceroomrocket.Service, db *gorm.DB) { + _, 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":"本地验证规则"}`), + }) + must(err, "save 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 levels") + + _, err = service.SaveRewardConfigs(ctx, voiceroomrocket.SaveRewardConfigsRequest{ + SysOrigin: "LIKEI", + 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}, + }, + }) + must(err, "save rewards") + + var count int64 + must(db.WithContext(ctx).Model(&model.VoiceRoomRocketLevelConfig{}).Where("sys_origin = ? AND enabled = ?", "LIKEI", true).Count(&count).Error, "count levels") + assert(count >= 6, "level config missing") +} + +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 mqEnvelope(body string) string { + encodedBody, err := json.Marshal(body) + must(err, "marshal mq body") + return fmt.Sprintf(`{"tag":"give_gift_v3","body":%s}`, string(encodedBody)) +} + +func env(key, fallback string) string { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + return value +} + +func must(err error, label string) { + if err != nil { + log.Fatalf("%s: %v", label, err) + } +} + +func assert(ok bool, message string) { + if !ok { + log.Fatal(message) + } +}