Compare commits
No commits in common. "31433b0a1bfe9a9f6b09c0d75d4bbd9d141919f9" and "2bba927458a9133938bcfb727444be691dab89ba" have entirely different histories.
31433b0a1b
...
2bba927458
@ -28,13 +28,10 @@ import (
|
|||||||
"chatapp3-golang/internal/service/registerreward"
|
"chatapp3-golang/internal/service/registerreward"
|
||||||
"chatapp3-golang/internal/service/roomturnoverreward"
|
"chatapp3-golang/internal/service/roomturnoverreward"
|
||||||
"chatapp3-golang/internal/service/signinreward"
|
"chatapp3-golang/internal/service/signinreward"
|
||||||
"chatapp3-golang/internal/service/smashegg"
|
|
||||||
"chatapp3-golang/internal/service/taskcenter"
|
"chatapp3-golang/internal/service/taskcenter"
|
||||||
"chatapp3-golang/internal/service/vip"
|
"chatapp3-golang/internal/service/vip"
|
||||||
"chatapp3-golang/internal/service/voiceroomredpacket"
|
"chatapp3-golang/internal/service/voiceroomredpacket"
|
||||||
"chatapp3-golang/internal/service/voiceroomrocket"
|
|
||||||
"chatapp3-golang/internal/service/weekstar"
|
"chatapp3-golang/internal/service/weekstar"
|
||||||
"chatapp3-golang/internal/service/wheel"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -59,13 +56,9 @@ func main() {
|
|||||||
signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
vipService := vip.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)
|
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 := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room)
|
||||||
voiceRoomRedPacketService.SetRegionResolver(&app.Gateways)
|
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)
|
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)
|
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)
|
||||||
@ -86,9 +79,6 @@ func main() {
|
|||||||
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
|
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
|
||||||
log.Fatalf("start task center message consumer failed: %v", err)
|
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 {
|
if err := taskCenterService.StartArchiveWorker(workerCtx); err != nil {
|
||||||
log.Fatalf("start task center archive worker failed: %v", err)
|
log.Fatalf("start task center archive worker failed: %v", err)
|
||||||
}
|
}
|
||||||
@ -115,12 +105,9 @@ func main() {
|
|||||||
RegisterReward: registerRewardService,
|
RegisterReward: registerRewardService,
|
||||||
RoomTurnoverReward: roomTurnoverRewardService,
|
RoomTurnoverReward: roomTurnoverRewardService,
|
||||||
SignInReward: signInRewardService,
|
SignInReward: signInRewardService,
|
||||||
SmashEgg: smashEggService,
|
|
||||||
TaskCenter: taskCenterService,
|
TaskCenter: taskCenterService,
|
||||||
VIP: vipService,
|
VIP: vipService,
|
||||||
Wheel: wheelService,
|
|
||||||
VoiceRoomRedPacket: voiceRoomRedPacketService,
|
VoiceRoomRedPacket: voiceRoomRedPacketService,
|
||||||
VoiceRoomRocket: voiceRoomRocketService,
|
|
||||||
WeekStar: weekStarService,
|
WeekStar: weekStarService,
|
||||||
})
|
})
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import (
|
|||||||
"chatapp3-golang/internal/service/registerreward"
|
"chatapp3-golang/internal/service/registerreward"
|
||||||
"chatapp3-golang/internal/service/taskcenter"
|
"chatapp3-golang/internal/service/taskcenter"
|
||||||
"chatapp3-golang/internal/service/voiceroomredpacket"
|
"chatapp3-golang/internal/service/voiceroomredpacket"
|
||||||
"chatapp3-golang/internal/service/voiceroomrocket"
|
|
||||||
"chatapp3-golang/internal/service/weekstar"
|
"chatapp3-golang/internal/service/weekstar"
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
@ -30,8 +29,6 @@ func main() {
|
|||||||
regionIMGroupService := regionimgroup.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 := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room)
|
||||||
voiceRoomRedPacketService.SetRegionResolver(&app.Gateways)
|
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)
|
workerCtx, workerCancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer workerCancel()
|
defer workerCancel()
|
||||||
@ -45,9 +42,6 @@ func main() {
|
|||||||
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
|
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
|
||||||
log.Fatalf("start task center message consumer failed: %v", err)
|
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 {
|
if err := taskCenterService.StartArchiveWorker(workerCtx); err != nil {
|
||||||
log.Fatalf("start task center archive worker failed: %v", err)
|
log.Fatalf("start task center archive worker failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,6 @@ type Config struct {
|
|||||||
WeekStar WeekStarConfig
|
WeekStar WeekStarConfig
|
||||||
RoomTurnoverReward RoomTurnoverRewardConfig
|
RoomTurnoverReward RoomTurnoverRewardConfig
|
||||||
VoiceRoomRedPacket VoiceRoomRedPacketConfig
|
VoiceRoomRedPacket VoiceRoomRedPacketConfig
|
||||||
VoiceRoomRocket VoiceRoomRocketConfig
|
|
||||||
TencentIM TencentIMConfig
|
TencentIM TencentIMConfig
|
||||||
Baishun BaishunConfig
|
Baishun BaishunConfig
|
||||||
Lingxian LingxianConfig
|
Lingxian LingxianConfig
|
||||||
@ -159,35 +158,6 @@ type VoiceRoomRedPacketConfig struct {
|
|||||||
ExpireZSetKey string
|
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 配置。
|
// TencentIMConfig 保存腾讯 IM REST API 配置。
|
||||||
type TencentIMConfig struct {
|
type TencentIMConfig struct {
|
||||||
AppID int64
|
AppID int64
|
||||||
@ -260,9 +230,6 @@ func Load() Config {
|
|||||||
taskCenterMQEndpoint := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ENDPOINT", "TASK_CENTER_EVENT_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
|
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"}, "")
|
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"}, "")
|
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{
|
return Config{
|
||||||
HTTP: HTTPConfig{
|
HTTP: HTTPConfig{
|
||||||
@ -408,63 +375,6 @@ func Load() Config {
|
|||||||
"voice_room:red_packet:expire_zset",
|
"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{
|
TencentIM: TencentIMConfig{
|
||||||
AppID: getEnvInt64Any(
|
AppID: getEnvInt64Any(
|
||||||
[]string{"CHATAPP_TENCENT_IM_APP_ID", "TENCENT_IM_APP_ID", "LIKEI_IM_APP_ID"},
|
[]string{"CHATAPP_TENCENT_IM_APP_ID", "TENCENT_IM_APP_ID", "LIKEI_IM_APP_ID"},
|
||||||
|
|||||||
@ -1,98 +0,0 @@
|
|||||||
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" }
|
|
||||||
@ -1,212 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
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,13 +22,10 @@ import (
|
|||||||
"chatapp3-golang/internal/service/registerreward"
|
"chatapp3-golang/internal/service/registerreward"
|
||||||
"chatapp3-golang/internal/service/roomturnoverreward"
|
"chatapp3-golang/internal/service/roomturnoverreward"
|
||||||
"chatapp3-golang/internal/service/signinreward"
|
"chatapp3-golang/internal/service/signinreward"
|
||||||
"chatapp3-golang/internal/service/smashegg"
|
|
||||||
"chatapp3-golang/internal/service/taskcenter"
|
"chatapp3-golang/internal/service/taskcenter"
|
||||||
"chatapp3-golang/internal/service/vip"
|
"chatapp3-golang/internal/service/vip"
|
||||||
"chatapp3-golang/internal/service/voiceroomredpacket"
|
"chatapp3-golang/internal/service/voiceroomredpacket"
|
||||||
"chatapp3-golang/internal/service/voiceroomrocket"
|
|
||||||
"chatapp3-golang/internal/service/weekstar"
|
"chatapp3-golang/internal/service/weekstar"
|
||||||
"chatapp3-golang/internal/service/wheel"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -51,12 +48,9 @@ type Services struct {
|
|||||||
RegisterReward *registerreward.RegisterRewardService
|
RegisterReward *registerreward.RegisterRewardService
|
||||||
RoomTurnoverReward *roomturnoverreward.Service
|
RoomTurnoverReward *roomturnoverreward.Service
|
||||||
SignInReward *signinreward.SignInRewardService
|
SignInReward *signinreward.SignInRewardService
|
||||||
SmashEgg *smashegg.Service
|
|
||||||
TaskCenter *taskcenter.Service
|
TaskCenter *taskcenter.Service
|
||||||
VIP *vip.Service
|
VIP *vip.Service
|
||||||
Wheel *wheel.Service
|
|
||||||
VoiceRoomRedPacket *voiceroomredpacket.Service
|
VoiceRoomRedPacket *voiceroomredpacket.Service
|
||||||
VoiceRoomRocket *voiceroomrocket.Service
|
|
||||||
WeekStar *weekstar.WeekStarService
|
WeekStar *weekstar.WeekStarService
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,11 +74,8 @@ func NewRouter(
|
|||||||
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
|
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
|
||||||
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
|
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
|
||||||
registerVipRoutes(engine, javaClient, services.VIP)
|
registerVipRoutes(engine, javaClient, services.VIP)
|
||||||
registerWheelRoutes(engine, javaClient, services.Wheel)
|
|
||||||
registerSmashEggRoutes(engine, javaClient, services.SmashEgg)
|
|
||||||
registerRegionIMGroupRoutes(engine, cfg, javaClient, services.RegionIMGroup)
|
registerRegionIMGroupRoutes(engine, cfg, javaClient, services.RegionIMGroup)
|
||||||
registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket)
|
registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket)
|
||||||
registerVoiceRoomRocketRoutes(engine, cfg, javaClient, services.VoiceRoomRocket)
|
|
||||||
registerWeekStarRoutes(engine, javaClient, services.WeekStar)
|
registerWeekStarRoutes(engine, javaClient, services.WeekStar)
|
||||||
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
|
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
|
||||||
registerHostCenterRoutes(engine, services.HostCenter)
|
registerHostCenterRoutes(engine, services.HostCenter)
|
||||||
@ -107,7 +98,7 @@ func corsMiddleware() gin.HandlerFunc {
|
|||||||
|
|
||||||
c.Header("Access-Control-Allow-Origin", origin)
|
c.Header("Access-Control-Allow-Origin", origin)
|
||||||
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
|
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
|
||||||
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-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-Expose-Headers", "Authorization")
|
c.Header("Access-Control-Expose-Headers", "Authorization")
|
||||||
c.Header("Access-Control-Max-Age", "86400")
|
c.Header("Access-Control-Max-Age", "86400")
|
||||||
|
|
||||||
|
|||||||
@ -1,243 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,223 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,389 +0,0 @@
|
|||||||
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"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type teamManagerInfoRow struct {
|
type administratorRow struct {
|
||||||
ID int64 `gorm:"column:id;primaryKey"`
|
ID int64 `gorm:"column:id;primaryKey"`
|
||||||
UserID int64 `gorm:"column:user_id"`
|
UserID int64 `gorm:"column:user_id"`
|
||||||
Origin string `gorm:"column:sys_origin"`
|
Status bool `gorm:"column:status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (teamManagerInfoRow) TableName() string {
|
func (administratorRow) TableName() string {
|
||||||
return "team_manager_base_info"
|
return "sys_administrator"
|
||||||
}
|
}
|
||||||
|
|
||||||
type userBaseInfoRow struct {
|
type userBaseInfoRow struct {
|
||||||
@ -320,8 +320,8 @@ func (s *Service) ensureManager(ctx context.Context, user AuthUser) error {
|
|||||||
|
|
||||||
var count int64
|
var count int64
|
||||||
err := s.db.WithContext(ctx).
|
err := s.db.WithContext(ctx).
|
||||||
Table("team_manager_base_info").
|
Table("sys_administrator").
|
||||||
Where("user_id = ?", user.UserID).
|
Where("user_id = ? AND status = ?", user.UserID, true).
|
||||||
Count(&count).Error
|
Count(&count).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return serverError("manager_query_failed", err.Error())
|
return serverError("manager_query_failed", err.Error())
|
||||||
|
|||||||
@ -418,7 +418,7 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
|
|||||||
t.Fatalf("open sqlite: %v", err)
|
t.Fatalf("open sqlite: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&teamManagerInfoRow{},
|
&administratorRow{},
|
||||||
&userBaseInfoRow{},
|
&userBaseInfoRow{},
|
||||||
&propsSourceRecordRow{},
|
&propsSourceRecordRow{},
|
||||||
&propsNobleVIPAbilityRow{},
|
&propsNobleVIPAbilityRow{},
|
||||||
@ -438,7 +438,7 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
|
|||||||
|
|
||||||
func seedManager(t *testing.T, db *gorm.DB, userID int64) {
|
func seedManager(t *testing.T, db *gorm.DB, userID int64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := db.Create(&teamManagerInfoRow{ID: userID, UserID: userID, Origin: "LIKEI"}).Error; err != nil {
|
if err := db.Create(&administratorRow{ID: userID, UserID: userID, Status: true}).Error; err != nil {
|
||||||
t.Fatalf("seed manager: %v", err)
|
t.Fatalf("seed manager: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,624 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,186 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,392 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,234 +0,0 @@
|
|||||||
//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
|
|
||||||
}
|
|
||||||
@ -1,273 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,123 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,243 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,299 +0,0 @@
|
|||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
//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
|
|
||||||
}
|
|
||||||
@ -1,483 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,461 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,190 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,544 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,735 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,279 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,142 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
@ -1,223 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,317 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,270 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,142 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,142 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,345 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,185 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,497 +0,0 @@
|
|||||||
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"`
|
|
||||||
}
|
|
||||||
@ -1,623 +0,0 @@
|
|||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,173 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,461 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,429 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,195 +0,0 @@
|
|||||||
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='语音房火箭在房间用户快照';
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
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='常驻活动转盘抽奖记录';
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
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='常驻活动砸蛋抽奖记录';
|
|
||||||
@ -1,121 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,189 +0,0 @@
|
|||||||
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';
|
|
||||||
@ -1,657 +0,0 @@
|
|||||||
//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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,422 +0,0 @@
|
|||||||
//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