package roomrocket import ( "context" "errors" "fmt" "slices" "strings" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/roomclient" ) const ( roomRocketLevelCount = 5 defaultFuelSource = "heat_value" defaultLaunchDelayMS = int64(30_000) defaultBroadcastScope = "region" defaultRewardStackPolicy = "allow_stack" ) var ( ErrInvalidArgument = errors.New("invalid argument") defaultLevelFuelThresholds = []int64{1_000, 3_000, 6_000, 10_000, 15_000} ) type Service struct { roomClient roomclient.Client } type RoomRocketConfig struct { AppCode string `json:"appCode"` Enabled bool `json:"enabled"` ConfigVersion int64 `json:"configVersion"` FuelSource string `json:"fuelSource"` LaunchDelayMS int64 `json:"launchDelayMs"` BroadcastEnabled bool `json:"broadcastEnabled"` BroadcastScope string `json:"broadcastScope"` BroadcastDelayMS int64 `json:"broadcastDelayMs"` RewardStackPolicy string `json:"rewardStackPolicy"` Levels []RoomRocketLevelConfig `json:"levels"` GiftFuelRules []GiftFuelRuleConfig `json:"giftFuelRules"` UpdatedByAdminID int64 `json:"updatedByAdminId"` CreatedAtMS int64 `json:"createdAtMs"` UpdatedAtMS int64 `json:"updatedAtMs"` } type RoomRocketLevelConfig struct { Level int32 `json:"level"` FuelThreshold int64 `json:"fuelThreshold"` CoverURL string `json:"coverUrl"` AnimationURL string `json:"animationUrl"` LaunchAnimationURL string `json:"launchAnimationUrl"` LaunchedImageURL string `json:"launchedImageUrl"` InRoomRewards []RoomRocketRewardItem `json:"inRoomRewards"` Top1Rewards []RoomRocketRewardItem `json:"top1Rewards"` IgniterRewards []RoomRocketRewardItem `json:"igniterRewards"` } type RoomRocketRewardItem struct { RewardItemID string `json:"rewardItemId"` ResourceGroupID int64 `json:"resourceGroupId"` Weight int64 `json:"weight"` Quantity int64 `json:"quantity"` DisplayName string `json:"displayName"` IconURL string `json:"iconUrl"` } type GiftFuelRuleConfig struct { RuleID string `json:"ruleId"` GiftID string `json:"giftId"` GiftTypeCode string `json:"giftTypeCode"` MultiplierPPM int64 `json:"multiplierPpm"` FixedFuel int64 `json:"fixedFuel"` Excluded bool `json:"excluded"` } type updateRoomRocketConfigRequest struct { Enabled bool `json:"enabled"` FuelSource string `json:"fuelSource"` LaunchDelayMS int64 `json:"launchDelayMs"` BroadcastEnabled bool `json:"broadcastEnabled"` BroadcastScope string `json:"broadcastScope"` BroadcastDelayMS int64 `json:"broadcastDelayMs"` RewardStackPolicy string `json:"rewardStackPolicy"` Levels []RoomRocketLevelConfig `json:"levels"` GiftFuelRules []GiftFuelRuleConfig `json:"giftFuelRules"` } func NewService(roomClient roomclient.Client) *Service { return &Service{roomClient: roomClient} } // GetConfig 读取当前 App 的语音房火箭配置;缺行时返回关闭态默认配置,便于首次进入后台页面。 func (s *Service) GetConfig(ctx context.Context) (RoomRocketConfig, error) { if s.roomClient == nil { return RoomRocketConfig{}, fmt.Errorf("room service client is not configured") } config, err := s.roomClient.GetRoomRocketConfig(ctx) if err != nil { return RoomRocketConfig{}, err } return configFromClient(config) } // UpdateConfig 保存房间火箭低频配置;版本号由后台递增,运行时只使用已提交版本。 func (s *Service) UpdateConfig(ctx context.Context, req updateRoomRocketConfigRequest, operatorAdminID int64) (RoomRocketConfig, error) { if s.roomClient == nil { return RoomRocketConfig{}, fmt.Errorf("room service client is not configured") } config, err := normalizeConfig(configFromUpdateRequest(ctx, req)) if err != nil { return RoomRocketConfig{}, err } saved, err := s.roomClient.UpdateRoomRocketConfig(ctx, roomclient.UpdateRoomRocketConfigRequest{ Config: configToClient(config), AdminID: operatorAdminID, }) if err != nil { return RoomRocketConfig{}, err } return configFromClient(saved) } func configFromUpdateRequest(ctx context.Context, req updateRoomRocketConfigRequest) RoomRocketConfig { // 房间火箭配置按 App 隔离;更新请求体不让前端传 app_code,必须使用中间件写入的上下文值。 return RoomRocketConfig{ AppCode: appctx.FromContext(ctx), Enabled: req.Enabled, FuelSource: req.FuelSource, LaunchDelayMS: req.LaunchDelayMS, BroadcastEnabled: req.BroadcastEnabled, BroadcastScope: req.BroadcastScope, BroadcastDelayMS: req.BroadcastDelayMS, RewardStackPolicy: req.RewardStackPolicy, Levels: req.Levels, GiftFuelRules: req.GiftFuelRules, } } func defaultConfig(appCode string) RoomRocketConfig { levels := make([]RoomRocketLevelConfig, 0, roomRocketLevelCount) for index, threshold := range defaultLevelFuelThresholds { levels = append(levels, RoomRocketLevelConfig{ Level: int32(index + 1), FuelThreshold: threshold, }) } return RoomRocketConfig{ AppCode: appCode, FuelSource: defaultFuelSource, LaunchDelayMS: defaultLaunchDelayMS, BroadcastEnabled: true, BroadcastScope: defaultBroadcastScope, RewardStackPolicy: defaultRewardStackPolicy, Levels: levels, GiftFuelRules: []GiftFuelRuleConfig{}, } } func normalizeConfig(config RoomRocketConfig) (RoomRocketConfig, error) { config.AppCode = strings.TrimSpace(config.AppCode) if config.AppCode == "" { return RoomRocketConfig{}, fmt.Errorf("%w: app_code 不能为空", ErrInvalidArgument) } if config.ConfigVersion < 0 { return RoomRocketConfig{}, fmt.Errorf("%w: 配置版本不能小于 0", ErrInvalidArgument) } config.FuelSource = defaultString(strings.TrimSpace(config.FuelSource), defaultFuelSource) if config.FuelSource != defaultFuelSource { return RoomRocketConfig{}, fmt.Errorf("%w: 燃料来源不支持", ErrInvalidArgument) } if config.LaunchDelayMS < 0 { return RoomRocketConfig{}, fmt.Errorf("%w: 发射倒计时不能小于 0", ErrInvalidArgument) } config.BroadcastScope = defaultString(strings.TrimSpace(config.BroadcastScope), defaultBroadcastScope) if !stringIn(config.BroadcastScope, "none", "region", "global") { return RoomRocketConfig{}, fmt.Errorf("%w: 广播范围不支持", ErrInvalidArgument) } if config.BroadcastDelayMS < 0 { return RoomRocketConfig{}, fmt.Errorf("%w: 广播延迟不能小于 0", ErrInvalidArgument) } config.RewardStackPolicy = defaultString(strings.TrimSpace(config.RewardStackPolicy), defaultRewardStackPolicy) if !stringIn(config.RewardStackPolicy, "allow_stack", "priority_only") { return RoomRocketConfig{}, fmt.Errorf("%w: 奖励叠加策略不支持", ErrInvalidArgument) } levels, err := normalizeLevels(config.Levels) if err != nil { return RoomRocketConfig{}, err } config.Levels = levels giftFuelRules, err := normalizeGiftFuelRules(config.GiftFuelRules) if err != nil { return RoomRocketConfig{}, err } config.GiftFuelRules = giftFuelRules return config, nil } func normalizeLevels(levels []RoomRocketLevelConfig) ([]RoomRocketLevelConfig, error) { if len(levels) == 0 { return defaultConfig("placeholder").Levels, nil } if len(levels) != roomRocketLevelCount { return nil, fmt.Errorf("%w: 火箭等级必须配置 5 级", ErrInvalidArgument) } seen := make(map[int32]bool, roomRocketLevelCount) normalized := make([]RoomRocketLevelConfig, 0, roomRocketLevelCount) for _, level := range levels { if level.Level < 1 || level.Level > roomRocketLevelCount { return nil, fmt.Errorf("%w: 火箭等级只能是 1 到 5", ErrInvalidArgument) } if seen[level.Level] { return nil, fmt.Errorf("%w: 火箭等级不能重复", ErrInvalidArgument) } seen[level.Level] = true if level.FuelThreshold <= 0 { return nil, fmt.Errorf("%w: 第 %d 级燃料阈值必须大于 0", ErrInvalidArgument, level.Level) } level.CoverURL = strings.TrimSpace(level.CoverURL) level.AnimationURL = strings.TrimSpace(level.AnimationURL) level.LaunchAnimationURL = strings.TrimSpace(level.LaunchAnimationURL) level.LaunchedImageURL = strings.TrimSpace(level.LaunchedImageURL) var err error if level.InRoomRewards, err = normalizeRewardPool(level.Level, "in_room", level.InRoomRewards); err != nil { return nil, err } if level.Top1Rewards, err = normalizeRewardPool(level.Level, "top1", level.Top1Rewards); err != nil { return nil, err } if level.IgniterRewards, err = normalizeRewardPool(level.Level, "igniter", level.IgniterRewards); err != nil { return nil, err } normalized = append(normalized, level) } slices.SortFunc(normalized, func(left, right RoomRocketLevelConfig) int { return int(left.Level - right.Level) }) return normalized, nil } func normalizeRewardPool(level int32, role string, rewards []RoomRocketRewardItem) ([]RoomRocketRewardItem, error) { normalized := make([]RoomRocketRewardItem, 0, len(rewards)) for index, reward := range rewards { reward.RewardItemID = strings.TrimSpace(reward.RewardItemID) reward.DisplayName = strings.TrimSpace(reward.DisplayName) reward.IconURL = strings.TrimSpace(reward.IconURL) if reward.RewardItemID == "" && reward.ResourceGroupID == 0 && reward.Weight == 0 && reward.Quantity == 0 && reward.DisplayName == "" && reward.IconURL == "" { continue } if reward.ResourceGroupID <= 0 { return nil, fmt.Errorf("%w: 第 %d 级奖励资源组不能为空", ErrInvalidArgument, level) } if reward.Weight <= 0 { return nil, fmt.Errorf("%w: 第 %d 级奖励权重必须大于 0", ErrInvalidArgument, level) } if reward.Quantity <= 0 { // 历史配置没有 quantity 字段;后台读写都按 1 份兼容,避免旧配置打开页面即失败。 reward.Quantity = 1 } if reward.RewardItemID == "" { reward.RewardItemID = fmt.Sprintf("level_%d_%s_%d", level, role, index+1) } normalized = append(normalized, reward) } return normalized, nil } func normalizeGiftFuelRules(rules []GiftFuelRuleConfig) ([]GiftFuelRuleConfig, error) { normalized := make([]GiftFuelRuleConfig, 0, len(rules)) for index, rule := range rules { rule.RuleID = strings.TrimSpace(rule.RuleID) rule.GiftID = strings.TrimSpace(rule.GiftID) rule.GiftTypeCode = strings.TrimSpace(rule.GiftTypeCode) if rule.RuleID == "" && rule.GiftID == "" && rule.GiftTypeCode == "" && rule.MultiplierPPM == 0 && rule.FixedFuel == 0 && !rule.Excluded { continue } if rule.GiftID == "" && rule.GiftTypeCode == "" { return nil, fmt.Errorf("%w: 礼物燃料规则必须选择礼物或礼物类型", ErrInvalidArgument) } if rule.MultiplierPPM < 0 || rule.FixedFuel < 0 { return nil, fmt.Errorf("%w: 礼物燃料规则不能配置负数", ErrInvalidArgument) } if !rule.Excluded && rule.MultiplierPPM == 0 && rule.FixedFuel == 0 { return nil, fmt.Errorf("%w: 礼物燃料规则必须配置倍率或固定燃料", ErrInvalidArgument) } if rule.RuleID == "" { rule.RuleID = fmt.Sprintf("gift_fuel_rule_%d", index+1) } normalized = append(normalized, rule) } return normalized, nil } func defaultString(value string, fallback string) string { if value == "" { return fallback } return value } func stringIn(value string, candidates ...string) bool { for _, candidate := range candidates { if value == candidate { return true } } return false } func configFromClient(input roomclient.RoomRocketConfig) (RoomRocketConfig, error) { config := RoomRocketConfig{ AppCode: input.AppCode, Enabled: input.Enabled, ConfigVersion: input.ConfigVersion, FuelSource: input.FuelSource, LaunchDelayMS: input.LaunchDelayMS, BroadcastEnabled: input.BroadcastEnabled, BroadcastScope: input.BroadcastScope, BroadcastDelayMS: input.BroadcastDelayMS, RewardStackPolicy: input.RewardStackPolicy, Levels: levelsFromClient(input.Levels), GiftFuelRules: fuelRulesFromClient(input.GiftFuelRules), UpdatedByAdminID: input.UpdatedByAdminID, CreatedAtMS: input.CreatedAtMS, UpdatedAtMS: input.UpdatedAtMS, } return normalizeConfig(config) } func configToClient(input RoomRocketConfig) roomclient.RoomRocketConfig { return roomclient.RoomRocketConfig{ Enabled: input.Enabled, FuelSource: input.FuelSource, LaunchDelayMS: input.LaunchDelayMS, BroadcastEnabled: input.BroadcastEnabled, BroadcastScope: input.BroadcastScope, BroadcastDelayMS: input.BroadcastDelayMS, RewardStackPolicy: input.RewardStackPolicy, Levels: levelsToClient(input.Levels), GiftFuelRules: fuelRulesToClient(input.GiftFuelRules), } } func levelsFromClient(input []roomclient.RoomRocketLevelConfig) []RoomRocketLevelConfig { out := make([]RoomRocketLevelConfig, 0, len(input)) for _, level := range input { out = append(out, RoomRocketLevelConfig{ Level: level.Level, FuelThreshold: level.FuelThreshold, CoverURL: level.CoverURL, AnimationURL: level.AnimationURL, LaunchAnimationURL: level.LaunchAnimationURL, LaunchedImageURL: level.LaunchedImageURL, InRoomRewards: rewardsFromClient(level.InRoomRewards), Top1Rewards: rewardsFromClient(level.Top1Rewards), IgniterRewards: rewardsFromClient(level.IgniterRewards), }) } return out } func levelsToClient(input []RoomRocketLevelConfig) []roomclient.RoomRocketLevelConfig { out := make([]roomclient.RoomRocketLevelConfig, 0, len(input)) for _, level := range input { out = append(out, roomclient.RoomRocketLevelConfig{ Level: level.Level, FuelThreshold: level.FuelThreshold, CoverURL: level.CoverURL, AnimationURL: level.AnimationURL, LaunchAnimationURL: level.LaunchAnimationURL, LaunchedImageURL: level.LaunchedImageURL, InRoomRewards: rewardsToClient(level.InRoomRewards), Top1Rewards: rewardsToClient(level.Top1Rewards), IgniterRewards: rewardsToClient(level.IgniterRewards), }) } return out } func rewardsFromClient(input []roomclient.RoomRocketRewardItem) []RoomRocketRewardItem { out := make([]RoomRocketRewardItem, 0, len(input)) for _, reward := range input { out = append(out, RoomRocketRewardItem{ RewardItemID: reward.RewardItemID, ResourceGroupID: reward.ResourceGroupID, Weight: reward.Weight, Quantity: reward.Quantity, DisplayName: reward.DisplayName, IconURL: reward.IconURL, }) } return out } func rewardsToClient(input []RoomRocketRewardItem) []roomclient.RoomRocketRewardItem { out := make([]roomclient.RoomRocketRewardItem, 0, len(input)) for _, reward := range input { out = append(out, roomclient.RoomRocketRewardItem{ RewardItemID: reward.RewardItemID, ResourceGroupID: reward.ResourceGroupID, Weight: reward.Weight, Quantity: reward.Quantity, DisplayName: reward.DisplayName, IconURL: reward.IconURL, }) } return out } func fuelRulesFromClient(input []roomclient.GiftFuelRuleConfig) []GiftFuelRuleConfig { out := make([]GiftFuelRuleConfig, 0, len(input)) for _, rule := range input { out = append(out, GiftFuelRuleConfig{ RuleID: rule.RuleID, GiftID: rule.GiftID, GiftTypeCode: rule.GiftTypeCode, MultiplierPPM: rule.MultiplierPPM, FixedFuel: rule.FixedFuel, Excluded: rule.Excluded, }) } return out } func fuelRulesToClient(input []GiftFuelRuleConfig) []roomclient.GiftFuelRuleConfig { out := make([]roomclient.GiftFuelRuleConfig, 0, len(input)) for _, rule := range input { out = append(out, roomclient.GiftFuelRuleConfig{ RuleID: rule.RuleID, GiftID: rule.GiftID, GiftTypeCode: rule.GiftTypeCode, MultiplierPPM: rule.MultiplierPPM, FixedFuel: rule.FixedFuel, Excluded: rule.Excluded, }) } return out }