Add voice room rocket feature

This commit is contained in:
hy001 2026-05-14 13:46:53 +08:00
parent 2bba927458
commit dd9020a542
25 changed files with 5750 additions and 10 deletions

View File

@ -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{

View File

@ -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)
}

View File

@ -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"},

View File

@ -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"
}

View File

@ -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)

View File

@ -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
}

View File

@ -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())

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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"`
}

View File

@ -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='语音房火箭在房间用户快照';

View File

@ -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)
}
}

View File

@ -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)
}
}