Compare commits

...

3 Commits

Author SHA1 Message Date
hy001
31433b0a1b 砸蛋,转盘 2026-05-15 09:58:01 +08:00
hy001
51b8b52c57 feat: complete voice room rocket launch rewards 2026-05-14 20:02:43 +08:00
hy001
dd9020a542 Add voice room rocket feature 2026-05-14 13:46:53 +08:00
52 changed files with 12742 additions and 11 deletions

View File

@ -28,10 +28,13 @@ import (
"chatapp3-golang/internal/service/registerreward"
"chatapp3-golang/internal/service/roomturnoverreward"
"chatapp3-golang/internal/service/signinreward"
"chatapp3-golang/internal/service/smashegg"
"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"
"chatapp3-golang/internal/service/wheel"
)
func main() {
@ -56,9 +59,13 @@ func main() {
signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
vipService := vip.NewService(app.Config, app.Repository.DB, &app.Gateways)
wheelService := wheel.NewService(app.Config, app.Repository.DB, &app.Gateways)
smashEggService := smashegg.NewService(app.Config, app.Repository.DB, &app.Gateways)
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)
voiceRoomRocketService.SetRegionBroadcaster(regionIMGroupService)
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 +86,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)
}
@ -105,9 +115,12 @@ func main() {
RegisterReward: registerRewardService,
RoomTurnoverReward: roomTurnoverRewardService,
SignInReward: signInRewardService,
SmashEgg: smashEggService,
TaskCenter: taskCenterService,
VIP: vipService,
Wheel: wheelService,
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,8 @@ 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)
voiceRoomRocketService.SetRegionBroadcaster(regionIMGroupService)
workerCtx, workerCancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer workerCancel()
@ -42,6 +45,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,98 @@
package model
import "time"
// SmashEggConfig stores the resident smash golden egg activity master config.
type SmashEggConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_smash_egg_config_sys_origin"`
Enabled bool `gorm:"column:enabled;index:idx_smash_egg_config_enabled"`
Timezone string `gorm:"column:timezone;size:64"`
RTPBasisPoints int `gorm:"column:rtp_basis_points"`
NewbiePoolEnabled bool `gorm:"column:newbie_pool_enabled"`
NewbieWindowDays int `gorm:"column:newbie_window_days"`
NewbieMaxDrawCount int `gorm:"column:newbie_max_draw_count"`
NewbieMinRechargeAmount int64 `gorm:"column:newbie_min_recharge_amount"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the smash egg master configuration table name.
func (SmashEggConfig) TableName() string { return "smash_egg_config" }
// SmashEggDrawOptionConfig stores one draw button option.
type SmashEggDrawOptionConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
ConfigID int64 `gorm:"column:config_id;index:idx_smash_egg_option_config,priority:1"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_smash_egg_option_enabled,priority:1"`
OptionKey string `gorm:"column:option_key;size:32"`
Label string `gorm:"column:label;size:64"`
Times int `gorm:"column:times"`
PriceGold int64 `gorm:"column:price_gold"`
Sort int `gorm:"column:sort"`
Enabled bool `gorm:"column:enabled;index:idx_smash_egg_option_enabled,priority:2"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the smash egg draw option configuration table name.
func (SmashEggDrawOptionConfig) TableName() string { return "smash_egg_draw_option_config" }
// SmashEggRewardConfig stores one prize on the smash egg activity.
type SmashEggRewardConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
ConfigID int64 `gorm:"column:config_id;index:idx_smash_egg_reward_config,priority:1"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_smash_egg_reward_enabled,priority:1"`
PoolType string `gorm:"column:pool_type;size:32;index:idx_smash_egg_reward_pool,priority:2"`
RewardType string `gorm:"column:reward_type;size:32"`
RewardGroupID *int64 `gorm:"column:reward_group_id"`
RewardGroupName string `gorm:"column:reward_group_name;size:255"`
ResourceType string `gorm:"column:resource_type;size:64"`
ResourceURL string `gorm:"column:resource_url;size:512"`
CoverURL string `gorm:"column:cover_url;size:512"`
AnimationURL string `gorm:"column:animation_url;size:512"`
DurationDays int `gorm:"column:duration_days"`
DisplayGoldAmount int64 `gorm:"column:display_gold_amount"`
GoldAmount int64 `gorm:"column:gold_amount"`
RewardValueGold int64 `gorm:"column:reward_value_gold"`
Probability int `gorm:"column:probability"`
Sort int `gorm:"column:sort"`
Enabled bool `gorm:"column:enabled;index:idx_smash_egg_reward_enabled,priority:2"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the smash egg reward configuration table name.
func (SmashEggRewardConfig) TableName() string { return "smash_egg_reward_config" }
// SmashEggDrawRecord stores each prize item produced by a smash egg draw.
type SmashEggDrawRecord struct {
ID int64 `gorm:"column:id;primaryKey"`
DrawNo string `gorm:"column:draw_no;size:64;index:idx_smash_egg_draw_record_draw_no"`
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_smash_egg_draw_record_event"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_smash_egg_draw_record_user_time,priority:1;index:idx_smash_egg_draw_record_day,priority:1"`
UserID int64 `gorm:"column:user_id;index:idx_smash_egg_draw_record_user_time,priority:2"`
PoolType string `gorm:"column:pool_type;size:32;index:idx_smash_egg_draw_record_user_pool,priority:3"`
DrawOptionID int64 `gorm:"column:draw_option_id"`
DrawTimes int `gorm:"column:draw_times"`
PaidGold int64 `gorm:"column:paid_gold"`
RewardType string `gorm:"column:reward_type;size:32"`
RewardGroupID *int64 `gorm:"column:reward_group_id"`
RewardGroupName string `gorm:"column:reward_group_name;size:255"`
ResourceType string `gorm:"column:resource_type;size:64"`
ResourceURL string `gorm:"column:resource_url;size:512"`
CoverURL string `gorm:"column:cover_url;size:512"`
AnimationURL string `gorm:"column:animation_url;size:512"`
DurationDays int `gorm:"column:duration_days"`
DisplayGoldAmount int64 `gorm:"column:display_gold_amount"`
GoldAmount int64 `gorm:"column:gold_amount"`
RewardValueGold int64 `gorm:"column:reward_value_gold"`
Probability int `gorm:"column:probability"`
Status string `gorm:"column:status;size:32;index:idx_smash_egg_draw_record_status"`
ErrorMessage string `gorm:"column:error_message;size:1024"`
CreateTime time.Time `gorm:"column:create_time;index:idx_smash_egg_draw_record_user_time,priority:3;index:idx_smash_egg_draw_record_day,priority:2"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the smash egg draw record table name.
func (SmashEggDrawRecord) TableName() string { return "smash_egg_draw_record" }

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

@ -0,0 +1,88 @@
package model
import "time"
// WheelConfig stores the resident activity wheel master configuration.
type WheelConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_wheel_config_sys_origin"`
Enabled bool `gorm:"column:enabled"`
Timezone string `gorm:"column:timezone;size:64"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the wheel master configuration table name.
func (WheelConfig) TableName() string { return "wheel_config" }
// WheelPoolConfig stores category-level wheel prices and switches.
type WheelPoolConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
ConfigID int64 `gorm:"column:config_id;uniqueIndex:uk_wheel_pool_config,priority:1;index:idx_wheel_pool_config,priority:1"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_wheel_pool_sys_origin,priority:1"`
Category string `gorm:"column:category;size:32;uniqueIndex:uk_wheel_pool_config,priority:2;index:idx_wheel_pool_sys_origin,priority:2"`
Enabled bool `gorm:"column:enabled"`
PriceOneGold int64 `gorm:"column:price_one_gold"`
PriceTenGold int64 `gorm:"column:price_ten_gold"`
PriceFiftyGold int64 `gorm:"column:price_fifty_gold"`
Sort int `gorm:"column:sort"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the wheel pool configuration table name.
func (WheelPoolConfig) TableName() string { return "wheel_pool_config" }
// WheelRewardConfig stores one reward option on the wheel.
type WheelRewardConfig struct {
ID int64 `gorm:"column:id;primaryKey"`
ConfigID int64 `gorm:"column:config_id;index:idx_wheel_reward_config,priority:1"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_wheel_reward_enabled,priority:1"`
Category string `gorm:"column:category;size:32;index:idx_wheel_reward_config,priority:2;index:idx_wheel_reward_enabled,priority:2"`
RewardType string `gorm:"column:reward_type;size:32"`
ResourceID *int64 `gorm:"column:resource_id"`
ResourceType string `gorm:"column:resource_type;size:64"`
ResourceName string `gorm:"column:resource_name;size:255"`
ResourceURL string `gorm:"column:resource_url;size:512"`
CoverURL string `gorm:"column:cover_url;size:512"`
DurationDays int `gorm:"column:duration_days"`
DisplayGoldAmount int64 `gorm:"column:display_gold_amount"`
GoldAmount int64 `gorm:"column:gold_amount"`
Probability int `gorm:"column:probability"`
Sort int `gorm:"column:sort"`
Enabled bool `gorm:"column:enabled;index:idx_wheel_reward_enabled,priority:3"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the wheel reward configuration table name.
func (WheelRewardConfig) TableName() string { return "wheel_reward_config" }
// WheelDrawRecord stores each reward item produced by a wheel draw.
type WheelDrawRecord struct {
ID int64 `gorm:"column:id;primaryKey"`
DrawNo string `gorm:"column:draw_no;size:64;index:idx_wheel_draw_record_draw_no"`
EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_wheel_draw_record_event"`
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_wheel_draw_record_user_time,priority:1"`
Category string `gorm:"column:category;size:32;index:idx_wheel_draw_record_category"`
UserID int64 `gorm:"column:user_id;index:idx_wheel_draw_record_user_time,priority:2"`
DrawTimes int `gorm:"column:draw_times"`
PaidGold int64 `gorm:"column:paid_gold"`
RewardType string `gorm:"column:reward_type;size:32"`
ResourceID *int64 `gorm:"column:resource_id"`
ResourceType string `gorm:"column:resource_type;size:64"`
ResourceName string `gorm:"column:resource_name;size:255"`
ResourceURL string `gorm:"column:resource_url;size:512"`
CoverURL string `gorm:"column:cover_url;size:512"`
DurationDays int `gorm:"column:duration_days"`
DisplayGoldAmount int64 `gorm:"column:display_gold_amount"`
GoldAmount int64 `gorm:"column:gold_amount"`
Probability int `gorm:"column:probability"`
Status string `gorm:"column:status;size:32;index:idx_wheel_draw_record_status"`
ErrorMessage string `gorm:"column:error_message;size:1024"`
CreateTime time.Time `gorm:"column:create_time;index:idx_wheel_draw_record_user_time,priority:3"`
UpdateTime time.Time `gorm:"column:update_time"`
}
// TableName returns the wheel draw record table name.
func (WheelDrawRecord) TableName() string { return "wheel_draw_record" }

View File

@ -22,10 +22,13 @@ import (
"chatapp3-golang/internal/service/registerreward"
"chatapp3-golang/internal/service/roomturnoverreward"
"chatapp3-golang/internal/service/signinreward"
"chatapp3-golang/internal/service/smashegg"
"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"
"chatapp3-golang/internal/service/wheel"
"github.com/gin-gonic/gin"
)
@ -48,9 +51,12 @@ type Services struct {
RegisterReward *registerreward.RegisterRewardService
RoomTurnoverReward *roomturnoverreward.Service
SignInReward *signinreward.SignInRewardService
SmashEgg *smashegg.Service
TaskCenter *taskcenter.Service
VIP *vip.Service
Wheel *wheel.Service
VoiceRoomRedPacket *voiceroomredpacket.Service
VoiceRoomRocket *voiceroomrocket.Service
WeekStar *weekstar.WeekStarService
}
@ -74,8 +80,11 @@ func NewRouter(
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
registerVipRoutes(engine, javaClient, services.VIP)
registerWheelRoutes(engine, javaClient, services.Wheel)
registerSmashEggRoutes(engine, javaClient, services.SmashEgg)
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)
@ -98,7 +107,7 @@ func corsMiddleware() gin.HandlerFunc {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
c.Header("Access-Control-Allow-Headers", "Authorization,Content-Type,Origin,Accept,req-lang,req-client,req-imei,req-app-intel,req-sys-origin,req-zone,X-Requested-With")
c.Header("Access-Control-Allow-Headers", "Authorization,Content-Type,Origin,Accept,sign,timestamp,token,req-lang,req-client,req-imei,req-app-intel,req-sys-origin,req-version,req-zone,X-Requested-With")
c.Header("Access-Control-Expose-Headers", "Authorization")
c.Header("Access-Control-Max-Age", "86400")

View File

@ -0,0 +1,243 @@
package router
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/service/smashegg"
"github.com/gin-gonic/gin"
)
// registerSmashEggRoutes registers app, legacy H5, and admin smash egg APIs.
func registerSmashEggRoutes(engine *gin.Engine, javaClient authGateway, service *smashegg.Service) {
if service == nil {
return
}
appGroup := engine.Group("/app/smash-golden-egg")
appGroup.Use(authMiddleware(javaClient))
appGroup.GET("/config", func(c *gin.Context) {
resp, err := service.GetAppConfig(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.POST("/draw", func(c *gin.Context) {
var req smashegg.DrawRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, smashegg.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.Draw(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.GET("/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.PageUserRecords(c.Request.Context(), mustAuthUser(c), cursor, limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.GET("/notices", func(c *gin.Context) {
resp, err := service.ListRecentNotices(c.Request.Context(), mustAuthUser(c).SysOrigin, 20)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.GET("/rank/day", func(c *gin.Context) {
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "100")))
resp, err := service.PageDayRank(c.Request.Context(), mustAuthUser(c).SysOrigin, cursor, limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup := engine.Group("/resident-activity/smash-golden-egg")
adminGroup.Use(consoleAuthMiddleware(javaClient))
adminGroup.GET("/config", func(c *gin.Context) {
resp, err := service.GetConfig(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 smashegg.SaveConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, smashegg.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.SaveConfig(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
adminGroup.GET("/draw-record/page", 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.PageAdminRecords(
c.Request.Context(),
c.Query("sysOrigin"),
smashegg.ParseUserID(c.Query("userId")),
c.Query("status"),
cursor,
limit,
c.Query("startTime"),
c.Query("endTime"),
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
h5Group := engine.Group("/h5/smash-golden-egg")
h5Group.Any("/config", func(c *gin.Context) {
handleLegacySmashEggAction(c, javaClient, service, "Action/TreasureHunt.getPrizeList")
})
h5Group.Any("/draw", func(c *gin.Context) {
handleLegacySmashEggAction(c, javaClient, service, "Action/TreasureHunt.startHunt")
})
h5Group.Any("/records", func(c *gin.Context) {
handleLegacySmashEggAction(c, javaClient, service, "Action/TreasureHunt.getMyHuntRecord")
})
h5Group.Any("/notices", func(c *gin.Context) {
handleLegacySmashEggAction(c, javaClient, service, "Action/TreasureHunt.getHuntRecord")
})
h5Group.Any("/rank/day", func(c *gin.Context) {
handleLegacySmashEggAction(c, javaClient, service, "Action/TreasureHunt.getDayRank")
})
engine.Any("/index.php", func(c *gin.Context) {
action := strings.TrimSpace(c.Query("action"))
if !strings.HasPrefix(action, "Action/TreasureHunt.") {
writeError(c, smashegg.NewAppError(http.StatusNotFound, "not_found", "legacy action is not supported"))
return
}
handleLegacySmashEggAction(c, javaClient, service, action)
})
}
func handleLegacySmashEggAction(c *gin.Context, javaClient authGateway, service *smashegg.Service, action string) {
user, err := legacySmashEggUser(c, javaClient)
if err != nil {
writeLegacyError(c, http.StatusUnauthorized, err.Error())
return
}
var resp map[string]any
switch action {
case "Action/TreasureHunt.getPrizeList":
resp, err = service.LegacyPrizeList(c.Request.Context(), user)
case "Action/TreasureHunt.startHunt":
times, _ := strconv.Atoi(strings.TrimSpace(firstNonEmptyQuery(c, "times", "num")))
if times <= 0 {
times = 1
}
resp, err = service.LegacyStartHunt(c.Request.Context(), user, times)
case "Action/TreasureHunt.getMyHuntRecord":
start, _ := strconv.Atoi(strings.TrimSpace(firstNonEmptyQuery(c, "offset", "start")))
limit, _ := strconv.Atoi(strings.TrimSpace(firstNonEmptyQuery(c, "limit", "num")))
if limit <= 0 {
limit = 20
}
resp, err = service.LegacyMyHuntRecords(c.Request.Context(), user, start, limit)
case "Action/TreasureHunt.getHuntRecord":
resp, err = service.LegacyHuntNotices(c.Request.Context(), user.SysOrigin)
case "Action/TreasureHunt.getDayRank":
resp, err = service.LegacyDayRank(c.Request.Context(), user.SysOrigin)
default:
writeLegacyError(c, http.StatusNotFound, "legacy action is not supported")
return
}
if err != nil {
writeLegacyError(c, http.StatusBadRequest, err.Error())
return
}
writeLegacyOK(c, resp)
}
func legacySmashEggUser(c *gin.Context, javaClient authGateway) (common.AuthUser, error) {
token := strings.TrimSpace(c.Query("token"))
if token == "" {
token = trimBearer(strings.TrimSpace(c.GetHeader("Authorization")))
}
if token != "" && javaClient != nil {
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
credential, err := javaClient.AuthenticateToken(ctx, token)
if err != nil {
return common.AuthUser{}, err
}
return common.AuthUser{
UserID: int64(credential.UserID),
SysOrigin: credential.SysOrigin,
Token: token,
}, nil
}
userID := smashegg.ParseUserID(firstNonEmptyQuery(c, "_login_uid", "uid", "userId"))
sysOrigin := strings.ToUpper(strings.TrimSpace(firstNonEmptyQuery(c, "sysOrigin", "originSys")))
if sysOrigin == "" {
sysOrigin = strings.ToUpper(strings.TrimSpace(c.GetHeader("req-sys-origin")))
}
if userID <= 0 || sysOrigin == "" {
return common.AuthUser{}, common.NewAppError(http.StatusUnauthorized, "missing_token", "token is required")
}
return common.AuthUser{UserID: userID, SysOrigin: sysOrigin, Token: token}, nil
}
func firstNonEmptyQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(c.Query(key)); value != "" {
return value
}
}
return ""
}
func writeLegacyOK(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{
"response_data": data,
"response_status": gin.H{
"code": 0,
"error": "",
},
})
}
func writeLegacyError(c *gin.Context, code int, message string) {
if message == "" {
message = "error"
}
c.JSON(http.StatusOK, gin.H{
"response_data": gin.H{},
"response_status": gin.H{
"code": code,
"error": message,
},
})
}

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

@ -0,0 +1,389 @@
package router
import (
"net/http"
"strconv"
"strings"
"chatapp3-golang/internal/service/wheel"
"github.com/gin-gonic/gin"
)
const legacyGoldRewardCoverURL = "static/img/yumi-gold-coin.png"
// registerWheelRoutes registers app, admin, and copied H5-compatible wheel APIs.
func registerWheelRoutes(engine *gin.Engine, javaClient authGateway, wheelService *wheel.Service) {
if wheelService == nil {
return
}
appGroup := engine.Group("/app/wheel")
appGroup.Use(authMiddleware(javaClient))
appGroup.GET("/config", func(c *gin.Context) {
resp, err := wheelService.GetAppConfig(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.POST("/draw", func(c *gin.Context) {
var req wheel.DrawRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, wheel.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := wheelService.Draw(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.GET("/history", func(c *gin.Context) {
cursor, limit := parseCursorLimit(c)
user := mustAuthUser(c)
resp, err := wheelService.PageRecords(c.Request.Context(), user.SysOrigin, c.Query("category"), user.UserID, "SUCCESS", cursor, limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
appGroup.GET("/hints", func(c *gin.Context) {
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
resp, err := wheelService.Hints(c.Request.Context(), mustAuthUser(c), c.Query("category"), limit)
if err != nil {
writeError(c, err)
return
}
writeOK(c, gin.H{"values": resp})
})
residentGroup := engine.Group("/resident-activity/wheel")
residentGroup.Use(consoleAuthMiddleware(javaClient))
residentGroup.GET("/config", func(c *gin.Context) {
resp, err := wheelService.GetConfig(c.Request.Context(), c.Query("sysOrigin"))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
residentGroup.POST("/config/save", func(c *gin.Context) {
var req wheel.SaveConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, wheel.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := wheelService.SaveConfig(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
residentGroup.POST("/config/category/save", func(c *gin.Context) {
var req wheel.SaveCategoryConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, wheel.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := wheelService.SaveCategoryConfig(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
residentGroup.GET("/record/page", func(c *gin.Context) {
cursor, limit := parseCursorLimit(c)
resp, err := wheelService.PageRecords(
c.Request.Context(),
c.Query("sysOrigin"),
c.Query("category"),
wheel.ParseUserID(c.Query("userId")),
strings.TrimSpace(c.Query("status")),
cursor,
limit,
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
registerWheelLegacyH5Routes(engine, javaClient, wheelService)
}
func registerWheelLegacyH5Routes(engine *gin.Engine, javaClient authGateway, wheelService *wheel.Service) {
legacyGroup := engine.Group("/client/api/v1")
legacyGroup.Use(authMiddleware(javaClient))
legacyGroup.GET("/config/mobile", func(c *gin.Context) {
resp, err := wheelService.GetAppConfig(c.Request.Context(), mustAuthUser(c))
if err != nil {
legacyError(c, err)
return
}
classic := legacyCategoryConfig(resp, "CLASSIC")
luxury := legacyCategoryConfig(resp, "LUXURY")
advanced := legacyCategoryConfig(resp, "ADVANCED")
legacyOK(c, gin.H{
"luckyBoxEnabled": classic.Enabled,
"middleLuckyBoxEnabled": luxury.Enabled,
"advancedLuckyBoxEnabled": advanced.Enabled,
"luckyBoxDrawAmount": classic.PriceOneGold,
"middleLuckyBoxDrawAmount": luxury.PriceOneGold,
"advancedLuckyBoxDrawAmount": advanced.PriceOneGold,
"luckyBoxDraw10Amount": classic.PriceTenGold,
"middleLuckyBoxDraw10Amount": luxury.PriceTenGold,
"advancedLuckyBoxDraw10Amount": advanced.PriceTenGold,
"luckyBoxDraw50Amount": classic.PriceFiftyGold,
"middleLuckyBoxDraw50Amount": luxury.PriceFiftyGold,
"advancedLuckyBoxDraw50Amount": advanced.PriceFiftyGold,
"wheelDrawAmount": classic.PriceOneGold,
"wheelDraw10Amount": classic.PriceTenGold,
"wheelDraw50Amount": classic.PriceFiftyGold,
})
})
legacyGroup.POST("/wallet/list", func(c *gin.Context) {
balance, err := wheelService.WalletBalances(c.Request.Context(), mustAuthUser(c))
if err != nil {
legacyError(c, err)
return
}
legacyOK(c, []gin.H{
{"walletType": "COIN", "balance": balance},
{"walletType": "DCOIN", "balance": 0},
})
})
probabilityGroup := legacyGroup.Group("/probability")
for _, prefix := range []string{"lucky-box", "middle-lucky-box", "advanced-lucky-box"} {
pathPrefix := "/" + prefix
category := legacyCategoryFromPrefix(prefix)
probabilityGroup.GET(pathPrefix+"/rewards", legacyRewardsHandler(wheelService, category))
probabilityGroup.POST(pathPrefix+"/rewards", legacyRewardsHandler(wheelService, category))
probabilityGroup.GET(pathPrefix+"/history", legacyHistoryHandler(wheelService, category))
probabilityGroup.POST(pathPrefix+"/history", legacyHistoryHandler(wheelService, category))
probabilityGroup.GET(pathPrefix+"/hints", legacyHintsHandler(wheelService, category))
probabilityGroup.POST(pathPrefix+"/hints", legacyHintsHandler(wheelService, category))
probabilityGroup.POST(pathPrefix+"/draw", legacyDrawHandler(wheelService, category))
}
}
func legacyRewardsHandler(wheelService *wheel.Service, category string) gin.HandlerFunc {
return func(c *gin.Context) {
resp, err := wheelService.GetAppConfig(c.Request.Context(), mustAuthUser(c))
if err != nil {
legacyError(c, err)
return
}
pool := legacyCategoryConfig(resp, category)
if !pool.Enabled {
legacyOK(c, []gin.H{})
return
}
items := make([]gin.H, 0, len(pool.Rewards))
for _, reward := range pool.Rewards {
items = append(items, gin.H{
"id": reward.ID,
"value": legacyRewardValue(reward),
"merchandise": gin.H{
"name": legacyRewardName(reward),
"coverUrl": legacyRewardCover(reward),
},
})
}
legacyOK(c, items)
}
}
func legacyDrawHandler(wheelService *wheel.Service, category string) gin.HandlerFunc {
return func(c *gin.Context) {
var req wheel.DrawRequest
_ = c.ShouldBindJSON(&req)
req.Category = category
resp, err := wheelService.Draw(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
legacyError(c, err)
return
}
rewards := make([]gin.H, 0, len(resp.Rewards))
for _, reward := range resp.Rewards {
rewards = append(rewards, gin.H{
"coverUrl": legacyDrawRewardCover(reward),
"count": reward.Count,
"name": legacyDrawRewardName(reward),
"value": reward.Value,
})
}
legacyOK(c, gin.H{
"drawNo": resp.DrawNo,
"roomId": resp.RoomID,
"rewardValue": resp.RewardValue,
"rewards": rewards,
"balanceAfter": resp.BalanceAfter,
})
}
}
func legacyHistoryHandler(wheelService *wheel.Service, category string) gin.HandlerFunc {
return func(c *gin.Context) {
cursor, limit := parseCursorLimit(c)
user := mustAuthUser(c)
resp, err := wheelService.PageRecords(c.Request.Context(), user.SysOrigin, category, user.UserID, "SUCCESS", cursor, limit)
if err != nil {
legacyError(c, err)
return
}
records := make([]gin.H, 0, len(resp.Records))
for _, record := range resp.Records {
records = append(records, gin.H{
"createdTime": record.CreateTime,
"drawNo": record.DrawNo,
"drawTimes": record.DrawTimes,
"rewardValue": legacyRecordValue(record),
"rewards": []gin.H{{
"coverUrl": legacyRecordCover(record),
"count": 1,
"name": legacyRecordName(record),
"value": legacyRecordValue(record),
}},
})
}
legacyOK(c, gin.H{"records": records, "total": resp.Total})
}
}
func legacyHintsHandler(wheelService *wheel.Service, category string) gin.HandlerFunc {
return func(c *gin.Context) {
resp, err := wheelService.Hints(c.Request.Context(), mustAuthUser(c), category, 20)
if err != nil {
legacyError(c, err)
return
}
values := make([]gin.H, 0, len(resp))
for index, reward := range resp {
values = append(values, gin.H{
"avatar": reward.CoverURL,
"nickname": "User" + strconv.Itoa(index+1),
"value": legacyDrawRewardName(reward),
})
}
legacyOK(c, gin.H{"values": values})
}
}
func parseCursorLimit(c *gin.Context) (int, int) {
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
return cursor, limit
}
func legacyOK(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": data})
}
func legacyError(c *gin.Context, err error) {
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "data": nil})
}
func legacyCategoryFromPrefix(prefix string) string {
switch prefix {
case "middle-lucky-box":
return "LUXURY"
case "advanced-lucky-box":
return "ADVANCED"
default:
return "CLASSIC"
}
}
func legacyCategoryConfig(resp *wheel.ConfigResponse, category string) wheel.CategoryConfigPayload {
for _, item := range resp.Categories {
if strings.EqualFold(item.Category, category) {
return item
}
}
return wheel.CategoryConfigPayload{Category: category, Rewards: []wheel.RewardConfigPayload{}}
}
func legacyRewardValue(reward wheel.RewardConfigPayload) int64 {
if strings.EqualFold(reward.RewardType, "GOLD") {
return reward.GoldAmount
}
return reward.DisplayGoldAmount
}
func legacyRewardCover(reward wheel.RewardConfigPayload) string {
if strings.TrimSpace(reward.CoverURL) != "" {
return reward.CoverURL
}
if strings.EqualFold(reward.RewardType, "GOLD") {
return legacyGoldRewardCoverURL
}
return ""
}
func legacyDrawRewardCover(reward wheel.DrawRewardPayload) string {
if strings.TrimSpace(reward.CoverURL) != "" {
return reward.CoverURL
}
if strings.EqualFold(reward.RewardType, "GOLD") {
return legacyGoldRewardCoverURL
}
return ""
}
func legacyRecordCover(record wheel.DrawRecordPayload) string {
if strings.TrimSpace(record.CoverURL) != "" {
return record.CoverURL
}
if strings.EqualFold(record.RewardType, "GOLD") {
return legacyGoldRewardCoverURL
}
return ""
}
func legacyRecordValue(record wheel.DrawRecordPayload) int64 {
if strings.EqualFold(record.RewardType, "GOLD") {
return record.GoldAmount
}
return record.DisplayGoldAmount
}
func legacyRewardName(reward wheel.RewardConfigPayload) string {
if reward.ResourceName != "" {
return reward.ResourceName
}
if reward.RewardType == "GOLD" {
return "金币"
}
return "Reward"
}
func legacyDrawRewardName(reward wheel.DrawRewardPayload) string {
if reward.Name != "" {
return reward.Name
}
if reward.ResourceName != "" {
return reward.ResourceName
}
if reward.RewardType == "GOLD" {
return "金币"
}
return "Reward"
}
func legacyRecordName(record wheel.DrawRecordPayload) string {
if record.ResourceName != "" {
return record.ResourceName
}
if record.RewardType == "GOLD" {
return "金币"
}
return "Reward"
}

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,624 @@
package smashegg
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
)
type configBundle struct {
Config *model.SmashEggConfig
DrawOptions []model.SmashEggDrawOptionConfig
Rewards []model.SmashEggRewardConfig
}
// GetConfig returns admin smash egg configuration.
func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*ConfigResponse, error) {
bundle, err := s.loadConfigBundle(ctx, sysOrigin, false)
if err != nil {
return nil, err
}
if bundle.Config == nil {
return defaultConfigResponse(sysOrigin), nil
}
return s.buildConfigResponse(ctx, bundle, true)
}
// GetAppConfig returns enabled smash egg configuration for app clients.
func (s *Service) GetAppConfig(ctx context.Context, user AuthUser) (*ConfigResponse, error) {
bundle, err := s.loadConfigBundle(ctx, user.SysOrigin, true)
if err != nil {
return nil, err
}
if bundle.Config == nil || !bundle.Config.Enabled {
return &ConfigResponse{
Configured: false,
SysOrigin: normalizeSysOrigin(user.SysOrigin),
Timezone: defaultTimezone,
RTPBasisPoints: defaultRTPBasisPoints,
RTPPercent: basisPointsPercent(defaultRTPBasisPoints),
ProbabilityTotal: probabilityTotal,
NewbieWindowDays: defaultNewbieWindowDays,
NewbieMaxDrawCount: defaultNewbieMaxDrawCount,
ActivePoolType: poolTypeGeneral,
DrawOptions: []DrawOptionPayload{},
Rewards: []RewardConfigPayload{},
}, nil
}
resp, err := s.buildConfigResponse(ctx, bundle, false)
if err != nil {
return nil, err
}
poolType, rewards, eligibility, err := s.resolveRewardPool(ctx, bundle, user)
if err != nil {
return nil, err
}
options := make([]DrawOptionPayload, 0, len(bundle.DrawOptions))
for _, row := range bundle.DrawOptions {
expectedValue := expectedRewardValueForOption(bundle.Config.RTPBasisPoints, row, rewards)
options = append(options, drawOptionPayload(row, expectedValue))
}
resp.ActivePoolType = poolType
resp.NewbieEligible = eligibility.Eligible
resp.NewbieRemainingDrawCount = eligibility.Remaining
resp.DrawOptions = options
resp.Rewards = rewardPayloadsFromConfig(rewards)
resp.ExpectedValueGold = amount2(expectedRewardValue(rewards))
resp.NewbieRewards = nil
return resp, nil
}
// SaveConfig saves admin smash egg configuration.
func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*ConfigResponse, error) {
normalized, err := normalizeSaveRequest(req)
if err != nil {
return nil, err
}
now := time.Now()
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var configRow model.SmashEggConfig
if err := tx.Where("sys_origin = ?", normalized.SysOrigin).First(&configRow).Error; errors.Is(err, gorm.ErrRecordNotFound) {
nextID, idErr := utils.NextID()
if idErr != nil {
return idErr
}
configRow = model.SmashEggConfig{
ID: nextID,
SysOrigin: normalized.SysOrigin,
CreateTime: now,
}
} else if err != nil {
return err
}
configRow.Enabled = normalized.Enabled
configRow.Timezone = normalized.Timezone
configRow.RTPBasisPoints = normalized.RTPBasisPoints
configRow.NewbiePoolEnabled = normalized.NewbiePoolEnabled
configRow.NewbieWindowDays = normalized.NewbieWindowDays
configRow.NewbieMaxDrawCount = normalized.NewbieMaxDrawCount
configRow.NewbieMinRechargeAmount = normalized.NewbieMinRechargeAmount
configRow.UpdateTime = now
if configRow.CreateTime.IsZero() {
configRow.CreateTime = now
}
if err := tx.Save(&configRow).Error; err != nil {
return err
}
if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.SmashEggDrawOptionConfig{}).Error; err != nil {
return err
}
if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.SmashEggRewardConfig{}).Error; err != nil {
return err
}
optionRows := make([]model.SmashEggDrawOptionConfig, 0, len(normalized.DrawOptions))
for index, item := range normalized.DrawOptions {
nextID, idErr := utils.NextID()
if idErr != nil {
return idErr
}
sortValue := item.Sort
if sortValue <= 0 {
sortValue = index + 1
}
optionRows = append(optionRows, model.SmashEggDrawOptionConfig{
ID: nextID,
ConfigID: configRow.ID,
SysOrigin: normalized.SysOrigin,
OptionKey: item.OptionKey,
Label: strings.TrimSpace(item.Label),
Times: item.Times,
PriceGold: item.PriceGold,
Sort: sortValue,
Enabled: item.Enabled,
CreateTime: now,
UpdateTime: now,
})
}
if len(optionRows) > 0 {
if err := tx.Create(&optionRows).Error; err != nil {
return err
}
}
allRewards := make([]RewardConfigInput, 0, len(normalized.Rewards)+len(normalized.NewbieRewards))
allRewards = append(allRewards, normalized.Rewards...)
allRewards = append(allRewards, normalized.NewbieRewards...)
rewardRows := make([]model.SmashEggRewardConfig, 0, len(allRewards))
for index, item := range allRewards {
nextID, idErr := utils.NextID()
if idErr != nil {
return idErr
}
sortValue := item.Sort
if sortValue <= 0 {
sortValue = index + 1
}
rewardRows = append(rewardRows, model.SmashEggRewardConfig{
ID: nextID,
ConfigID: configRow.ID,
SysOrigin: normalized.SysOrigin,
PoolType: normalizePoolType(item.PoolType),
RewardType: item.RewardType,
RewardGroupID: item.RewardGroupID,
RewardGroupName: strings.TrimSpace(item.RewardGroupName),
ResourceType: strings.TrimSpace(item.ResourceType),
ResourceURL: strings.TrimSpace(item.ResourceURL),
CoverURL: strings.TrimSpace(item.CoverURL),
AnimationURL: strings.TrimSpace(item.AnimationURL),
DurationDays: item.DurationDays,
DisplayGoldAmount: item.DisplayGoldAmount,
GoldAmount: item.GoldAmount,
RewardValueGold: item.RewardValueGold,
Probability: item.Probability,
Sort: sortValue,
Enabled: item.Enabled,
CreateTime: now,
UpdateTime: now,
})
}
if len(rewardRows) > 0 {
return tx.Create(&rewardRows).Error
}
return nil
}); err != nil {
return nil, err
}
return s.GetConfig(ctx, normalized.SysOrigin)
}
func normalizeSaveRequest(req SaveConfigRequest) (SaveConfigRequest, error) {
sysOrigin := normalizeSysOrigin(req.SysOrigin)
if sysOrigin == "" {
return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required")
}
timezone := normalizeTimezone(req.Timezone)
if _, err := time.LoadLocation(timezone); err != nil {
return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error())
}
rtp := req.RTPBasisPoints
if rtp <= 0 {
rtp = defaultRTPBasisPoints
}
if rtp > 100000 {
return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_rtp", "rtpBasisPoints must be less than or equal to 100000")
}
options := append([]DrawOptionInput(nil), req.DrawOptions...)
if len(options) == 0 {
options = defaultDrawOptionInputs()
}
sortOptionInputs(options)
enabledOptionCount := 0
seenTimes := map[int]struct{}{}
for index := range options {
item := &options[index]
item.OptionKey = normalizeOptionKey(item.OptionKey, index)
item.Label = strings.TrimSpace(item.Label)
if item.Label == "" {
item.Label = item.OptionKey
}
if item.Sort <= 0 {
item.Sort = index + 1
}
if item.Times <= 0 {
return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_draw_times", "draw option times must be greater than 0")
}
if item.PriceGold <= 0 {
return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_draw_price", "draw option priceGold must be greater than 0")
}
if _, exists := seenTimes[item.Times]; exists {
return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "duplicate_draw_times", "draw option times must be unique")
}
seenTimes[item.Times] = struct{}{}
if item.Enabled {
enabledOptionCount++
}
}
rewards, enabledRewardCount, err := normalizeRewardInputs(req.Rewards, poolTypeGeneral)
if err != nil {
return SaveConfigRequest{}, err
}
newbieRewards, newbieEnabledRewardCount, err := normalizeRewardInputs(req.NewbieRewards, poolTypeNewbie)
if err != nil {
return SaveConfigRequest{}, err
}
newbieWindowDays := req.NewbieWindowDays
if newbieWindowDays <= 0 {
newbieWindowDays = defaultNewbieWindowDays
}
if newbieWindowDays > 90 {
return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_newbie_window_days", "newbieWindowDays must be less than or equal to 90")
}
newbieMaxDrawCount := req.NewbieMaxDrawCount
if req.NewbiePoolEnabled && newbieMaxDrawCount <= 0 {
newbieMaxDrawCount = defaultNewbieMaxDrawCount
}
if newbieMaxDrawCount < 0 {
return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_newbie_max_draw_count", "newbieMaxDrawCount must be greater than or equal to 0")
}
if req.NewbieMinRechargeAmount < 0 {
return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_newbie_min_recharge_amount", "newbieMinRechargeAmount must be greater than or equal to 0")
}
if req.Enabled && enabledOptionCount == 0 {
return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "empty_draw_options", "enabled smash egg requires at least one enabled draw option")
}
if req.Enabled && enabledRewardCount == 0 {
return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "empty_rewards", "enabled smash egg requires at least one enabled reward")
}
if enabledOptionCount > 0 && enabledRewardCount > 0 {
rewards, err = applyAutoProbabilitiesToInputs(rtp, options, rewards, "general")
if err != nil {
return SaveConfigRequest{}, err
}
}
if req.Enabled && req.NewbiePoolEnabled && newbieEnabledRewardCount == 0 {
return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "empty_newbie_rewards", "enabled newbie pool requires at least one enabled reward")
}
if req.NewbiePoolEnabled && enabledOptionCount > 0 && newbieEnabledRewardCount > 0 {
newbieRewards, err = applyAutoProbabilitiesToInputs(rtp, options, newbieRewards, "newbie")
if err != nil {
return SaveConfigRequest{}, err
}
}
return SaveConfigRequest{
SysOrigin: sysOrigin,
Enabled: req.Enabled,
Timezone: timezone,
RTPBasisPoints: rtp,
NewbiePoolEnabled: req.NewbiePoolEnabled,
NewbieWindowDays: newbieWindowDays,
NewbieMaxDrawCount: newbieMaxDrawCount,
NewbieMinRechargeAmount: req.NewbieMinRechargeAmount,
DrawOptions: options,
Rewards: rewards,
NewbieRewards: newbieRewards,
}, nil
}
func normalizeRewardInputs(input []RewardConfigInput, poolType string) ([]RewardConfigInput, int, error) {
rewards := append([]RewardConfigInput(nil), input...)
sortRewardInputs(rewards)
enabledRewardCount := 0
for index := range rewards {
item := &rewards[index]
item.PoolType = normalizePoolType(poolType)
item.RewardType = normalizeRewardType(item.RewardType)
if item.RewardType == "" {
item.RewardType = rewardTypeGold
}
item.ResourceType = strings.ToUpper(strings.TrimSpace(item.ResourceType))
item.ResourceName = strings.TrimSpace(firstNonEmpty(item.ResourceName, item.RewardGroupName))
item.ResourceURL = strings.TrimSpace(item.ResourceURL)
item.RewardGroupName = strings.TrimSpace(item.RewardGroupName)
item.CoverURL = strings.TrimSpace(item.CoverURL)
item.AnimationURL = strings.TrimSpace(item.AnimationURL)
if item.Sort <= 0 {
item.Sort = index + 1
}
switch item.RewardType {
case rewardTypeGold:
if item.GoldAmount <= 0 {
return nil, 0, NewAppError(http.StatusBadRequest, "invalid_gold_amount", "gold reward requires goldAmount")
}
item.ResourceID = 0
item.ResourceType = ""
item.ResourceName = ""
item.ResourceURL = ""
item.RewardGroupID = nil
item.RewardGroupName = "金币"
item.DurationDays = 0
item.DisplayGoldAmount = 0
item.RewardValueGold = item.GoldAmount
case rewardTypeResource:
if item.ResourceID.Int64() <= 0 && item.RewardGroupID != nil {
item.ResourceID = ResourceID(*item.RewardGroupID)
}
if item.ResourceID.Int64() <= 0 {
return nil, 0, NewAppError(http.StatusBadRequest, "invalid_resource", "resource reward requires resourceId")
}
if item.ResourceType == "" {
return nil, 0, NewAppError(http.StatusBadRequest, "invalid_resource_type", "resource reward requires resourceType")
}
if item.DurationDays <= 0 {
return nil, 0, NewAppError(http.StatusBadRequest, "invalid_duration_days", "resource reward requires durationDays greater than 0")
}
if item.DisplayGoldAmount <= 0 {
if item.RewardValueGold > 0 {
item.DisplayGoldAmount = item.RewardValueGold
} else {
return nil, 0, NewAppError(http.StatusBadRequest, "invalid_display_gold_amount", "resource reward displayGoldAmount must be greater than 0")
}
}
item.RewardGroupID = resourceIDPtr(item.ResourceID)
if item.ResourceName == "" {
item.ResourceName = "资源 " + strconv.FormatInt(item.ResourceID.Int64(), 10)
}
item.RewardGroupName = item.ResourceName
item.GoldAmount = 0
item.RewardValueGold = item.DisplayGoldAmount
if item.AnimationURL == "" {
item.AnimationURL = item.ResourceURL
}
default:
return nil, 0, NewAppError(http.StatusBadRequest, "invalid_reward_type", "rewardType must be RESOURCE or GOLD")
}
if item.RewardValueGold <= 0 {
return nil, 0, NewAppError(http.StatusBadRequest, "invalid_reward_value", "reward value must be greater than 0")
}
item.Probability = 0
if item.Enabled {
enabledRewardCount++
}
}
return rewards, enabledRewardCount, nil
}
func weightedRewardValueFromInputs(rewards []RewardConfigInput) int64 {
var total int64
for _, reward := range rewards {
if !reward.Enabled || reward.Probability <= 0 {
continue
}
total += reward.RewardValueGold * int64(reward.Probability)
}
return total
}
func drawOptionRTPBasisPointsFromWeightedValue(weightedValue int64, times int, priceGold int64) int {
if weightedValue <= 0 || times <= 0 || priceGold <= 0 {
return 0
}
numerator := weightedValue * int64(times) * 10000
denominator := priceGold * int64(probabilityTotal)
return int(roundDiv(numerator, denominator))
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func effectiveNewbieWindowDays(value int) int {
if value <= 0 {
return defaultNewbieWindowDays
}
return value
}
func effectiveNewbieMaxDrawCount(value int, enabled bool) int {
if value <= 0 && enabled {
return defaultNewbieMaxDrawCount
}
return value
}
func (s *Service) loadConfigBundle(ctx context.Context, sysOrigin string, onlyEnabled bool) (*configBundle, error) {
sysOrigin = normalizeSysOrigin(sysOrigin)
if sysOrigin == "" {
return nil, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required")
}
var configRow model.SmashEggConfig
err := s.db.WithContext(ctx).Where("sys_origin = ?", sysOrigin).First(&configRow).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return &configBundle{}, nil
}
if err != nil {
return nil, err
}
optionQuery := s.db.WithContext(ctx).Where("config_id = ?", configRow.ID).Order("sort asc, id asc")
rewardQuery := s.db.WithContext(ctx).Where("config_id = ?", configRow.ID).Order("sort asc, id asc")
if onlyEnabled {
optionQuery = optionQuery.Where("enabled = ?", true)
rewardQuery = rewardQuery.Where("enabled = ?", true)
}
var options []model.SmashEggDrawOptionConfig
if err := optionQuery.Find(&options).Error; err != nil {
return nil, err
}
var rewards []model.SmashEggRewardConfig
if err := rewardQuery.Find(&rewards).Error; err != nil {
return nil, err
}
return &configBundle{Config: &configRow, DrawOptions: options, Rewards: rewards}, nil
}
func defaultConfigResponse(sysOrigin string) *ConfigResponse {
return &ConfigResponse{
Configured: false,
SysOrigin: normalizeSysOrigin(sysOrigin),
Enabled: false,
Timezone: defaultTimezone,
RTPBasisPoints: defaultRTPBasisPoints,
RTPPercent: basisPointsPercent(defaultRTPBasisPoints),
ProbabilityTotal: probabilityTotal,
ExpectedValueGold: "0.00",
NewbieWindowDays: defaultNewbieWindowDays,
NewbieMaxDrawCount: defaultNewbieMaxDrawCount,
ActivePoolType: poolTypeGeneral,
DrawOptions: defaultDrawOptionPayloads(0),
Rewards: []RewardConfigPayload{},
NewbieRewards: []RewardConfigPayload{},
}
}
func (s *Service) buildConfigResponse(ctx context.Context, bundle *configBundle, includeRewardItems bool) (*ConfigResponse, error) {
configRow := bundle.Config
generalRewards := rewardsForPool(bundle.Rewards, poolTypeGeneral)
newbieRewards := rewardsForPool(bundle.Rewards, poolTypeNewbie)
expectedValue := expectedRewardValue(generalRewards)
newbieExpectedValue := expectedRewardValue(newbieRewards)
options := make([]DrawOptionPayload, 0, len(bundle.DrawOptions))
for _, row := range bundle.DrawOptions {
optionExpectedValue := expectedRewardValueForOption(configRow.RTPBasisPoints, row, generalRewards)
options = append(options, drawOptionPayload(row, optionExpectedValue))
}
rewards := rewardPayloadsFromConfig(generalRewards)
newbieRewardPayloads := rewardPayloadsFromConfig(newbieRewards)
return &ConfigResponse{
Configured: true,
ID: configRow.ID,
SysOrigin: configRow.SysOrigin,
Enabled: configRow.Enabled,
Timezone: normalizeTimezone(configRow.Timezone),
RTPBasisPoints: configRow.RTPBasisPoints,
RTPPercent: basisPointsPercent(configRow.RTPBasisPoints),
ProbabilityTotal: probabilityTotal,
ExpectedValueGold: amount2(expectedValue),
NewbiePoolEnabled: configRow.NewbiePoolEnabled,
NewbieWindowDays: effectiveNewbieWindowDays(configRow.NewbieWindowDays),
NewbieMaxDrawCount: effectiveNewbieMaxDrawCount(configRow.NewbieMaxDrawCount, configRow.NewbiePoolEnabled),
NewbieMinRechargeAmount: configRow.NewbieMinRechargeAmount,
NewbieExpectedValueGold: amount2(newbieExpectedValue),
ActivePoolType: poolTypeGeneral,
DrawOptions: options,
Rewards: rewards,
NewbieRewards: newbieRewardPayloads,
UpdateTime: formatDateTime(configRow.UpdateTime),
}, nil
}
func rewardPayloadsFromConfig(rewards []model.SmashEggRewardConfig) []RewardConfigPayload {
payloads := make([]RewardConfigPayload, 0, len(rewards))
for _, row := range rewards {
payloads = append(payloads, rewardPayloadFromConfig(row))
}
return payloads
}
func drawOptionPayload(row model.SmashEggDrawOptionConfig, expectedValuePerDraw float64) DrawOptionPayload {
rtp := 0
if row.PriceGold > 0 {
rtp = int((expectedValuePerDraw * float64(row.Times) / float64(row.PriceGold) * 10000) + 0.5)
}
return DrawOptionPayload{
ID: row.ID,
Enabled: row.Enabled,
Sort: row.Sort,
OptionKey: row.OptionKey,
Label: row.Label,
Times: row.Times,
PriceGold: row.PriceGold,
RTPBasisPoints: rtp,
RTPPercent: basisPointsPercent(rtp),
ExpectedValueGold: amount2(expectedValuePerDraw * float64(row.Times)),
}
}
func rewardPayloadFromConfig(row model.SmashEggRewardConfig) RewardConfigPayload {
return RewardConfigPayload{
ID: row.ID,
Enabled: row.Enabled,
Sort: row.Sort,
PoolType: normalizePoolType(row.PoolType),
RewardType: row.RewardType,
ResourceID: ResourceID(int64PtrValue(row.RewardGroupID)),
ResourceType: row.ResourceType,
ResourceName: row.RewardGroupName,
ResourceURL: row.ResourceURL,
RewardGroupID: row.RewardGroupID,
RewardGroupName: row.RewardGroupName,
CoverURL: row.CoverURL,
AnimationURL: row.AnimationURL,
DurationDays: row.DurationDays,
DisplayGoldAmount: row.DisplayGoldAmount,
GoldAmount: row.GoldAmount,
RewardValueGold: row.RewardValueGold,
Probability: row.Probability,
ProbabilityPercent: probabilityPercent(row.Probability),
}
}
func rewardsForPool(rewards []model.SmashEggRewardConfig, poolType string) []model.SmashEggRewardConfig {
poolType = normalizePoolType(poolType)
result := make([]model.SmashEggRewardConfig, 0, len(rewards))
for _, reward := range rewards {
reward.RewardType = normalizeRewardType(reward.RewardType)
if normalizePoolType(reward.PoolType) == poolType {
result = append(result, reward)
}
}
return result
}
func expectedRewardValue(rewards []model.SmashEggRewardConfig) float64 {
var total float64
for _, reward := range rewards {
if !reward.Enabled || reward.Probability <= 0 {
continue
}
total += float64(reward.RewardValueGold) * float64(reward.Probability) / probabilityTotal
}
return total
}
func defaultDrawOptionInputs() []DrawOptionInput {
return []DrawOptionInput{
{Enabled: true, Sort: 1, OptionKey: optionKeyOne, Label: "Smash 1", Times: 1, PriceGold: 500},
{Enabled: true, Sort: 2, OptionKey: optionKeyTen, Label: "Smash 10", Times: 10, PriceGold: 5000},
{Enabled: true, Sort: 3, OptionKey: optionKeyHundred, Label: "Smash 100", Times: 100, PriceGold: 50000},
}
}
func defaultDrawOptionPayloads(expectedValuePerDraw float64) []DrawOptionPayload {
defaults := defaultDrawOptionInputs()
rows := make([]DrawOptionPayload, 0, len(defaults))
for _, item := range defaults {
rows = append(rows, drawOptionPayload(model.SmashEggDrawOptionConfig{
Enabled: item.Enabled,
Sort: item.Sort,
OptionKey: item.OptionKey,
Label: item.Label,
Times: item.Times,
PriceGold: item.PriceGold,
}, expectedValuePerDraw))
}
return rows
}

View File

@ -0,0 +1,186 @@
package smashegg
import (
"testing"
"chatapp3-golang/internal/integration"
)
func TestNormalizeSaveRequestAcceptsNewbiePoolWithinRTPTolerance(t *testing.T) {
normalized, err := normalizeSaveRequest(SaveConfigRequest{
SysOrigin: "yumi",
Enabled: true,
Timezone: "Asia/Riyadh",
RTPBasisPoints: 9500,
NewbiePoolEnabled: true,
NewbieWindowDays: 7,
NewbieMaxDrawCount: 3,
DrawOptions: []DrawOptionInput{
{Enabled: true, Sort: 1, OptionKey: "ONE", Label: "1", Times: 1, PriceGold: 100},
{Enabled: true, Sort: 2, OptionKey: "TEN", Label: "10", Times: 10, PriceGold: 1000},
},
Rewards: []RewardConfigInput{{
Enabled: true,
RewardType: rewardTypeGold,
GoldAmount: 95,
RewardValueGold: 95,
}},
NewbieRewards: []RewardConfigInput{{
Enabled: true,
RewardType: rewardTypeGold,
GoldAmount: 96,
RewardValueGold: 96,
}},
})
if err != nil {
t.Fatalf("normalizeSaveRequest returned error: %v", err)
}
if normalized.Rewards[0].Probability != probabilityTotal {
t.Fatalf("general probability = %d, want %d", normalized.Rewards[0].Probability, probabilityTotal)
}
if normalized.NewbieRewards[0].Probability != probabilityTotal {
t.Fatalf("newbie probability = %d, want %d", normalized.NewbieRewards[0].Probability, probabilityTotal)
}
}
func TestNormalizeSaveRequestAutoCalculatesProbabilities(t *testing.T) {
normalized, err := normalizeSaveRequest(SaveConfigRequest{
SysOrigin: "yumi",
Enabled: true,
Timezone: "Asia/Riyadh",
RTPBasisPoints: 9500,
DrawOptions: []DrawOptionInput{
{Enabled: true, Sort: 1, OptionKey: "ONE", Label: "1", Times: 1, PriceGold: 500},
},
Rewards: []RewardConfigInput{
{Enabled: true, RewardType: rewardTypeGold, GoldAmount: 50},
{Enabled: true, RewardType: rewardTypeGold, GoldAmount: 100},
{Enabled: true, RewardType: rewardTypeGold, GoldAmount: 500},
{Enabled: true, RewardType: rewardTypeGold, GoldAmount: 5000},
},
})
if err != nil {
t.Fatalf("normalizeSaveRequest returned error: %v", err)
}
total := 0
for _, reward := range normalized.Rewards {
total += reward.Probability
}
if total != probabilityTotal {
t.Fatalf("probability total = %d, want %d", total, probabilityTotal)
}
weighted := weightedRewardValueFromInputs(normalized.Rewards)
rtp := drawOptionRTPBasisPointsFromWeightedValue(weighted, normalized.DrawOptions[0].Times, normalized.DrawOptions[0].PriceGold)
if rtp < normalized.RTPBasisPoints-rtpToleranceBasisPoints || rtp > normalized.RTPBasisPoints+rtpToleranceBasisPoints {
t.Fatalf("auto RTP = %d, want %d ± %d", rtp, normalized.RTPBasisPoints, rtpToleranceBasisPoints)
}
}
func TestNormalizeSaveRequestAutoCalculatesDraftProbabilities(t *testing.T) {
normalized, err := normalizeSaveRequest(SaveConfigRequest{
SysOrigin: "yumi",
Enabled: false,
Timezone: "Asia/Riyadh",
RTPBasisPoints: 9500,
DrawOptions: []DrawOptionInput{
{Enabled: true, Sort: 1, OptionKey: "ONE", Label: "1", Times: 1, PriceGold: 500},
},
Rewards: []RewardConfigInput{
{Enabled: true, RewardType: rewardTypeGold, GoldAmount: 50},
{Enabled: true, RewardType: rewardTypeGold, GoldAmount: 100},
{Enabled: true, RewardType: rewardTypeGold, GoldAmount: 500},
{Enabled: true, RewardType: rewardTypeGold, GoldAmount: 10000},
},
})
if err != nil {
t.Fatalf("normalizeSaveRequest returned error: %v", err)
}
total := 0
for _, reward := range normalized.Rewards {
if reward.Enabled && reward.Probability <= 0 {
t.Fatalf("enabled reward %d probability = %d, want > 0", reward.GoldAmount, reward.Probability)
}
total += reward.Probability
}
if total != probabilityTotal {
t.Fatalf("probability total = %d, want %d", total, probabilityTotal)
}
}
func TestNormalizeSaveRequestRejectsNewbiePoolOutsideRTPTolerance(t *testing.T) {
_, err := normalizeSaveRequest(SaveConfigRequest{
SysOrigin: "yumi",
Enabled: true,
Timezone: "Asia/Riyadh",
RTPBasisPoints: 9500,
NewbiePoolEnabled: true,
NewbieWindowDays: 7,
NewbieMaxDrawCount: 3,
DrawOptions: []DrawOptionInput{
{Enabled: true, Sort: 1, OptionKey: "ONE", Label: "1", Times: 1, PriceGold: 100},
},
Rewards: []RewardConfigInput{{
Enabled: true,
RewardType: rewardTypeGold,
GoldAmount: 95,
RewardValueGold: 95,
}},
NewbieRewards: []RewardConfigInput{{
Enabled: true,
RewardType: rewardTypeGold,
GoldAmount: 110,
RewardValueGold: 110,
}},
})
if err == nil {
t.Fatal("normalizeSaveRequest succeeded, want RTP validation error")
}
}
func TestNormalizeSaveRequestAcceptsResourceRewardAndUsesDisplayPrice(t *testing.T) {
normalized, err := normalizeSaveRequest(SaveConfigRequest{
SysOrigin: "yumi",
Enabled: true,
Timezone: "Asia/Riyadh",
RTPBasisPoints: 9500,
DrawOptions: []DrawOptionInput{
{Enabled: true, Sort: 1, OptionKey: "ONE", Label: "1", Times: 1, PriceGold: 100},
},
Rewards: []RewardConfigInput{{
Enabled: true,
RewardType: "RESOURCE",
ResourceID: ResourceID(123),
ResourceName: "Avatar Frame",
ResourceType: "AVATAR_FRAME",
CoverURL: "https://example.test/frame.png",
DurationDays: 7,
DisplayGoldAmount: 95,
}},
})
if err != nil {
t.Fatalf("normalizeSaveRequest returned error: %v", err)
}
reward := normalized.Rewards[0]
if reward.RewardType != rewardTypeResource {
t.Fatalf("reward type = %s, want RESOURCE", reward.RewardType)
}
if reward.RewardGroupID == nil || *reward.RewardGroupID != 123 {
t.Fatalf("reward group id = %v, want 123", reward.RewardGroupID)
}
if reward.RewardValueGold != 95 || reward.DisplayGoldAmount != 95 || reward.GoldAmount != 0 {
t.Fatalf("normalized resource values = value:%d display:%d gold:%d", reward.RewardValueGold, reward.DisplayGoldAmount, reward.GoldAmount)
}
}
func TestTotalRechargeAtLeast(t *testing.T) {
items := []integration.UserTotalRecharge{
{Amount: integration.DecimalString("80.50")},
{Amount: integration.DecimalString("20")},
}
if !totalRechargeAtLeast(items, 100) {
t.Fatal("totalRechargeAtLeast returned false, want true")
}
if totalRechargeAtLeast(items, 101) {
t.Fatal("totalRechargeAtLeast returned true, want false")
}
}

View File

@ -0,0 +1,392 @@
package smashegg
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
)
// Draw executes one smash egg draw request for an authenticated user.
func (s *Service) Draw(ctx context.Context, user AuthUser, req DrawRequest) (*DrawResponse, error) {
sysOrigin := normalizeSysOrigin(user.SysOrigin)
if sysOrigin == "" || user.UserID <= 0 {
return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required")
}
if s.java == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "smash_egg_gateway_unavailable", "reward gateway is unavailable")
}
bundle, err := s.loadConfigBundle(ctx, sysOrigin, true)
if err != nil {
return nil, err
}
if bundle.Config == nil || !bundle.Config.Enabled {
return nil, NewAppError(http.StatusNotFound, "smash_egg_not_available", "smash egg is not enabled")
}
if len(bundle.DrawOptions) == 0 {
return nil, NewAppError(http.StatusNotFound, "smash_egg_options_empty", "smash egg draw options are not configured")
}
if len(bundle.Rewards) == 0 {
return nil, NewAppError(http.StatusNotFound, "smash_egg_rewards_empty", "smash egg rewards are not configured")
}
option, err := optionForTimes(bundle.DrawOptions, req.Times)
if err != nil {
return nil, err
}
poolType, rewardPool, eligibility, err := s.resolveRewardPool(ctx, bundle, user)
if err != nil {
return nil, err
}
if len(rewardPool) == 0 {
return nil, NewAppError(http.StatusNotFound, "smash_egg_rewards_empty", "smash egg rewards are not configured")
}
rewardPool, err = rewardsWithAutoProbabilitiesForOption(bundle.Config.RTPBasisPoints, option, rewardPool)
if err != nil {
return nil, err
}
selected := make([]model.SmashEggRewardConfig, 0, option.Times)
for index := 0; index < option.Times; index++ {
reward, pickErr := pickReward(rewardPool)
if pickErr != nil {
return nil, pickErr
}
selected = append(selected, reward)
}
drawID, err := utils.NextID()
if err != nil {
return nil, err
}
drawNo := fmt.Sprintf("SMASHEGG%d", drawID)
records, err := s.createPendingRecords(ctx, drawNo, user.UserID, sysOrigin, poolType, option, selected)
if err != nil {
return nil, err
}
if err := s.deductDrawPrice(ctx, drawNo, user.UserID, sysOrigin, option.PriceGold); err != nil {
_ = s.markDrawRecords(ctx, drawNo, drawStatusFailed, err.Error())
return nil, mapWalletError("smash_egg_wallet_deduct_failed", err)
}
for index := range records {
record := &records[index]
if err := s.grantReward(ctx, record); err != nil {
_ = s.markRecordStatus(ctx, record.ID, drawStatusFailed, err.Error())
return nil, NewAppError(http.StatusBadGateway, "smash_egg_reward_grant_failed", err.Error())
}
record.Status = drawStatusSuccess
record.UpdateTime = time.Now()
if err := s.markRecordStatus(ctx, record.ID, drawStatusSuccess, ""); err != nil {
return nil, err
}
}
var balanceAfter int64
if balanceMap, balanceErr := s.java.MapGoldBalance(ctx, []int64{user.UserID}); balanceErr == nil {
balanceAfter = balanceMap[user.UserID]
}
return buildDrawResponse(drawNo, user.UserID, sysOrigin, poolType, option.Times, option.PriceGold, balanceAfter, eligibility.remainingAfterUse(poolType), records), nil
}
func optionForTimes(options []model.SmashEggDrawOptionConfig, times int) (model.SmashEggDrawOptionConfig, error) {
if times <= 0 && len(options) > 0 {
return options[0], nil
}
for _, option := range options {
if option.Enabled && option.Times == times {
return option, nil
}
}
return model.SmashEggDrawOptionConfig{}, NewAppError(http.StatusBadRequest, "invalid_draw_times", "draw option is not configured")
}
func pickReward(rewards []model.SmashEggRewardConfig) (model.SmashEggRewardConfig, error) {
total := 0
for _, reward := range rewards {
if reward.Enabled && reward.Probability > 0 {
total += reward.Probability
}
}
if total <= 0 {
return model.SmashEggRewardConfig{}, NewAppError(http.StatusNotFound, "smash_egg_rewards_empty", "smash egg rewards are not configured")
}
n, err := rand.Int(rand.Reader, big.NewInt(int64(total)))
if err != nil {
return model.SmashEggRewardConfig{}, err
}
target := int(n.Int64()) + 1
accumulated := 0
for _, reward := range rewards {
if !reward.Enabled || reward.Probability <= 0 {
continue
}
accumulated += reward.Probability
if target <= accumulated {
return reward, nil
}
}
return rewards[len(rewards)-1], nil
}
func (s *Service) createPendingRecords(
ctx context.Context,
drawNo string,
userID int64,
sysOrigin string,
poolType string,
option model.SmashEggDrawOptionConfig,
rewards []model.SmashEggRewardConfig,
) ([]model.SmashEggDrawRecord, error) {
now := time.Now()
records := make([]model.SmashEggDrawRecord, 0, len(rewards))
for index, reward := range rewards {
nextID, err := utils.NextID()
if err != nil {
return nil, err
}
records = append(records, model.SmashEggDrawRecord{
ID: nextID,
DrawNo: drawNo,
EventID: fmt.Sprintf("%s:REWARD:%d", drawNo, index+1),
SysOrigin: sysOrigin,
UserID: userID,
PoolType: normalizePoolType(poolType),
DrawOptionID: option.ID,
DrawTimes: option.Times,
PaidGold: option.PriceGold,
RewardType: reward.RewardType,
RewardGroupID: reward.RewardGroupID,
RewardGroupName: reward.RewardGroupName,
ResourceType: reward.ResourceType,
ResourceURL: reward.ResourceURL,
CoverURL: reward.CoverURL,
AnimationURL: reward.AnimationURL,
DurationDays: reward.DurationDays,
DisplayGoldAmount: reward.DisplayGoldAmount,
GoldAmount: reward.GoldAmount,
RewardValueGold: reward.RewardValueGold,
Probability: reward.Probability,
Status: drawStatusPending,
CreateTime: now,
UpdateTime: now,
})
}
if len(records) == 0 {
return records, nil
}
if err := s.db.WithContext(ctx).Create(&records).Error; err != nil {
return nil, err
}
return records, nil
}
func (s *Service) deductDrawPrice(ctx context.Context, drawNo string, userID int64, sysOrigin string, paidGold int64) error {
if paidGold <= 0 {
return nil
}
return s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{
ReceiptType: walletReceiptExpenditure,
UserID: userID,
SysOrigin: sysOrigin,
EventID: fmt.Sprintf("%s:PAY", drawNo),
Remark: fmt.Sprintf("smash egg draw %s", drawNo),
Amount: integration.NewPennyAmountPayloadFromDollar(paidGold),
CloseDelayAsset: false,
OpUserType: "APP",
CustomizeOrigin: walletOrigin,
CustomizeOriginDesc: walletOriginDesc,
})
}
func (s *Service) grantReward(ctx context.Context, record *model.SmashEggDrawRecord) error {
switch normalizeRewardType(record.RewardType) {
case rewardTypeGold:
if record.GoldAmount <= 0 {
return fmt.Errorf("gold reward amount is empty")
}
return s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{
ReceiptType: walletReceiptIncome,
UserID: record.UserID,
SysOrigin: record.SysOrigin,
EventID: record.EventID,
Remark: fmt.Sprintf("smash egg reward %s", record.DrawNo),
Amount: integration.NewPennyAmountPayloadFromDollar(record.GoldAmount),
CloseDelayAsset: false,
OpUserType: "APP",
CustomizeOrigin: walletOrigin,
CustomizeOriginDesc: walletOriginDesc,
})
case rewardTypeResource:
return s.grantResourceReward(ctx, record)
default:
return fmt.Errorf("unsupported reward type %s", record.RewardType)
}
}
func (s *Service) grantResourceReward(ctx context.Context, record *model.SmashEggDrawRecord) error {
if record.RewardGroupID == nil || *record.RewardGroupID <= 0 {
return fmt.Errorf("resource reward is empty")
}
days := record.DurationDays
if days <= 0 {
return fmt.Errorf("resource reward duration is empty")
}
resourceID := *record.RewardGroupID
resourceType := strings.ToUpper(strings.TrimSpace(record.ResourceType))
if resourceType == "" {
return fmt.Errorf("resource reward type is empty")
}
if resourceType == resourceTypeBadge || resourceType == resourceTypeRoomBadge {
if err := s.java.ActivateTemporaryBadge(ctx, record.UserID, resourceID, days); err != nil {
return err
}
_ = s.java.RemoveUserProfileCacheAll(ctx, record.UserID)
return nil
}
useProps := resourceType != resourceTypeGift
if err := s.java.GivePropsBackpack(ctx, integration.GivePropsBackpackRequest{
AcceptUserID: record.UserID,
PropsID: resourceID,
Type: resourceType,
Origin: walletOrigin,
OriginDesc: walletOriginDesc,
Days: days,
UseProps: useProps,
AllowGive: false,
}); err != nil {
return err
}
if useProps {
if err := s.java.SwitchUseProps(ctx, record.UserID, resourceID); err != nil {
return err
}
_ = s.java.RemoveUserProfileCacheAll(ctx, record.UserID)
}
return nil
}
func (s *Service) markDrawRecords(ctx context.Context, drawNo string, status string, message string) error {
return s.db.WithContext(ctx).Model(&model.SmashEggDrawRecord{}).
Where("draw_no = ?", drawNo).
Updates(map[string]any{
"status": status,
"error_message": trimErrorMessage(message),
"update_time": time.Now(),
}).Error
}
func (s *Service) markRecordStatus(ctx context.Context, id int64, status string, message string) error {
return s.db.WithContext(ctx).Model(&model.SmashEggDrawRecord{}).
Where("id = ?", id).
Updates(map[string]any{
"status": status,
"error_message": trimErrorMessage(message),
"update_time": time.Now(),
}).Error
}
func buildDrawResponse(
drawNo string,
userID int64,
sysOrigin string,
poolType string,
times int,
paidGold int64,
balanceAfter int64,
newbieRemainingDrawCount int,
records []model.SmashEggDrawRecord,
) *DrawResponse {
payloadRecords := make([]DrawRecordPayload, 0, len(records))
for _, record := range records {
payloadRecords = append(payloadRecords, drawRecordPayload(record))
}
rewards, rewardValue := aggregateRewards(records)
return &DrawResponse{
DrawNo: drawNo,
UserID: userID,
SysOrigin: sysOrigin,
PoolType: normalizePoolType(poolType),
DrawTimes: times,
PaidGold: paidGold,
RewardValue: rewardValue,
Rewards: rewards,
Records: payloadRecords,
BalanceAfter: balanceAfter,
NewbieRemainingDrawCount: newbieRemainingDrawCount,
}
}
func aggregateRewards(records []model.SmashEggDrawRecord) ([]DrawRewardPayload, int64) {
type bucket struct {
payload DrawRewardPayload
}
buckets := map[string]*bucket{}
order := make([]string, 0, len(records))
var total int64
for _, record := range records {
value := record.RewardValueGold
total += value
key := fmt.Sprintf("%s:%d:%s:%d:%d", record.RewardType, int64PtrValue(record.RewardGroupID), record.ResourceType, record.GoldAmount, record.DurationDays)
if _, exists := buckets[key]; !exists {
name := strings.TrimSpace(record.RewardGroupName)
if name == "" && record.RewardType == rewardTypeGold {
name = "金币"
}
buckets[key] = &bucket{payload: DrawRewardPayload{
RewardType: record.RewardType,
ResourceID: ResourceID(int64PtrValue(record.RewardGroupID)),
ResourceType: record.ResourceType,
ResourceName: record.RewardGroupName,
ResourceURL: record.ResourceURL,
RewardGroupID: record.RewardGroupID,
RewardGroupName: record.RewardGroupName,
CoverURL: record.CoverURL,
AnimationURL: record.AnimationURL,
DurationDays: record.DurationDays,
DisplayGoldAmount: record.DisplayGoldAmount,
GoldAmount: record.GoldAmount,
RewardValueGold: record.RewardValueGold,
Probability: record.Probability,
Name: name,
Value: value,
}}
order = append(order, key)
}
buckets[key].payload.Count++
}
result := make([]DrawRewardPayload, 0, len(order))
for _, key := range order {
result = append(result, buckets[key].payload)
}
return result, total
}
func int64PtrValue(value *int64) int64 {
if value == nil {
return 0
}
return *value
}
func mapWalletError(code string, err error) *AppError {
message := err.Error()
normalized := strings.ToLower(message)
if strings.Contains(normalized, "insufficient_balance") ||
strings.Contains(normalized, "balance not made") ||
strings.Contains(normalized, `"errorcode":5000`) {
return NewAppError(http.StatusNotAcceptable, "smash_egg_insufficient_balance", "insufficient balance")
}
return NewAppError(http.StatusBadGateway, code, message)
}

View File

@ -0,0 +1,234 @@
//go:build integration
package smashegg
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"testing"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/repo"
)
type realResourceRow struct {
ID int64 `gorm:"column:id"`
Type string `gorm:"column:type"`
Name string `gorm:"column:name"`
Cover string `gorm:"column:cover"`
SourceURL string `gorm:"column:source_url"`
AmountGold int64 `gorm:"column:amount_gold"`
}
func TestRealDataAdminConfigThenDraw(t *testing.T) {
ctx := context.Background()
cfg := config.Load()
repository, err := repo.New(cfg)
if err != nil {
t.Fatalf("init repository: %v", err)
}
if err := repository.Ping(ctx); err != nil {
t.Fatalf("ping real dependencies: %v", err)
}
gateways := integration.NewGateways(cfg)
service := NewService(cfg, repository.DB, &gateways)
sysOrigin := "LIKEI"
userID := realFlowUserID()
resource := firstRealResource(t, ctx, repository, sysOrigin)
req := SaveConfigRequest{
SysOrigin: sysOrigin,
Enabled: true,
Timezone: "Asia/Riyadh",
RTPBasisPoints: 9500,
NewbiePoolEnabled: true,
NewbieWindowDays: 90,
NewbieMaxDrawCount: 1000,
NewbieMinRechargeAmount: 0,
DrawOptions: []DrawOptionInput{
{Enabled: true, Sort: 1, OptionKey: optionKeyOne, Label: "Smash 1", Times: 1, PriceGold: 500},
{Enabled: true, Sort: 2, OptionKey: optionKeyTen, Label: "Smash 10", Times: 10, PriceGold: 5000},
{Enabled: true, Sort: 3, OptionKey: optionKeyHundred, Label: "Smash 100", Times: 100, PriceGold: 50000},
},
Rewards: []RewardConfigInput{
{Enabled: true, Sort: 1, RewardType: rewardTypeGold, GoldAmount: 50},
{Enabled: true, Sort: 2, RewardType: rewardTypeGold, GoldAmount: 100},
{Enabled: true, Sort: 3, RewardType: rewardTypeGold, GoldAmount: 500},
{
Enabled: true,
Sort: 4,
RewardType: rewardTypeResource,
ResourceID: ResourceID(resource.ID),
ResourceType: resource.Type,
ResourceName: resource.Name,
ResourceURL: resource.SourceURL,
CoverURL: resource.Cover,
AnimationURL: resource.SourceURL,
DurationDays: 1,
DisplayGoldAmount: resource.AmountGold,
},
},
NewbieRewards: []RewardConfigInput{
{Enabled: true, Sort: 1, RewardType: rewardTypeGold, GoldAmount: 400},
{Enabled: true, Sort: 2, RewardType: rewardTypeGold, GoldAmount: 500},
{Enabled: true, Sort: 3, RewardType: rewardTypeGold, GoldAmount: 600},
},
}
configResp, err := service.SaveConfig(ctx, req)
if err != nil {
t.Fatalf("save admin config: %v", err)
}
if configResp.RTPBasisPoints != 9500 || len(configResp.DrawOptions) != 3 {
t.Fatalf("unexpected config response: rtp=%d options=%d", configResp.RTPBasisPoints, len(configResp.DrawOptions))
}
assertProbabilityTotal(t, "general", configResp.Rewards)
assertProbabilityTotal(t, "newbie", configResp.NewbieRewards)
assertResourceRewardPresent(t, configResp.Rewards, resource.ID)
t.Logf("admin_config saved sysOrigin=%s rtp=%s options=%d generalRewards=%d newbieRewards=%d resource=%s:%d price=%d",
configResp.SysOrigin, configResp.RTPPercent, len(configResp.DrawOptions), len(configResp.Rewards), len(configResp.NewbieRewards), resource.Type, resource.ID, resource.AmountGold)
appConfig, err := service.GetAppConfig(ctx, AuthUser{UserID: userID, SysOrigin: sysOrigin})
if err != nil {
t.Fatalf("get app config: %v", err)
}
if appConfig.ActivePoolType != poolTypeNewbie || !appConfig.NewbieEligible {
t.Fatalf("expected newbie pool, got pool=%s eligible=%v remaining=%d", appConfig.ActivePoolType, appConfig.NewbieEligible, appConfig.NewbieRemainingDrawCount)
}
t.Logf("app_config activePool=%s newbieRemaining=%d drawOptions=%d", appConfig.ActivePoolType, appConfig.NewbieRemainingDrawCount, len(appConfig.DrawOptions))
balanceBefore, err := goldBalance(ctx, &gateways, userID)
if err != nil {
t.Fatalf("balance before: %v", err)
}
if balanceBefore < 5000 {
t.Fatalf("test user balance=%d is below draw price", balanceBefore)
}
var recordsBefore int64
if err := repository.DB.WithContext(ctx).Table("smash_egg_draw_record").Where("sys_origin = ? AND user_id = ?", sysOrigin, userID).Count(&recordsBefore).Error; err != nil {
t.Fatalf("count records before: %v", err)
}
drawResp, err := service.Draw(ctx, AuthUser{UserID: userID, SysOrigin: sysOrigin}, DrawRequest{Times: 10})
if err != nil {
t.Fatalf("draw: %v", err)
}
if drawResp.DrawTimes != 10 || drawResp.PaidGold != 5000 || drawResp.PoolType != poolTypeNewbie {
t.Fatalf("unexpected draw response: times=%d paid=%d pool=%s", drawResp.DrawTimes, drawResp.PaidGold, drawResp.PoolType)
}
if len(drawResp.Records) != 10 || len(drawResp.Rewards) == 0 {
t.Fatalf("unexpected reward records: records=%d rewards=%d", len(drawResp.Records), len(drawResp.Rewards))
}
for _, record := range drawResp.Records {
if record.Status != drawStatusSuccess {
t.Fatalf("record %d status=%s error=%s", record.ID, record.Status, record.ErrorMessage)
}
}
balanceAfter, err := goldBalance(ctx, &gateways, userID)
if err != nil {
t.Fatalf("balance after: %v", err)
}
expectedBalanceAfter := balanceBefore - drawResp.PaidGold + drawResp.RewardValue
if balanceAfter != expectedBalanceAfter {
t.Fatalf("balance after=%d, want %d (before=%d paid=%d reward=%d)", balanceAfter, expectedBalanceAfter, balanceBefore, drawResp.PaidGold, drawResp.RewardValue)
}
var recordsAfter int64
if err := repository.DB.WithContext(ctx).Table("smash_egg_draw_record").Where("sys_origin = ? AND user_id = ?", sysOrigin, userID).Count(&recordsAfter).Error; err != nil {
t.Fatalf("count records after: %v", err)
}
if recordsAfter-recordsBefore != int64(drawResp.DrawTimes) {
t.Fatalf("record delta=%d, want %d", recordsAfter-recordsBefore, drawResp.DrawTimes)
}
t.Logf("draw_success drawNo=%s paid=%d reward=%d balanceBefore=%d balanceAfter=%d recordsDelta=%d",
drawResp.DrawNo, drawResp.PaidGold, drawResp.RewardValue, balanceBefore, balanceAfter, recordsAfter-recordsBefore)
adminRecords, err := service.PageAdminRecords(ctx, sysOrigin, userID, drawStatusSuccess, 1, 20, "", "")
if err != nil {
t.Fatalf("page admin records: %v", err)
}
if len(adminRecords.Records) == 0 || adminRecords.Records[0].DrawNo == "" {
t.Fatalf("admin records are empty after draw")
}
t.Logf("admin_records total=%d firstDrawNo=%s firstReward=%s", adminRecords.Total, adminRecords.Records[0].DrawNo, adminRecords.Records[0].RewardGroupName)
}
func realFlowUserID() int64 {
raw := strings.TrimSpace(os.Getenv("SMASH_EGG_REAL_FLOW_USER_ID"))
if raw == "" {
return 2052720059744202753
}
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil || parsed <= 0 {
return 2052720059744202753
}
return parsed
}
func firstRealResource(t *testing.T, ctx context.Context, repository *repo.Repository, sysOrigin string) realResourceRow {
t.Helper()
var row realResourceRow
err := repository.DB.WithContext(ctx).Raw(
`SELECT id, type, name, cover, source_url, CAST(amount AS SIGNED) AS amount_gold
FROM props_source_record
WHERE sys_origin = ? AND is_del = 0 AND amount > 0 AND amount < 300000
ORDER BY amount ASC, id ASC
LIMIT 1`,
sysOrigin,
).Scan(&row).Error
if err != nil {
t.Fatalf("query real resource: %v", err)
}
if row.ID <= 0 {
t.Fatalf("no real resource found for sysOrigin=%s", sysOrigin)
}
if strings.TrimSpace(row.Type) == "" {
t.Fatalf("real resource %d has empty type", row.ID)
}
if row.AmountGold <= 0 {
row.AmountGold = 5000
}
return row
}
func assertProbabilityTotal(t *testing.T, name string, rewards []RewardConfigPayload) {
t.Helper()
total := 0
for _, reward := range rewards {
if reward.Enabled {
total += reward.Probability
}
}
if total != probabilityTotal {
t.Fatalf("%s probability total=%d, want %d", name, total, probabilityTotal)
}
}
func assertResourceRewardPresent(t *testing.T, rewards []RewardConfigPayload, resourceID int64) {
t.Helper()
for _, reward := range rewards {
if reward.RewardType == rewardTypeResource && reward.ResourceID.Int64() == resourceID && reward.DisplayGoldAmount > 0 {
return
}
}
t.Fatalf("resource reward %d with display price was not found", resourceID)
}
func goldBalance(ctx context.Context, gateways *integration.Gateways, userID int64) (int64, error) {
balances, err := gateways.MapGoldBalance(ctx, []int64{userID})
if err != nil {
return 0, err
}
value, ok := balances[userID]
if !ok {
return 0, fmt.Errorf("missing balance for user %d", userID)
}
return value, nil
}

View File

@ -0,0 +1,273 @@
package smashegg
import (
"context"
"strconv"
"time"
"chatapp3-golang/internal/model"
)
const legacyGoldImage = "assets/common/yumi_coin.png"
// LegacyPrizeList returns the shape expected by the copied H5 bundle.
func (s *Service) LegacyPrizeList(ctx context.Context, user AuthUser) (map[string]any, error) {
config, err := s.GetAppConfig(ctx, user)
if err != nil {
return nil, err
}
var balance int64
if s.java != nil && user.UserID > 0 {
if balanceMap, balanceErr := s.java.MapGoldBalance(ctx, []int64{user.UserID}); balanceErr == nil {
balance = balanceMap[user.UserID]
}
}
drawOptions := config.DrawOptions
if len(drawOptions) == 0 {
drawOptions = defaultDrawOptionPayloads(0)
}
mapPrice := int64(0)
if len(drawOptions) > 0 && drawOptions[0].Times > 0 {
mapPrice = drawOptions[0].PriceGold / int64(drawOptions[0].Times)
}
return map[string]any{
"activity": map[string]any{"enabled": config.Enabled, "rtp": config.RTPPercent},
"coins": balance,
"drawOptions": legacyDrawOptions(drawOptions),
"mapPrice": mapPrice,
"normalList": legacyPrizeList(config.Rewards),
"notices": []any{},
"owner": map[string]any{},
"rankData": map[string]any{},
"superList": []any{},
"topOne": map[string]any{},
}, nil
}
// LegacyStartHunt executes draw and returns the shape expected by the H5 bundle.
func (s *Service) LegacyStartHunt(ctx context.Context, user AuthUser, times int) (map[string]any, error) {
resp, err := s.Draw(ctx, user, DrawRequest{Times: times})
if err != nil {
return nil, err
}
return map[string]any{
"balance": resp.BalanceAfter,
"list": legacyRewardList(resp.Rewards),
}, nil
}
// LegacyMyHuntRecords returns the user's draw records in the H5 bundle shape.
func (s *Service) LegacyMyHuntRecords(ctx context.Context, user AuthUser, start int, limit int) (map[string]any, error) {
if start < 0 {
start = 0
}
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
var rows []model.SmashEggDrawRecord
if err := s.db.WithContext(ctx).
Where("sys_origin = ? AND user_id = ?", normalizeSysOrigin(user.SysOrigin), user.UserID).
Order("create_time desc, id desc").
Offset(start).
Limit(limit).
Find(&rows).Error; err != nil {
return nil, err
}
grouped := make([]map[string]any, 0)
index := map[string]int{}
for _, row := range rows {
if _, exists := index[row.DrawNo]; !exists {
index[row.DrawNo] = len(grouped)
grouped = append(grouped, map[string]any{
"huntTime": strconv.FormatInt(row.CreateTime.Unix(), 10),
"prizeList": []map[string]any{},
})
}
itemIndex := index[row.DrawNo]
list := grouped[itemIndex]["prizeList"].([]map[string]any)
grouped[itemIndex]["prizeList"] = append(list, legacyRecordPrize(row))
}
return map[string]any{
"finished": len(rows) < limit,
"list": grouped,
}, nil
}
// LegacyHuntNotices returns recent notices in the H5 bundle shape.
func (s *Service) LegacyHuntNotices(ctx context.Context, sysOrigin string) (map[string]any, error) {
notices, err := s.ListRecentNotices(ctx, sysOrigin, 20)
if err != nil {
return nil, err
}
list := make([]map[string]any, 0, len(notices))
for _, item := range notices {
list = append(list, map[string]any{
"nick": "User " + strconv.FormatInt(item.UserID, 10),
"prizeList": []map[string]any{{
"image": legacyRewardImage(item.RewardType, item.CoverURL),
"num": legacyRewardNum(item.GoldAmount),
}},
})
}
return map[string]any{"list": list}, nil
}
// LegacyDayRank returns day rank in the H5 bundle shape.
func (s *Service) LegacyDayRank(ctx context.Context, sysOrigin string) (map[string]any, error) {
rank, err := s.PageDayRank(ctx, sysOrigin, 1, 100)
if err != nil {
return nil, err
}
list := make([]map[string]any, 0, len(rank))
for _, item := range rank {
list = append(list, map[string]any{
"avatar": "",
"gotPrices": item.Score,
"nick": "User " + strconv.FormatInt(item.UserID, 10),
"uid": item.UserID,
})
}
return map[string]any{"list": list}, nil
}
func legacyDrawOptions(options []DrawOptionPayload) []map[string]any {
result := make([]map[string]any, 0, len(options))
for _, option := range options {
if !option.Enabled {
continue
}
result = append(result, map[string]any{
"key": option.OptionKey,
"label": option.Label,
"price": option.PriceGold,
"times": option.Times,
})
}
return result
}
func legacyPrizeList(rewards []RewardConfigPayload) []map[string]any {
result := make([]map[string]any, 0, len(rewards))
for _, reward := range rewards {
if !reward.Enabled {
continue
}
result = append(result, map[string]any{
"id": firstNonZeroInt64(reward.ResourceID.Int64(), int64PtrValue(reward.RewardGroupID), reward.GoldAmount),
"image": legacyRewardImage(reward.RewardType, reward.CoverURL),
"name": legacyRewardName(reward.RewardGroupName, reward.RewardType),
"num": legacyRewardNum(reward.GoldAmount),
"price": legacyDisplayPrice(reward.RewardType, reward.GoldAmount, reward.DisplayGoldAmount, reward.RewardValueGold),
"type": legacyRewardType(reward.RewardType),
"animationUrl": reward.AnimationURL,
})
}
return result
}
func legacyRewardList(rewards []DrawRewardPayload) []map[string]any {
result := make([]map[string]any, 0, len(rewards))
for _, reward := range rewards {
result = append(result, map[string]any{
"id": firstNonZeroInt64(int64PtrValue(reward.RewardGroupID), reward.GoldAmount),
"image": legacyRewardImage(reward.RewardType, reward.CoverURL),
"name": legacyRewardName(reward.RewardGroupName, reward.RewardType),
"num": legacyDrawRewardNum(reward),
"price": legacyDisplayPrice(reward.RewardType, reward.GoldAmount, reward.DisplayGoldAmount, reward.RewardValueGold),
"type": legacyRewardType(reward.RewardType),
"animationUrl": reward.AnimationURL,
})
}
return result
}
func legacyRecordPrize(row model.SmashEggDrawRecord) map[string]any {
return map[string]any{
"id": firstNonZeroInt64(int64PtrValue(row.RewardGroupID), row.GoldAmount),
"image": legacyRewardImage(row.RewardType, row.CoverURL),
"name": legacyRewardName(row.RewardGroupName, row.RewardType),
"num": legacyRewardNum(row.GoldAmount),
"price": legacyDisplayPrice(row.RewardType, row.GoldAmount, row.DisplayGoldAmount, row.RewardValueGold),
"type": legacyRewardType(row.RewardType),
"animationUrl": row.AnimationURL,
}
}
func legacyRewardImage(rewardType string, coverURL string) string {
if rewardType == rewardTypeGold {
return legacyGoldImage
}
if coverURL != "" {
return coverURL
}
return legacyGoldImage
}
func legacyRewardName(name string, rewardType string) string {
if name != "" {
return name
}
if rewardType == rewardTypeGold {
return "Gold"
}
return "Reward"
}
func legacyRewardNum(goldAmount int64) int64 {
if goldAmount > 0 {
return goldAmount
}
return 1
}
func legacyDrawRewardNum(reward DrawRewardPayload) int64 {
if reward.RewardType == rewardTypeGold {
return legacyRewardNum(reward.GoldAmount)
}
return int64(maxInt(1, reward.Count))
}
func legacyRewardType(rewardType string) int {
if rewardType == rewardTypeGold {
return 1
}
return 2
}
func legacyDisplayPrice(rewardType string, goldAmount int64, displayGoldAmount int64, rewardValueGold int64) int64 {
if rewardType == rewardTypeGold && goldAmount > 0 {
return goldAmount
}
if displayGoldAmount > 0 {
return displayGoldAmount
}
return rewardValueGold
}
func firstNonZeroInt64(values ...int64) int64 {
for _, value := range values {
if value > 0 {
return value
}
}
return time.Now().UnixNano()
}
func maxInt(minimum int, value int) int {
if value < minimum {
return minimum
}
return value
}
func maxInt64(minimum int64, value int64) int64 {
if value < minimum {
return minimum
}
return value
}

View File

@ -0,0 +1,123 @@
package smashegg
import (
"context"
"errors"
"math/big"
"strings"
"time"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"gorm.io/gorm"
)
type newbieEligibility struct {
Eligible bool
Remaining int
}
func (item newbieEligibility) remainingAfterUse(poolType string) int {
if normalizePoolType(poolType) != poolTypeNewbie {
return item.Remaining
}
if item.Remaining <= 0 {
return 0
}
return item.Remaining - 1
}
func (s *Service) resolveRewardPool(ctx context.Context, bundle *configBundle, user AuthUser) (string, []model.SmashEggRewardConfig, newbieEligibility, error) {
eligibility := newbieEligibility{}
if bundle == nil || bundle.Config == nil {
return poolTypeGeneral, nil, eligibility, nil
}
generalRewards := rewardsForPool(bundle.Rewards, poolTypeGeneral)
if !bundle.Config.NewbiePoolEnabled || user.UserID <= 0 {
return poolTypeGeneral, generalRewards, eligibility, nil
}
newbieRewards := rewardsForPool(bundle.Rewards, poolTypeNewbie)
if len(newbieRewards) == 0 {
return poolTypeGeneral, generalRewards, eligibility, nil
}
resolved, err := s.resolveNewbieEligibility(ctx, bundle.Config, user)
if err != nil {
return "", nil, eligibility, err
}
if !resolved.Eligible {
return poolTypeGeneral, generalRewards, resolved, nil
}
return poolTypeNewbie, newbieRewards, resolved, nil
}
func (s *Service) resolveNewbieEligibility(ctx context.Context, configRow *model.SmashEggConfig, user AuthUser) (newbieEligibility, error) {
result := newbieEligibility{}
if configRow == nil || !configRow.NewbiePoolEnabled || user.UserID <= 0 {
return result, nil
}
windowDays := effectiveNewbieWindowDays(configRow.NewbieWindowDays)
maxDrawCount := effectiveNewbieMaxDrawCount(configRow.NewbieMaxDrawCount, true)
if maxDrawCount <= 0 {
return result, nil
}
var userInfo model.UserBaseInfo
err := s.db.WithContext(ctx).Where("id = ?", user.UserID).First(&userInfo).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return result, nil
}
if err != nil {
return result, err
}
if userInfo.CreateTime.IsZero() {
return result, nil
}
if time.Since(userInfo.CreateTime) > time.Duration(windowDays)*24*time.Hour {
return result, nil
}
var used int64
if err := s.db.WithContext(ctx).Model(&model.SmashEggDrawRecord{}).
Where("sys_origin = ? AND user_id = ? AND pool_type = ? AND status IN ?", normalizeSysOrigin(user.SysOrigin), user.UserID, poolTypeNewbie, []string{drawStatusPending, drawStatusSuccess}).
Distinct("draw_no").
Count(&used).Error; err != nil {
return result, err
}
remaining := maxDrawCount - int(used)
if remaining <= 0 {
return result, nil
}
if configRow.NewbieMinRechargeAmount > 0 {
if s.java == nil {
return result, nil
}
rechargeMap, err := s.java.MapUserTotalRecharge(ctx, []int64{user.UserID})
if err != nil {
return result, nil
}
if !totalRechargeAtLeast(rechargeMap[user.UserID], configRow.NewbieMinRechargeAmount) {
return result, nil
}
}
result.Eligible = true
result.Remaining = remaining
return result, nil
}
func totalRechargeAtLeast(items []integration.UserTotalRecharge, threshold int64) bool {
if threshold <= 0 {
return true
}
total := new(big.Rat)
for _, item := range items {
if strings.EqualFold(strings.TrimSpace(item.Type), "REFUND") {
continue
}
total.Add(total, parseDecimalRat(item.Amount))
}
return total.Cmp(new(big.Rat).SetInt64(threshold)) >= 0
}

View File

@ -0,0 +1,243 @@
package smashegg
import (
"fmt"
"math"
"net/http"
"sort"
"chatapp3-golang/internal/model"
)
type probabilityItem struct {
index int
value int64
}
func applyAutoProbabilitiesToInputs(targetRTP int, options []DrawOptionInput, rewards []RewardConfigInput, poolName string) ([]RewardConfigInput, error) {
if err := validateAutoProbabilitiesForInputs(targetRTP, options, rewards, poolName); err != nil {
return nil, err
}
baseOption, ok := firstEnabledInputOption(options)
if !ok {
return rewards, nil
}
probabilities, err := autoProbabilitiesForInputOption(targetRTP, baseOption, rewards, poolName)
if err != nil {
return nil, err
}
for index := range rewards {
rewards[index].Probability = probabilities[index]
}
return rewards, nil
}
func validateAutoProbabilitiesForInputs(targetRTP int, options []DrawOptionInput, rewards []RewardConfigInput, poolName string) error {
for _, option := range options {
if !option.Enabled {
continue
}
probabilities, err := autoProbabilitiesForInputOption(targetRTP, option, rewards, poolName)
if err != nil {
return err
}
weightedValue := weightedInputRewardValue(rewards, probabilities)
rtp := drawOptionRTPBasisPointsFromWeightedValue(weightedValue, option.Times, option.PriceGold)
if rtp < targetRTP-rtpToleranceBasisPoints || rtp > targetRTP+rtpToleranceBasisPoints {
return NewAppError(
http.StatusBadRequest,
"invalid_rtp",
fmt.Sprintf("%s pool option %s auto RTP %s%% is outside target %s%% ±1.00%%", poolName, option.Label, basisPointsPercent(rtp), basisPointsPercent(targetRTP)),
)
}
}
return nil
}
func rewardsWithAutoProbabilitiesForOption(targetRTP int, option model.SmashEggDrawOptionConfig, rewards []model.SmashEggRewardConfig) ([]model.SmashEggRewardConfig, error) {
probabilities, err := autoProbabilitiesForModelOption(targetRTP, option, rewards, normalizePoolType(firstPoolType(rewards)))
if err != nil {
return nil, err
}
next := append([]model.SmashEggRewardConfig(nil), rewards...)
for index := range next {
next[index].Probability = probabilities[index]
}
return next, nil
}
func firstPoolType(rewards []model.SmashEggRewardConfig) string {
for _, reward := range rewards {
if reward.PoolType != "" {
return reward.PoolType
}
}
return poolTypeGeneral
}
func expectedRewardValueForOption(targetRTP int, option model.SmashEggDrawOptionConfig, rewards []model.SmashEggRewardConfig) float64 {
next, err := rewardsWithAutoProbabilitiesForOption(targetRTP, option, rewards)
if err != nil {
return expectedRewardValue(rewards)
}
return expectedRewardValue(next)
}
func autoProbabilitiesForInputOption(targetRTP int, option DrawOptionInput, rewards []RewardConfigInput, poolName string) ([]int, error) {
items := make([]probabilityItem, 0, len(rewards))
for index, reward := range rewards {
if reward.Enabled {
items = append(items, probabilityItem{index: index, value: reward.RewardValueGold})
}
}
return calculateAutoProbabilities(targetWeightedValue(option.PriceGold, option.Times, targetRTP), len(rewards), items, poolName, option.Label)
}
func autoProbabilitiesForModelOption(targetRTP int, option model.SmashEggDrawOptionConfig, rewards []model.SmashEggRewardConfig, poolName string) ([]int, error) {
items := make([]probabilityItem, 0, len(rewards))
for index, reward := range rewards {
if reward.Enabled {
items = append(items, probabilityItem{index: index, value: reward.RewardValueGold})
}
}
return calculateAutoProbabilities(targetWeightedValue(option.PriceGold, option.Times, targetRTP), len(rewards), items, poolName, option.Label)
}
func calculateAutoProbabilities(targetWeighted int64, rewardCount int, items []probabilityItem, poolName string, optionLabel string) ([]int, error) {
probabilities := make([]int, rewardCount)
if len(items) == 0 {
return probabilities, NewAppError(http.StatusBadRequest, "empty_rewards", poolName+" pool requires at least one enabled reward")
}
if len(items) > probabilityTotal {
return probabilities, NewAppError(http.StatusBadRequest, "too_many_rewards", fmt.Sprintf("%s pool enabled reward count cannot exceed %d", poolName, probabilityTotal))
}
for _, item := range items {
if item.value <= 0 {
return probabilities, NewAppError(http.StatusBadRequest, "invalid_reward_value", poolName+" pool enabled reward value must be greater than 0")
}
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].value == items[j].value {
return items[i].index < items[j].index
}
return items[i].value < items[j].value
})
var baseWeighted int64
for _, item := range items {
probabilities[item.index] = 1
baseWeighted += item.value
}
remaining := probabilityTotal - len(items)
if remaining == 0 {
return probabilities, nil
}
residualTarget := targetWeighted - baseWeighted
minResidual := items[0].value * int64(remaining)
maxResidual := items[len(items)-1].value * int64(remaining)
if residualTarget < minResidual {
probabilities[items[0].index] += remaining
return probabilities, nil
}
if residualTarget > maxResidual {
probabilities[items[len(items)-1].index] += remaining
return probabilities, nil
}
low := items[0]
high := items[len(items)-1]
for index, item := range items {
if item.value*int64(remaining) <= residualTarget {
low = item
if index+1 < len(items) {
high = items[index+1]
} else {
high = item
}
}
}
if low.value == high.value {
probabilities[low.index] += remaining
return probabilities, nil
}
spread := high.value - low.value
highCount := roundDiv(residualTarget-low.value*int64(remaining), spread)
if highCount < 0 {
highCount = 0
}
if highCount > int64(remaining) {
highCount = int64(remaining)
}
highCount = bestHighCount(residualTarget, low.value, high.value, int64(remaining), highCount)
lowCount := int64(remaining) - highCount
probabilities[low.index] += int(lowCount)
probabilities[high.index] += int(highCount)
return probabilities, nil
}
func bestHighCount(target int64, lowValue int64, highValue int64, remaining int64, seed int64) int64 {
best := seed
bestDiff := weightedDiff(target, lowValue, highValue, remaining, seed)
for _, candidate := range []int64{seed - 1, seed + 1} {
if candidate < 0 || candidate > remaining {
continue
}
diff := weightedDiff(target, lowValue, highValue, remaining, candidate)
if diff < bestDiff {
best = candidate
bestDiff = diff
}
}
return best
}
func weightedDiff(target int64, lowValue int64, highValue int64, remaining int64, highCount int64) int64 {
value := lowValue*remaining + (highValue-lowValue)*highCount
return absInt64(target - value)
}
func weightedInputRewardValue(rewards []RewardConfigInput, probabilities []int) int64 {
var total int64
for index, reward := range rewards {
if index >= len(probabilities) || !reward.Enabled || probabilities[index] <= 0 {
continue
}
total += reward.RewardValueGold * int64(probabilities[index])
}
return total
}
func targetWeightedValue(priceGold int64, times int, targetRTP int) int64 {
if priceGold <= 0 || times <= 0 || targetRTP <= 0 {
return 0
}
return roundDiv(priceGold*int64(targetRTP)*int64(probabilityTotal), int64(times)*10000)
}
func firstEnabledInputOption(options []DrawOptionInput) (DrawOptionInput, bool) {
for _, option := range options {
if option.Enabled {
return option, true
}
}
return DrawOptionInput{}, false
}
func roundDiv(numerator int64, denominator int64) int64 {
if denominator == 0 {
return 0
}
if numerator >= 0 {
return (numerator + denominator/2) / denominator
}
return -int64(math.Round(float64(-numerator) / float64(denominator)))
}
func absInt64(value int64) int64 {
if value < 0 {
return -value
}
return value
}

View File

@ -0,0 +1,299 @@
package smashegg
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/model"
"gorm.io/gorm"
)
// PageUserRecords returns a user's own smash egg draw records.
func (s *Service) PageUserRecords(ctx context.Context, user AuthUser, cursor int, limit int) (*RecordPageResponse, error) {
cursor, limit = normalizeRecordPage(cursor, limit)
var total int64
query := s.db.WithContext(ctx).Model(&model.SmashEggDrawRecord{}).
Where("sys_origin = ? AND user_id = ?", normalizeSysOrigin(user.SysOrigin), user.UserID)
if err := query.Count(&total).Error; err != nil {
return nil, err
}
var rows []model.SmashEggDrawRecord
if err := query.Order("create_time desc, id desc").
Offset((cursor - 1) * limit).
Limit(limit).
Find(&rows).Error; err != nil {
return nil, err
}
return &RecordPageResponse{
Records: drawRecordPayloads(rows),
Total: total,
Current: cursor,
Size: limit,
}, nil
}
// PageAdminRecords returns admin smash egg draw records.
func (s *Service) PageAdminRecords(ctx context.Context, sysOrigin string, userID int64, status string, cursor int, limit int, startTime string, endTime string) (*RecordPageResponse, error) {
cursor, limit = normalizeRecordPage(cursor, limit)
filter, err := newAdminRecordFilter(sysOrigin, userID, status, startTime, endTime)
if err != nil {
return nil, err
}
query := s.adminRecordQuery(ctx, filter)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, err
}
var rows []model.SmashEggDrawRecord
if err := s.adminRecordQuery(ctx, filter).
Order("create_time desc, id desc").
Offset((cursor - 1) * limit).
Limit(limit).
Find(&rows).Error; err != nil {
return nil, err
}
summary, err := s.adminRecordSummary(ctx, filter)
if err != nil {
return nil, err
}
return &RecordPageResponse{
Records: drawRecordPayloads(rows),
Total: total,
Current: cursor,
Size: limit,
TotalPaidGold: summary.TotalPaidGold,
TotalRewardGold: summary.TotalRewardGold,
ReturnRatioBasisPoints: summary.ReturnRatioBasisPoints,
ReturnRatioPercent: basisPointsPercent(summary.ReturnRatioBasisPoints),
StartTime: formatDateTime(filter.StartTime),
EndTime: formatDateTime(filter.EndTime),
}, nil
}
type adminRecordFilter struct {
SysOrigin string
UserID int64
Status string
StartTime time.Time
EndTime time.Time
}
type adminRecordSummary struct {
TotalPaidGold int64
TotalRewardGold int64
ReturnRatioBasisPoints int
}
func newAdminRecordFilter(sysOrigin string, userID int64, status string, startRaw string, endRaw string) (adminRecordFilter, error) {
start, end, err := parseAdminRecordTimeRange(startRaw, endRaw)
if err != nil {
return adminRecordFilter{}, err
}
return adminRecordFilter{
SysOrigin: normalizeSysOrigin(sysOrigin),
UserID: userID,
Status: strings.ToUpper(strings.TrimSpace(status)),
StartTime: start,
EndTime: end,
}, nil
}
func parseAdminRecordTimeRange(startRaw string, endRaw string) (time.Time, time.Time, error) {
startRaw = strings.TrimSpace(startRaw)
endRaw = strings.TrimSpace(endRaw)
now := time.Now()
if startRaw == "" && endRaw == "" {
return now.AddDate(0, 0, -7), now, nil
}
var start time.Time
var end time.Time
var err error
if startRaw != "" {
start, err = parseAdminRecordTime(startRaw, false)
if err != nil {
return time.Time{}, time.Time{}, err
}
}
if endRaw != "" {
end, err = parseAdminRecordTime(endRaw, true)
if err != nil {
return time.Time{}, time.Time{}, err
}
}
if startRaw == "" {
start = end.AddDate(0, 0, -7)
}
if endRaw == "" {
end = start.AddDate(0, 0, 7)
}
if end.Before(start) {
return time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "bad_time_range", "endTime must be greater than startTime")
}
return start, end, nil
}
func parseAdminRecordTime(raw string, endOfDay bool) (time.Time, error) {
for _, layout := range []string{
"2006-01-02 15:04:05",
time.RFC3339,
"2006-01-02T15:04:05.000Z07:00",
"2006-01-02",
} {
var parsed time.Time
var err error
if strings.Contains(layout, "Z07:00") {
parsed, err = time.Parse(layout, raw)
} else {
parsed, err = time.ParseInLocation(layout, raw, time.Local)
}
if err == nil {
if layout == "2006-01-02" && endOfDay {
parsed = parsed.Add(24*time.Hour - time.Nanosecond)
}
return parsed, nil
}
}
return time.Time{}, NewAppError(http.StatusBadRequest, "bad_time", fmt.Sprintf("invalid time: %s", raw))
}
func (s *Service) adminRecordQuery(ctx context.Context, filter adminRecordFilter) *gorm.DB {
query := s.db.WithContext(ctx).Model(&model.SmashEggDrawRecord{}).
Where("create_time >= ? AND create_time <= ?", filter.StartTime, filter.EndTime)
if filter.SysOrigin != "" {
query = query.Where("sys_origin = ?", filter.SysOrigin)
}
if filter.UserID > 0 {
query = query.Where("user_id = ?", filter.UserID)
}
if filter.Status != "" {
query = query.Where("status = ?", filter.Status)
}
return query
}
func (s *Service) adminRecordSummary(ctx context.Context, filter adminRecordFilter) (adminRecordSummary, error) {
var summary adminRecordSummary
if err := s.adminRecordQuery(ctx, filter).
Select("COALESCE(SUM(reward_value_gold), 0)").
Scan(&summary.TotalRewardGold).Error; err != nil {
return summary, err
}
drawPaid := s.adminRecordQuery(ctx, filter).
Select("draw_no, MAX(paid_gold) AS paid_gold").
Group("draw_no")
if err := s.db.WithContext(ctx).
Table("(?) AS draw_paid", drawPaid).
Select("COALESCE(SUM(paid_gold), 0)").
Scan(&summary.TotalPaidGold).Error; err != nil {
return summary, err
}
if summary.TotalPaidGold > 0 {
summary.ReturnRatioBasisPoints = int((summary.TotalRewardGold*10000 + summary.TotalPaidGold/2) / summary.TotalPaidGold)
}
return summary, nil
}
// ListRecentNotices returns recent successful prize notices.
func (s *Service) ListRecentNotices(ctx context.Context, sysOrigin string, limit int) ([]DrawRecordPayload, error) {
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
var rows []model.SmashEggDrawRecord
query := s.db.WithContext(ctx).Where("status = ?", drawStatusSuccess)
if normalized := normalizeSysOrigin(sysOrigin); normalized != "" {
query = query.Where("sys_origin = ?", normalized)
}
if err := query.Order("create_time desc, id desc").Limit(limit).Find(&rows).Error; err != nil {
return nil, err
}
return drawRecordPayloads(rows), nil
}
// PageDayRank returns today's top users by reward value.
func (s *Service) PageDayRank(ctx context.Context, sysOrigin string, cursor int, limit int) ([]DayRankPayload, error) {
cursor, limit = normalizeRecordPage(cursor, limit)
normalized := normalizeSysOrigin(sysOrigin)
now := time.Now()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
end := start.Add(24 * time.Hour)
type row struct {
UserID int64
Score int64
}
var rows []row
query := s.db.WithContext(ctx).Model(&model.SmashEggDrawRecord{}).
Select("user_id, COALESCE(SUM(reward_value_gold), 0) AS score").
Where("status = ? AND create_time >= ? AND create_time < ?", drawStatusSuccess, start, end)
if normalized != "" {
query = query.Where("sys_origin = ?", normalized)
}
if err := query.Group("user_id").
Order("score desc, user_id asc").
Offset((cursor - 1) * limit).
Limit(limit).
Scan(&rows).Error; err != nil {
return nil, err
}
result := make([]DayRankPayload, 0, len(rows))
for _, item := range rows {
result = append(result, DayRankPayload{
UserID: item.UserID,
Score: item.Score,
})
}
return result, nil
}
type DayRankPayload struct {
UserID int64 `json:"userId"`
Score int64 `json:"score"`
}
func drawRecordPayloads(rows []model.SmashEggDrawRecord) []DrawRecordPayload {
records := make([]DrawRecordPayload, 0, len(rows))
for _, row := range rows {
records = append(records, drawRecordPayload(row))
}
return records
}
func drawRecordPayload(row model.SmashEggDrawRecord) DrawRecordPayload {
return DrawRecordPayload{
ID: row.ID,
DrawNo: row.DrawNo,
EventID: row.EventID,
UserID: row.UserID,
SysOrigin: row.SysOrigin,
PoolType: normalizePoolType(row.PoolType),
DrawOptionID: row.DrawOptionID,
DrawTimes: row.DrawTimes,
PaidGold: row.PaidGold,
RewardType: row.RewardType,
ResourceID: ResourceID(int64PtrValue(row.RewardGroupID)),
ResourceType: row.ResourceType,
ResourceName: row.RewardGroupName,
ResourceURL: row.ResourceURL,
RewardGroupID: row.RewardGroupID,
RewardGroupName: row.RewardGroupName,
CoverURL: row.CoverURL,
AnimationURL: row.AnimationURL,
DurationDays: row.DurationDays,
DisplayGoldAmount: row.DisplayGoldAmount,
GoldAmount: row.GoldAmount,
RewardValueGold: row.RewardValueGold,
Probability: row.Probability,
Status: row.Status,
ErrorMessage: row.ErrorMessage,
CreateTime: formatDateTime(row.CreateTime),
UpdateTime: formatDateTime(row.UpdateTime),
}
}

View File

@ -0,0 +1,103 @@
package smashegg
import (
"context"
"testing"
"time"
"chatapp3-golang/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestPageAdminRecordsDefaultsToLastWeekAndSummarizesGold(t *testing.T) {
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.SmashEggDrawRecord{}); err != nil {
t.Fatalf("migrate sqlite: %v", err)
}
now := time.Now().Add(-time.Hour).Truncate(time.Second)
rows := []model.SmashEggDrawRecord{
{
DrawNo: "draw-ten",
EventID: "draw-ten-1",
SysOrigin: "LIKEI",
UserID: 1001,
DrawTimes: 10,
PaidGold: 1000,
RewardType: rewardTypeGold,
GoldAmount: 100,
RewardValueGold: 100,
Status: drawStatusSuccess,
CreateTime: now,
UpdateTime: now,
},
{
DrawNo: "draw-ten",
EventID: "draw-ten-2",
SysOrigin: "LIKEI",
UserID: 1001,
DrawTimes: 10,
PaidGold: 1000,
RewardType: rewardTypeGold,
GoldAmount: 200,
RewardValueGold: 200,
Status: drawStatusSuccess,
CreateTime: now,
UpdateTime: now,
},
{
DrawNo: "draw-one",
EventID: "draw-one-1",
SysOrigin: "LIKEI",
UserID: 1002,
DrawTimes: 1,
PaidGold: 100,
RewardType: rewardTypeGold,
GoldAmount: 50,
RewardValueGold: 50,
Status: drawStatusSuccess,
CreateTime: now,
UpdateTime: now,
},
{
DrawNo: "draw-old",
EventID: "draw-old-1",
SysOrigin: "LIKEI",
UserID: 1003,
DrawTimes: 1,
PaidGold: 100,
RewardType: rewardTypeGold,
GoldAmount: 100,
RewardValueGold: 100,
Status: drawStatusSuccess,
CreateTime: now.AddDate(0, 0, -8),
UpdateTime: now.AddDate(0, 0, -8),
},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatalf("insert records: %v", err)
}
service := &Service{db: db}
resp, err := service.PageAdminRecords(context.Background(), "likei", 0, drawStatusSuccess, 1, 20, "", "")
if err != nil {
t.Fatalf("PageAdminRecords returned error: %v", err)
}
if resp.Total != 3 {
t.Fatalf("total = %d, want 3", resp.Total)
}
if resp.TotalPaidGold != 1100 {
t.Fatalf("total paid gold = %d, want 1100", resp.TotalPaidGold)
}
if resp.TotalRewardGold != 350 {
t.Fatalf("total reward gold = %d, want 350", resp.TotalRewardGold)
}
if resp.ReturnRatioBasisPoints != 3182 || resp.ReturnRatioPercent != "31.82" {
t.Fatalf("return ratio = %d/%s, want 3182/31.82", resp.ReturnRatioBasisPoints, resp.ReturnRatioPercent)
}
}

View File

@ -0,0 +1,122 @@
//go:build simulation
package smashegg
import (
"fmt"
"testing"
"chatapp3-golang/internal/model"
)
func TestSimulateNewbiePoolRTP1000Users100000Draws(t *testing.T) {
const (
userCount = 1000
totalDraws = 100000
pricePerDraw = int64(500)
targetRTPBasis = 9500
)
option := model.SmashEggDrawOptionConfig{
ID: 1,
OptionKey: optionKeyOne,
Label: "Smash 1",
Times: 1,
PriceGold: pricePerDraw,
Enabled: true,
}
rewardTiers := []model.SmashEggRewardConfig{
{ID: 1, PoolType: poolTypeNewbie, RewardType: rewardTypeGold, RewardGroupName: "50 Gold", RewardValueGold: 50, GoldAmount: 50, Enabled: true},
{ID: 2, PoolType: poolTypeNewbie, RewardType: rewardTypeGold, RewardGroupName: "100 Gold", RewardValueGold: 100, GoldAmount: 100, Enabled: true},
{ID: 3, PoolType: poolTypeNewbie, RewardType: rewardTypeGold, RewardGroupName: "500 Gold", RewardValueGold: 500, GoldAmount: 500, Enabled: true},
{ID: 4, PoolType: poolTypeNewbie, RewardType: rewardTypeGold, RewardGroupName: "5000 Gold", RewardValueGold: 5000, GoldAmount: 5000, Enabled: true},
}
rewards, err := rewardsWithAutoProbabilitiesForOption(targetRTPBasis, option, rewardTiers)
if err != nil {
t.Fatalf("calculate auto probabilities failed: %v", err)
}
totalProbability := 0
for _, reward := range rewards {
totalProbability += reward.Probability
}
if totalProbability != probabilityTotal {
t.Fatalf("total probability = %d, want %d", totalProbability, probabilityTotal)
}
theoreticalValue := expectedRewardValue(rewards)
totalPaid := pricePerDraw * totalDraws
totalPayout := int64(0)
userPaid := make([]int64, userCount)
userPayout := make([]int64, userCount)
rewardHits := make(map[int64]int, len(rewards))
for drawIndex := 0; drawIndex < totalDraws; drawIndex++ {
userIndex := drawIndex % userCount
reward, err := pickReward(rewards)
if err != nil {
t.Fatalf("pickReward failed: %v", err)
}
totalPayout += reward.RewardValueGold
userPaid[userIndex] += pricePerDraw
userPayout[userIndex] += reward.RewardValueGold
rewardHits[reward.ID]++
}
actualRTP := float64(totalPayout) / float64(totalPaid) * 100
theoreticalRTP := theoreticalValue / float64(pricePerDraw) * 100
deviation := actualRTP - float64(targetRTPBasis)/100
minUserRTP, maxUserRTP := userRTPRange(userPaid, userPayout)
if absFloat64(theoreticalRTP-float64(targetRTPBasis)/100) > 1 {
t.Fatalf("theoretical RTP %.4f%% is outside target %.2f%% ±1.00%%", theoreticalRTP, float64(targetRTPBasis)/100)
}
if absFloat64(deviation) > 1 {
t.Fatalf("actual RTP %.4f%% is outside target %.2f%% ±1.00%%", actualRTP, float64(targetRTPBasis)/100)
}
t.Logf("users=%d totalDraws=%d pricePerDraw=%d random=crypto/rand no-seed", userCount, totalDraws, pricePerDraw)
t.Logf("targetRTP=%.2f%% theoreticalRTP=%.2f%% actualRTP=%.4f%% deviation=%+.4f%%", float64(targetRTPBasis)/100, theoreticalRTP, actualRTP, deviation)
t.Logf("totalPaid=%d totalPayout=%d net=%d", totalPaid, totalPayout, totalPayout-totalPaid)
t.Logf("perUserDraws=%d minUserRTP=%.2f%% maxUserRTP=%.2f%%", totalDraws/userCount, minUserRTP, maxUserRTP)
for _, reward := range rewards {
hits := rewardHits[reward.ID]
t.Logf(
"reward=%s value=%d probability=%s%% hits=%d hitRate=%.4f%%",
reward.RewardGroupName,
reward.RewardValueGold,
probabilityPercent(reward.Probability),
hits,
float64(hits)/float64(totalDraws)*100,
)
}
}
func userRTPRange(paid []int64, payout []int64) (float64, float64) {
minRTP := 0.0
maxRTP := 0.0
for index := range paid {
if paid[index] <= 0 {
continue
}
rtp := float64(payout[index]) / float64(paid[index]) * 100
if minRTP == 0 || rtp < minRTP {
minRTP = rtp
}
if rtp > maxRTP {
maxRTP = rtp
}
}
return minRTP, maxRTP
}
func absFloat64(value float64) float64 {
if value < 0 {
return -value
}
return value
}
func Example_simulationCommand() {
fmt.Println("go test -tags simulation ./internal/service/smashegg -run TestSimulateNewbiePoolRTP1000Users100000Draws -count=1 -v")
// Output:
// go test -tags simulation ./internal/service/smashegg -run TestSimulateNewbiePoolRTP1000Users100000Draws -count=1 -v
}

View File

@ -0,0 +1,483 @@
package smashegg
import (
"context"
"encoding/json"
"fmt"
"math/big"
"sort"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"gorm.io/gorm"
)
const (
probabilityTotal = 100 * 10000
defaultTimezone = "Asia/Riyadh"
defaultRTPBasisPoints = 9500
rtpToleranceBasisPoints = 100
defaultNewbieWindowDays = 7
defaultNewbieMaxDrawCount = 3
poolTypeGeneral = "GENERAL"
poolTypeNewbie = "NEWBIE"
optionKeyOne = "ONE"
optionKeyTen = "TEN"
optionKeyHundred = "HUNDRED"
rewardTypeResource = "RESOURCE"
rewardTypeGold = "GOLD"
resourceTypeBadge = "BADGE"
resourceTypeRoomBadge = "ROOM_BADGE"
resourceTypeGift = "GIFT"
drawStatusPending = "PENDING"
drawStatusSuccess = "SUCCESS"
drawStatusFailed = "FAILED"
walletReceiptIncome = "INCOME"
walletReceiptExpenditure = "EXPENDITURE"
walletOrigin = "SMASH_GOLDEN_EGG"
walletOriginDesc = "Smash golden egg"
)
type AppError = common.AppError
type AuthUser = common.AuthUser
var NewAppError = common.NewAppError
type smashEggDB interface {
WithContext(ctx context.Context) *gorm.DB
}
type gateway interface {
ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error
GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) 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
MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error)
MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error)
}
// ResourceID preserves Java int64 ids across JSON boundaries by emitting strings.
type ResourceID int64
func (id ResourceID) Int64() int64 {
return int64(id)
}
func (id ResourceID) MarshalJSON() ([]byte, error) {
if id <= 0 {
return []byte("null"), nil
}
return []byte(strconv.Quote(strconv.FormatInt(int64(id), 10))), nil
}
func (id *ResourceID) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
if raw == "" || raw == "null" {
*id = 0
return nil
}
if strings.HasPrefix(raw, "\"") {
unquoted, err := strconv.Unquote(raw)
if err != nil {
return err
}
raw = strings.TrimSpace(unquoted)
}
if raw == "" {
*id = 0
return nil
}
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return err
}
*id = ResourceID(parsed)
return nil
}
// Service owns resident smash golden egg configuration, draw, and records APIs.
type Service struct {
cfg config.Config
db smashEggDB
java gateway
}
// NewService creates a resident smash golden egg service.
func NewService(cfg config.Config, db smashEggDB, javaGateway gateway) *Service {
return &Service{cfg: cfg, db: db, java: javaGateway}
}
// SaveConfigRequest is the admin payload for saving smash egg settings.
type SaveConfigRequest struct {
SysOrigin string `json:"sysOrigin"`
Enabled bool `json:"enabled"`
Timezone string `json:"timezone"`
RTPBasisPoints int `json:"rtpBasisPoints"`
NewbiePoolEnabled bool `json:"newbiePoolEnabled"`
NewbieWindowDays int `json:"newbieWindowDays"`
NewbieMaxDrawCount int `json:"newbieMaxDrawCount"`
NewbieMinRechargeAmount int64 `json:"newbieMinRechargeAmount"`
DrawOptions []DrawOptionInput `json:"drawOptions"`
Rewards []RewardConfigInput `json:"rewards"`
NewbieRewards []RewardConfigInput `json:"newbieRewards"`
}
// DrawOptionInput is one button option submitted by admin.
type DrawOptionInput struct {
ID int64 `json:"id"`
Enabled bool `json:"enabled"`
Sort int `json:"sort"`
OptionKey string `json:"optionKey"`
Label string `json:"label"`
Times int `json:"times"`
PriceGold int64 `json:"priceGold"`
}
// RewardConfigInput is one reward row submitted by admin.
type RewardConfigInput struct {
ID int64 `json:"id"`
Enabled bool `json:"enabled"`
Sort int `json:"sort"`
PoolType string `json:"poolType"`
RewardType string `json:"rewardType"`
ResourceID ResourceID `json:"resourceId"`
ResourceType string `json:"resourceType"`
ResourceName string `json:"resourceName"`
ResourceURL string `json:"resourceUrl"`
RewardGroupID *int64 `json:"rewardGroupId"`
RewardGroupName string `json:"rewardGroupName"`
CoverURL string `json:"coverUrl"`
AnimationURL string `json:"animationUrl"`
DurationDays int `json:"durationDays"`
DisplayGoldAmount int64 `json:"displayGoldAmount"`
GoldAmount int64 `json:"goldAmount"`
RewardValueGold int64 `json:"rewardValueGold"`
Probability int `json:"probability"`
}
// ConfigResponse is returned to admin and app config readers.
type ConfigResponse struct {
Configured bool `json:"configured"`
ID int64 `json:"id"`
SysOrigin string `json:"sysOrigin"`
Enabled bool `json:"enabled"`
Timezone string `json:"timezone"`
RTPBasisPoints int `json:"rtpBasisPoints"`
RTPPercent string `json:"rtpPercent"`
ProbabilityTotal int `json:"probabilityTotal"`
ExpectedValueGold string `json:"expectedValueGold"`
NewbiePoolEnabled bool `json:"newbiePoolEnabled"`
NewbieWindowDays int `json:"newbieWindowDays"`
NewbieMaxDrawCount int `json:"newbieMaxDrawCount"`
NewbieMinRechargeAmount int64 `json:"newbieMinRechargeAmount"`
NewbieExpectedValueGold string `json:"newbieExpectedValueGold,omitempty"`
ActivePoolType string `json:"activePoolType"`
NewbieEligible bool `json:"newbieEligible"`
NewbieRemainingDrawCount int `json:"newbieRemainingDrawCount"`
DrawOptions []DrawOptionPayload `json:"drawOptions"`
Rewards []RewardConfigPayload `json:"rewards"`
NewbieRewards []RewardConfigPayload `json:"newbieRewards,omitempty"`
UpdateTime string `json:"updateTime,omitempty"`
}
// DrawOptionPayload is one configured draw button exposed to clients.
type DrawOptionPayload struct {
ID int64 `json:"id"`
Enabled bool `json:"enabled"`
Sort int `json:"sort"`
OptionKey string `json:"optionKey"`
Label string `json:"label"`
Times int `json:"times"`
PriceGold int64 `json:"priceGold"`
RTPBasisPoints int `json:"rtpBasisPoints"`
RTPPercent string `json:"rtpPercent"`
ExpectedValueGold string `json:"expectedValueGold"`
}
// RewardConfigPayload is one configured reward exposed to clients.
type RewardConfigPayload struct {
ID int64 `json:"id"`
Enabled bool `json:"enabled"`
Sort int `json:"sort"`
PoolType string `json:"poolType"`
RewardType string `json:"rewardType"`
ResourceID ResourceID `json:"resourceId,omitempty"`
ResourceType string `json:"resourceType,omitempty"`
ResourceName string `json:"resourceName,omitempty"`
ResourceURL string `json:"resourceUrl,omitempty"`
RewardGroupID *int64 `json:"rewardGroupId,omitempty"`
RewardGroupName string `json:"rewardGroupName,omitempty"`
CoverURL string `json:"coverUrl,omitempty"`
AnimationURL string `json:"animationUrl,omitempty"`
DurationDays int `json:"durationDays,omitempty"`
DisplayGoldAmount int64 `json:"displayGoldAmount,omitempty"`
GoldAmount int64 `json:"goldAmount,omitempty"`
RewardValueGold int64 `json:"rewardValueGold,omitempty"`
Probability int `json:"probability"`
ProbabilityPercent string `json:"probabilityPercent"`
RewardItems []RewardGroupItem `json:"rewardItems,omitempty"`
}
// RewardGroupItem mirrors Java reward group item details.
type RewardGroupItem struct {
ID int64 `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
Quantity int64 `json:"quantity"`
Cover string `json:"cover"`
Remark string `json:"remark,omitempty"`
}
// DrawRequest is an app draw request.
type DrawRequest struct {
Times int `json:"times"`
}
// DrawResponse is the app draw result.
type DrawResponse struct {
DrawNo string `json:"drawNo"`
UserID int64 `json:"userId"`
SysOrigin string `json:"sysOrigin"`
PoolType string `json:"poolType"`
DrawTimes int `json:"drawTimes"`
PaidGold int64 `json:"paidGold"`
RewardValue int64 `json:"rewardValue"`
Rewards []DrawRewardPayload `json:"rewards"`
Records []DrawRecordPayload `json:"records"`
BalanceAfter int64 `json:"balanceAfter,omitempty"`
NewbieRemainingDrawCount int `json:"newbieRemainingDrawCount,omitempty"`
}
// DrawRewardPayload aggregates identical rewards in one draw response.
type DrawRewardPayload struct {
RewardType string `json:"rewardType"`
ResourceID ResourceID `json:"resourceId,omitempty"`
ResourceType string `json:"resourceType,omitempty"`
ResourceName string `json:"resourceName,omitempty"`
ResourceURL string `json:"resourceUrl,omitempty"`
RewardGroupID *int64 `json:"rewardGroupId,omitempty"`
RewardGroupName string `json:"rewardGroupName,omitempty"`
CoverURL string `json:"coverUrl,omitempty"`
AnimationURL string `json:"animationUrl,omitempty"`
DurationDays int `json:"durationDays,omitempty"`
DisplayGoldAmount int64 `json:"displayGoldAmount,omitempty"`
GoldAmount int64 `json:"goldAmount,omitempty"`
RewardValueGold int64 `json:"rewardValueGold,omitempty"`
Probability int `json:"probability,omitempty"`
Count int `json:"count"`
Name string `json:"name,omitempty"`
Value int64 `json:"value,omitempty"`
}
// DrawRecordPayload is one draw record row.
type DrawRecordPayload struct {
ID int64 `json:"id"`
DrawNo string `json:"drawNo"`
EventID string `json:"eventId,omitempty"`
UserID int64 `json:"userId"`
SysOrigin string `json:"sysOrigin"`
PoolType string `json:"poolType"`
DrawOptionID int64 `json:"drawOptionId"`
DrawTimes int `json:"drawTimes"`
PaidGold int64 `json:"paidGold"`
RewardType string `json:"rewardType"`
ResourceID ResourceID `json:"resourceId,omitempty"`
ResourceType string `json:"resourceType,omitempty"`
ResourceName string `json:"resourceName,omitempty"`
ResourceURL string `json:"resourceUrl,omitempty"`
RewardGroupID *int64 `json:"rewardGroupId,omitempty"`
RewardGroupName string `json:"rewardGroupName,omitempty"`
CoverURL string `json:"coverUrl,omitempty"`
AnimationURL string `json:"animationUrl,omitempty"`
DurationDays int `json:"durationDays,omitempty"`
DisplayGoldAmount int64 `json:"displayGoldAmount,omitempty"`
GoldAmount int64 `json:"goldAmount,omitempty"`
RewardValueGold int64 `json:"rewardValueGold,omitempty"`
Probability int `json:"probability"`
Status string `json:"status"`
ErrorMessage string `json:"errorMessage,omitempty"`
CreateTime string `json:"createTime,omitempty"`
UpdateTime string `json:"updateTime,omitempty"`
}
// RecordPageResponse is an admin or app record page.
type RecordPageResponse struct {
Records []DrawRecordPayload `json:"records"`
Total int64 `json:"total"`
Current int `json:"current"`
Size int `json:"size"`
TotalPaidGold int64 `json:"totalPaidGold"`
TotalRewardGold int64 `json:"totalRewardGold"`
ReturnRatioBasisPoints int `json:"returnRatioBasisPoints"`
ReturnRatioPercent string `json:"returnRatioPercent"`
StartTime string `json:"startTime,omitempty"`
EndTime string `json:"endTime,omitempty"`
}
func normalizeSysOrigin(sysOrigin string) string {
return strings.ToUpper(strings.TrimSpace(sysOrigin))
}
func normalizeTimezone(timezone string) string {
timezone = strings.TrimSpace(timezone)
if timezone == "" {
return defaultTimezone
}
return timezone
}
func normalizeRewardType(rewardType string) string {
switch strings.ToUpper(strings.TrimSpace(rewardType)) {
case "RESOURCE", "RESOURCE_GROUP", "PROPS":
return rewardTypeResource
case rewardTypeGold:
return rewardTypeGold
default:
return strings.ToUpper(strings.TrimSpace(rewardType))
}
}
func normalizePoolType(poolType string) string {
poolType = strings.ToUpper(strings.TrimSpace(poolType))
switch poolType {
case poolTypeNewbie:
return poolTypeNewbie
default:
return poolTypeGeneral
}
}
func normalizeOptionKey(optionKey string, index int) string {
optionKey = strings.ToUpper(strings.TrimSpace(optionKey))
if optionKey != "" {
return optionKey
}
switch index {
case 0:
return optionKeyOne
case 1:
return optionKeyTen
case 2:
return optionKeyHundred
default:
return fmt.Sprintf("OPTION_%d", index+1)
}
}
func formatDateTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("2006-01-02 15:04:05")
}
func trimErrorMessage(message string) string {
message = strings.TrimSpace(message)
if len(message) <= 1024 {
return message
}
return message[:1024]
}
func ParseUserID(value string) int64 {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
parsed, _ := strconv.ParseInt(value, 10, 64)
return parsed
}
func probabilityPercent(probability int) string {
if probability <= 0 {
return "0"
}
text := fmt.Sprintf("%.5f", float64(probability)*100/float64(probabilityTotal))
text = strings.TrimRight(text, "0")
return strings.TrimRight(text, ".")
}
func resourceIDPtr(value ResourceID) *int64 {
if value.Int64() <= 0 {
return nil
}
next := value.Int64()
return &next
}
func basisPointsPercent(basisPoints int) string {
if basisPoints <= 0 {
return "0.00"
}
return fmt.Sprintf("%.2f", float64(basisPoints)/100)
}
func amount2(value float64) string {
return fmt.Sprintf("%.2f", value)
}
func parseDecimalRat(value integration.DecimalString) *big.Rat {
raw := strings.TrimSpace(string(value))
if raw == "" {
return new(big.Rat)
}
result, ok := new(big.Rat).SetString(raw)
if !ok {
return new(big.Rat)
}
return result
}
func normalizeRecordPage(cursor int, limit int) (int, int) {
if cursor <= 0 {
cursor = 1
}
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
return cursor, limit
}
func sortRewardInputs(rewards []RewardConfigInput) {
sort.SliceStable(rewards, func(i, j int) bool {
if rewards[i].Sort == rewards[j].Sort {
return rewards[i].ID < rewards[j].ID
}
return rewards[i].Sort < rewards[j].Sort
})
}
func sortOptionInputs(options []DrawOptionInput) {
sort.SliceStable(options, func(i, j int) bool {
if options[i].Sort == options[j].Sort {
return options[i].ID < options[j].ID
}
return options[i].Sort < options[j].Sort
})
}
func mustJSONString(value any) string {
data, err := json.Marshal(value)
if err != nil {
return "{}"
}
return string(data)
}

View File

@ -0,0 +1,461 @@
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 := defaultTimezone
maxLevel := req.MaxLevel
if maxLevel <= 0 {
maxLevel = defaultMaxLevel
} else if maxLevel > defaultMaxLevel {
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)
targetLevels := map[int]struct{}{}
if req.Level > 0 {
if req.Level > defaultMaxLevel {
return nil, NewAppError(http.StatusBadRequest, "invalid_level", "level must be between 1 and 6")
}
targetLevels[req.Level] = struct{}{}
}
if err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now()
savedIDsByLevel := map[int][]int64{}
if len(req.Rewards) == 0 {
if len(targetLevels) == 0 {
return NewAppError(http.StatusBadRequest, "missing_reward_configs", "rewards is required")
}
for level := range targetLevels {
if err := tx.Where("sys_origin = ? AND level = ?", sysOrigin, level).
Delete(&model.VoiceRoomRocketRewardConfig{}).Error; err != nil {
return err
}
}
return nil
}
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")
}
if req.Level > 0 && item.Level != req.Level {
return NewAppError(http.StatusBadRequest, "invalid_level", "reward level must match request level")
}
targetLevels[item.Level] = struct{}{}
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")
}
if rewardType == rewardTypeNone && scene != rewardSceneInRoom {
return NewAppError(http.StatusBadRequest, "invalid_reward_type", "NONE reward is only allowed for IN_ROOM")
}
rewardName := strings.TrimSpace(item.RewardName)
if rewardType == rewardTypeNone && rewardName == "" {
rewardName = "轮空"
}
if rewardName == "" {
return NewAppError(http.StatusBadRequest, "missing_reward_name", "rewardName is required")
}
amount := item.RewardAmount
if rewardType == rewardTypeNone {
amount = 0
item.RewardItemID = nil
item.ExpireDays = nil
} else 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
}
savedIDsByLevel[item.Level] = append(savedIDsByLevel[item.Level], row.ID)
}
for level := range targetLevels {
query := tx.Where("sys_origin = ? AND level = ?", sysOrigin, level)
if ids := savedIDsByLevel[level]; len(ids) > 0 {
query = query.Where("id NOT IN ?", ids)
}
if err := query.Delete(&model.VoiceRoomRocketRewardConfig{}).Error; err != nil {
return err
}
}
return nil
}); err != nil {
return nil, err
}
return s.ListRewardConfigs(ctx, sysOrigin, req.Level)
}
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: defaultTimezone,
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: defaultTimezone,
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,544 @@
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 {
if err := s.markLevelReachedLocked(ctx, tx, statusRow, sendUserID, now); err != nil {
return err
}
launch, err := s.scheduleLaunchLocked(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, sendUserID)
}
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) markLevelReachedLocked(ctx context.Context, tx *gorm.DB, statusRow model.VoiceRoomRocketStatus, igniteUserID int64, now time.Time) error {
return tx.WithContext(ctx).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": levelRoundStatusReached,
"ignite_user_id": igniteUserID,
"reach_time": now,
"update_time": now,
}).Error
}
func (s *Service) scheduleLaunchLocked(ctx context.Context, tx *gorm.DB, statusRow model.VoiceRoomRocketStatus, igniteUserID int64, now time.Time) (launchResult, error) {
if s.repo.Redis == nil {
return s.launchLocked(ctx, tx, statusRow, igniteUserID, now)
}
due := now.Add(time.Duration(defaultLaunchDelaySeconds) * time.Second)
if err := s.repo.Redis.ZAdd(ctx, s.launchDueZSetKey(), redis.Z{
Score: float64(due.Unix()),
Member: strconv.FormatInt(statusRow.ID, 10),
}).Err(); err != nil {
return launchResult{}, err
}
return launchResult{Launched: false}, nil
}
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
}
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": gorm.Expr("COALESCE(reach_time, ?)", now),
"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,735 @@
package voiceroomrocket
import (
"context"
"encoding/json"
"strconv"
"strings"
"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.Processed || resp.Launched || resp.LaunchNo != "" {
t.Fatalf("launch should be delayed, response = %+v", resp)
}
launched, err := service.ProcessDueLaunches(ctx, time.Now().Add(11*time.Second), 10)
if err != nil {
t.Fatalf("ProcessDueLaunches() error = %v", err)
}
if launched != 1 {
t.Fatalf("due launches = %d, want 1", launched)
}
processed, err := service.ProcessDueInRoomRewards(ctx, time.Now().Add(30*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 TestProcessGiftPayloadDelaysLaunchWithRedis(t *testing.T) {
gateway := newFakeRocketGateway()
service, db, _, 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)
payload := `{
"trackId":"track-launch-delay",
"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.Processed || resp.Launched {
t.Fatalf("response = %+v, want processed delayed launch", resp)
}
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.Status != statusLaunching || status.CurrentEnergy != 100 {
t.Fatalf("status = %+v, want launching full energy", status)
}
var launchCount int64
if err := db.Model(&model.VoiceRoomRocketLaunch{}).Count(&launchCount).Error; err != nil {
t.Fatalf("count launch before due: %v", err)
}
if launchCount != 0 {
t.Fatalf("launch count before due = %d, want 0", launchCount)
}
processed, err := service.ProcessDueLaunches(ctx, time.Now().Add(9*time.Second), 10)
if err != nil {
t.Fatalf("ProcessDueLaunches(before due) error = %v", err)
}
if processed != 0 {
t.Fatalf("launches before due = %d, want 0", processed)
}
processed, err = service.ProcessDueLaunches(ctx, time.Now().Add(11*time.Second), 10)
if err != nil {
t.Fatalf("ProcessDueLaunches(after due) error = %v", err)
}
if processed != 1 {
t.Fatalf("launches after due = %d, want 1", processed)
}
if err := db.Model(&model.VoiceRoomRocketLaunch{}).Count(&launchCount).Error; err != nil {
t.Fatalf("count launch after due: %v", err)
}
if launchCount != 1 {
t.Fatalf("launch count after due = %d, want 1", launchCount)
}
}
func TestPickInRoomRewardCanBeEmpty(t *testing.T) {
service, _ := newTestService(t)
launch := model.VoiceRoomRocketLaunch{LaunchNo: "VRR-empty"}
rewards := []model.VoiceRoomRocketRewardConfig{
{
RewardScene: rewardSceneInRoom,
RewardType: rewardTypeNone,
RewardName: "轮空",
Weight: 1,
},
}
got := service.pickInRoomRewardsForUser(launch, rewards, 1001)
if len(got) != 0 {
t.Fatalf("picked rewards = %+v, want empty", got)
}
}
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 TestStatusUpdatePublishesRegionIM(t *testing.T) {
gateway := newFakeRocketGateway()
service, db, _, mr := newTestServiceWithRedis(t, gateway)
defer mr.Close()
service.regionResolver = fakeRocketRegionResolver{regions: map[string]string{"LIKEI|SA": "AR"}}
regionIM := &fakeRocketRegionBroadcaster{}
service.SetRegionBroadcaster(regionIM)
ctx := context.Background()
seedRocketConfig(t, db)
seedLevelConfigs(t, db, []int64{100, 200, 300, 400, 500, 600})
payload := `{
"trackId":"track-region-status",
"sysOrigin":"LIKEI",
"sendUserId":"1001",
"roomId":"9001",
"quantity":1,
"giftConfig":{"id":"11","giftCandy":10,"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.Processed {
t.Fatalf("response = %+v, want processed", resp)
}
if len(regionIM.messages) != 1 {
t.Fatalf("region messages = %+v, want one", regionIM.messages)
}
if regionIM.messages[0].regionCode != "AR" || regionIM.messages[0].messageType != imTypeStatusUpdate {
t.Fatalf("region message = %+v", regionIM.messages[0])
}
}
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 TestSaveAdminConfigForcesRiyadhTimezoneAndReturnsEnergyRule(t *testing.T) {
service, _ := newTestService(t)
ctx := context.Background()
resp, err := service.SaveAdminConfig(ctx, SaveConfigRequest{
SysOrigin: "LIKEI",
Enabled: true,
Timezone: "UTC",
MaxLevel: 6,
RankingTopLimit: 50,
RewardRecordKeepDays: 35,
BroadcastDurationSeconds: 7,
InRoomRewardDelaySeconds: 10,
EnergyRuleJSON: json.RawMessage(`{"allowGiftIds":[11]}`),
RuleText: json.RawMessage(`{"title":"rocket"}`),
})
if err != nil {
t.Fatalf("SaveAdminConfig() error = %v", err)
}
if resp.Timezone != defaultTimezone {
t.Fatalf("timezone = %s, want %s", resp.Timezone, defaultTimezone)
}
if string(resp.EnergyRuleJSON) != `{"allowGiftIds":[11]}` {
t.Fatalf("energyRuleJson = %s", string(resp.EnergyRuleJSON))
}
}
func TestSaveRewardConfigsReplacesAndClearsSelectedLevel(t *testing.T) {
service, db := newTestService(t)
ctx := context.Background()
seedRewardConfig(t, db, 1, rewardSceneTop1, rewardTypeGold, 10)
seedRewardConfig(t, db, 1, rewardSceneIgnite, rewardTypeGold, 5)
seedRewardConfig(t, db, 2, rewardSceneTop1, rewardTypeGold, 20)
saved, err := service.SaveRewardConfigs(ctx, SaveRewardConfigsRequest{
SysOrigin: "LIKEI",
Level: 1,
Rewards: []RewardConfigPayload{
{
Level: 1,
RewardScene: rewardSceneTop1,
RewardType: rewardTypeGold,
RewardName: "new gold",
RewardAmount: 99,
Enabled: true,
Sort: 1,
},
},
})
if err != nil {
t.Fatalf("SaveRewardConfigs(replace) error = %v", err)
}
if len(saved) != 1 || saved[0].RewardAmount != 99 {
t.Fatalf("saved rewards = %+v, want one amount 99", saved)
}
var level1Count int64
if err := db.Model(&model.VoiceRoomRocketRewardConfig{}).
Where("sys_origin = ? AND level = ?", "LIKEI", 1).
Count(&level1Count).Error; err != nil {
t.Fatalf("count level 1 rewards: %v", err)
}
if level1Count != 1 {
t.Fatalf("level 1 reward count = %d, want 1", level1Count)
}
var level2Count int64
if err := db.Model(&model.VoiceRoomRocketRewardConfig{}).
Where("sys_origin = ? AND level = ?", "LIKEI", 2).
Count(&level2Count).Error; err != nil {
t.Fatalf("count level 2 rewards: %v", err)
}
if level2Count != 1 {
t.Fatalf("level 2 reward count = %d, want 1", level2Count)
}
saved, err = service.SaveRewardConfigs(ctx, SaveRewardConfigsRequest{
SysOrigin: "LIKEI",
Level: 1,
Rewards: []RewardConfigPayload{},
})
if err != nil {
t.Fatalf("SaveRewardConfigs(clear) error = %v", err)
}
if len(saved) != 0 {
t.Fatalf("saved rewards after clear = %+v, want empty", saved)
}
if err := db.Model(&model.VoiceRoomRocketRewardConfig{}).
Where("sys_origin = ? AND level = ?", "LIKEI", 1).
Count(&level1Count).Error; err != nil {
t.Fatalf("count cleared level 1 rewards: %v", err)
}
if level1Count != 0 {
t.Fatalf("level 1 reward count after clear = %d, want 0", level1Count)
}
if err := db.Model(&model.VoiceRoomRocketRewardConfig{}).
Where("sys_origin = ? AND level = ?", "LIKEI", 2).
Count(&level2Count).Error; err != nil {
t.Fatalf("count retained level 2 rewards: %v", err)
}
if level2Count != 1 {
t.Fatalf("level 2 reward count after clear = %d, want 1", level2Count)
}
}
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
}
type fakeRocketRegionResolver struct {
regions map[string]string
}
type fakeRocketRegionBroadcaster struct {
messages []fakeRocketRegionMessage
}
type fakeRocketRegionMessage struct {
sysOrigin string
regionCode string
messageType 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", CountryCode: "SA"}, 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 (r fakeRocketRegionResolver) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
return r.regions[sysOrigin+"|"+countryCode], nil
}
func (b *fakeRocketRegionBroadcaster) SendRegionCustomMessage(ctx context.Context, sysOrigin string, regionCode string, body any) (string, error) {
messageType := ""
if payload, ok := body.(map[string]any); ok {
messageType, _ = payload["type"].(string)
}
b.messages = append(b.messages, fakeRocketRegionMessage{
sysOrigin: sysOrigin,
regionCode: regionCode,
messageType: messageType,
body: body,
})
return "@region-" + strings.ToLower(regionCode), 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,279 @@
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
}
weighted := make([]model.VoiceRoomRocketRewardConfig, 0, len(rewards))
totalWeight := 0
for _, reward := range rewards {
weight := reward.Weight
if weight <= 0 {
weight = 1
}
reward.Weight = weight
weighted = append(weighted, reward)
totalWeight += weight
}
if totalWeight <= 0 {
return nil
}
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 {
if strings.EqualFold(strings.TrimSpace(reward.RewardType), rewardTypeNone) {
return nil
}
return []model.VoiceRoomRocketRewardConfig{reward}
}
}
return nil
}
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,142 @@
package voiceroomrocket
import (
"context"
"errors"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/model"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ProcessDueLaunches 扫描进度满 10 秒后的火箭发射任务。
func (s *Service) ProcessDueLaunches(ctx context.Context, now time.Time, limit int64) (int, error) {
if limit <= 0 {
limit = int64(defaultWorkerBatchSize)
}
processed := 0
if s.repo.Redis != nil {
statusIDs, err := s.repo.Redis.ZRangeByScore(ctx, s.launchDueZSetKey(), &redis.ZRangeBy{
Min: "-inf",
Max: strconv.FormatInt(now.Unix(), 10),
Count: limit,
}).Result()
if err != nil {
return 0, err
}
for _, rawID := range statusIDs {
statusID, parseErr := strconv.ParseInt(strings.TrimSpace(rawID), 10, 64)
if parseErr != nil || statusID <= 0 {
_ = s.repo.Redis.ZRem(context.Background(), s.launchDueZSetKey(), rawID).Err()
continue
}
done, err := s.processDueLaunchStatus(ctx, statusID, now)
if err != nil {
return processed, err
}
if done {
if err := s.repo.Redis.ZRem(context.Background(), s.launchDueZSetKey(), rawID).Err(); err != nil {
return processed, err
}
processed++
}
}
}
backfilled, err := s.processStaleLaunchingStatuses(ctx, now, limit)
if err != nil {
return processed, err
}
return processed + backfilled, nil
}
func (s *Service) processStaleLaunchingStatuses(ctx context.Context, now time.Time, limit int64) (int, error) {
if limit <= 0 {
limit = int64(defaultWorkerBatchSize)
}
var rows []model.VoiceRoomRocketStatus
if err := s.repo.DB.WithContext(ctx).
Where("status = ?", statusLaunching).
Order("update_time ASC").
Limit(int(limit)).
Find(&rows).Error; err != nil {
return 0, err
}
processed := 0
for _, row := range rows {
done, err := s.processDueLaunchStatus(ctx, row.ID, now)
if err != nil {
return processed, err
}
if done {
processed++
}
}
return processed, nil
}
func (s *Service) processDueLaunchStatus(ctx context.Context, statusID int64, now time.Time) (bool, error) {
var launch model.VoiceRoomRocketLaunch
done := false
var launched bool
err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var statusRow model.VoiceRoomRocketStatus
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", statusID).
First(&statusRow).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
done = true
return nil
}
if err != nil {
return err
}
if statusRow.Status != statusLaunching || statusRow.CurrentEnergy < statusRow.NeedEnergy {
done = true
return nil
}
var round model.VoiceRoomRocketLevelRound
err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where(
"sys_origin = ? AND room_id = ? AND day_key = ? AND round_no = ? AND level = ?",
statusRow.SysOrigin, statusRow.RoomID, statusRow.DayKey, statusRow.RoundNo, statusRow.CurrentLevel,
).
First(&round).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
if round.ReachTime == nil || round.IgniteUserID == nil || *round.IgniteUserID <= 0 {
return nil
}
due := round.ReachTime.Add(time.Duration(defaultLaunchDelaySeconds) * time.Second)
if now.Before(due) {
return nil
}
result, err := s.launchLocked(ctx, tx, statusRow, *round.IgniteUserID, now)
if err != nil {
return err
}
launch = result.Launch
launched = result.Launched
done = true
return nil
})
if err != nil {
return false, err
}
if launched {
_ = s.notifyLaunch(context.Background(), launch)
}
return done, nil
}
func (s *Service) launchDueZSetKey() string {
return "voice_room:rocket:launch_due"
}

View File

@ -0,0 +1,223 @@
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.StartLaunchWorker(ctx)
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
}
// StartLaunchWorker 启动满进度后延迟发射扫描。
func (s *Service) StartLaunchWorker(ctx context.Context) {
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 launch worker started. interval=%s batch=%d delay=%ds", interval, batchSize, defaultLaunchDelaySeconds)
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
if _, err := s.ProcessDueLaunches(ctx, time.Now(), batchSize); err != nil && ctx.Err() == nil {
log.Printf("voice room rocket launch scan failed: %v", err)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}
// 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,317 @@
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 {
if strings.EqualFold(strings.TrimSpace(row.RewardType), rewardTypeNone) {
continue
}
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,
rewardTypeNone:
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)
}
var energyRuleJSON json.RawMessage
if strings.TrimSpace(snapshot.EnergyRuleJSON) != "" {
energyRuleJSON = json.RawMessage(snapshot.EnergyRuleJSON)
}
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,
EnergyRuleJSON: energyRuleJSON,
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,270 @@
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, senderUserIDs ...int64) error {
data := map[string]any{
"roomId": strconv.FormatInt(roomID, 10),
}
if status, err := s.GetStatus(ctx, AuthUser{SysOrigin: sysOrigin}, roomID); err == nil && status != nil {
payload, _ := json.Marshal(status)
_ = json.Unmarshal(payload, &data)
}
senderUserID := int64(0)
if len(senderUserIDs) > 0 {
senderUserID = senderUserIDs[0]
}
if senderUserID > 0 {
data["senderUserId"] = strconv.FormatInt(senderUserID, 10)
}
if s.repo.Redis != nil {
body, _ := json.Marshal(map[string]any{
"type": imTypeStatusUpdate,
"sysOrigin": sysOrigin,
"roomId": strconv.FormatInt(roomID, 10),
"data": data,
})
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()
}
_ = s.notifyRegionStatusUpdate(ctx, sysOrigin, senderUserID, data)
if s.im == nil {
return nil
}
groupID := s.resolveRoomAccount(ctx, roomID)
return s.im.SendCustomGroupMessage(ctx, groupID, map[string]any{
"type": imTypeStatusUpdate,
"data": data,
})
}
func (s *Service) notifyRegionStatusUpdate(ctx context.Context, sysOrigin string, senderUserID int64, data map[string]any) error {
if s.regionBroadcast == nil || s.regionResolver == nil || s.userProfiles == nil || senderUserID <= 0 {
return nil
}
profile, err := s.userProfiles.GetUserProfile(ctx, senderUserID)
if err != nil {
return err
}
countryCode := strings.TrimSpace(profile.CountryCode)
if countryCode == "" {
return nil
}
regionCode, err := s.regionResolver.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)
if err != nil {
return err
}
regionCode = strings.TrimSpace(regionCode)
if regionCode == "" {
return nil
}
body := map[string]any{
"type": imTypeStatusUpdate,
"data": data,
}
_, err = s.regionBroadcast.SendRegionCustomMessage(ctx, sysOrigin, regionCode, body)
return err
}
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,345 @@
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) {
if strings.EqualFold(strings.TrimSpace(reward.RewardType), rewardTypeNone) {
return model.VoiceRoomRocketRewardRecord{}, nil
}
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,185 @@
package voiceroomrocket
import (
"context"
"errors"
"math"
"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)
countdownSeconds := s.launchCountdownSeconds(ctx, status, time.Now())
return &StatusResponse{
RoomID: roomID,
DayKey: status.DayKey,
RoundNo: status.RoundNo,
DisplayRound: status.RoundNo > 1,
Status: status.Status,
CurrentLevel: status.CurrentLevel,
CurrentEnergy: status.CurrentEnergy,
NeedEnergy: status.NeedEnergy,
EnergyPercent: percent,
DisplayPercent: displayPercent,
LaunchCountdownSeconds: countdownSeconds,
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) launchCountdownSeconds(ctx context.Context, status model.VoiceRoomRocketStatus, now time.Time) int {
if status.Status != statusLaunching {
return 0
}
var round model.VoiceRoomRocketLevelRound
err := s.repo.DB.WithContext(ctx).
Where(
"sys_origin = ? AND room_id = ? AND day_key = ? AND round_no = ? AND level = ?",
status.SysOrigin, status.RoomID, status.DayKey, status.RoundNo, status.CurrentLevel,
).
First(&round).Error
if err != nil || round.ReachTime == nil || round.ReachTime.IsZero() {
return defaultLaunchDelaySeconds
}
due := round.ReachTime.Add(time.Duration(defaultLaunchDelaySeconds) * time.Second)
remaining := due.Sub(now).Seconds()
if remaining <= 0 {
return 0
}
return int(math.Ceil(remaining))
}
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,497 @@
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
defaultLaunchDelaySeconds = 10
defaultWorkerBatchSize = 100
defaultWorkerInterval = time.Second
defaultRoomLockTTL = 10 * time.Second
statusCharging = "CHARGING"
statusLaunching = "LAUNCHING"
levelRoundStatusCharging = "CHARGING"
levelRoundStatusReached = "REACHED"
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"
rewardTypeNone = "NONE"
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 regionResolver interface {
ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error)
}
type regionBroadcaster interface {
SendRegionCustomMessage(ctx context.Context, sysOrigin string, regionCode string, body any) (string, 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
regionResolver regionResolver
regionBroadcast regionBroadcaster
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
if resolver, ok := any(gateways).(regionResolver); ok {
service.regionResolver = resolver
}
}
return service
}
// SetIMGateway 允许测试或后续装配替换 IM 发送实现。
func (s *Service) SetIMGateway(gateway imGateway) {
if s != nil {
s.im = gateway
}
}
// SetRegionBroadcaster 允许装配区域 IM 广播服务。
func (s *Service) SetRegionBroadcaster(broadcaster regionBroadcaster) {
if s != nil {
s.regionBroadcast = broadcaster
}
}
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"`
EnergyRuleJSON json.RawMessage `json:"energyRuleJson,omitempty"`
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"`
Level int `json:"level"`
Rewards []RewardConfigPayload `json:"rewards"`
}
type StatusResponse struct {
RoomID int64 `json:"roomId,string"`
DayKey string `json:"dayKey"`
RoundNo int `json:"roundNo"`
DisplayRound bool `json:"displayRound"`
Status string `json:"status"`
CurrentLevel int `json:"currentLevel"`
CurrentEnergy int64 `json:"currentEnergy"`
NeedEnergy int64 `json:"needEnergy"`
EnergyPercent string `json:"energyPercent"`
DisplayPercent int `json:"displayPercent"`
LaunchCountdownSeconds int `json:"launchCountdownSeconds,omitempty"`
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,623 @@
package wheel
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
)
type configBundle struct {
Config *model.WheelConfig
Categories []model.WheelPoolConfig
Rewards []model.WheelRewardConfig
}
// GetConfig returns admin wheel configuration.
func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*ConfigResponse, error) {
bundle, err := s.loadConfigBundle(ctx, sysOrigin, false)
if err != nil {
return nil, err
}
if bundle.Config == nil {
return defaultConfigResponse(sysOrigin), nil
}
return s.buildConfigResponse(bundle)
}
// GetAppConfig returns enabled wheel configuration for app clients.
func (s *Service) GetAppConfig(ctx context.Context, user AuthUser) (*ConfigResponse, error) {
bundle, err := s.loadConfigBundle(ctx, user.SysOrigin, true)
if err != nil {
return nil, err
}
if bundle.Config == nil {
return &ConfigResponse{
Configured: false,
SysOrigin: normalizeSysOrigin(user.SysOrigin),
Timezone: defaultTimezone,
ProbabilityTotal: probabilityTotal,
Categories: defaultCategoryPayloads(false),
}, nil
}
return s.buildConfigResponse(bundle)
}
// SaveConfig saves admin wheel configuration.
func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*ConfigResponse, error) {
normalized, err := normalizeSaveRequest(req)
if err != nil {
return nil, err
}
configEnabled := false
for _, category := range normalized.Categories {
if category.Enabled {
configEnabled = true
break
}
}
now := time.Now()
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var configRow model.WheelConfig
if err := tx.Where("sys_origin = ?", normalized.SysOrigin).First(&configRow).Error; errors.Is(err, gorm.ErrRecordNotFound) {
nextID, idErr := utils.NextID()
if idErr != nil {
return idErr
}
configRow = model.WheelConfig{
ID: nextID,
SysOrigin: normalized.SysOrigin,
CreateTime: now,
}
} else if err != nil {
return err
}
configRow.Enabled = configEnabled
configRow.Timezone = normalized.Timezone
configRow.UpdateTime = now
if configRow.CreateTime.IsZero() {
configRow.CreateTime = now
}
if err := tx.Save(&configRow).Error; err != nil {
return err
}
if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.WheelPoolConfig{}).Error; err != nil {
return err
}
if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.WheelRewardConfig{}).Error; err != nil {
return err
}
poolRows := make([]model.WheelPoolConfig, 0, len(normalized.Categories))
rewardRows := make([]model.WheelRewardConfig, 0, 28)
for _, category := range normalized.Categories {
meta, _ := categoryMeta(category.Category)
poolID, idErr := utils.NextID()
if idErr != nil {
return idErr
}
poolRows = append(poolRows, model.WheelPoolConfig{
ID: poolID,
ConfigID: configRow.ID,
SysOrigin: normalized.SysOrigin,
Category: category.Category,
Enabled: category.Enabled,
PriceOneGold: category.PriceOneGold,
PriceTenGold: category.PriceTenGold,
PriceFiftyGold: category.PriceFiftyGold,
Sort: meta.Sort,
CreateTime: now,
UpdateTime: now,
})
for index, item := range category.Rewards {
nextID, rewardIDErr := utils.NextID()
if rewardIDErr != nil {
return rewardIDErr
}
sortValue := item.Sort
if sortValue <= 0 {
sortValue = index + 1
}
rewardRows = append(rewardRows, model.WheelRewardConfig{
ID: nextID,
ConfigID: configRow.ID,
SysOrigin: normalized.SysOrigin,
Category: category.Category,
RewardType: item.RewardType,
ResourceID: resourceIDPtr(item.ResourceID),
ResourceType: item.ResourceType,
ResourceName: strings.TrimSpace(item.ResourceName),
ResourceURL: strings.TrimSpace(item.ResourceURL),
CoverURL: strings.TrimSpace(item.CoverURL),
DurationDays: item.DurationDays,
DisplayGoldAmount: item.DisplayGoldAmount,
GoldAmount: item.GoldAmount,
Probability: item.Probability,
Sort: sortValue,
Enabled: item.Enabled,
CreateTime: now,
UpdateTime: now,
})
}
}
if len(poolRows) > 0 {
if err := tx.Create(&poolRows).Error; err != nil {
return err
}
}
if len(rewardRows) > 0 {
return tx.Create(&rewardRows).Error
}
return nil
}); err != nil {
return nil, err
}
return s.GetConfig(ctx, normalized.SysOrigin)
}
// SaveCategoryConfig saves one admin wheel category without touching the other categories.
func (s *Service) SaveCategoryConfig(ctx context.Context, req SaveCategoryConfigRequest) (*ConfigResponse, error) {
normalized, err := normalizeSaveCategoryRequest(req)
if err != nil {
return nil, err
}
now := time.Now()
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var configRow model.WheelConfig
if err := tx.Where("sys_origin = ?", normalized.SysOrigin).First(&configRow).Error; errors.Is(err, gorm.ErrRecordNotFound) {
nextID, idErr := utils.NextID()
if idErr != nil {
return idErr
}
configRow = model.WheelConfig{
ID: nextID,
SysOrigin: normalized.SysOrigin,
CreateTime: now,
}
} else if err != nil {
return err
}
configRow.Timezone = normalized.Timezone
configRow.UpdateTime = now
if configRow.CreateTime.IsZero() {
configRow.CreateTime = now
}
if err := tx.Save(&configRow).Error; err != nil {
return err
}
category := normalized.Category
meta, _ := categoryMeta(category.Category)
if err := tx.Where("config_id = ? AND category = ?", configRow.ID, category.Category).Delete(&model.WheelPoolConfig{}).Error; err != nil {
return err
}
if err := tx.Where("config_id = ? AND category = ?", configRow.ID, category.Category).Delete(&model.WheelRewardConfig{}).Error; err != nil {
return err
}
poolID, idErr := utils.NextID()
if idErr != nil {
return idErr
}
poolRow := model.WheelPoolConfig{
ID: poolID,
ConfigID: configRow.ID,
SysOrigin: normalized.SysOrigin,
Category: category.Category,
Enabled: category.Enabled,
PriceOneGold: category.PriceOneGold,
PriceTenGold: category.PriceTenGold,
PriceFiftyGold: category.PriceFiftyGold,
Sort: meta.Sort,
CreateTime: now,
UpdateTime: now,
}
if err := tx.Create(&poolRow).Error; err != nil {
return err
}
rewardRows := make([]model.WheelRewardConfig, 0, len(category.Rewards))
for index, item := range category.Rewards {
nextID, rewardIDErr := utils.NextID()
if rewardIDErr != nil {
return rewardIDErr
}
sortValue := item.Sort
if sortValue <= 0 {
sortValue = index + 1
}
rewardRows = append(rewardRows, model.WheelRewardConfig{
ID: nextID,
ConfigID: configRow.ID,
SysOrigin: normalized.SysOrigin,
Category: category.Category,
RewardType: item.RewardType,
ResourceID: resourceIDPtr(item.ResourceID),
ResourceType: item.ResourceType,
ResourceName: strings.TrimSpace(item.ResourceName),
ResourceURL: strings.TrimSpace(item.ResourceURL),
CoverURL: strings.TrimSpace(item.CoverURL),
DurationDays: item.DurationDays,
DisplayGoldAmount: item.DisplayGoldAmount,
GoldAmount: item.GoldAmount,
Probability: item.Probability,
Sort: sortValue,
Enabled: item.Enabled,
CreateTime: now,
UpdateTime: now,
})
}
if len(rewardRows) > 0 {
if err := tx.Create(&rewardRows).Error; err != nil {
return err
}
}
var enabledPools int64
if err := tx.Model(&model.WheelPoolConfig{}).
Where("config_id = ? AND enabled = ?", configRow.ID, true).
Count(&enabledPools).Error; err != nil {
return err
}
return tx.Model(&model.WheelConfig{}).
Where("id = ?", configRow.ID).
Updates(map[string]any{
"enabled": enabledPools > 0,
"timezone": normalized.Timezone,
"update_time": now,
}).Error
}); err != nil {
return nil, err
}
return s.GetConfig(ctx, normalized.SysOrigin)
}
func normalizeSaveRequest(req SaveConfigRequest) (SaveConfigRequest, error) {
sysOrigin := normalizeSysOrigin(req.SysOrigin)
if sysOrigin == "" {
return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required")
}
timezone := normalizeTimezone(req.Timezone)
if _, err := time.LoadLocation(timezone); err != nil {
return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error())
}
inputByCategory := make(map[string]CategoryConfigInput, len(req.Categories))
for _, category := range req.Categories {
normalizedCategory := normalizeCategory(category.Category)
if _, ok := categoryMeta(normalizedCategory); !ok {
return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_category", "category must be CLASSIC, LUXURY, or ADVANCED")
}
category.Category = normalizedCategory
inputByCategory[normalizedCategory] = category
}
categories := make([]CategoryConfigInput, 0, len(wheelCategories))
for _, meta := range wheelCategories {
category, exists := inputByCategory[meta.Category]
if !exists {
category = defaultCategoryInput(meta)
}
category.Category = meta.Category
if category.PriceOneGold <= 0 || category.PriceTenGold <= 0 || category.PriceFiftyGold <= 0 {
return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_price", fmt.Sprintf("%s draw prices must be greater than 0", meta.Category))
}
normalizedRewards, err := normalizeCategoryRewards(meta, category.Rewards, category.Enabled)
if err != nil {
return SaveConfigRequest{}, err
}
category.Rewards = normalizedRewards
categories = append(categories, category)
}
return SaveConfigRequest{
SysOrigin: sysOrigin,
Enabled: req.Enabled,
Timezone: timezone,
Categories: categories,
}, nil
}
func normalizeSaveCategoryRequest(req SaveCategoryConfigRequest) (SaveCategoryConfigRequest, error) {
sysOrigin := normalizeSysOrigin(req.SysOrigin)
if sysOrigin == "" {
return SaveCategoryConfigRequest{}, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required")
}
timezone := normalizeTimezone(req.Timezone)
if _, err := time.LoadLocation(timezone); err != nil {
return SaveCategoryConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error())
}
category := req.Category
category.Category = normalizeCategory(category.Category)
meta, ok := categoryMeta(category.Category)
if !ok {
return SaveCategoryConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_category", "category must be CLASSIC, LUXURY, or ADVANCED")
}
if category.PriceOneGold <= 0 || category.PriceTenGold <= 0 || category.PriceFiftyGold <= 0 {
return SaveCategoryConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_price", fmt.Sprintf("%s draw prices must be greater than 0", meta.Category))
}
rewards, err := normalizeCategoryRewards(meta, category.Rewards, category.Enabled)
if err != nil {
return SaveCategoryConfigRequest{}, err
}
category.Rewards = rewards
return SaveCategoryConfigRequest{
SysOrigin: sysOrigin,
Timezone: timezone,
Category: category,
}, nil
}
func normalizeCategoryRewards(meta CategoryMeta, rewards []RewardConfigInput, requireEnabled bool) ([]RewardConfigInput, error) {
if len(rewards) != meta.RewardCount {
return nil, NewAppError(http.StatusBadRequest, "invalid_reward_count", fmt.Sprintf("%s rewards must contain exactly %d items", meta.Category, meta.RewardCount))
}
normalized := append([]RewardConfigInput(nil), rewards...)
sortRewardInputs(normalized)
enabledProbability := 0
enabledCount := 0
for index := range normalized {
item := &normalized[index]
item.RewardType = normalizeRewardType(item.RewardType)
item.ResourceType = strings.ToUpper(strings.TrimSpace(item.ResourceType))
item.ResourceName = strings.TrimSpace(item.ResourceName)
item.ResourceURL = strings.TrimSpace(item.ResourceURL)
item.CoverURL = strings.TrimSpace(item.CoverURL)
if item.Sort <= 0 {
item.Sort = index + 1
}
switch item.RewardType {
case rewardTypeResource:
if requireEnabled && item.ResourceID.Int64() <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_resource", fmt.Sprintf("%s resource reward requires resourceId", meta.Category))
}
if requireEnabled && item.ResourceType == "" {
return nil, NewAppError(http.StatusBadRequest, "invalid_resource_type", fmt.Sprintf("%s resource reward requires resourceType", meta.Category))
}
if requireEnabled && item.DurationDays <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_duration_days", fmt.Sprintf("%s resource reward requires durationDays greater than 0", meta.Category))
}
if requireEnabled && item.DisplayGoldAmount <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_display_gold_amount", fmt.Sprintf("%s resource reward display gold amount must be greater than 0", meta.Category))
}
if item.ResourceID.Int64() > 0 && item.ResourceName == "" {
item.ResourceName = fmt.Sprintf("资源 %d", item.ResourceID.Int64())
}
item.GoldAmount = 0
case rewardTypeGold:
if requireEnabled && item.GoldAmount <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_gold_amount", fmt.Sprintf("%s gold reward requires goldAmount", meta.Category))
}
item.ResourceID = 0
item.ResourceType = ""
item.ResourceName = "金币"
item.ResourceURL = ""
item.CoverURL = ""
item.DurationDays = 0
item.DisplayGoldAmount = 0
default:
return nil, NewAppError(http.StatusBadRequest, "invalid_reward_type", "rewardType must be RESOURCE or GOLD")
}
if item.Probability < 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_probability", "probability must be greater than or equal to 0")
}
if item.Enabled {
enabledCount++
if requireEnabled && item.Probability <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_probability", fmt.Sprintf("%s enabled reward probability must be greater than 0", meta.Category))
}
enabledProbability += item.Probability
}
}
if requireEnabled {
if enabledCount != meta.RewardCount {
return nil, NewAppError(http.StatusBadRequest, "invalid_enabled_reward_count", fmt.Sprintf("%s must enable exactly %d rewards", meta.Category, meta.RewardCount))
}
if enabledProbability != probabilityTotal {
return nil, NewAppError(http.StatusBadRequest, "invalid_probability_total", fmt.Sprintf("%s enabled reward probability total must equal 10000", meta.Category))
}
}
return normalized, nil
}
func (s *Service) loadConfigBundle(ctx context.Context, sysOrigin string, onlyEnabledRewards bool) (*configBundle, error) {
sysOrigin = normalizeSysOrigin(sysOrigin)
if sysOrigin == "" {
return nil, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required")
}
var configRow model.WheelConfig
err := s.db.WithContext(ctx).Where("sys_origin = ?", sysOrigin).First(&configRow).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return &configBundle{}, nil
}
if err != nil {
return nil, err
}
var categoryRows []model.WheelPoolConfig
categoryQuery := s.db.WithContext(ctx).
Where("config_id = ?", configRow.ID).
Order("sort asc, id asc")
if err := categoryQuery.Find(&categoryRows).Error; err != nil {
return nil, err
}
rewardQuery := s.db.WithContext(ctx).
Where("config_id = ?", configRow.ID).
Order("category asc, sort asc, id asc")
if onlyEnabledRewards {
rewardQuery = rewardQuery.Where("enabled = ?", true)
}
var rewardRows []model.WheelRewardConfig
if err := rewardQuery.Find(&rewardRows).Error; err != nil {
return nil, err
}
return &configBundle{Config: &configRow, Categories: categoryRows, Rewards: rewardRows}, nil
}
func defaultConfigResponse(sysOrigin string) *ConfigResponse {
return &ConfigResponse{
Configured: false,
SysOrigin: normalizeSysOrigin(sysOrigin),
Enabled: false,
Timezone: defaultTimezone,
ProbabilityTotal: probabilityTotal,
Categories: defaultCategoryPayloads(false),
}
}
func defaultCategoryInput(meta CategoryMeta) CategoryConfigInput {
return CategoryConfigInput{
Category: meta.Category,
Enabled: false,
PriceOneGold: meta.DefaultPriceOneGold,
PriceTenGold: meta.DefaultPriceTenGold,
PriceFiftyGold: meta.DefaultPriceFiftyGold,
Rewards: defaultRewardInputs(meta.RewardCount),
}
}
func defaultRewardInputs(count int) []RewardConfigInput {
rewards := make([]RewardConfigInput, 0, count)
for index := 0; index < count; index++ {
rewards = append(rewards, RewardConfigInput{
Enabled: true,
Sort: index + 1,
RewardType: rewardTypeResource,
Probability: 0,
})
}
return rewards
}
func defaultCategoryPayloads(enabled bool) []CategoryConfigPayload {
result := make([]CategoryConfigPayload, 0, len(wheelCategories))
for _, meta := range wheelCategories {
rewards := make([]RewardConfigPayload, 0, meta.RewardCount)
for index := 0; index < meta.RewardCount; index++ {
rewards = append(rewards, RewardConfigPayload{
Enabled: true,
Sort: index + 1,
RewardType: rewardTypeResource,
Probability: 0,
ProbabilityPercent: "0.00",
})
}
result = append(result, CategoryConfigPayload{
Category: meta.Category,
Label: meta.Label,
Enabled: enabled,
RewardCount: meta.RewardCount,
PriceOneGold: meta.DefaultPriceOneGold,
PriceTenGold: meta.DefaultPriceTenGold,
PriceFiftyGold: meta.DefaultPriceFiftyGold,
ProbabilityTotal: probabilityTotal,
Rewards: rewards,
})
}
return result
}
func (s *Service) buildConfigResponse(bundle *configBundle) (*ConfigResponse, error) {
configRow := bundle.Config
categoryByName := make(map[string]model.WheelPoolConfig, len(bundle.Categories))
for _, row := range bundle.Categories {
categoryByName[normalizeCategory(row.Category)] = row
}
rewardsByCategory := make(map[string][]model.WheelRewardConfig, len(wheelCategories))
for _, row := range bundle.Rewards {
category := normalizeCategory(row.Category)
rewardsByCategory[category] = append(rewardsByCategory[category], row)
}
categories := make([]CategoryConfigPayload, 0, len(wheelCategories))
for _, meta := range wheelCategories {
pool, exists := categoryByName[meta.Category]
if !exists {
pool = model.WheelPoolConfig{
Category: meta.Category,
Enabled: false,
PriceOneGold: meta.DefaultPriceOneGold,
PriceTenGold: meta.DefaultPriceTenGold,
PriceFiftyGold: meta.DefaultPriceFiftyGold,
Sort: meta.Sort,
}
}
rewards := make([]RewardConfigPayload, 0, len(rewardsByCategory[meta.Category]))
for _, row := range rewardsByCategory[meta.Category] {
rewards = append(rewards, rewardPayloadFromConfig(row))
}
for len(rewards) < meta.RewardCount {
rewards = append(rewards, RewardConfigPayload{
Enabled: true,
Sort: len(rewards) + 1,
RewardType: rewardTypeResource,
Probability: 0,
ProbabilityPercent: "0.00",
})
}
if len(rewards) > meta.RewardCount {
rewards = rewards[:meta.RewardCount]
}
categories = append(categories, CategoryConfigPayload{
Category: meta.Category,
Label: meta.Label,
Enabled: pool.Enabled,
RewardCount: meta.RewardCount,
PriceOneGold: pool.PriceOneGold,
PriceTenGold: pool.PriceTenGold,
PriceFiftyGold: pool.PriceFiftyGold,
ProbabilityTotal: probabilityTotal,
Rewards: rewards,
})
}
return &ConfigResponse{
Configured: true,
ID: configRow.ID,
SysOrigin: configRow.SysOrigin,
Enabled: configRow.Enabled,
Timezone: normalizeTimezone(configRow.Timezone),
ProbabilityTotal: probabilityTotal,
Categories: categories,
UpdateTime: formatDateTime(configRow.UpdateTime),
}, nil
}
func rewardPayloadFromConfig(row model.WheelRewardConfig) RewardConfigPayload {
return RewardConfigPayload{
ID: row.ID,
Enabled: row.Enabled,
Sort: row.Sort,
RewardType: row.RewardType,
ResourceID: resourceIDValue(row.ResourceID),
ResourceType: row.ResourceType,
ResourceName: row.ResourceName,
ResourceURL: row.ResourceURL,
CoverURL: row.CoverURL,
DurationDays: row.DurationDays,
DisplayGoldAmount: row.DisplayGoldAmount,
GoldAmount: row.GoldAmount,
Probability: row.Probability,
ProbabilityPercent: probabilityPercent(row.Probability),
}
}

View File

@ -0,0 +1,173 @@
package wheel
import (
"encoding/json"
"testing"
)
func TestNormalizeSaveRequestRequiresCategoryProbabilityTotalWhenEnabled(t *testing.T) {
req := validSaveRequestForTest()
req.Categories[0].Rewards[0].Probability--
_, err := normalizeSaveRequest(req)
if err == nil {
t.Fatal("normalizeSaveRequest() expected probability total error")
}
}
func TestNormalizeSaveRequestAllowsZeroProbabilityDraftWhenDisabled(t *testing.T) {
req := validSaveRequestForTest()
req.Enabled = false
for categoryIndex := range req.Categories {
req.Categories[categoryIndex].Enabled = false
for rewardIndex := range req.Categories[categoryIndex].Rewards {
req.Categories[categoryIndex].Rewards[rewardIndex].Probability = 0
}
}
if _, err := normalizeSaveRequest(req); err != nil {
t.Fatalf("normalizeSaveRequest() error = %v", err)
}
}
func TestNormalizeSaveCategoryRequestOnlyValidatesTargetCategory(t *testing.T) {
req := SaveCategoryConfigRequest{
SysOrigin: "yumi",
Timezone: "Asia/Riyadh",
Category: validCategoryForTest(wheelCategories[1]),
}
req.Category.Rewards[0].Probability--
if _, err := normalizeSaveCategoryRequest(req); err == nil {
t.Fatal("normalizeSaveCategoryRequest() expected probability total error")
}
req.Category.Enabled = false
for index := range req.Category.Rewards {
req.Category.Rewards[index].Probability = 0
req.Category.Rewards[index].ResourceID = 0
}
if _, err := normalizeSaveCategoryRequest(req); err != nil {
t.Fatalf("normalizeSaveCategoryRequest() disabled draft error = %v", err)
}
}
func TestNormalizeSaveRequestRequiresFixedRewardCount(t *testing.T) {
req := validSaveRequestForTest()
req.Categories[2].Rewards = req.Categories[2].Rewards[:11]
_, err := normalizeSaveRequest(req)
if err == nil {
t.Fatal("normalizeSaveRequest() expected reward count error")
}
}
func TestNormalizeSaveRequestNormalizesGoldReward(t *testing.T) {
req := validSaveRequestForTest()
req.Categories[0].Rewards[0] = RewardConfigInput{
Enabled: true,
Sort: 1,
RewardType: "gold",
ResourceID: ResourceID(999),
ResourceType: "RIDE",
ResourceName: "",
GoldAmount: 500,
Probability: req.Categories[0].Rewards[0].Probability,
}
normalized, err := normalizeSaveRequest(req)
if err != nil {
t.Fatalf("normalizeSaveRequest() error = %v", err)
}
if normalized.SysOrigin != "YUMI" {
t.Fatalf("SysOrigin = %q", normalized.SysOrigin)
}
reward := normalized.Categories[0].Rewards[0]
if reward.RewardType != rewardTypeGold {
t.Fatalf("RewardType = %q", reward.RewardType)
}
if reward.ResourceID.Int64() != 0 {
t.Fatal("gold reward should not keep resourceId")
}
if reward.ResourceName == "" {
t.Fatal("gold reward should get a display name")
}
}
func TestNormalizeDrawTimes(t *testing.T) {
for _, times := range []int{0, 1, 10, 50} {
if _, err := normalizeDrawTimes(times); err != nil {
t.Fatalf("normalizeDrawTimes(%d) error = %v", times, err)
}
}
if _, err := normalizeDrawTimes(2); err == nil {
t.Fatal("normalizeDrawTimes(2) expected error")
}
}
func TestWheelResourceIDAcceptsStringResourceID(t *testing.T) {
var payload RewardConfigInput
if err := json.Unmarshal([]byte(`{"resourceId":"2045029471274201089"}`), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if payload.ResourceID.Int64() != 2045029471274201089 {
t.Fatalf("resource id = %d", payload.ResourceID.Int64())
}
body, err := json.Marshal(RewardConfigPayload{ResourceID: payload.ResourceID})
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
if got := string(body); got != `{"id":0,"enabled":false,"sort":0,"rewardType":"","resourceId":"2045029471274201089","probability":0,"probabilityPercent":""}` {
t.Fatalf("json = %s", got)
}
}
func validSaveRequestForTest() SaveConfigRequest {
categories := make([]CategoryConfigInput, 0, len(wheelCategories))
for _, meta := range wheelCategories {
categories = append(categories, validCategoryForTest(meta))
}
return SaveConfigRequest{
SysOrigin: "yumi",
Enabled: true,
Timezone: "Asia/Riyadh",
Categories: categories,
}
}
func validCategoryForTest(meta CategoryMeta) CategoryConfigInput {
probabilities := distributedProbabilityForTest(meta.RewardCount)
rewards := make([]RewardConfigInput, 0, meta.RewardCount)
for index := 0; index < meta.RewardCount; index++ {
rewards = append(rewards, RewardConfigInput{
Enabled: true,
Sort: index + 1,
RewardType: rewardTypeResource,
ResourceID: ResourceID(meta.Sort*1000 + index + 1),
ResourceType: "RIDE",
ResourceName: meta.Label,
DurationDays: 7,
DisplayGoldAmount: int64(1000 + index),
Probability: probabilities[index],
})
}
return CategoryConfigInput{
Category: meta.Category,
Enabled: true,
PriceOneGold: meta.DefaultPriceOneGold,
PriceTenGold: meta.DefaultPriceTenGold,
PriceFiftyGold: meta.DefaultPriceFiftyGold,
Rewards: rewards,
}
}
func distributedProbabilityForTest(count int) []int {
base := probabilityTotal / count
remainder := probabilityTotal % count
values := make([]int, count)
for index := range values {
values[index] = base
}
values[count-1] += remainder
return values
}

View File

@ -0,0 +1,461 @@
package wheel
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
)
// Draw executes one wheel draw request for an authenticated user.
func (s *Service) Draw(ctx context.Context, user AuthUser, req DrawRequest) (*DrawResponse, error) {
sysOrigin := normalizeSysOrigin(user.SysOrigin)
if sysOrigin == "" || user.UserID <= 0 {
return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required")
}
if s.java == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "wheel_gateway_unavailable", "reward gateway is unavailable")
}
roomID, err := s.resolveCurrentRoomID(ctx, user)
if err != nil {
return nil, err
}
category := normalizeCategory(req.Category)
if _, ok := categoryMeta(category); !ok {
return nil, NewAppError(http.StatusBadRequest, "invalid_category", "category must be CLASSIC, LUXURY, or ADVANCED")
}
times, err := normalizeDrawTimes(req.Times)
if err != nil {
return nil, err
}
bundle, err := s.loadConfigBundle(ctx, sysOrigin, true)
if err != nil {
return nil, err
}
if bundle.Config == nil || !bundle.Config.Enabled {
return nil, NewAppError(http.StatusNotFound, "wheel_not_available", "wheel is not enabled")
}
pool, rewards, err := selectDrawPool(bundle, category)
if err != nil {
return nil, err
}
if len(rewards) == 0 {
return nil, NewAppError(http.StatusNotFound, "wheel_rewards_empty", "wheel rewards are not configured")
}
paidGold, err := priceForTimes(pool, times)
if err != nil {
return nil, err
}
selected := make([]model.WheelRewardConfig, 0, times)
for index := 0; index < times; index++ {
reward, pickErr := pickReward(rewards)
if pickErr != nil {
return nil, pickErr
}
selected = append(selected, reward)
}
drawID, err := utils.NextID()
if err != nil {
return nil, err
}
drawNo := fmt.Sprintf("WHEEL%d", drawID)
records, err := s.createPendingRecords(ctx, drawNo, user.UserID, sysOrigin, category, times, paidGold, selected)
if err != nil {
return nil, err
}
if err := s.deductDrawPrice(ctx, drawNo, user.UserID, sysOrigin, paidGold); err != nil {
_ = s.markDrawRecords(ctx, drawNo, drawStatusFailed, err.Error())
return nil, mapWalletError("wheel_wallet_deduct_failed", err)
}
for index := range records {
record := &records[index]
if err := s.grantReward(ctx, record); err != nil {
_ = s.markRecordStatus(ctx, record.ID, drawStatusFailed, err.Error())
return nil, NewAppError(http.StatusBadGateway, "wheel_reward_grant_failed", err.Error())
}
record.Status = drawStatusSuccess
record.UpdateTime = time.Now()
if err := s.markRecordStatus(ctx, record.ID, drawStatusSuccess, ""); err != nil {
return nil, err
}
}
var balanceAfter int64
if balanceMap, balanceErr := s.java.MapGoldBalance(ctx, []int64{user.UserID}); balanceErr == nil {
balanceAfter = balanceMap[user.UserID]
}
return buildDrawResponse(drawNo, user.UserID, sysOrigin, roomID, category, times, paidGold, balanceAfter, records), nil
}
func (s *Service) resolveCurrentRoomID(ctx context.Context, user AuthUser) (int64, error) {
profile, err := s.java.GetRoomProfileByUserID(ctx, user.UserID)
if err != nil {
return 0, NewAppError(http.StatusBadGateway, "wheel_current_room_lookup_failed", err.Error())
}
roomID := int64(profile.ID)
if roomID <= 0 {
return 0, NewAppError(http.StatusBadRequest, "wheel_current_room_required", "current room is required")
}
if profile.SysOrigin != "" && !strings.EqualFold(profile.SysOrigin, user.SysOrigin) {
return 0, NewAppError(http.StatusBadRequest, "wheel_current_room_sys_origin_mismatch", "current room sysOrigin does not match user")
}
return roomID, nil
}
func selectDrawPool(bundle *configBundle, category string) (model.WheelPoolConfig, []model.WheelRewardConfig, error) {
var pool model.WheelPoolConfig
foundPool := false
for _, item := range bundle.Categories {
if normalizeCategory(item.Category) == category {
pool = item
foundPool = true
break
}
}
if !foundPool || !pool.Enabled {
return model.WheelPoolConfig{}, nil, NewAppError(http.StatusNotFound, "wheel_category_not_available", "wheel category is not enabled")
}
rewards := make([]model.WheelRewardConfig, 0, len(bundle.Rewards))
for _, item := range bundle.Rewards {
if normalizeCategory(item.Category) == category && item.Enabled {
rewards = append(rewards, item)
}
}
meta, _ := categoryMeta(category)
if len(rewards) != meta.RewardCount {
return model.WheelPoolConfig{}, nil, NewAppError(http.StatusNotFound, "wheel_rewards_incomplete", fmt.Sprintf("%s rewards must contain exactly %d enabled items", category, meta.RewardCount))
}
for _, reward := range rewards {
if err := validateDrawableReward(reward); err != nil {
return model.WheelPoolConfig{}, nil, NewAppError(http.StatusInternalServerError, "wheel_reward_invalid", err.Error())
}
}
return pool, rewards, nil
}
func validateDrawableReward(reward model.WheelRewardConfig) error {
switch normalizeRewardType(reward.RewardType) {
case rewardTypeGold:
if reward.GoldAmount <= 0 {
return fmt.Errorf("gold reward amount is empty")
}
case rewardTypeResource:
if reward.ResourceID == nil || *reward.ResourceID <= 0 {
return fmt.Errorf("resource reward is empty")
}
if strings.TrimSpace(reward.ResourceType) == "" {
return fmt.Errorf("resource reward type is empty")
}
if reward.DurationDays <= 0 {
return fmt.Errorf("resource reward duration is empty")
}
default:
return fmt.Errorf("unsupported reward type %s", reward.RewardType)
}
return nil
}
func priceForTimes(configRow model.WheelPoolConfig, times int) (int64, error) {
switch times {
case 1:
return configRow.PriceOneGold, nil
case 10:
return configRow.PriceTenGold, nil
case 50:
return configRow.PriceFiftyGold, nil
default:
return 0, NewAppError(http.StatusBadRequest, "invalid_draw_times", "times must be 1, 10, or 50")
}
}
func pickReward(rewards []model.WheelRewardConfig) (model.WheelRewardConfig, error) {
total := 0
for _, reward := range rewards {
if reward.Enabled && reward.Probability > 0 {
total += reward.Probability
}
}
if total <= 0 {
return model.WheelRewardConfig{}, NewAppError(http.StatusNotFound, "wheel_rewards_empty", "wheel rewards are not configured")
}
n, err := rand.Int(rand.Reader, big.NewInt(int64(total)))
if err != nil {
return model.WheelRewardConfig{}, err
}
target := int(n.Int64()) + 1
accumulated := 0
for _, reward := range rewards {
if !reward.Enabled || reward.Probability <= 0 {
continue
}
accumulated += reward.Probability
if target <= accumulated {
return reward, nil
}
}
return rewards[len(rewards)-1], nil
}
func (s *Service) createPendingRecords(
ctx context.Context,
drawNo string,
userID int64,
sysOrigin string,
category string,
times int,
paidGold int64,
rewards []model.WheelRewardConfig,
) ([]model.WheelDrawRecord, error) {
now := time.Now()
records := make([]model.WheelDrawRecord, 0, len(rewards))
for index, reward := range rewards {
nextID, err := utils.NextID()
if err != nil {
return nil, err
}
records = append(records, model.WheelDrawRecord{
ID: nextID,
DrawNo: drawNo,
EventID: fmt.Sprintf("%s:REWARD:%d", drawNo, index+1),
SysOrigin: sysOrigin,
Category: category,
UserID: userID,
DrawTimes: times,
PaidGold: paidGold,
RewardType: normalizeRewardType(reward.RewardType),
ResourceID: reward.ResourceID,
ResourceType: strings.ToUpper(strings.TrimSpace(reward.ResourceType)),
ResourceName: reward.ResourceName,
ResourceURL: reward.ResourceURL,
CoverURL: reward.CoverURL,
DurationDays: reward.DurationDays,
DisplayGoldAmount: reward.DisplayGoldAmount,
GoldAmount: reward.GoldAmount,
Probability: reward.Probability,
Status: drawStatusPending,
CreateTime: now,
UpdateTime: now,
})
}
if len(records) == 0 {
return records, nil
}
if err := s.db.WithContext(ctx).Create(&records).Error; err != nil {
return nil, err
}
return records, nil
}
func (s *Service) deductDrawPrice(ctx context.Context, drawNo string, userID int64, sysOrigin string, paidGold int64) error {
if paidGold <= 0 {
return nil
}
return s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{
ReceiptType: walletReceiptExpenditure,
UserID: userID,
SysOrigin: sysOrigin,
EventID: fmt.Sprintf("%s:PAY", drawNo),
Remark: fmt.Sprintf("wheel draw %s", drawNo),
Amount: integration.NewPennyAmountPayloadFromDollar(paidGold),
CloseDelayAsset: false,
OpUserType: "APP",
CustomizeOrigin: walletOrigin,
CustomizeOriginDesc: walletOriginDesc,
})
}
func (s *Service) grantReward(ctx context.Context, record *model.WheelDrawRecord) error {
switch normalizeRewardType(record.RewardType) {
case rewardTypeGold:
if record.GoldAmount <= 0 {
return fmt.Errorf("gold reward amount is empty")
}
return s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{
ReceiptType: walletReceiptIncome,
UserID: record.UserID,
SysOrigin: record.SysOrigin,
EventID: record.EventID,
Remark: fmt.Sprintf("wheel reward %s", record.DrawNo),
Amount: integration.NewPennyAmountPayloadFromDollar(record.GoldAmount),
CloseDelayAsset: false,
OpUserType: "APP",
CustomizeOrigin: walletOrigin,
CustomizeOriginDesc: walletOriginDesc,
})
case rewardTypeResource:
return s.grantResourceReward(ctx, record)
default:
return fmt.Errorf("unsupported reward type %s", record.RewardType)
}
}
func (s *Service) grantResourceReward(ctx context.Context, record *model.WheelDrawRecord) error {
if record.ResourceID == nil || *record.ResourceID <= 0 {
return fmt.Errorf("resource reward is empty")
}
days := record.DurationDays
if days <= 0 {
return fmt.Errorf("resource reward duration is empty")
}
resourceID := *record.ResourceID
resourceType := strings.ToUpper(strings.TrimSpace(record.ResourceType))
if resourceType == "" {
return fmt.Errorf("resource reward type is empty")
}
if resourceType == resourceTypeBadge || resourceType == resourceTypeRoomBadge {
if err := s.java.ActivateTemporaryBadge(ctx, record.UserID, resourceID, days); err != nil {
return err
}
_ = s.java.RemoveUserProfileCacheAll(ctx, record.UserID)
return nil
}
useProps := resourceType != resourceTypeGift
if err := s.java.GivePropsBackpack(ctx, integration.GivePropsBackpackRequest{
AcceptUserID: record.UserID,
PropsID: resourceID,
Type: resourceType,
Origin: walletOrigin,
OriginDesc: walletOriginDesc,
Days: days,
UseProps: useProps,
AllowGive: false,
}); err != nil {
return err
}
if useProps {
if err := s.java.SwitchUseProps(ctx, record.UserID, resourceID); err != nil {
return err
}
_ = s.java.RemoveUserProfileCacheAll(ctx, record.UserID)
}
return nil
}
func (s *Service) markDrawRecords(ctx context.Context, drawNo string, status string, message string) error {
return s.db.WithContext(ctx).Model(&model.WheelDrawRecord{}).
Where("draw_no = ?", drawNo).
Updates(map[string]any{
"status": status,
"error_message": trimErrorMessage(message),
"update_time": time.Now(),
}).Error
}
func (s *Service) markRecordStatus(ctx context.Context, id int64, status string, message string) error {
return s.db.WithContext(ctx).Model(&model.WheelDrawRecord{}).
Where("id = ?", id).
Updates(map[string]any{
"status": status,
"error_message": trimErrorMessage(message),
"update_time": time.Now(),
}).Error
}
func buildDrawResponse(
drawNo string,
userID int64,
sysOrigin string,
roomID int64,
category string,
times int,
paidGold int64,
balanceAfter int64,
records []model.WheelDrawRecord,
) *DrawResponse {
payloadRecords := make([]DrawRecordPayload, 0, len(records))
for _, record := range records {
payloadRecords = append(payloadRecords, drawRecordPayload(record))
}
rewards, rewardValue := aggregateRewards(records)
return &DrawResponse{
DrawNo: drawNo,
UserID: userID,
SysOrigin: sysOrigin,
RoomID: roomID,
Category: category,
DrawTimes: times,
PaidGold: paidGold,
RewardValue: rewardValue,
Rewards: rewards,
Records: payloadRecords,
BalanceAfter: balanceAfter,
}
}
func aggregateRewards(records []model.WheelDrawRecord) ([]DrawRewardPayload, int64) {
type bucket struct {
payload DrawRewardPayload
}
buckets := map[string]*bucket{}
order := make([]string, 0, len(records))
var total int64
for _, record := range records {
value := record.GoldAmount
if record.RewardType != rewardTypeGold {
value = record.DisplayGoldAmount
}
total += value
key := fmt.Sprintf("%s:%d:%s:%d:%d", record.RewardType, int64PtrValue(record.ResourceID), record.ResourceType, record.GoldAmount, record.DisplayGoldAmount)
if _, exists := buckets[key]; !exists {
name := strings.TrimSpace(record.ResourceName)
if name == "" && record.RewardType == rewardTypeGold {
name = "金币"
}
buckets[key] = &bucket{payload: DrawRewardPayload{
RewardType: record.RewardType,
ResourceID: resourceIDValue(record.ResourceID),
ResourceType: record.ResourceType,
ResourceName: record.ResourceName,
ResourceURL: record.ResourceURL,
CoverURL: record.CoverURL,
DurationDays: record.DurationDays,
DisplayGoldAmount: record.DisplayGoldAmount,
GoldAmount: record.GoldAmount,
Probability: record.Probability,
Name: name,
Value: value,
}}
order = append(order, key)
}
buckets[key].payload.Count++
}
result := make([]DrawRewardPayload, 0, len(order))
for _, key := range order {
result = append(result, buckets[key].payload)
}
return result, total
}
func int64PtrValue(value *int64) int64 {
if value == nil {
return 0
}
return *value
}
func mapWalletError(code string, err error) *AppError {
message := err.Error()
normalized := strings.ToLower(message)
if strings.Contains(normalized, "insufficient_balance") ||
strings.Contains(normalized, "balance not made") ||
strings.Contains(normalized, `"errorcode":5000`) {
return NewAppError(http.StatusNotAcceptable, "wheel_insufficient_balance", "insufficient balance")
}
return NewAppError(http.StatusBadGateway, code, message)
}

View File

@ -0,0 +1,58 @@
package wheel
import (
"math"
"testing"
"chatapp3-golang/internal/model"
)
func TestPickRewardDistribution1000000Draws(t *testing.T) {
const (
draws = 1000000
maxAllowedDriftPercent = 1.0
)
rewards := []model.WheelRewardConfig{
{ID: 1, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Coral Crown", Probability: 3000},
{ID: 2, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Aurora Wing", Probability: 2200},
{ID: 3, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Moon Card", Probability: 1500},
{ID: 4, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Snow Pet", Probability: 1200},
{ID: 5, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Love Banner", Probability: 900},
{ID: 6, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Emerald Ring", Probability: 600},
{ID: 7, Enabled: true, RewardType: rewardTypeResource, ResourceName: "Royal Chest", Probability: 400},
{ID: 8, Enabled: true, RewardType: rewardTypeGold, ResourceName: "Gold", Probability: 200},
}
counts := make(map[int64]int, len(rewards))
for index := 0; index < draws; index++ {
reward, err := pickReward(rewards)
if err != nil {
t.Fatalf("pickReward() error = %v", err)
}
counts[reward.ID]++
}
for _, reward := range rewards {
expectedPercent := float64(reward.Probability) / probabilityTotal * 100
actualPercent := float64(counts[reward.ID]) / draws * 100
driftPercent := math.Abs(actualPercent - expectedPercent)
t.Logf(
"reward=%s configured=%.2f%% actual=%.2f%% drift=%.2f%% hits=%d/%d",
reward.ResourceName,
expectedPercent,
actualPercent,
driftPercent,
counts[reward.ID],
draws,
)
if driftPercent > maxAllowedDriftPercent {
t.Fatalf(
"reward %s probability drift %.2f%% exceeds %.2f%%",
reward.ResourceName,
driftPercent,
maxAllowedDriftPercent,
)
}
}
}

View File

@ -0,0 +1,156 @@
package wheel
import (
"context"
"strings"
"chatapp3-golang/internal/model"
)
// PageRecords returns admin draw records.
func (s *Service) PageRecords(
ctx context.Context,
sysOrigin string,
category string,
userID int64,
status string,
cursor int,
limit int,
) (*RecordPageResponse, error) {
sysOrigin = normalizeSysOrigin(sysOrigin)
if sysOrigin == "" {
return nil, NewAppError(400, "bad_request", "sysOrigin is required")
}
cursor, limit = normalizeRecordPage(cursor, limit)
query := s.db.WithContext(ctx).Model(&model.WheelDrawRecord{}).
Where("sys_origin = ?", sysOrigin)
if strings.TrimSpace(category) != "" {
query = query.Where("category = ?", normalizeCategory(category))
}
if userID > 0 {
query = query.Where("user_id = ?", userID)
}
if status != "" {
query = query.Where("status = ?", status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, err
}
var rows []model.WheelDrawRecord
if err := query.Order("create_time desc, id desc").
Offset((cursor - 1) * limit).
Limit(limit).
Find(&rows).Error; err != nil {
return nil, err
}
records := make([]DrawRecordPayload, 0, len(rows))
for _, row := range rows {
records = append(records, drawRecordPayload(row))
}
return &RecordPageResponse{
Records: records,
Total: total,
Current: cursor,
Size: limit,
}, nil
}
// PageUserHistory returns app draw history for the authenticated user.
func (s *Service) PageUserHistory(ctx context.Context, user AuthUser, cursor int, limit int) (*RecordPageResponse, error) {
return s.PageRecords(ctx, user.SysOrigin, "", user.UserID, drawStatusSuccess, cursor, limit)
}
// Hints returns recent successful rewards for ticker displays.
func (s *Service) Hints(ctx context.Context, user AuthUser, category string, limit int) ([]DrawRewardPayload, error) {
if limit <= 0 {
limit = 20
}
if limit > 50 {
limit = 50
}
query := s.db.WithContext(ctx).
Where("sys_origin = ? AND status = ?", normalizeSysOrigin(user.SysOrigin), drawStatusSuccess).
Order("create_time desc, id desc").
Limit(limit)
if strings.TrimSpace(category) != "" {
query = query.Where("category = ?", normalizeCategory(category))
}
var rows []model.WheelDrawRecord
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
rewards := make([]DrawRewardPayload, 0, len(rows))
for _, row := range rows {
name := row.ResourceName
if name == "" && row.RewardType == rewardTypeGold {
name = "金币"
}
rewards = append(rewards, DrawRewardPayload{
RewardType: row.RewardType,
ResourceID: resourceIDValue(row.ResourceID),
ResourceType: row.ResourceType,
ResourceName: row.ResourceName,
ResourceURL: row.ResourceURL,
CoverURL: row.CoverURL,
DurationDays: row.DurationDays,
DisplayGoldAmount: row.DisplayGoldAmount,
GoldAmount: row.GoldAmount,
Probability: row.Probability,
Count: 1,
Name: name,
Value: displayValueForRecord(row),
})
}
return rewards, nil
}
// WalletBalances returns current gold balance in the shape needed by the H5 compatibility API.
func (s *Service) WalletBalances(ctx context.Context, user AuthUser) (int64, error) {
if s.java == nil {
return 0, nil
}
balances, err := s.java.MapGoldBalance(ctx, []int64{user.UserID})
if err != nil {
return 0, err
}
return balances[user.UserID], nil
}
func drawRecordPayload(row model.WheelDrawRecord) DrawRecordPayload {
return DrawRecordPayload{
ID: row.ID,
DrawNo: row.DrawNo,
EventID: row.EventID,
UserID: row.UserID,
SysOrigin: row.SysOrigin,
Category: row.Category,
DrawTimes: row.DrawTimes,
PaidGold: row.PaidGold,
RewardType: row.RewardType,
ResourceID: resourceIDValue(row.ResourceID),
ResourceType: row.ResourceType,
ResourceName: row.ResourceName,
ResourceURL: row.ResourceURL,
CoverURL: row.CoverURL,
DurationDays: row.DurationDays,
DisplayGoldAmount: row.DisplayGoldAmount,
GoldAmount: row.GoldAmount,
Probability: row.Probability,
Status: row.Status,
ErrorMessage: row.ErrorMessage,
CreateTime: formatDateTime(row.CreateTime),
UpdateTime: formatDateTime(row.UpdateTime),
}
}
func displayValueForRecord(row model.WheelDrawRecord) int64 {
if normalizeRewardType(row.RewardType) == rewardTypeGold {
return row.GoldAmount
}
return row.DisplayGoldAmount
}

View File

@ -0,0 +1,429 @@
package wheel
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"gorm.io/gorm"
)
const (
probabilityTotal = 10000
defaultTimezone = "Asia/Riyadh"
categoryClassic = "CLASSIC"
categoryLuxury = "LUXURY"
categoryAdvanced = "ADVANCED"
rewardTypeResource = "RESOURCE"
rewardTypeGold = "GOLD"
resourceTypeBadge = "BADGE"
resourceTypeRoomBadge = "ROOM_BADGE"
resourceTypeGift = "GIFT"
drawStatusPending = "PENDING"
drawStatusSuccess = "SUCCESS"
drawStatusFailed = "FAILED"
walletReceiptIncome = "INCOME"
walletReceiptExpenditure = "EXPENDITURE"
walletOrigin = "YUMI_WHEEL"
walletOriginDesc = "Yumi wheel"
)
var wheelCategories = []CategoryMeta{
{Category: categoryClassic, Label: "Classic", RewardCount: 8, Sort: 1, DefaultPriceOneGold: 500, DefaultPriceTenGold: 5000, DefaultPriceFiftyGold: 25000},
{Category: categoryLuxury, Label: "Luxury", RewardCount: 8, Sort: 2, DefaultPriceOneGold: 2000, DefaultPriceTenGold: 20000, DefaultPriceFiftyGold: 100000},
{Category: categoryAdvanced, Label: "Advanced", RewardCount: 12, Sort: 3, DefaultPriceOneGold: 10000, DefaultPriceTenGold: 100000, DefaultPriceFiftyGold: 500000},
}
type AppError = common.AppError
type AuthUser = common.AuthUser
var NewAppError = common.NewAppError
// ResourceID preserves Java int64 ids across JSON boundaries by emitting strings.
type ResourceID int64
func (id ResourceID) Int64() int64 {
return int64(id)
}
func (id ResourceID) MarshalJSON() ([]byte, error) {
if id <= 0 {
return []byte("null"), nil
}
return []byte(strconv.Quote(strconv.FormatInt(int64(id), 10))), nil
}
func (id *ResourceID) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
if raw == "" || raw == "null" {
*id = 0
return nil
}
if strings.HasPrefix(raw, "\"") {
unquoted, err := strconv.Unquote(raw)
if err != nil {
return err
}
raw = strings.TrimSpace(unquoted)
}
if raw == "" {
*id = 0
return nil
}
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return err
}
*id = ResourceID(parsed)
return nil
}
type wheelDB interface {
WithContext(ctx context.Context) *gorm.DB
}
type wheelGateway interface {
ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error
GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) 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
MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error)
GetRoomProfileByUserID(ctx context.Context, userID int64) (integration.RoomProfile, error)
}
// Service owns resident wheel configuration, draw, and record APIs.
type Service struct {
cfg config.Config
db wheelDB
java wheelGateway
}
// NewService creates a resident wheel service.
func NewService(cfg config.Config, db wheelDB, javaGateway wheelGateway) *Service {
return &Service{cfg: cfg, db: db, java: javaGateway}
}
// CategoryMeta describes a fixed wheel category.
type CategoryMeta struct {
Category string `json:"category"`
Label string `json:"label"`
RewardCount int `json:"rewardCount"`
Sort int `json:"sort"`
DefaultPriceOneGold int64 `json:"defaultPriceOneGold"`
DefaultPriceTenGold int64 `json:"defaultPriceTenGold"`
DefaultPriceFiftyGold int64 `json:"defaultPriceFiftyGold"`
}
// SaveConfigRequest is the admin payload for saving wheel settings.
type SaveConfigRequest struct {
SysOrigin string `json:"sysOrigin"`
Enabled bool `json:"enabled"`
Timezone string `json:"timezone"`
Categories []CategoryConfigInput `json:"categories"`
}
// SaveCategoryConfigRequest saves one wheel category independently.
type SaveCategoryConfigRequest struct {
SysOrigin string `json:"sysOrigin"`
Timezone string `json:"timezone"`
Category CategoryConfigInput `json:"category"`
}
// CategoryConfigInput is the admin payload for one wheel category.
type CategoryConfigInput struct {
Category string `json:"category"`
Enabled bool `json:"enabled"`
PriceOneGold int64 `json:"priceOneGold"`
PriceTenGold int64 `json:"priceTenGold"`
PriceFiftyGold int64 `json:"priceFiftyGold"`
Rewards []RewardConfigInput `json:"rewards"`
}
// RewardConfigInput is one wheel reward row submitted by admin.
type RewardConfigInput struct {
ID int64 `json:"id"`
Enabled bool `json:"enabled"`
Sort int `json:"sort"`
RewardType string `json:"rewardType"`
ResourceID ResourceID `json:"resourceId"`
ResourceType string `json:"resourceType"`
ResourceName string `json:"resourceName"`
ResourceURL string `json:"resourceUrl"`
CoverURL string `json:"coverUrl"`
DurationDays int `json:"durationDays"`
DisplayGoldAmount int64 `json:"displayGoldAmount"`
GoldAmount int64 `json:"goldAmount"`
Probability int `json:"probability"`
}
// ConfigResponse is returned to admin and app config readers.
type ConfigResponse struct {
Configured bool `json:"configured"`
ID int64 `json:"id"`
SysOrigin string `json:"sysOrigin"`
Enabled bool `json:"enabled"`
Timezone string `json:"timezone"`
ProbabilityTotal int `json:"probabilityTotal"`
Categories []CategoryConfigPayload `json:"categories"`
UpdateTime string `json:"updateTime,omitempty"`
}
// CategoryConfigPayload is one wheel category exposed to admin and app clients.
type CategoryConfigPayload struct {
Category string `json:"category"`
Label string `json:"label"`
Enabled bool `json:"enabled"`
RewardCount int `json:"rewardCount"`
PriceOneGold int64 `json:"priceOneGold"`
PriceTenGold int64 `json:"priceTenGold"`
PriceFiftyGold int64 `json:"priceFiftyGold"`
ProbabilityTotal int `json:"probabilityTotal"`
Rewards []RewardConfigPayload `json:"rewards"`
}
// RewardConfigPayload is one configured wheel reward exposed to clients.
type RewardConfigPayload struct {
ID int64 `json:"id"`
Enabled bool `json:"enabled"`
Sort int `json:"sort"`
RewardType string `json:"rewardType"`
ResourceID ResourceID `json:"resourceId,omitempty"`
ResourceType string `json:"resourceType,omitempty"`
ResourceName string `json:"resourceName,omitempty"`
ResourceURL string `json:"resourceUrl,omitempty"`
CoverURL string `json:"coverUrl,omitempty"`
DurationDays int `json:"durationDays,omitempty"`
DisplayGoldAmount int64 `json:"displayGoldAmount,omitempty"`
GoldAmount int64 `json:"goldAmount,omitempty"`
Probability int `json:"probability"`
ProbabilityPercent string `json:"probabilityPercent"`
}
// DrawRequest is an app draw request.
type DrawRequest struct {
Category string `json:"category"`
Times int `json:"times"`
}
// DrawResponse is the app draw result.
type DrawResponse struct {
DrawNo string `json:"drawNo"`
UserID int64 `json:"userId"`
SysOrigin string `json:"sysOrigin"`
RoomID int64 `json:"roomId,string,omitempty"`
Category string `json:"category"`
DrawTimes int `json:"drawTimes"`
PaidGold int64 `json:"paidGold"`
RewardValue int64 `json:"rewardValue"`
Rewards []DrawRewardPayload `json:"rewards"`
Records []DrawRecordPayload `json:"records"`
BalanceAfter int64 `json:"balanceAfter,omitempty"`
}
// DrawRewardPayload aggregates identical rewards in one draw response.
type DrawRewardPayload struct {
RewardType string `json:"rewardType"`
ResourceID ResourceID `json:"resourceId,omitempty"`
ResourceType string `json:"resourceType,omitempty"`
ResourceName string `json:"resourceName,omitempty"`
ResourceURL string `json:"resourceUrl,omitempty"`
CoverURL string `json:"coverUrl,omitempty"`
DurationDays int `json:"durationDays,omitempty"`
DisplayGoldAmount int64 `json:"displayGoldAmount,omitempty"`
GoldAmount int64 `json:"goldAmount,omitempty"`
Probability int `json:"probability,omitempty"`
Count int `json:"count"`
Name string `json:"name,omitempty"`
Value int64 `json:"value,omitempty"`
}
// DrawRecordPayload is one draw record row.
type DrawRecordPayload struct {
ID int64 `json:"id"`
DrawNo string `json:"drawNo"`
EventID string `json:"eventId,omitempty"`
UserID int64 `json:"userId"`
SysOrigin string `json:"sysOrigin"`
Category string `json:"category"`
DrawTimes int `json:"drawTimes"`
PaidGold int64 `json:"paidGold"`
RewardType string `json:"rewardType"`
ResourceID ResourceID `json:"resourceId,omitempty"`
ResourceType string `json:"resourceType,omitempty"`
ResourceName string `json:"resourceName,omitempty"`
ResourceURL string `json:"resourceUrl,omitempty"`
CoverURL string `json:"coverUrl,omitempty"`
DurationDays int `json:"durationDays,omitempty"`
DisplayGoldAmount int64 `json:"displayGoldAmount,omitempty"`
GoldAmount int64 `json:"goldAmount,omitempty"`
Probability int `json:"probability"`
Status string `json:"status"`
ErrorMessage string `json:"errorMessage,omitempty"`
CreateTime string `json:"createTime,omitempty"`
UpdateTime string `json:"updateTime,omitempty"`
}
// RecordPageResponse is an admin record page.
type RecordPageResponse struct {
Records []DrawRecordPayload `json:"records"`
Total int64 `json:"total"`
Current int `json:"current"`
Size int `json:"size"`
}
func normalizeSysOrigin(sysOrigin string) string {
return strings.ToUpper(strings.TrimSpace(sysOrigin))
}
func normalizeTimezone(timezone string) string {
timezone = strings.TrimSpace(timezone)
if timezone == "" {
return defaultTimezone
}
return timezone
}
func normalizeCategory(category string) string {
switch strings.ToUpper(strings.TrimSpace(category)) {
case "", "LUCKY-BOX", "LUCKY_BOX", "CLASSIC":
return categoryClassic
case "MIDDLE", "MIDDLE-LUCKY-BOX", "MIDDLE_LUCKY_BOX", "LUXURY":
return categoryLuxury
case "ADVANCED", "ADVANCED-LUCKY-BOX", "ADVANCED_LUCKY_BOX":
return categoryAdvanced
default:
return strings.ToUpper(strings.TrimSpace(category))
}
}
func categoryMeta(category string) (CategoryMeta, bool) {
category = normalizeCategory(category)
for _, item := range wheelCategories {
if item.Category == category {
return item, true
}
}
return CategoryMeta{}, false
}
func categoryMetaMap() map[string]CategoryMeta {
result := make(map[string]CategoryMeta, len(wheelCategories))
for _, item := range wheelCategories {
result[item.Category] = item
}
return result
}
func normalizeRewardType(rewardType string) string {
switch strings.ToUpper(strings.TrimSpace(rewardType)) {
case "RESOURCE", "RESOURCE_GROUP", "PROPS":
return rewardTypeResource
case rewardTypeGold:
return rewardTypeGold
default:
return strings.ToUpper(strings.TrimSpace(rewardType))
}
}
func formatDateTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("2006-01-02 15:04:05")
}
func trimErrorMessage(message string) string {
message = strings.TrimSpace(message)
if len(message) <= 1024 {
return message
}
return message[:1024]
}
func resourceIDPtr(value ResourceID) *int64 {
if value.Int64() <= 0 {
return nil
}
id := value.Int64()
return &id
}
func resourceIDValue(value *int64) ResourceID {
if value == nil || *value <= 0 {
return 0
}
return ResourceID(*value)
}
func ParseUserID(value string) int64 {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
parsed, _ := strconv.ParseInt(value, 10, 64)
return parsed
}
func probabilityPercent(probability int) string {
if probability <= 0 {
return "0.00"
}
return fmt.Sprintf("%.2f", float64(probability)/100)
}
func normalizeDrawTimes(times int) (int, error) {
switch times {
case 0:
return 1, nil
case 1, 10, 50:
return times, nil
default:
return 0, NewAppError(http.StatusBadRequest, "invalid_draw_times", "times must be 1, 10, or 50")
}
}
func normalizeRecordPage(cursor int, limit int) (int, int) {
if cursor <= 0 {
cursor = 1
}
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
return cursor, limit
}
func sortRewardInputs(rewards []RewardConfigInput) {
sort.SliceStable(rewards, func(i, j int) bool {
if rewards[i].Sort == rewards[j].Sort {
return rewards[i].ID < rewards[j].ID
}
return rewards[i].Sort < rewards[j].Sort
})
}
func mustJSONString(value any) string {
data, err := json.Marshal(value)
if err != nil {
return "{}"
}
return string(data)
}

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,82 @@
CREATE TABLE IF NOT EXISTS `wheel_config` (
`id` bigint NOT NULL COMMENT '主键ID',
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
`enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用',
`timezone` varchar(64) NOT NULL DEFAULT 'Asia/Riyadh' COMMENT '业务时区',
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_wheel_config_sys_origin` (`sys_origin`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻活动转盘配置';
CREATE TABLE IF NOT EXISTS `wheel_pool_config` (
`id` bigint NOT NULL COMMENT '主键ID',
`config_id` bigint NOT NULL COMMENT '主配置ID',
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
`category` varchar(32) NOT NULL COMMENT '转盘分类 CLASSIC/LUXURY/ADVANCED',
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用',
`price_one_gold` bigint NOT NULL DEFAULT '0' COMMENT '抽1次价格金币',
`price_ten_gold` bigint NOT NULL DEFAULT '0' COMMENT '抽10次价格金币',
`price_fifty_gold` bigint NOT NULL DEFAULT '0' COMMENT '抽50次价格金币',
`sort` int NOT NULL DEFAULT '0' COMMENT '排序',
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_wheel_pool_config` (`config_id`, `category`),
KEY `idx_wheel_pool_sys_origin` (`sys_origin`, `category`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻活动转盘分类配置';
CREATE TABLE IF NOT EXISTS `wheel_reward_config` (
`id` bigint NOT NULL COMMENT '主键ID',
`config_id` bigint NOT NULL COMMENT '主配置ID',
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
`category` varchar(32) NOT NULL COMMENT '转盘分类 CLASSIC/LUXURY/ADVANCED',
`reward_type` varchar(32) NOT NULL COMMENT '奖励类型 RESOURCE/GOLD',
`resource_id` bigint DEFAULT NULL COMMENT '资源ID',
`resource_type` varchar(64) NOT NULL DEFAULT '' COMMENT '资源类型',
`resource_name` varchar(255) NOT NULL DEFAULT '' COMMENT '资源名称快照',
`resource_url` varchar(512) NOT NULL DEFAULT '' COMMENT '资源动效URL快照',
`cover_url` varchar(512) NOT NULL DEFAULT '' COMMENT '封面图',
`duration_days` int NOT NULL DEFAULT '0' COMMENT '资源有效天数',
`display_gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '资源奖励前端展示金币价格',
`gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '金币数量',
`probability` int NOT NULL DEFAULT '0' COMMENT '中奖概率,万分比',
`sort` int NOT NULL DEFAULT '0' COMMENT '排序',
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用',
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_wheel_reward_config` (`config_id`, `category`, `sort`),
KEY `idx_wheel_reward_enabled` (`sys_origin`, `category`, `enabled`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻活动转盘奖励配置';
CREATE TABLE IF NOT EXISTS `wheel_draw_record` (
`id` bigint NOT NULL COMMENT '主键ID',
`draw_no` varchar(64) NOT NULL COMMENT '抽奖单号',
`event_id` varchar(128) NOT NULL COMMENT '奖励发放幂等事件ID',
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
`category` varchar(32) NOT NULL COMMENT '转盘分类 CLASSIC/LUXURY/ADVANCED',
`user_id` bigint NOT NULL COMMENT '用户ID',
`draw_times` int NOT NULL DEFAULT '1' COMMENT '本次抽奖次数',
`paid_gold` bigint NOT NULL DEFAULT '0' COMMENT '本次消耗金币',
`reward_type` varchar(32) NOT NULL COMMENT '奖励类型 RESOURCE/GOLD',
`resource_id` bigint DEFAULT NULL COMMENT '资源ID快照',
`resource_type` varchar(64) NOT NULL DEFAULT '' COMMENT '资源类型快照',
`resource_name` varchar(255) NOT NULL DEFAULT '' COMMENT '资源名称快照',
`resource_url` varchar(512) NOT NULL DEFAULT '' COMMENT '资源动效URL快照',
`cover_url` varchar(512) NOT NULL DEFAULT '' COMMENT '封面图快照',
`duration_days` int NOT NULL DEFAULT '0' COMMENT '资源有效天数快照',
`display_gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '资源奖励前端展示金币价格快照',
`gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '金币数量快照',
`probability` int NOT NULL DEFAULT '0' COMMENT '概率快照,万分比',
`status` varchar(32) NOT NULL DEFAULT 'PENDING' COMMENT '状态 PENDING/SUCCESS/FAILED',
`error_message` varchar(1024) NOT NULL DEFAULT '' COMMENT '失败原因',
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_wheel_draw_record_event` (`event_id`),
KEY `idx_wheel_draw_record_draw_no` (`draw_no`),
KEY `idx_wheel_draw_record_category` (`category`),
KEY `idx_wheel_draw_record_user_time` (`sys_origin`, `user_id`, `create_time`),
KEY `idx_wheel_draw_record_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻活动转盘抽奖记录';

View File

@ -0,0 +1,81 @@
CREATE TABLE IF NOT EXISTS `smash_egg_config` (
`id` bigint NOT NULL COMMENT '主键ID',
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
`enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用',
`timezone` varchar(64) NOT NULL DEFAULT 'Asia/Riyadh' COMMENT '业务时区',
`rtp_basis_points` int NOT NULL DEFAULT '9500' COMMENT '目标RTP万分比',
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_smash_egg_config_sys_origin` (`sys_origin`),
KEY `idx_smash_egg_config_enabled` (`enabled`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻活动砸蛋配置';
CREATE TABLE IF NOT EXISTS `smash_egg_draw_option_config` (
`id` bigint NOT NULL COMMENT '主键ID',
`config_id` bigint NOT NULL COMMENT '主配置ID',
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
`option_key` varchar(32) NOT NULL DEFAULT '' COMMENT '按钮标识',
`label` varchar(64) NOT NULL DEFAULT '' COMMENT '按钮文案',
`times` int NOT NULL DEFAULT '1' COMMENT '抽奖次数',
`price_gold` bigint NOT NULL DEFAULT '0' COMMENT '该按钮消耗金币',
`sort` int NOT NULL DEFAULT '0' COMMENT '排序',
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用',
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_smash_egg_option_config` (`config_id`, `sort`),
KEY `idx_smash_egg_option_enabled` (`sys_origin`, `enabled`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻活动砸蛋按钮配置';
CREATE TABLE IF NOT EXISTS `smash_egg_reward_config` (
`id` bigint NOT NULL COMMENT '主键ID',
`config_id` bigint NOT NULL COMMENT '主配置ID',
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
`reward_type` varchar(32) NOT NULL COMMENT '奖励类型 RESOURCE_GROUP/GOLD',
`reward_group_id` bigint DEFAULT NULL COMMENT '资源组ID',
`reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '资源组名称快照',
`cover_url` varchar(512) NOT NULL DEFAULT '' COMMENT '封面图',
`animation_url` varchar(512) NOT NULL DEFAULT '' COMMENT '动态图',
`duration_days` int NOT NULL DEFAULT '0' COMMENT '资源组展示天数',
`gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '金币数量',
`reward_value_gold` bigint NOT NULL DEFAULT '0' COMMENT '奖品折算金币价值用于RTP计算',
`probability` int NOT NULL DEFAULT '0' COMMENT '中奖概率100%=1000000',
`sort` int NOT NULL DEFAULT '0' COMMENT '排序',
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用',
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_smash_egg_reward_config` (`config_id`, `sort`),
KEY `idx_smash_egg_reward_enabled` (`sys_origin`, `enabled`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻活动砸蛋奖励配置';
CREATE TABLE IF NOT EXISTS `smash_egg_draw_record` (
`id` bigint NOT NULL COMMENT '主键ID',
`draw_no` varchar(64) NOT NULL COMMENT '抽奖单号',
`event_id` varchar(128) NOT NULL COMMENT '奖励发放幂等事件ID',
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
`user_id` bigint NOT NULL COMMENT '用户ID',
`draw_option_id` bigint NOT NULL DEFAULT '0' COMMENT '按钮配置ID',
`draw_times` int NOT NULL DEFAULT '1' COMMENT '本次抽奖次数',
`paid_gold` bigint NOT NULL DEFAULT '0' COMMENT '本次消耗金币',
`reward_type` varchar(32) NOT NULL COMMENT '奖励类型 RESOURCE_GROUP/GOLD',
`reward_group_id` bigint DEFAULT NULL COMMENT '资源组ID',
`reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '资源组名称快照',
`cover_url` varchar(512) NOT NULL DEFAULT '' COMMENT '封面图快照',
`animation_url` varchar(512) NOT NULL DEFAULT '' COMMENT '动态图快照',
`duration_days` int NOT NULL DEFAULT '0' COMMENT '天数快照',
`gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '金币数量快照',
`reward_value_gold` bigint NOT NULL DEFAULT '0' COMMENT '奖品折算金币价值',
`probability` int NOT NULL DEFAULT '0' COMMENT '概率快照100%=1000000',
`status` varchar(32) NOT NULL DEFAULT 'PENDING' COMMENT '状态 PENDING/SUCCESS/FAILED',
`error_message` varchar(1024) NOT NULL DEFAULT '' COMMENT '失败原因',
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_smash_egg_draw_record_event` (`event_id`),
KEY `idx_smash_egg_draw_record_draw_no` (`draw_no`),
KEY `idx_smash_egg_draw_record_user_time` (`sys_origin`, `user_id`, `create_time`),
KEY `idx_smash_egg_draw_record_day` (`sys_origin`, `create_time`),
KEY `idx_smash_egg_draw_record_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='常驻活动砸蛋抽奖记录';

View File

@ -0,0 +1,121 @@
SET @current_schema := DATABASE();
SET @add_newbie_pool_enabled_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'smash_egg_config'
AND column_name = 'newbie_pool_enabled'
),
'ALTER TABLE `smash_egg_config` ADD COLUMN `newbie_pool_enabled` tinyint(1) NOT NULL DEFAULT 0 COMMENT ''是否启用新人奖池'' AFTER `rtp_basis_points`',
'SELECT 1'
);
PREPARE add_newbie_pool_enabled_stmt FROM @add_newbie_pool_enabled_sql;
EXECUTE add_newbie_pool_enabled_stmt;
DEALLOCATE PREPARE add_newbie_pool_enabled_stmt;
SET @add_newbie_window_days_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'smash_egg_config'
AND column_name = 'newbie_window_days'
),
'ALTER TABLE `smash_egg_config` ADD COLUMN `newbie_window_days` int NOT NULL DEFAULT 7 COMMENT ''新人资格窗口天数'' AFTER `newbie_pool_enabled`',
'SELECT 1'
);
PREPARE add_newbie_window_days_stmt FROM @add_newbie_window_days_sql;
EXECUTE add_newbie_window_days_stmt;
DEALLOCATE PREPARE add_newbie_window_days_stmt;
SET @add_newbie_max_draw_count_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'smash_egg_config'
AND column_name = 'newbie_max_draw_count'
),
'ALTER TABLE `smash_egg_config` ADD COLUMN `newbie_max_draw_count` int NOT NULL DEFAULT 3 COMMENT ''每个用户最多使用新人奖池的抽奖请求次数'' AFTER `newbie_window_days`',
'SELECT 1'
);
PREPARE add_newbie_max_draw_count_stmt FROM @add_newbie_max_draw_count_sql;
EXECUTE add_newbie_max_draw_count_stmt;
DEALLOCATE PREPARE add_newbie_max_draw_count_stmt;
SET @add_newbie_min_recharge_amount_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'smash_egg_config'
AND column_name = 'newbie_min_recharge_amount'
),
'ALTER TABLE `smash_egg_config` ADD COLUMN `newbie_min_recharge_amount` bigint NOT NULL DEFAULT 0 COMMENT ''新人奖池最低累计充值金额0表示不限制'' AFTER `newbie_max_draw_count`',
'SELECT 1'
);
PREPARE add_newbie_min_recharge_amount_stmt FROM @add_newbie_min_recharge_amount_sql;
EXECUTE add_newbie_min_recharge_amount_stmt;
DEALLOCATE PREPARE add_newbie_min_recharge_amount_stmt;
SET @add_reward_pool_type_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'smash_egg_reward_config'
AND column_name = 'pool_type'
),
'ALTER TABLE `smash_egg_reward_config` ADD COLUMN `pool_type` varchar(32) NOT NULL DEFAULT ''GENERAL'' COMMENT ''奖池类型 GENERAL/NEWBIE'' AFTER `sys_origin`',
'SELECT 1'
);
PREPARE add_reward_pool_type_stmt FROM @add_reward_pool_type_sql;
EXECUTE add_reward_pool_type_stmt;
DEALLOCATE PREPARE add_reward_pool_type_stmt;
SET @add_draw_record_pool_type_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'smash_egg_draw_record'
AND column_name = 'pool_type'
),
'ALTER TABLE `smash_egg_draw_record` ADD COLUMN `pool_type` varchar(32) NOT NULL DEFAULT ''GENERAL'' COMMENT ''奖池类型 GENERAL/NEWBIE'' AFTER `user_id`',
'SELECT 1'
);
PREPARE add_draw_record_pool_type_stmt FROM @add_draw_record_pool_type_sql;
EXECUTE add_draw_record_pool_type_stmt;
DEALLOCATE PREPARE add_draw_record_pool_type_stmt;
SET @add_reward_pool_index_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.statistics
WHERE table_schema = @current_schema
AND table_name = 'smash_egg_reward_config'
AND index_name = 'idx_smash_egg_reward_pool'
),
'ALTER TABLE `smash_egg_reward_config` ADD KEY `idx_smash_egg_reward_pool` (`config_id`, `pool_type`, `enabled`, `sort`)',
'SELECT 1'
);
PREPARE add_reward_pool_index_stmt FROM @add_reward_pool_index_sql;
EXECUTE add_reward_pool_index_stmt;
DEALLOCATE PREPARE add_reward_pool_index_stmt;
SET @add_draw_user_pool_index_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.statistics
WHERE table_schema = @current_schema
AND table_name = 'smash_egg_draw_record'
AND index_name = 'idx_smash_egg_draw_record_user_pool'
),
'ALTER TABLE `smash_egg_draw_record` ADD KEY `idx_smash_egg_draw_record_user_pool` (`sys_origin`, `user_id`, `pool_type`, `status`, `create_time`)',
'SELECT 1'
);
PREPARE add_draw_user_pool_index_stmt FROM @add_draw_user_pool_index_sql;
EXECUTE add_draw_user_pool_index_stmt;
DEALLOCATE PREPARE add_draw_user_pool_index_stmt;

View File

@ -0,0 +1,91 @@
SET @current_schema := DATABASE();
SET @add_reward_resource_type_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'smash_egg_reward_config'
AND column_name = 'resource_type'
),
'ALTER TABLE `smash_egg_reward_config` ADD COLUMN `resource_type` varchar(64) NOT NULL DEFAULT '''' COMMENT ''资源类型'' AFTER `reward_group_name`',
'SELECT 1'
);
PREPARE add_reward_resource_type_stmt FROM @add_reward_resource_type_sql;
EXECUTE add_reward_resource_type_stmt;
DEALLOCATE PREPARE add_reward_resource_type_stmt;
SET @add_reward_resource_url_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'smash_egg_reward_config'
AND column_name = 'resource_url'
),
'ALTER TABLE `smash_egg_reward_config` ADD COLUMN `resource_url` varchar(512) NOT NULL DEFAULT '''' COMMENT ''资源动效/源文件URL'' AFTER `resource_type`',
'SELECT 1'
);
PREPARE add_reward_resource_url_stmt FROM @add_reward_resource_url_sql;
EXECUTE add_reward_resource_url_stmt;
DEALLOCATE PREPARE add_reward_resource_url_stmt;
SET @add_reward_display_gold_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'smash_egg_reward_config'
AND column_name = 'display_gold_amount'
),
'ALTER TABLE `smash_egg_reward_config` ADD COLUMN `display_gold_amount` bigint NOT NULL DEFAULT 0 COMMENT ''资源前端展示金币价格'' AFTER `duration_days`',
'SELECT 1'
);
PREPARE add_reward_display_gold_stmt FROM @add_reward_display_gold_sql;
EXECUTE add_reward_display_gold_stmt;
DEALLOCATE PREPARE add_reward_display_gold_stmt;
SET @add_record_resource_type_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'smash_egg_draw_record'
AND column_name = 'resource_type'
),
'ALTER TABLE `smash_egg_draw_record` ADD COLUMN `resource_type` varchar(64) NOT NULL DEFAULT '''' COMMENT ''资源类型快照'' AFTER `reward_group_name`',
'SELECT 1'
);
PREPARE add_record_resource_type_stmt FROM @add_record_resource_type_sql;
EXECUTE add_record_resource_type_stmt;
DEALLOCATE PREPARE add_record_resource_type_stmt;
SET @add_record_resource_url_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'smash_egg_draw_record'
AND column_name = 'resource_url'
),
'ALTER TABLE `smash_egg_draw_record` ADD COLUMN `resource_url` varchar(512) NOT NULL DEFAULT '''' COMMENT ''资源动效/源文件URL快照'' AFTER `resource_type`',
'SELECT 1'
);
PREPARE add_record_resource_url_stmt FROM @add_record_resource_url_sql;
EXECUTE add_record_resource_url_stmt;
DEALLOCATE PREPARE add_record_resource_url_stmt;
SET @add_record_display_gold_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'smash_egg_draw_record'
AND column_name = 'display_gold_amount'
),
'ALTER TABLE `smash_egg_draw_record` ADD COLUMN `display_gold_amount` bigint NOT NULL DEFAULT 0 COMMENT ''资源前端展示金币价格快照'' AFTER `duration_days`',
'SELECT 1'
);
PREPARE add_record_display_gold_stmt FROM @add_record_display_gold_sql;
EXECUTE add_record_display_gold_stmt;
DEALLOCATE PREPARE add_record_display_gold_stmt;

View File

@ -0,0 +1,189 @@
SET @current_schema := DATABASE();
SET @add_wheel_reward_resource_id_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'wheel_reward_config'
AND column_name = 'resource_id'
),
'ALTER TABLE `wheel_reward_config` ADD COLUMN `resource_id` bigint DEFAULT NULL COMMENT ''资源ID'' AFTER `reward_type`',
'SELECT 1'
);
PREPARE add_wheel_reward_resource_id_stmt FROM @add_wheel_reward_resource_id_sql;
EXECUTE add_wheel_reward_resource_id_stmt;
DEALLOCATE PREPARE add_wheel_reward_resource_id_stmt;
SET @add_wheel_reward_resource_type_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'wheel_reward_config'
AND column_name = 'resource_type'
),
'ALTER TABLE `wheel_reward_config` ADD COLUMN `resource_type` varchar(64) NOT NULL DEFAULT '''' COMMENT ''资源类型'' AFTER `resource_id`',
'SELECT 1'
);
PREPARE add_wheel_reward_resource_type_stmt FROM @add_wheel_reward_resource_type_sql;
EXECUTE add_wheel_reward_resource_type_stmt;
DEALLOCATE PREPARE add_wheel_reward_resource_type_stmt;
SET @add_wheel_reward_resource_name_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'wheel_reward_config'
AND column_name = 'resource_name'
),
'ALTER TABLE `wheel_reward_config` ADD COLUMN `resource_name` varchar(255) NOT NULL DEFAULT '''' COMMENT ''资源名称快照'' AFTER `resource_type`',
'SELECT 1'
);
PREPARE add_wheel_reward_resource_name_stmt FROM @add_wheel_reward_resource_name_sql;
EXECUTE add_wheel_reward_resource_name_stmt;
DEALLOCATE PREPARE add_wheel_reward_resource_name_stmt;
SET @add_wheel_reward_resource_url_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'wheel_reward_config'
AND column_name = 'resource_url'
),
'ALTER TABLE `wheel_reward_config` ADD COLUMN `resource_url` varchar(512) NOT NULL DEFAULT '''' COMMENT ''资源动效URL快照'' AFTER `resource_name`',
'SELECT 1'
);
PREPARE add_wheel_reward_resource_url_stmt FROM @add_wheel_reward_resource_url_sql;
EXECUTE add_wheel_reward_resource_url_stmt;
DEALLOCATE PREPARE add_wheel_reward_resource_url_stmt;
SET @add_wheel_reward_duration_days_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'wheel_reward_config'
AND column_name = 'duration_days'
),
'ALTER TABLE `wheel_reward_config` ADD COLUMN `duration_days` int NOT NULL DEFAULT 0 COMMENT ''资源有效天数'' AFTER `cover_url`',
'SELECT 1'
);
PREPARE add_wheel_reward_duration_days_stmt FROM @add_wheel_reward_duration_days_sql;
EXECUTE add_wheel_reward_duration_days_stmt;
DEALLOCATE PREPARE add_wheel_reward_duration_days_stmt;
SET @add_wheel_reward_display_gold_amount_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'wheel_reward_config'
AND column_name = 'display_gold_amount'
),
'ALTER TABLE `wheel_reward_config` ADD COLUMN `display_gold_amount` bigint NOT NULL DEFAULT 0 COMMENT ''资源奖励前端展示金币价格'' AFTER `duration_days`',
'SELECT 1'
);
PREPARE add_wheel_reward_display_gold_amount_stmt FROM @add_wheel_reward_display_gold_amount_sql;
EXECUTE add_wheel_reward_display_gold_amount_stmt;
DEALLOCATE PREPARE add_wheel_reward_display_gold_amount_stmt;
SET @add_wheel_draw_resource_id_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'wheel_draw_record'
AND column_name = 'resource_id'
),
'ALTER TABLE `wheel_draw_record` ADD COLUMN `resource_id` bigint DEFAULT NULL COMMENT ''资源ID快照'' AFTER `reward_type`',
'SELECT 1'
);
PREPARE add_wheel_draw_resource_id_stmt FROM @add_wheel_draw_resource_id_sql;
EXECUTE add_wheel_draw_resource_id_stmt;
DEALLOCATE PREPARE add_wheel_draw_resource_id_stmt;
SET @add_wheel_draw_resource_type_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'wheel_draw_record'
AND column_name = 'resource_type'
),
'ALTER TABLE `wheel_draw_record` ADD COLUMN `resource_type` varchar(64) NOT NULL DEFAULT '''' COMMENT ''资源类型快照'' AFTER `resource_id`',
'SELECT 1'
);
PREPARE add_wheel_draw_resource_type_stmt FROM @add_wheel_draw_resource_type_sql;
EXECUTE add_wheel_draw_resource_type_stmt;
DEALLOCATE PREPARE add_wheel_draw_resource_type_stmt;
SET @add_wheel_draw_resource_name_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'wheel_draw_record'
AND column_name = 'resource_name'
),
'ALTER TABLE `wheel_draw_record` ADD COLUMN `resource_name` varchar(255) NOT NULL DEFAULT '''' COMMENT ''资源名称快照'' AFTER `resource_type`',
'SELECT 1'
);
PREPARE add_wheel_draw_resource_name_stmt FROM @add_wheel_draw_resource_name_sql;
EXECUTE add_wheel_draw_resource_name_stmt;
DEALLOCATE PREPARE add_wheel_draw_resource_name_stmt;
SET @add_wheel_draw_resource_url_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'wheel_draw_record'
AND column_name = 'resource_url'
),
'ALTER TABLE `wheel_draw_record` ADD COLUMN `resource_url` varchar(512) NOT NULL DEFAULT '''' COMMENT ''资源动效URL快照'' AFTER `resource_name`',
'SELECT 1'
);
PREPARE add_wheel_draw_resource_url_stmt FROM @add_wheel_draw_resource_url_sql;
EXECUTE add_wheel_draw_resource_url_stmt;
DEALLOCATE PREPARE add_wheel_draw_resource_url_stmt;
SET @add_wheel_draw_duration_days_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'wheel_draw_record'
AND column_name = 'duration_days'
),
'ALTER TABLE `wheel_draw_record` ADD COLUMN `duration_days` int NOT NULL DEFAULT 0 COMMENT ''资源有效天数快照'' AFTER `cover_url`',
'SELECT 1'
);
PREPARE add_wheel_draw_duration_days_stmt FROM @add_wheel_draw_duration_days_sql;
EXECUTE add_wheel_draw_duration_days_stmt;
DEALLOCATE PREPARE add_wheel_draw_duration_days_stmt;
SET @add_wheel_draw_display_gold_amount_sql := IF(
NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @current_schema
AND table_name = 'wheel_draw_record'
AND column_name = 'display_gold_amount'
),
'ALTER TABLE `wheel_draw_record` ADD COLUMN `display_gold_amount` bigint NOT NULL DEFAULT 0 COMMENT ''资源奖励前端展示金币价格快照'' AFTER `duration_days`',
'SELECT 1'
);
PREPARE add_wheel_draw_display_gold_amount_stmt FROM @add_wheel_draw_display_gold_amount_sql;
EXECUTE add_wheel_draw_display_gold_amount_stmt;
DEALLOCATE PREPARE add_wheel_draw_display_gold_amount_stmt;
UPDATE `wheel_reward_config`
SET `reward_type` = 'RESOURCE'
WHERE `reward_type` = 'RESOURCE_GROUP';
UPDATE `wheel_draw_record`
SET `reward_type` = 'RESOURCE'
WHERE `reward_type` = 'RESOURCE_GROUP';

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