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/vip"
|
||||
"chatapp3-golang/internal/service/voiceroomredpacket"
|
||||
"chatapp3-golang/internal/service/voiceroomrocket"
|
||||
"chatapp3-golang/internal/service/weekstar"
|
||||
)
|
||||
|
||||
@ -59,6 +60,7 @@ func main() {
|
||||
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room)
|
||||
voiceRoomRedPacketService.SetRegionResolver(&app.Gateways)
|
||||
voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)
|
||||
@ -79,6 +81,9 @@ func main() {
|
||||
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
|
||||
log.Fatalf("start task center message consumer failed: %v", err)
|
||||
}
|
||||
if err := voiceRoomRocketService.Start(workerCtx); err != nil {
|
||||
log.Fatalf("start voice room rocket workers failed: %v", err)
|
||||
}
|
||||
if err := taskCenterService.StartArchiveWorker(workerCtx); err != nil {
|
||||
log.Fatalf("start task center archive worker failed: %v", err)
|
||||
}
|
||||
@ -108,6 +113,7 @@ func main() {
|
||||
TaskCenter: taskCenterService,
|
||||
VIP: vipService,
|
||||
VoiceRoomRedPacket: voiceRoomRedPacketService,
|
||||
VoiceRoomRocket: voiceRoomRocketService,
|
||||
WeekStar: weekStarService,
|
||||
})
|
||||
server := &http.Server{
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"chatapp3-golang/internal/service/registerreward"
|
||||
"chatapp3-golang/internal/service/taskcenter"
|
||||
"chatapp3-golang/internal/service/voiceroomredpacket"
|
||||
"chatapp3-golang/internal/service/voiceroomrocket"
|
||||
"chatapp3-golang/internal/service/weekstar"
|
||||
"context"
|
||||
"log"
|
||||
@ -29,6 +30,7 @@ func main() {
|
||||
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||
voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room)
|
||||
voiceRoomRedPacketService.SetRegionResolver(&app.Gateways)
|
||||
voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||
|
||||
workerCtx, workerCancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer workerCancel()
|
||||
@ -42,6 +44,9 @@ func main() {
|
||||
if err := taskCenterService.StartMessageConsumer(workerCtx); err != nil {
|
||||
log.Fatalf("start task center message consumer failed: %v", err)
|
||||
}
|
||||
if err := voiceRoomRocketService.Start(workerCtx); err != nil {
|
||||
log.Fatalf("start voice room rocket workers failed: %v", err)
|
||||
}
|
||||
if err := taskCenterService.StartArchiveWorker(workerCtx); err != nil {
|
||||
log.Fatalf("start task center archive worker failed: %v", err)
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ type Config struct {
|
||||
WeekStar WeekStarConfig
|
||||
RoomTurnoverReward RoomTurnoverRewardConfig
|
||||
VoiceRoomRedPacket VoiceRoomRedPacketConfig
|
||||
VoiceRoomRocket VoiceRoomRocketConfig
|
||||
TencentIM TencentIMConfig
|
||||
Baishun BaishunConfig
|
||||
Lingxian LingxianConfig
|
||||
@ -158,6 +159,35 @@ type VoiceRoomRedPacketConfig struct {
|
||||
ExpireZSetKey string
|
||||
}
|
||||
|
||||
// VoiceRoomRocketConfig 保存语音房火箭 Go 侧运行配置。
|
||||
type VoiceRoomRocketConfig struct {
|
||||
DefaultSysOrigin string
|
||||
Timezone string
|
||||
RewardRetryStreamKey string
|
||||
InRoomRewardDueZSetKey string
|
||||
RoomLockTTLSeconds int
|
||||
BroadcastStreamKey string
|
||||
InRoomRewardScanIntervalSeconds int
|
||||
InRoomRewardBatchSize int
|
||||
InRoomRewardUserLimit int
|
||||
RewardGrantScanIntervalSeconds int
|
||||
RewardGrantBatchSize int
|
||||
MQ VoiceRoomRocketMQConfig
|
||||
}
|
||||
|
||||
// VoiceRoomRocketMQConfig 保存语音房火箭送礼 MQ 消费配置。
|
||||
type VoiceRoomRocketMQConfig struct {
|
||||
Enabled bool
|
||||
Endpoint string
|
||||
Namespace string
|
||||
AccessKey string
|
||||
AccessSecret string
|
||||
SecurityToken string
|
||||
ConsumerGroup string
|
||||
Topic string
|
||||
Tag string
|
||||
}
|
||||
|
||||
// TencentIMConfig 保存腾讯 IM REST API 配置。
|
||||
type TencentIMConfig struct {
|
||||
AppID int64
|
||||
@ -230,6 +260,9 @@ func Load() Config {
|
||||
taskCenterMQEndpoint := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ENDPOINT", "TASK_CENTER_EVENT_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
|
||||
taskCenterMQAccessKey := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_KEY", "TASK_CENTER_EVENT_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "")
|
||||
taskCenterMQAccessSecret := getEnvAny([]string{"CHATAPP_TASK_CENTER_MQ_ACCESS_SECRET", "TASK_CENTER_EVENT_MQ_SECRET_KEY", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
|
||||
voiceRoomRocketMQEndpoint := getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
|
||||
voiceRoomRocketMQAccessKey := getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "")
|
||||
voiceRoomRocketMQAccessSecret := getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ACCESS_SECRET", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
|
||||
|
||||
return Config{
|
||||
HTTP: HTTPConfig{
|
||||
@ -375,6 +408,63 @@ func Load() Config {
|
||||
"voice_room:red_packet:expire_zset",
|
||||
),
|
||||
},
|
||||
VoiceRoomRocket: VoiceRoomRocketConfig{
|
||||
DefaultSysOrigin: strings.ToUpper(getEnvAny(
|
||||
[]string{"CHATAPP_VOICE_ROOM_ROCKET_DEFAULT_SYS_ORIGIN", "VOICE_ROOM_ROCKET_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
|
||||
"LIKEI",
|
||||
)),
|
||||
Timezone: getEnvAny(
|
||||
[]string{"CHATAPP_VOICE_ROOM_ROCKET_TIMEZONE", "VOICE_ROOM_ROCKET_TIMEZONE", "CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"},
|
||||
"Asia/Riyadh",
|
||||
),
|
||||
RewardRetryStreamKey: getEnvAny(
|
||||
[]string{"CHATAPP_VOICE_ROOM_ROCKET_REWARD_RETRY_STREAM_KEY", "VOICE_ROOM_ROCKET_REWARD_RETRY_STREAM_KEY"},
|
||||
"voice_room:rocket:reward_retry",
|
||||
),
|
||||
InRoomRewardDueZSetKey: getEnvAny(
|
||||
[]string{"CHATAPP_VOICE_ROOM_ROCKET_IN_ROOM_DUE_ZSET_KEY", "VOICE_ROOM_ROCKET_IN_ROOM_DUE_ZSET_KEY"},
|
||||
"voice_room:rocket:in_room_due",
|
||||
),
|
||||
RoomLockTTLSeconds: getEnvIntAny(
|
||||
[]string{"CHATAPP_VOICE_ROOM_ROCKET_ROOM_LOCK_TTL_SECONDS", "VOICE_ROOM_ROCKET_ROOM_LOCK_TTL_SECONDS"},
|
||||
10,
|
||||
),
|
||||
BroadcastStreamKey: getEnvAny(
|
||||
[]string{"CHATAPP_VOICE_ROOM_ROCKET_BROADCAST_STREAM_KEY", "VOICE_ROOM_ROCKET_BROADCAST_STREAM_KEY"},
|
||||
"voice_room:rocket:broadcast",
|
||||
),
|
||||
InRoomRewardScanIntervalSeconds: getEnvIntAny(
|
||||
[]string{"CHATAPP_VOICE_ROOM_ROCKET_IN_ROOM_SCAN_INTERVAL_SECONDS", "VOICE_ROOM_ROCKET_IN_ROOM_SCAN_INTERVAL_SECONDS"},
|
||||
1,
|
||||
),
|
||||
InRoomRewardBatchSize: getEnvIntAny(
|
||||
[]string{"CHATAPP_VOICE_ROOM_ROCKET_IN_ROOM_BATCH_SIZE", "VOICE_ROOM_ROCKET_IN_ROOM_BATCH_SIZE"},
|
||||
100,
|
||||
),
|
||||
InRoomRewardUserLimit: getEnvIntAny(
|
||||
[]string{"CHATAPP_VOICE_ROOM_ROCKET_IN_ROOM_USER_LIMIT", "VOICE_ROOM_ROCKET_IN_ROOM_USER_LIMIT"},
|
||||
3,
|
||||
),
|
||||
RewardGrantScanIntervalSeconds: getEnvIntAny(
|
||||
[]string{"CHATAPP_VOICE_ROOM_ROCKET_REWARD_GRANT_SCAN_INTERVAL_SECONDS", "VOICE_ROOM_ROCKET_REWARD_GRANT_SCAN_INTERVAL_SECONDS"},
|
||||
1,
|
||||
),
|
||||
RewardGrantBatchSize: getEnvIntAny(
|
||||
[]string{"CHATAPP_VOICE_ROOM_ROCKET_REWARD_GRANT_BATCH_SIZE", "VOICE_ROOM_ROCKET_REWARD_GRANT_BATCH_SIZE"},
|
||||
100,
|
||||
),
|
||||
MQ: VoiceRoomRocketMQConfig{
|
||||
Enabled: getEnvBoolAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ENABLED", "VOICE_ROOM_ROCKET_MQ_ENABLED"}, false),
|
||||
Endpoint: voiceRoomRocketMQEndpoint,
|
||||
Namespace: getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_NAMESPACE", "VOICE_ROOM_ROCKET_MQ_NAMESPACE"}, ""),
|
||||
AccessKey: voiceRoomRocketMQAccessKey,
|
||||
AccessSecret: voiceRoomRocketMQAccessSecret,
|
||||
SecurityToken: getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_SECURITY_TOKEN", "VOICE_ROOM_ROCKET_MQ_SECURITY_TOKEN"}, ""),
|
||||
ConsumerGroup: getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_CONSUMER_GROUP", "VOICE_ROOM_ROCKET_MQ_CONSUMER_GROUP"}, "voice-room-rocket"),
|
||||
Topic: getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_TOPIC", "VOICE_ROOM_ROCKET_MQ_TOPIC"}, "RC_DEFAULT_APP_ORDINARY"),
|
||||
Tag: getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_TAG", "VOICE_ROOM_ROCKET_MQ_TAG"}, "give_gift_v3"),
|
||||
},
|
||||
},
|
||||
TencentIM: TencentIMConfig{
|
||||
AppID: getEnvInt64Any(
|
||||
[]string{"CHATAPP_TENCENT_IM_APP_ID", "TENCENT_IM_APP_ID", "LIKEI_IM_APP_ID"},
|
||||
|
||||
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/vip"
|
||||
"chatapp3-golang/internal/service/voiceroomredpacket"
|
||||
"chatapp3-golang/internal/service/voiceroomrocket"
|
||||
"chatapp3-golang/internal/service/weekstar"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@ -51,6 +52,7 @@ type Services struct {
|
||||
TaskCenter *taskcenter.Service
|
||||
VIP *vip.Service
|
||||
VoiceRoomRedPacket *voiceroomredpacket.Service
|
||||
VoiceRoomRocket *voiceroomrocket.Service
|
||||
WeekStar *weekstar.WeekStarService
|
||||
}
|
||||
|
||||
@ -76,6 +78,7 @@ func NewRouter(
|
||||
registerVipRoutes(engine, javaClient, services.VIP)
|
||||
registerRegionIMGroupRoutes(engine, cfg, javaClient, services.RegionIMGroup)
|
||||
registerVoiceRoomRedPacketRoutes(engine, javaClient, services.VoiceRoomRedPacket)
|
||||
registerVoiceRoomRocketRoutes(engine, cfg, javaClient, services.VoiceRoomRocket)
|
||||
registerWeekStarRoutes(engine, javaClient, services.WeekStar)
|
||||
registerRoomTurnoverRewardRoutes(engine, javaClient, services.RoomTurnoverReward)
|
||||
registerHostCenterRoutes(engine, services.HostCenter)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
type administratorRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
Status bool `gorm:"column:status"`
|
||||
type teamManagerInfoRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
Origin string `gorm:"column:sys_origin"`
|
||||
}
|
||||
|
||||
func (administratorRow) TableName() string {
|
||||
return "sys_administrator"
|
||||
func (teamManagerInfoRow) TableName() string {
|
||||
return "team_manager_base_info"
|
||||
}
|
||||
|
||||
type userBaseInfoRow struct {
|
||||
@ -320,8 +320,8 @@ func (s *Service) ensureManager(ctx context.Context, user AuthUser) error {
|
||||
|
||||
var count int64
|
||||
err := s.db.WithContext(ctx).
|
||||
Table("sys_administrator").
|
||||
Where("user_id = ? AND status = ?", user.UserID, true).
|
||||
Table("team_manager_base_info").
|
||||
Where("user_id = ?", user.UserID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return serverError("manager_query_failed", err.Error())
|
||||
|
||||
@ -418,7 +418,7 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&administratorRow{},
|
||||
&teamManagerInfoRow{},
|
||||
&userBaseInfoRow{},
|
||||
&propsSourceRecordRow{},
|
||||
&propsNobleVIPAbilityRow{},
|
||||
@ -438,7 +438,7 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
|
||||
|
||||
func seedManager(t *testing.T, db *gorm.DB, userID int64) {
|
||||
t.Helper()
|
||||
if err := db.Create(&administratorRow{ID: userID, UserID: userID, Status: true}).Error; err != nil {
|
||||
if err := db.Create(&teamManagerInfoRow{ID: userID, UserID: userID, Origin: "LIKEI"}).Error; err != nil {
|
||||
t.Fatalf("seed manager: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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