Compare commits
3 Commits
2bba927458
...
31433b0a1b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31433b0a1b | ||
|
|
51b8b52c57 | ||
|
|
dd9020a542 |
@ -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{
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"},
|
||||
|
||||
98
internal/model/smash_egg_models.go
Normal file
98
internal/model/smash_egg_models.go
Normal 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" }
|
||||
212
internal/model/voice_room_rocket_models.go
Normal file
212
internal/model/voice_room_rocket_models.go
Normal 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"
|
||||
}
|
||||
88
internal/model/wheel_models.go
Normal file
88
internal/model/wheel_models.go
Normal 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" }
|
||||
@ -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")
|
||||
|
||||
|
||||
243
internal/router/smash_egg_routes.go
Normal file
243
internal/router/smash_egg_routes.go
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
223
internal/router/voice_room_rocket_routes.go
Normal file
223
internal/router/voice_room_rocket_routes.go
Normal 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
|
||||
}
|
||||
389
internal/router/wheel_routes.go
Normal file
389
internal/router/wheel_routes.go
Normal 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"
|
||||
}
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
624
internal/service/smashegg/config.go
Normal file
624
internal/service/smashegg/config.go
Normal 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
|
||||
}
|
||||
186
internal/service/smashegg/config_test.go
Normal file
186
internal/service/smashegg/config_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
392
internal/service/smashegg/draw.go
Normal file
392
internal/service/smashegg/draw.go
Normal 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)
|
||||
}
|
||||
234
internal/service/smashegg/flow_integration_test.go
Normal file
234
internal/service/smashegg/flow_integration_test.go
Normal 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
|
||||
}
|
||||
273
internal/service/smashegg/legacy.go
Normal file
273
internal/service/smashegg/legacy.go
Normal 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
|
||||
}
|
||||
123
internal/service/smashegg/newbie.go
Normal file
123
internal/service/smashegg/newbie.go
Normal 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
|
||||
}
|
||||
243
internal/service/smashegg/probability.go
Normal file
243
internal/service/smashegg/probability.go
Normal 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
|
||||
}
|
||||
299
internal/service/smashegg/records.go
Normal file
299
internal/service/smashegg/records.go
Normal 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),
|
||||
}
|
||||
}
|
||||
103
internal/service/smashegg/records_test.go
Normal file
103
internal/service/smashegg/records_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
122
internal/service/smashegg/simulation_test.go
Normal file
122
internal/service/smashegg/simulation_test.go
Normal 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
|
||||
}
|
||||
483
internal/service/smashegg/types.go
Normal file
483
internal/service/smashegg/types.go
Normal 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)
|
||||
}
|
||||
461
internal/service/voiceroomrocket/config.go
Normal file
461
internal/service/voiceroomrocket/config.go
Normal 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
|
||||
}
|
||||
190
internal/service/voiceroomrocket/energy.go
Normal file
190
internal/service/voiceroomrocket/energy.go
Normal 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
|
||||
}
|
||||
544
internal/service/voiceroomrocket/event.go
Normal file
544
internal/service/voiceroomrocket/event.go
Normal 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
|
||||
}
|
||||
735
internal/service/voiceroomrocket/event_test.go
Normal file
735
internal/service/voiceroomrocket/event_test.go
Normal 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)
|
||||
}
|
||||
279
internal/service/voiceroomrocket/in_room_reward.go
Normal file
279
internal/service/voiceroomrocket/in_room_reward.go
Normal 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
|
||||
}
|
||||
142
internal/service/voiceroomrocket/launch_worker.go
Normal file
142
internal/service/voiceroomrocket/launch_worker.go
Normal 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"
|
||||
}
|
||||
223
internal/service/voiceroomrocket/lifecycle.go
Normal file
223
internal/service/voiceroomrocket/lifecycle.go
Normal 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
|
||||
}
|
||||
317
internal/service/voiceroomrocket/mapper.go
Normal file
317
internal/service/voiceroomrocket/mapper.go
Normal 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,
|
||||
}
|
||||
}
|
||||
270
internal/service/voiceroomrocket/notify.go
Normal file
270
internal/service/voiceroomrocket/notify.go
Normal 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)
|
||||
}
|
||||
68
internal/service/voiceroomrocket/presence.go
Normal file
68
internal/service/voiceroomrocket/presence.go
Normal 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)
|
||||
}
|
||||
142
internal/service/voiceroomrocket/ranking.go
Normal file
142
internal/service/voiceroomrocket/ranking.go
Normal 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)
|
||||
}
|
||||
142
internal/service/voiceroomrocket/records.go
Normal file
142
internal/service/voiceroomrocket/records.go
Normal 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
|
||||
}
|
||||
345
internal/service/voiceroomrocket/reward.go
Normal file
345
internal/service/voiceroomrocket/reward.go
Normal 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)
|
||||
}
|
||||
185
internal/service/voiceroomrocket/status.go
Normal file
185
internal/service/voiceroomrocket/status.go
Normal 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
|
||||
}
|
||||
497
internal/service/voiceroomrocket/types.go
Normal file
497
internal/service/voiceroomrocket/types.go
Normal 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"`
|
||||
}
|
||||
623
internal/service/wheel/config.go
Normal file
623
internal/service/wheel/config.go
Normal 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),
|
||||
}
|
||||
}
|
||||
173
internal/service/wheel/config_test.go
Normal file
173
internal/service/wheel/config_test.go
Normal 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
|
||||
}
|
||||
461
internal/service/wheel/draw.go
Normal file
461
internal/service/wheel/draw.go
Normal 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)
|
||||
}
|
||||
58
internal/service/wheel/probability_test.go
Normal file
58
internal/service/wheel/probability_test.go
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
156
internal/service/wheel/records.go
Normal file
156
internal/service/wheel/records.go
Normal 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
|
||||
}
|
||||
429
internal/service/wheel/types.go
Normal file
429
internal/service/wheel/types.go
Normal 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)
|
||||
}
|
||||
195
migrations/032_voice_room_rocket.sql
Normal file
195
migrations/032_voice_room_rocket.sql
Normal 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='语音房火箭在房间用户快照';
|
||||
82
migrations/033_resident_wheel.sql
Normal file
82
migrations/033_resident_wheel.sql
Normal 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='常驻活动转盘抽奖记录';
|
||||
81
migrations/034_resident_smash_egg.sql
Normal file
81
migrations/034_resident_smash_egg.sql
Normal 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='常驻活动砸蛋抽奖记录';
|
||||
121
migrations/035_resident_smash_egg_newbie_pool.sql
Normal file
121
migrations/035_resident_smash_egg_newbie_pool.sql
Normal 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;
|
||||
91
migrations/036_resident_smash_egg_resource_rewards.sql
Normal file
91
migrations/036_resident_smash_egg_resource_rewards.sql
Normal 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;
|
||||
189
migrations/036_wheel_resource_reward.sql
Normal file
189
migrations/036_wheel_resource_reward.sql
Normal 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';
|
||||
657
scripts/voice_room_rocket_live_verify.go
Normal file
657
scripts/voice_room_rocket_live_verify.go
Normal 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)
|
||||
}
|
||||
}
|
||||
422
scripts/voice_room_rocket_local_verify.go
Normal file
422
scripts/voice_room_rocket_local_verify.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user