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