435 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"`
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.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.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,
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,
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
}