feat: complete voice room rocket launch rewards

This commit is contained in:
hy001 2026-05-14 20:02:43 +08:00
parent dd9020a542
commit 51b8b52c57
13 changed files with 684 additions and 69 deletions

View File

@ -61,6 +61,7 @@ func main() {
voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room) voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room)
voiceRoomRedPacketService.SetRegionResolver(&app.Gateways) voiceRoomRedPacketService.SetRegionResolver(&app.Gateways)
voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
voiceRoomRocketService.SetRegionBroadcaster(regionIMGroupService)
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways) roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways)
hostCenterService := hostcenter.NewService(app.Config, &app.Gateways) hostCenterService := hostcenter.NewService(app.Config, &app.Gateways)

View File

@ -31,6 +31,7 @@ func main() {
voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room) voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room)
voiceRoomRedPacketService.SetRegionResolver(&app.Gateways) voiceRoomRedPacketService.SetRegionResolver(&app.Gateways)
voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
voiceRoomRocketService.SetRegionBroadcaster(regionIMGroupService)
workerCtx, workerCancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) workerCtx, workerCancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer workerCancel() defer workerCancel()

View File

@ -39,13 +39,12 @@ func (s *Service) GetAdminConfig(ctx context.Context, sysOrigin string) (*Config
// SaveAdminConfig 保存后台系统配置。 // SaveAdminConfig 保存后台系统配置。
func (s *Service) SaveAdminConfig(ctx context.Context, req SaveConfigRequest) (*ConfigResponse, error) { func (s *Service) SaveAdminConfig(ctx context.Context, req SaveConfigRequest) (*ConfigResponse, error) {
sysOrigin := s.normalizeSysOrigin(req.SysOrigin) sysOrigin := s.normalizeSysOrigin(req.SysOrigin)
timezone := normalizeTimezone(req.Timezone) timezone := defaultTimezone
if _, err := time.LoadLocation(timezone); err != nil {
return nil, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error())
}
maxLevel := req.MaxLevel maxLevel := req.MaxLevel
if maxLevel <= 0 { if maxLevel <= 0 {
maxLevel = defaultMaxLevel maxLevel = defaultMaxLevel
} else if maxLevel > defaultMaxLevel {
maxLevel = defaultMaxLevel
} }
rankingLimit := req.RankingTopLimit rankingLimit := req.RankingTopLimit
if rankingLimit <= 0 || rankingLimit > 100 { if rankingLimit <= 0 || rankingLimit > 100 {
@ -206,11 +205,28 @@ func (s *Service) ListRewardConfigs(ctx context.Context, sysOrigin string, level
// SaveRewardConfigs 批量保存后台奖励配置。 // SaveRewardConfigs 批量保存后台奖励配置。
func (s *Service) SaveRewardConfigs(ctx context.Context, req SaveRewardConfigsRequest) ([]RewardConfigPayload, error) { func (s *Service) SaveRewardConfigs(ctx context.Context, req SaveRewardConfigsRequest) ([]RewardConfigPayload, error) {
sysOrigin := s.normalizeSysOrigin(req.SysOrigin) sysOrigin := s.normalizeSysOrigin(req.SysOrigin)
if len(req.Rewards) == 0 { targetLevels := map[int]struct{}{}
return nil, NewAppError(http.StatusBadRequest, "missing_reward_configs", "rewards is required") if req.Level > 0 {
if req.Level > defaultMaxLevel {
return nil, NewAppError(http.StatusBadRequest, "invalid_level", "level must be between 1 and 6")
}
targetLevels[req.Level] = struct{}{}
} }
if err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now() now := time.Now()
savedIDsByLevel := map[int][]int64{}
if len(req.Rewards) == 0 {
if len(targetLevels) == 0 {
return NewAppError(http.StatusBadRequest, "missing_reward_configs", "rewards is required")
}
for level := range targetLevels {
if err := tx.Where("sys_origin = ? AND level = ?", sysOrigin, level).
Delete(&model.VoiceRoomRocketRewardConfig{}).Error; err != nil {
return err
}
}
return nil
}
for _, item := range req.Rewards { for _, item := range req.Rewards {
scene := strings.ToUpper(strings.TrimSpace(item.RewardScene)) scene := strings.ToUpper(strings.TrimSpace(item.RewardScene))
if scene != rewardSceneTop1 && scene != rewardSceneIgnite && scene != rewardSceneInRoom { if scene != rewardSceneTop1 && scene != rewardSceneIgnite && scene != rewardSceneInRoom {
@ -219,6 +235,10 @@ func (s *Service) SaveRewardConfigs(ctx context.Context, req SaveRewardConfigsRe
if item.Level <= 0 || item.Level > defaultMaxLevel { if item.Level <= 0 || item.Level > defaultMaxLevel {
return NewAppError(http.StatusBadRequest, "invalid_level", "level must be between 1 and 6") return NewAppError(http.StatusBadRequest, "invalid_level", "level must be between 1 and 6")
} }
if req.Level > 0 && item.Level != req.Level {
return NewAppError(http.StatusBadRequest, "invalid_level", "reward level must match request level")
}
targetLevels[item.Level] = struct{}{}
rewardType := strings.ToUpper(strings.TrimSpace(item.RewardType)) rewardType := strings.ToUpper(strings.TrimSpace(item.RewardType))
if rewardType == "" { if rewardType == "" {
return NewAppError(http.StatusBadRequest, "missing_reward_type", "rewardType is required") return NewAppError(http.StatusBadRequest, "missing_reward_type", "rewardType is required")
@ -226,12 +246,22 @@ func (s *Service) SaveRewardConfigs(ctx context.Context, req SaveRewardConfigsRe
if !isSupportedRewardType(rewardType) { if !isSupportedRewardType(rewardType) {
return NewAppError(http.StatusBadRequest, "invalid_reward_type", "rewardType is invalid") return NewAppError(http.StatusBadRequest, "invalid_reward_type", "rewardType is invalid")
} }
if rewardType == rewardTypeNone && scene != rewardSceneInRoom {
return NewAppError(http.StatusBadRequest, "invalid_reward_type", "NONE reward is only allowed for IN_ROOM")
}
rewardName := strings.TrimSpace(item.RewardName) rewardName := strings.TrimSpace(item.RewardName)
if rewardType == rewardTypeNone && rewardName == "" {
rewardName = "轮空"
}
if rewardName == "" { if rewardName == "" {
return NewAppError(http.StatusBadRequest, "missing_reward_name", "rewardName is required") return NewAppError(http.StatusBadRequest, "missing_reward_name", "rewardName is required")
} }
amount := item.RewardAmount amount := item.RewardAmount
if amount <= 0 { if rewardType == rewardTypeNone {
amount = 0
item.RewardItemID = nil
item.ExpireDays = nil
} else if amount <= 0 {
amount = 1 amount = 1
} }
var row model.VoiceRoomRocketRewardConfig var row model.VoiceRoomRocketRewardConfig
@ -268,12 +298,22 @@ func (s *Service) SaveRewardConfigs(ctx context.Context, req SaveRewardConfigsRe
if err := tx.Save(&row).Error; err != nil { if err := tx.Save(&row).Error; err != nil {
return err return err
} }
savedIDsByLevel[item.Level] = append(savedIDsByLevel[item.Level], row.ID)
}
for level := range targetLevels {
query := tx.Where("sys_origin = ? AND level = ?", sysOrigin, level)
if ids := savedIDsByLevel[level]; len(ids) > 0 {
query = query.Where("id NOT IN ?", ids)
}
if err := query.Delete(&model.VoiceRoomRocketRewardConfig{}).Error; err != nil {
return err
}
} }
return nil return nil
}); err != nil { }); err != nil {
return nil, err return nil, err
} }
return s.ListRewardConfigs(ctx, sysOrigin, 0) return s.ListRewardConfigs(ctx, sysOrigin, req.Level)
} }
func (s *Service) getConfigResponse(ctx context.Context, sysOrigin string, currentLevel int) (*ConfigResponse, error) { func (s *Service) getConfigResponse(ctx context.Context, sysOrigin string, currentLevel int) (*ConfigResponse, error) {
@ -304,7 +344,7 @@ func (s *Service) loadConfig(ctx context.Context, sysOrigin string) (configSnaps
return configSnapshot{ return configSnapshot{
SysOrigin: sysOrigin, SysOrigin: sysOrigin,
Enabled: false, Enabled: false,
Timezone: normalizeTimezone(s.cfg.VoiceRoomRocket.Timezone), Timezone: defaultTimezone,
MaxLevel: defaultMaxLevel, MaxLevel: defaultMaxLevel,
RankingTopLimit: defaultRankingTopLimit, RankingTopLimit: defaultRankingTopLimit,
RewardRecordKeepDays: defaultRewardRecordKeepDays, RewardRecordKeepDays: defaultRewardRecordKeepDays,
@ -320,7 +360,7 @@ func (s *Service) loadConfig(ctx context.Context, sysOrigin string) (configSnaps
ID: row.ID, ID: row.ID,
SysOrigin: row.SysOrigin, SysOrigin: row.SysOrigin,
Enabled: row.Enabled, Enabled: row.Enabled,
Timezone: normalizeTimezone(row.Timezone), Timezone: defaultTimezone,
MaxLevel: positiveOrDefault(row.MaxLevel, defaultMaxLevel), MaxLevel: positiveOrDefault(row.MaxLevel, defaultMaxLevel),
RankingTopLimit: clampPositive(row.RankingTopLimit, defaultRankingTopLimit, 100), RankingTopLimit: clampPositive(row.RankingTopLimit, defaultRankingTopLimit, 100),
RewardRecordKeepDays: positiveOrDefault(row.RewardRecordKeepDays, defaultRewardRecordKeepDays), RewardRecordKeepDays: positiveOrDefault(row.RewardRecordKeepDays, defaultRewardRecordKeepDays),

View File

@ -193,7 +193,10 @@ func (s *Service) ProcessGiftEvent(ctx context.Context, event giftEvent, rawPayl
return err return err
} }
if newEnergy >= statusRow.NeedEnergy { if newEnergy >= statusRow.NeedEnergy {
launch, err := s.launchLocked(ctx, tx, statusRow, sendUserID, now) if err := s.markLevelReachedLocked(ctx, tx, statusRow, sendUserID, now); err != nil {
return err
}
launch, err := s.scheduleLaunchLocked(ctx, tx, statusRow, sendUserID, now)
if err != nil { if err != nil {
return err return err
} }
@ -209,7 +212,7 @@ func (s *Service) ProcessGiftEvent(ctx context.Context, event giftEvent, rawPayl
if result.Launched { if result.Launched {
_ = s.notifyLaunch(context.Background(), result.Launch) _ = s.notifyLaunch(context.Background(), result.Launch)
} else if resp.Processed { } else if resp.Processed {
_ = s.notifyStatusUpdate(context.Background(), sysOrigin, roomID) _ = s.notifyStatusUpdate(context.Background(), sysOrigin, roomID, sendUserID)
} }
return resp, nil return resp, nil
} }
@ -292,6 +295,35 @@ func (s *Service) addContribution(ctx context.Context, tx *gorm.DB, statusRow mo
}).Error }).Error
} }
func (s *Service) markLevelReachedLocked(ctx context.Context, tx *gorm.DB, statusRow model.VoiceRoomRocketStatus, igniteUserID int64, now time.Time) error {
return tx.WithContext(ctx).Model(&model.VoiceRoomRocketLevelRound{}).
Where(
"sys_origin = ? AND room_id = ? AND day_key = ? AND round_no = ? AND level = ?",
statusRow.SysOrigin, statusRow.RoomID, statusRow.DayKey, statusRow.RoundNo, statusRow.CurrentLevel,
).
Updates(map[string]any{
"final_energy": statusRow.NeedEnergy,
"status": levelRoundStatusReached,
"ignite_user_id": igniteUserID,
"reach_time": now,
"update_time": now,
}).Error
}
func (s *Service) scheduleLaunchLocked(ctx context.Context, tx *gorm.DB, statusRow model.VoiceRoomRocketStatus, igniteUserID int64, now time.Time) (launchResult, error) {
if s.repo.Redis == nil {
return s.launchLocked(ctx, tx, statusRow, igniteUserID, now)
}
due := now.Add(time.Duration(defaultLaunchDelaySeconds) * time.Second)
if err := s.repo.Redis.ZAdd(ctx, s.launchDueZSetKey(), redis.Z{
Score: float64(due.Unix()),
Member: strconv.FormatInt(statusRow.ID, 10),
}).Err(); err != nil {
return launchResult{}, err
}
return launchResult{Launched: false}, nil
}
func (s *Service) launchLocked(ctx context.Context, tx *gorm.DB, statusRow model.VoiceRoomRocketStatus, igniteUserID int64, now time.Time) (launchResult, error) { 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) top1UserID, err := s.resolveTop1UserID(ctx, tx, statusRow)
if err != nil { if err != nil {
@ -326,7 +358,6 @@ func (s *Service) launchLocked(ctx context.Context, tx *gorm.DB, statusRow model
} }
return launchResult{}, err return launchResult{}, err
} }
reachTime := now
if err := tx.Model(&model.VoiceRoomRocketLevelRound{}). if err := tx.Model(&model.VoiceRoomRocketLevelRound{}).
Where( Where(
"sys_origin = ? AND room_id = ? AND day_key = ? AND round_no = ? AND level = ?", "sys_origin = ? AND room_id = ? AND day_key = ? AND round_no = ? AND level = ?",
@ -337,7 +368,7 @@ func (s *Service) launchLocked(ctx context.Context, tx *gorm.DB, statusRow model
"status": levelRoundStatusLaunched, "status": levelRoundStatusLaunched,
"top1_user_id": top1UserID, "top1_user_id": top1UserID,
"ignite_user_id": igniteUserID, "ignite_user_id": igniteUserID,
"reach_time": reachTime, "reach_time": gorm.Expr("COALESCE(reach_time, ?)", now),
"launch_time": now, "launch_time": now,
"update_time": now, "update_time": now,
}).Error; err != nil { }).Error; err != nil {

View File

@ -2,7 +2,9 @@ package voiceroomrocket
import ( import (
"context" "context"
"encoding/json"
"strconv" "strconv"
"strings"
"testing" "testing"
"time" "time"
@ -199,11 +201,19 @@ func TestInRoomRewardWorkerSnapshotsSelectsAndGrants(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("ProcessGiftPayload() error = %v", err) t.Fatalf("ProcessGiftPayload() error = %v", err)
} }
if !resp.Launched || resp.LaunchNo == "" { if !resp.Processed || resp.Launched || resp.LaunchNo != "" {
t.Fatalf("launch response = %+v", resp) t.Fatalf("launch should be delayed, response = %+v", resp)
} }
processed, err := service.ProcessDueInRoomRewards(ctx, time.Now().Add(20*time.Second), 10) launched, err := service.ProcessDueLaunches(ctx, time.Now().Add(11*time.Second), 10)
if err != nil {
t.Fatalf("ProcessDueLaunches() error = %v", err)
}
if launched != 1 {
t.Fatalf("due launches = %d, want 1", launched)
}
processed, err := service.ProcessDueInRoomRewards(ctx, time.Now().Add(30*time.Second), 10)
if err != nil { if err != nil {
t.Fatalf("ProcessDueInRoomRewards() error = %v", err) t.Fatalf("ProcessDueInRoomRewards() error = %v", err)
} }
@ -245,6 +255,87 @@ func TestInRoomRewardWorkerSnapshotsSelectsAndGrants(t *testing.T) {
} }
} }
func TestProcessGiftPayloadDelaysLaunchWithRedis(t *testing.T) {
gateway := newFakeRocketGateway()
service, db, _, mr := newTestServiceWithRedis(t, gateway)
defer mr.Close()
ctx := context.Background()
seedRocketConfig(t, db)
seedLevelConfigs(t, db, []int64{100, 200, 300, 400, 500, 600})
seedRewardConfig(t, db, 1, rewardSceneTop1, rewardTypeGold, 10)
payload := `{
"trackId":"track-launch-delay",
"sysOrigin":"LIKEI",
"sendUserId":"1001",
"roomId":"9001",
"quantity":1,
"giftConfig":{"id":"11","giftCandy":100,"type":"GOLD","giftTab":"NORMAL"},
"createTime":` + strconvFormatMillis(time.Now()) + `
}`
resp, err := service.ProcessGiftPayload(ctx, payload)
if err != nil {
t.Fatalf("ProcessGiftPayload() error = %v", err)
}
if !resp.Processed || resp.Launched {
t.Fatalf("response = %+v, want processed delayed launch", resp)
}
var status model.VoiceRoomRocketStatus
if err := db.Where("sys_origin = ? AND room_id = ?", "LIKEI", int64(9001)).First(&status).Error; err != nil {
t.Fatalf("load status: %v", err)
}
if status.Status != statusLaunching || status.CurrentEnergy != 100 {
t.Fatalf("status = %+v, want launching full energy", status)
}
var launchCount int64
if err := db.Model(&model.VoiceRoomRocketLaunch{}).Count(&launchCount).Error; err != nil {
t.Fatalf("count launch before due: %v", err)
}
if launchCount != 0 {
t.Fatalf("launch count before due = %d, want 0", launchCount)
}
processed, err := service.ProcessDueLaunches(ctx, time.Now().Add(9*time.Second), 10)
if err != nil {
t.Fatalf("ProcessDueLaunches(before due) error = %v", err)
}
if processed != 0 {
t.Fatalf("launches before due = %d, want 0", processed)
}
processed, err = service.ProcessDueLaunches(ctx, time.Now().Add(11*time.Second), 10)
if err != nil {
t.Fatalf("ProcessDueLaunches(after due) error = %v", err)
}
if processed != 1 {
t.Fatalf("launches after due = %d, want 1", processed)
}
if err := db.Model(&model.VoiceRoomRocketLaunch{}).Count(&launchCount).Error; err != nil {
t.Fatalf("count launch after due: %v", err)
}
if launchCount != 1 {
t.Fatalf("launch count after due = %d, want 1", launchCount)
}
}
func TestPickInRoomRewardCanBeEmpty(t *testing.T) {
service, _ := newTestService(t)
launch := model.VoiceRoomRocketLaunch{LaunchNo: "VRR-empty"}
rewards := []model.VoiceRoomRocketRewardConfig{
{
RewardScene: rewardSceneInRoom,
RewardType: rewardTypeNone,
RewardName: "轮空",
Weight: 1,
},
}
got := service.pickInRoomRewardsForUser(launch, rewards, 1001)
if len(got) != 0 {
t.Fatalf("picked rewards = %+v, want empty", got)
}
}
func TestNotifyLaunchPublishesIMAndRedisStream(t *testing.T) { func TestNotifyLaunchPublishesIMAndRedisStream(t *testing.T) {
gateway := newFakeRocketGateway() gateway := newFakeRocketGateway()
service, db, rdb, mr := newTestServiceWithRedis(t, gateway) service, db, rdb, mr := newTestServiceWithRedis(t, gateway)
@ -278,6 +369,42 @@ func TestNotifyLaunchPublishesIMAndRedisStream(t *testing.T) {
} }
} }
func TestStatusUpdatePublishesRegionIM(t *testing.T) {
gateway := newFakeRocketGateway()
service, db, _, mr := newTestServiceWithRedis(t, gateway)
defer mr.Close()
service.regionResolver = fakeRocketRegionResolver{regions: map[string]string{"LIKEI|SA": "AR"}}
regionIM := &fakeRocketRegionBroadcaster{}
service.SetRegionBroadcaster(regionIM)
ctx := context.Background()
seedRocketConfig(t, db)
seedLevelConfigs(t, db, []int64{100, 200, 300, 400, 500, 600})
payload := `{
"trackId":"track-region-status",
"sysOrigin":"LIKEI",
"sendUserId":"1001",
"roomId":"9001",
"quantity":1,
"giftConfig":{"id":"11","giftCandy":10,"type":"GOLD","giftTab":"NORMAL"},
"createTime":` + strconvFormatMillis(time.Now()) + `
}`
resp, err := service.ProcessGiftPayload(ctx, payload)
if err != nil {
t.Fatalf("ProcessGiftPayload() error = %v", err)
}
if !resp.Processed {
t.Fatalf("response = %+v, want processed", resp)
}
if len(regionIM.messages) != 1 {
t.Fatalf("region messages = %+v, want one", regionIM.messages)
}
if regionIM.messages[0].regionCode != "AR" || regionIM.messages[0].messageType != imTypeStatusUpdate {
t.Fatalf("region message = %+v", regionIM.messages[0])
}
}
func TestDayKeyUsesUTCPlus3BusinessDay(t *testing.T) { func TestDayKeyUsesUTCPlus3BusinessDay(t *testing.T) {
location := resolveLocation("Asia/Riyadh") location := resolveLocation("Asia/Riyadh")
before := time.Date(2026, 5, 13, 20, 59, 59, 0, time.UTC) before := time.Date(2026, 5, 13, 20, 59, 59, 0, time.UTC)
@ -290,6 +417,110 @@ func TestDayKeyUsesUTCPlus3BusinessDay(t *testing.T) {
} }
} }
func TestSaveAdminConfigForcesRiyadhTimezoneAndReturnsEnergyRule(t *testing.T) {
service, _ := newTestService(t)
ctx := context.Background()
resp, err := service.SaveAdminConfig(ctx, SaveConfigRequest{
SysOrigin: "LIKEI",
Enabled: true,
Timezone: "UTC",
MaxLevel: 6,
RankingTopLimit: 50,
RewardRecordKeepDays: 35,
BroadcastDurationSeconds: 7,
InRoomRewardDelaySeconds: 10,
EnergyRuleJSON: json.RawMessage(`{"allowGiftIds":[11]}`),
RuleText: json.RawMessage(`{"title":"rocket"}`),
})
if err != nil {
t.Fatalf("SaveAdminConfig() error = %v", err)
}
if resp.Timezone != defaultTimezone {
t.Fatalf("timezone = %s, want %s", resp.Timezone, defaultTimezone)
}
if string(resp.EnergyRuleJSON) != `{"allowGiftIds":[11]}` {
t.Fatalf("energyRuleJson = %s", string(resp.EnergyRuleJSON))
}
}
func TestSaveRewardConfigsReplacesAndClearsSelectedLevel(t *testing.T) {
service, db := newTestService(t)
ctx := context.Background()
seedRewardConfig(t, db, 1, rewardSceneTop1, rewardTypeGold, 10)
seedRewardConfig(t, db, 1, rewardSceneIgnite, rewardTypeGold, 5)
seedRewardConfig(t, db, 2, rewardSceneTop1, rewardTypeGold, 20)
saved, err := service.SaveRewardConfigs(ctx, SaveRewardConfigsRequest{
SysOrigin: "LIKEI",
Level: 1,
Rewards: []RewardConfigPayload{
{
Level: 1,
RewardScene: rewardSceneTop1,
RewardType: rewardTypeGold,
RewardName: "new gold",
RewardAmount: 99,
Enabled: true,
Sort: 1,
},
},
})
if err != nil {
t.Fatalf("SaveRewardConfigs(replace) error = %v", err)
}
if len(saved) != 1 || saved[0].RewardAmount != 99 {
t.Fatalf("saved rewards = %+v, want one amount 99", saved)
}
var level1Count int64
if err := db.Model(&model.VoiceRoomRocketRewardConfig{}).
Where("sys_origin = ? AND level = ?", "LIKEI", 1).
Count(&level1Count).Error; err != nil {
t.Fatalf("count level 1 rewards: %v", err)
}
if level1Count != 1 {
t.Fatalf("level 1 reward count = %d, want 1", level1Count)
}
var level2Count int64
if err := db.Model(&model.VoiceRoomRocketRewardConfig{}).
Where("sys_origin = ? AND level = ?", "LIKEI", 2).
Count(&level2Count).Error; err != nil {
t.Fatalf("count level 2 rewards: %v", err)
}
if level2Count != 1 {
t.Fatalf("level 2 reward count = %d, want 1", level2Count)
}
saved, err = service.SaveRewardConfigs(ctx, SaveRewardConfigsRequest{
SysOrigin: "LIKEI",
Level: 1,
Rewards: []RewardConfigPayload{},
})
if err != nil {
t.Fatalf("SaveRewardConfigs(clear) error = %v", err)
}
if len(saved) != 0 {
t.Fatalf("saved rewards after clear = %+v, want empty", saved)
}
if err := db.Model(&model.VoiceRoomRocketRewardConfig{}).
Where("sys_origin = ? AND level = ?", "LIKEI", 1).
Count(&level1Count).Error; err != nil {
t.Fatalf("count cleared level 1 rewards: %v", err)
}
if level1Count != 0 {
t.Fatalf("level 1 reward count after clear = %d, want 0", level1Count)
}
if err := db.Model(&model.VoiceRoomRocketRewardConfig{}).
Where("sys_origin = ? AND level = ?", "LIKEI", 2).
Count(&level2Count).Error; err != nil {
t.Fatalf("count retained level 2 rewards: %v", err)
}
if level2Count != 1 {
t.Fatalf("level 2 reward count after clear = %d, want 1", level2Count)
}
}
func newTestService(t *testing.T) (*Service, *gorm.DB) { func newTestService(t *testing.T) (*Service, *gorm.DB) {
t.Helper() t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
@ -351,6 +582,21 @@ type fakeRocketIMMessage struct {
body any body any
} }
type fakeRocketRegionResolver struct {
regions map[string]string
}
type fakeRocketRegionBroadcaster struct {
messages []fakeRocketRegionMessage
}
type fakeRocketRegionMessage struct {
sysOrigin string
regionCode string
messageType string
body any
}
func newFakeRocketGateway() *fakeRocketGateway { func newFakeRocketGateway() *fakeRocketGateway {
return &fakeRocketGateway{goldEvents: map[string]integration.GoldReceiptCommand{}} return &fakeRocketGateway{goldEvents: map[string]integration.GoldReceiptCommand{}}
} }
@ -364,7 +610,7 @@ func (g *fakeRocketGateway) MapRoomProfiles(ctx context.Context, roomIDs []int64
} }
func (g *fakeRocketGateway) GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error) { 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 return integration.UserProfile{ID: integration.Int64Value(userID), Account: strconv.FormatInt(userID, 10), UserNickname: "user", CountryCode: "SA"}, nil
} }
func (g *fakeRocketGateway) ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error { func (g *fakeRocketGateway) ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error {
@ -404,6 +650,24 @@ func (g *fakeRocketIM) SendCustomGroupMessage(ctx context.Context, groupID strin
return nil return nil
} }
func (r fakeRocketRegionResolver) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
return r.regions[sysOrigin+"|"+countryCode], nil
}
func (b *fakeRocketRegionBroadcaster) SendRegionCustomMessage(ctx context.Context, sysOrigin string, regionCode string, body any) (string, error) {
messageType := ""
if payload, ok := body.(map[string]any); ok {
messageType, _ = payload["type"].(string)
}
b.messages = append(b.messages, fakeRocketRegionMessage{
sysOrigin: sysOrigin,
regionCode: regionCode,
messageType: messageType,
body: body,
})
return "@region-" + strings.ToLower(regionCode), nil
}
func seedRocketConfig(t *testing.T, db *gorm.DB) { func seedRocketConfig(t *testing.T, db *gorm.DB) {
t.Helper() t.Helper()
now := time.Now() now := time.Now()

View File

@ -232,20 +232,19 @@ func (s *Service) pickInRoomRewardsForUser(launch model.VoiceRoomRocketLaunch, r
if len(rewards) == 0 { if len(rewards) == 0 {
return nil return nil
} }
fixed := make([]model.VoiceRoomRocketRewardConfig, 0, len(rewards))
weighted := make([]model.VoiceRoomRocketRewardConfig, 0, len(rewards)) weighted := make([]model.VoiceRoomRocketRewardConfig, 0, len(rewards))
totalWeight := 0 totalWeight := 0
for _, reward := range rewards { for _, reward := range rewards {
if reward.Weight <= 0 { weight := reward.Weight
fixed = append(fixed, reward) if weight <= 0 {
continue weight = 1
} }
reward.Weight = weight
weighted = append(weighted, reward) weighted = append(weighted, reward)
totalWeight += reward.Weight totalWeight += weight
} }
result := append([]model.VoiceRoomRocketRewardConfig(nil), fixed...)
if totalWeight <= 0 { if totalWeight <= 0 {
return result return nil
} }
rng := rand.New(rand.NewSource(seedFromStrings(launch.LaunchNo, strconv.FormatInt(userID, 10), "reward"))) rng := rand.New(rand.NewSource(seedFromStrings(launch.LaunchNo, strconv.FormatInt(userID, 10), "reward")))
point := rng.Intn(totalWeight) + 1 point := rng.Intn(totalWeight) + 1
@ -253,11 +252,13 @@ func (s *Service) pickInRoomRewardsForUser(launch model.VoiceRoomRocketLaunch, r
for _, reward := range weighted { for _, reward := range weighted {
acc += reward.Weight acc += reward.Weight
if point <= acc { if point <= acc {
result = append(result, reward) if strings.EqualFold(strings.TrimSpace(reward.RewardType), rewardTypeNone) {
break return nil
}
return []model.VoiceRoomRocketRewardConfig{reward}
} }
} }
return result return nil
} }
func seedFromStrings(parts ...string) int64 { func seedFromStrings(parts ...string) int64 {

View File

@ -0,0 +1,142 @@
package voiceroomrocket
import (
"context"
"errors"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/model"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ProcessDueLaunches 扫描进度满 10 秒后的火箭发射任务。
func (s *Service) ProcessDueLaunches(ctx context.Context, now time.Time, limit int64) (int, error) {
if limit <= 0 {
limit = int64(defaultWorkerBatchSize)
}
processed := 0
if s.repo.Redis != nil {
statusIDs, err := s.repo.Redis.ZRangeByScore(ctx, s.launchDueZSetKey(), &redis.ZRangeBy{
Min: "-inf",
Max: strconv.FormatInt(now.Unix(), 10),
Count: limit,
}).Result()
if err != nil {
return 0, err
}
for _, rawID := range statusIDs {
statusID, parseErr := strconv.ParseInt(strings.TrimSpace(rawID), 10, 64)
if parseErr != nil || statusID <= 0 {
_ = s.repo.Redis.ZRem(context.Background(), s.launchDueZSetKey(), rawID).Err()
continue
}
done, err := s.processDueLaunchStatus(ctx, statusID, now)
if err != nil {
return processed, err
}
if done {
if err := s.repo.Redis.ZRem(context.Background(), s.launchDueZSetKey(), rawID).Err(); err != nil {
return processed, err
}
processed++
}
}
}
backfilled, err := s.processStaleLaunchingStatuses(ctx, now, limit)
if err != nil {
return processed, err
}
return processed + backfilled, nil
}
func (s *Service) processStaleLaunchingStatuses(ctx context.Context, now time.Time, limit int64) (int, error) {
if limit <= 0 {
limit = int64(defaultWorkerBatchSize)
}
var rows []model.VoiceRoomRocketStatus
if err := s.repo.DB.WithContext(ctx).
Where("status = ?", statusLaunching).
Order("update_time ASC").
Limit(int(limit)).
Find(&rows).Error; err != nil {
return 0, err
}
processed := 0
for _, row := range rows {
done, err := s.processDueLaunchStatus(ctx, row.ID, now)
if err != nil {
return processed, err
}
if done {
processed++
}
}
return processed, nil
}
func (s *Service) processDueLaunchStatus(ctx context.Context, statusID int64, now time.Time) (bool, error) {
var launch model.VoiceRoomRocketLaunch
done := false
var launched bool
err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var statusRow model.VoiceRoomRocketStatus
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", statusID).
First(&statusRow).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
done = true
return nil
}
if err != nil {
return err
}
if statusRow.Status != statusLaunching || statusRow.CurrentEnergy < statusRow.NeedEnergy {
done = true
return nil
}
var round model.VoiceRoomRocketLevelRound
err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where(
"sys_origin = ? AND room_id = ? AND day_key = ? AND round_no = ? AND level = ?",
statusRow.SysOrigin, statusRow.RoomID, statusRow.DayKey, statusRow.RoundNo, statusRow.CurrentLevel,
).
First(&round).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
if round.ReachTime == nil || round.IgniteUserID == nil || *round.IgniteUserID <= 0 {
return nil
}
due := round.ReachTime.Add(time.Duration(defaultLaunchDelaySeconds) * time.Second)
if now.Before(due) {
return nil
}
result, err := s.launchLocked(ctx, tx, statusRow, *round.IgniteUserID, now)
if err != nil {
return err
}
launch = result.Launch
launched = result.Launched
done = true
return nil
})
if err != nil {
return false, err
}
if launched {
_ = s.notifyLaunch(context.Background(), launch)
}
return done, nil
}
func (s *Service) launchDueZSetKey() string {
return "voice_room:rocket:launch_due"
}

View File

@ -17,6 +17,7 @@ func (s *Service) Start(ctx context.Context) error {
if err := s.StartMessageConsumer(ctx); err != nil { if err := s.StartMessageConsumer(ctx); err != nil {
return err return err
} }
s.StartLaunchWorker(ctx)
s.StartInRoomRewardWorker(ctx) s.StartInRoomRewardWorker(ctx)
s.StartRewardGrantWorker(ctx) s.StartRewardGrantWorker(ctx)
return nil return nil
@ -38,6 +39,33 @@ func (s *Service) StartMessageConsumer(ctx context.Context) error {
return nil return nil
} }
// StartLaunchWorker 启动满进度后延迟发射扫描。
func (s *Service) StartLaunchWorker(ctx context.Context) {
interval := time.Duration(s.cfg.VoiceRoomRocket.InRoomRewardScanIntervalSeconds) * time.Second
if interval <= 0 {
interval = defaultWorkerInterval
}
batchSize := int64(s.cfg.VoiceRoomRocket.InRoomRewardBatchSize)
if batchSize <= 0 {
batchSize = defaultWorkerBatchSize
}
log.Printf("voice room rocket launch worker started. interval=%s batch=%d delay=%ds", interval, batchSize, defaultLaunchDelaySeconds)
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
if _, err := s.ProcessDueLaunches(ctx, time.Now(), batchSize); err != nil && ctx.Err() == nil {
log.Printf("voice room rocket launch scan failed: %v", err)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}
// StartInRoomRewardWorker 启动在房奖励到期扫描。 // StartInRoomRewardWorker 启动在房奖励到期扫描。
func (s *Service) StartInRoomRewardWorker(ctx context.Context) { func (s *Service) StartInRoomRewardWorker(ctx context.Context) {
if s.repo.Redis == nil { if s.repo.Redis == nil {

View File

@ -177,6 +177,9 @@ func rewardPreviewPayload(rows []model.VoiceRoomRocketRewardConfig) RewardPrevie
}) })
resp := RewardPreviewResponse{} resp := RewardPreviewResponse{}
for _, row := range rows { for _, row := range rows {
if strings.EqualFold(strings.TrimSpace(row.RewardType), rewardTypeNone) {
continue
}
payload := rewardPayload(row) payload := rewardPayload(row)
switch strings.ToUpper(strings.TrimSpace(row.RewardScene)) { switch strings.ToUpper(strings.TrimSpace(row.RewardScene)) {
case rewardSceneTop1: case rewardSceneTop1:
@ -211,7 +214,8 @@ func isSupportedRewardType(rewardType string) bool {
rewardTypeChatBubble, rewardTypeChatBubble,
rewardTypeVIPCard, rewardTypeVIPCard,
rewardTypeGift, rewardTypeGift,
rewardTypeBadge: rewardTypeBadge,
rewardTypeNone:
return true return true
default: default:
return false return false
@ -234,6 +238,10 @@ func configResponse(snapshot configSnapshot, levels []model.VoiceRoomRocketLevel
if strings.TrimSpace(snapshot.RuleText) != "" { if strings.TrimSpace(snapshot.RuleText) != "" {
ruleText = json.RawMessage(snapshot.RuleText) ruleText = json.RawMessage(snapshot.RuleText)
} }
var energyRuleJSON json.RawMessage
if strings.TrimSpace(snapshot.EnergyRuleJSON) != "" {
energyRuleJSON = json.RawMessage(snapshot.EnergyRuleJSON)
}
return &ConfigResponse{ return &ConfigResponse{
Configured: snapshot.Configured, Configured: snapshot.Configured,
ID: snapshot.ID, ID: snapshot.ID,
@ -245,6 +253,7 @@ func configResponse(snapshot configSnapshot, levels []model.VoiceRoomRocketLevel
RewardRecordKeepDays: snapshot.RewardRecordKeepDays, RewardRecordKeepDays: snapshot.RewardRecordKeepDays,
BroadcastDurationSeconds: snapshot.BroadcastDurationSeconds, BroadcastDurationSeconds: snapshot.BroadcastDurationSeconds,
InRoomRewardDelaySeconds: snapshot.InRoomRewardDelaySeconds, InRoomRewardDelaySeconds: snapshot.InRoomRewardDelaySeconds,
EnergyRuleJSON: energyRuleJSON,
RuleText: ruleText, RuleText: ruleText,
Levels: levelPayloads, Levels: levelPayloads,
RewardPreview: rewardPreviewPayload(rewards), RewardPreview: rewardPreviewPayload(rewards),

View File

@ -13,12 +13,27 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
func (s *Service) notifyStatusUpdate(ctx context.Context, sysOrigin string, roomID int64) error { func (s *Service) notifyStatusUpdate(ctx context.Context, sysOrigin string, roomID int64, senderUserIDs ...int64) error {
data := map[string]any{
"roomId": strconv.FormatInt(roomID, 10),
}
if status, err := s.GetStatus(ctx, AuthUser{SysOrigin: sysOrigin}, roomID); err == nil && status != nil {
payload, _ := json.Marshal(status)
_ = json.Unmarshal(payload, &data)
}
senderUserID := int64(0)
if len(senderUserIDs) > 0 {
senderUserID = senderUserIDs[0]
}
if senderUserID > 0 {
data["senderUserId"] = strconv.FormatInt(senderUserID, 10)
}
if s.repo.Redis != nil { if s.repo.Redis != nil {
body, _ := json.Marshal(map[string]any{ body, _ := json.Marshal(map[string]any{
"type": imTypeStatusUpdate, "type": imTypeStatusUpdate,
"sysOrigin": sysOrigin, "sysOrigin": sysOrigin,
"roomId": strconv.FormatInt(roomID, 10), "roomId": strconv.FormatInt(roomID, 10),
"data": data,
}) })
streamKey := strings.TrimSpace(s.cfg.VoiceRoomRocket.BroadcastStreamKey) streamKey := strings.TrimSpace(s.cfg.VoiceRoomRocket.BroadcastStreamKey)
if streamKey == "" { if streamKey == "" {
@ -33,18 +48,45 @@ func (s *Service) notifyStatusUpdate(ctx context.Context, sysOrigin string, room
}, },
}).Err() }).Err()
} }
_ = s.notifyRegionStatusUpdate(ctx, sysOrigin, senderUserID, data)
if s.im == nil { if s.im == nil {
return nil return nil
} }
groupID := s.resolveRoomAccount(ctx, roomID) groupID := s.resolveRoomAccount(ctx, roomID)
return s.im.SendCustomGroupMessage(ctx, groupID, map[string]any{ return s.im.SendCustomGroupMessage(ctx, groupID, map[string]any{
"type": imTypeStatusUpdate, "type": imTypeStatusUpdate,
"data": map[string]any{ "data": data,
"roomId": strconv.FormatInt(roomID, 10),
},
}) })
} }
func (s *Service) notifyRegionStatusUpdate(ctx context.Context, sysOrigin string, senderUserID int64, data map[string]any) error {
if s.regionBroadcast == nil || s.regionResolver == nil || s.userProfiles == nil || senderUserID <= 0 {
return nil
}
profile, err := s.userProfiles.GetUserProfile(ctx, senderUserID)
if err != nil {
return err
}
countryCode := strings.TrimSpace(profile.CountryCode)
if countryCode == "" {
return nil
}
regionCode, err := s.regionResolver.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode)
if err != nil {
return err
}
regionCode = strings.TrimSpace(regionCode)
if regionCode == "" {
return nil
}
body := map[string]any{
"type": imTypeStatusUpdate,
"data": data,
}
_, err = s.regionBroadcast.SendRegionCustomMessage(ctx, sysOrigin, regionCode, body)
return err
}
func (s *Service) notifyLaunch(ctx context.Context, launch model.VoiceRoomRocketLaunch) error { func (s *Service) notifyLaunch(ctx context.Context, launch model.VoiceRoomRocketLaunch) error {
triggerUser := map[string]any{} triggerUser := map[string]any{}
if launch.IgniteUserID != nil && s.userProfiles != nil { if launch.IgniteUserID != nil && s.userProfiles != nil {

View File

@ -46,6 +46,9 @@ func (s *Service) createImmediateRewardRecords(ctx context.Context, tx *gorm.DB,
} }
func (s *Service) createRewardRecord(ctx context.Context, tx *gorm.DB, launch model.VoiceRoomRocketLaunch, reward model.VoiceRoomRocketRewardConfig, userID int64, now time.Time) (model.VoiceRoomRocketRewardRecord, error) { func (s *Service) createRewardRecord(ctx context.Context, tx *gorm.DB, launch model.VoiceRoomRocketLaunch, reward model.VoiceRoomRocketRewardConfig, userID int64, now time.Time) (model.VoiceRoomRocketRewardRecord, error) {
if strings.EqualFold(strings.TrimSpace(reward.RewardType), rewardTypeNone) {
return model.VoiceRoomRocketRewardRecord{}, nil
}
id, err := utils.NextID() id, err := utils.NextID()
if err != nil { if err != nil {
return model.VoiceRoomRocketRewardRecord{}, err return model.VoiceRoomRocketRewardRecord{}, err

View File

@ -3,6 +3,7 @@ package voiceroomrocket
import ( import (
"context" "context"
"errors" "errors"
"math"
"net/http" "net/http"
"time" "time"
@ -43,20 +44,23 @@ func (s *Service) GetStatus(ctx context.Context, user AuthUser, roomID int64) (*
shake := status.NeedEnergy > 0 && shake := status.NeedEnergy > 0 &&
float64(status.CurrentEnergy)*100/float64(status.NeedEnergy) >= parsePercent(currentLevelConfig.ShakeThresholdPercent) float64(status.CurrentEnergy)*100/float64(status.NeedEnergy) >= parsePercent(currentLevelConfig.ShakeThresholdPercent)
kings, _ := s.topKingRecords(ctx, snapshot.SysOrigin, roomID, status.DayKey, status.RoundNo, status.CurrentLevel, 3) kings, _ := s.topKingRecords(ctx, snapshot.SysOrigin, roomID, status.DayKey, status.RoundNo, status.CurrentLevel, 3)
countdownSeconds := s.launchCountdownSeconds(ctx, status, time.Now())
return &StatusResponse{ return &StatusResponse{
RoomID: roomID, RoomID: roomID,
DayKey: status.DayKey, DayKey: status.DayKey,
RoundNo: status.RoundNo, RoundNo: status.RoundNo,
DisplayRound: status.RoundNo > 1, DisplayRound: status.RoundNo > 1,
CurrentLevel: status.CurrentLevel, Status: status.Status,
CurrentEnergy: status.CurrentEnergy, CurrentLevel: status.CurrentLevel,
NeedEnergy: status.NeedEnergy, CurrentEnergy: status.CurrentEnergy,
EnergyPercent: percent, NeedEnergy: status.NeedEnergy,
DisplayPercent: displayPercent, EnergyPercent: percent,
Shake: shake, DisplayPercent: displayPercent,
Levels: buildLevelPayloads(levels), LaunchCountdownSeconds: countdownSeconds,
RewardPreview: rewardPreviewPayload(rewards), Shake: shake,
RocketKings: kings, Levels: buildLevelPayloads(levels),
RewardPreview: rewardPreviewPayload(rewards),
RocketKings: kings,
}, nil }, nil
} }
@ -111,6 +115,28 @@ func (s *Service) loadOrInitStatus(ctx context.Context, tx *gorm.DB, sysOrigin s
return row, nil return row, nil
} }
func (s *Service) launchCountdownSeconds(ctx context.Context, status model.VoiceRoomRocketStatus, now time.Time) int {
if status.Status != statusLaunching {
return 0
}
var round model.VoiceRoomRocketLevelRound
err := s.repo.DB.WithContext(ctx).
Where(
"sys_origin = ? AND room_id = ? AND day_key = ? AND round_no = ? AND level = ?",
status.SysOrigin, status.RoomID, status.DayKey, status.RoundNo, status.CurrentLevel,
).
First(&round).Error
if err != nil || round.ReachTime == nil || round.ReachTime.IsZero() {
return defaultLaunchDelaySeconds
}
due := round.ReachTime.Add(time.Duration(defaultLaunchDelaySeconds) * time.Second)
remaining := due.Sub(now).Seconds()
if remaining <= 0 {
return 0
}
return int(math.Ceil(remaining))
}
func (s *Service) ensureLevelRound(ctx context.Context, tx *gorm.DB, sysOrigin string, roomID int64, day string, roundNo int, level int, needEnergy int64, now time.Time) error { 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) db := s.repo.DB.WithContext(ctx)
if tx != nil { if tx != nil {

View File

@ -28,6 +28,7 @@ const (
defaultBroadcastDurationSeconds = 7 defaultBroadcastDurationSeconds = 7
defaultInRoomRewardDelaySeconds = 10 defaultInRoomRewardDelaySeconds = 10
defaultInRoomRewardUserLimit = 3 defaultInRoomRewardUserLimit = 3
defaultLaunchDelaySeconds = 10
defaultWorkerBatchSize = 100 defaultWorkerBatchSize = 100
defaultWorkerInterval = time.Second defaultWorkerInterval = time.Second
defaultRoomLockTTL = 10 * time.Second defaultRoomLockTTL = 10 * time.Second
@ -36,6 +37,7 @@ const (
statusLaunching = "LAUNCHING" statusLaunching = "LAUNCHING"
levelRoundStatusCharging = "CHARGING" levelRoundStatusCharging = "CHARGING"
levelRoundStatusReached = "REACHED"
levelRoundStatusLaunched = "LAUNCHED" levelRoundStatusLaunched = "LAUNCHED"
eventStatusIgnored = "IGNORED" eventStatusIgnored = "IGNORED"
@ -66,6 +68,7 @@ const (
rewardTypeVIPCard = "VIP_CARD" rewardTypeVIPCard = "VIP_CARD"
rewardTypeGift = "GIFT" rewardTypeGift = "GIFT"
rewardTypeBadge = "BADGE" rewardTypeBadge = "BADGE"
rewardTypeNone = "NONE"
propsTypeNobleVIP = "NOBLE_VIP" propsTypeNobleVIP = "NOBLE_VIP"
propsTypeRide = "RIDE" propsTypeRide = "RIDE"
@ -101,6 +104,14 @@ type userProfileGateway interface {
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error) GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
} }
type regionResolver interface {
ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error)
}
type regionBroadcaster interface {
SendRegionCustomMessage(ctx context.Context, sysOrigin string, regionCode string, body any) (string, error)
}
type walletGateway interface { type walletGateway interface {
ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error
ExistsGoldEvent(ctx context.Context, eventID string) (bool, error) ExistsGoldEvent(ctx context.Context, eventID string) (bool, error)
@ -128,16 +139,18 @@ type ports struct {
// Service 承接语音房火箭配置、状态、送礼加能量、发射、榜单和奖励记录。 // Service 承接语音房火箭配置、状态、送礼加能量、发射、榜单和奖励记录。
type Service struct { type Service struct {
cfg config.Config cfg config.Config
repo ports repo ports
gateways gatewayBundle gateways gatewayBundle
im imGateway im imGateway
roomProfiles roomProfileGateway roomProfiles roomProfileGateway
userProfiles userProfileGateway userProfiles userProfileGateway
wallet walletGateway wallet walletGateway
rewardGateway rewardGateway rewardGateway rewardGateway
location *time.Location regionResolver regionResolver
rocketConsumer rmq.PushConsumer regionBroadcast regionBroadcaster
location *time.Location
rocketConsumer rmq.PushConsumer
} }
// NewService 创建语音房火箭服务。 // NewService 创建语音房火箭服务。
@ -162,6 +175,9 @@ func NewService(cfg config.Config, db voiceRoomRocketDB, cache redis.Cmdable, ga
service.userProfiles = gateways service.userProfiles = gateways
service.wallet = gateways service.wallet = gateways
service.rewardGateway = gateways service.rewardGateway = gateways
if resolver, ok := any(gateways).(regionResolver); ok {
service.regionResolver = resolver
}
} }
return service return service
} }
@ -173,6 +189,13 @@ func (s *Service) SetIMGateway(gateway imGateway) {
} }
} }
// SetRegionBroadcaster 允许装配区域 IM 广播服务。
func (s *Service) SetRegionBroadcaster(broadcaster regionBroadcaster) {
if s != nil {
s.regionBroadcast = broadcaster
}
}
type flexibleInt64 int64 type flexibleInt64 int64
// FlexibleInt64 暴露给本地验证和路由测试,兼容 JSON 字符串形式的 Snowflake ID。 // FlexibleInt64 暴露给本地验证和路由测试,兼容 JSON 字符串形式的 Snowflake ID。
@ -331,6 +354,7 @@ type ConfigResponse struct {
RewardRecordKeepDays int `json:"rewardRecordKeepDays"` RewardRecordKeepDays int `json:"rewardRecordKeepDays"`
BroadcastDurationSeconds int `json:"broadcastDurationSeconds"` BroadcastDurationSeconds int `json:"broadcastDurationSeconds"`
InRoomRewardDelaySeconds int `json:"inRoomRewardDelaySeconds"` InRoomRewardDelaySeconds int `json:"inRoomRewardDelaySeconds"`
EnergyRuleJSON json.RawMessage `json:"energyRuleJson,omitempty"`
RuleText json.RawMessage `json:"ruleText,omitempty"` RuleText json.RawMessage `json:"ruleText,omitempty"`
Levels []LevelConfigPayload `json:"levels"` Levels []LevelConfigPayload `json:"levels"`
RewardPreview RewardPreviewResponse `json:"rewardPreview"` RewardPreview RewardPreviewResponse `json:"rewardPreview"`
@ -357,23 +381,26 @@ type SaveLevelConfigsRequest struct {
type SaveRewardConfigsRequest struct { type SaveRewardConfigsRequest struct {
SysOrigin string `json:"sysOrigin"` SysOrigin string `json:"sysOrigin"`
Level int `json:"level"`
Rewards []RewardConfigPayload `json:"rewards"` Rewards []RewardConfigPayload `json:"rewards"`
} }
type StatusResponse struct { type StatusResponse struct {
RoomID int64 `json:"roomId,string"` RoomID int64 `json:"roomId,string"`
DayKey string `json:"dayKey"` DayKey string `json:"dayKey"`
RoundNo int `json:"roundNo"` RoundNo int `json:"roundNo"`
DisplayRound bool `json:"displayRound"` DisplayRound bool `json:"displayRound"`
CurrentLevel int `json:"currentLevel"` Status string `json:"status"`
CurrentEnergy int64 `json:"currentEnergy"` CurrentLevel int `json:"currentLevel"`
NeedEnergy int64 `json:"needEnergy"` CurrentEnergy int64 `json:"currentEnergy"`
EnergyPercent string `json:"energyPercent"` NeedEnergy int64 `json:"needEnergy"`
DisplayPercent int `json:"displayPercent"` EnergyPercent string `json:"energyPercent"`
Shake bool `json:"shake"` DisplayPercent int `json:"displayPercent"`
Levels []LevelConfigPayload `json:"levels"` LaunchCountdownSeconds int `json:"launchCountdownSeconds,omitempty"`
RewardPreview RewardPreviewResponse `json:"rewardPreview"` Shake bool `json:"shake"`
RocketKings []KingRecord `json:"rocketKings"` Levels []LevelConfigPayload `json:"levels"`
RewardPreview RewardPreviewResponse `json:"rewardPreview"`
RocketKings []KingRecord `json:"rocketKings"`
} }
type KingResponse struct { type KingResponse struct {