package smashegg import ( "context" "errors" "net/http" "strconv" "strings" "time" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "gorm.io/gorm" ) type configBundle struct { Config *model.SmashEggConfig DrawOptions []model.SmashEggDrawOptionConfig Rewards []model.SmashEggRewardConfig } // GetConfig returns admin smash egg configuration. func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*ConfigResponse, error) { bundle, err := s.loadConfigBundle(ctx, sysOrigin, false) if err != nil { return nil, err } if bundle.Config == nil { return defaultConfigResponse(sysOrigin), nil } return s.buildConfigResponse(ctx, bundle, true) } // GetAppConfig returns enabled smash egg configuration for app clients. func (s *Service) GetAppConfig(ctx context.Context, user AuthUser) (*ConfigResponse, error) { bundle, err := s.loadConfigBundle(ctx, user.SysOrigin, true) if err != nil { return nil, err } if bundle.Config == nil || !bundle.Config.Enabled { return &ConfigResponse{ Configured: false, SysOrigin: normalizeSysOrigin(user.SysOrigin), Timezone: defaultTimezone, RTPBasisPoints: defaultRTPBasisPoints, RTPPercent: basisPointsPercent(defaultRTPBasisPoints), ProbabilityTotal: probabilityTotal, NewbieWindowDays: defaultNewbieWindowDays, NewbieMaxDrawCount: defaultNewbieMaxDrawCount, ActivePoolType: poolTypeGeneral, DrawOptions: []DrawOptionPayload{}, Rewards: []RewardConfigPayload{}, }, nil } resp, err := s.buildConfigResponse(ctx, bundle, false) if err != nil { return nil, err } poolType, rewards, eligibility, err := s.resolveRewardPool(ctx, bundle, user) if err != nil { return nil, err } options := make([]DrawOptionPayload, 0, len(bundle.DrawOptions)) for _, row := range bundle.DrawOptions { expectedValue := expectedRewardValueForOption(bundle.Config.RTPBasisPoints, row, rewards) options = append(options, drawOptionPayload(row, expectedValue)) } resp.ActivePoolType = poolType resp.NewbieEligible = eligibility.Eligible resp.NewbieRemainingDrawCount = eligibility.Remaining resp.DrawOptions = options resp.Rewards = rewardPayloadsFromConfig(rewards) resp.ExpectedValueGold = amount2(expectedRewardValue(rewards)) resp.NewbieRewards = nil return resp, nil } // SaveConfig saves admin smash egg configuration. func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*ConfigResponse, error) { normalized, err := normalizeSaveRequest(req) if err != nil { return nil, err } now := time.Now() if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var configRow model.SmashEggConfig if err := tx.Where("sys_origin = ?", normalized.SysOrigin).First(&configRow).Error; errors.Is(err, gorm.ErrRecordNotFound) { nextID, idErr := utils.NextID() if idErr != nil { return idErr } configRow = model.SmashEggConfig{ ID: nextID, SysOrigin: normalized.SysOrigin, CreateTime: now, } } else if err != nil { return err } configRow.Enabled = normalized.Enabled configRow.Timezone = normalized.Timezone configRow.RTPBasisPoints = normalized.RTPBasisPoints configRow.NewbiePoolEnabled = normalized.NewbiePoolEnabled configRow.NewbieWindowDays = normalized.NewbieWindowDays configRow.NewbieMaxDrawCount = normalized.NewbieMaxDrawCount configRow.NewbieMinRechargeAmount = normalized.NewbieMinRechargeAmount configRow.UpdateTime = now if configRow.CreateTime.IsZero() { configRow.CreateTime = now } if err := tx.Save(&configRow).Error; err != nil { return err } if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.SmashEggDrawOptionConfig{}).Error; err != nil { return err } if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.SmashEggRewardConfig{}).Error; err != nil { return err } optionRows := make([]model.SmashEggDrawOptionConfig, 0, len(normalized.DrawOptions)) for index, item := range normalized.DrawOptions { nextID, idErr := utils.NextID() if idErr != nil { return idErr } sortValue := item.Sort if sortValue <= 0 { sortValue = index + 1 } optionRows = append(optionRows, model.SmashEggDrawOptionConfig{ ID: nextID, ConfigID: configRow.ID, SysOrigin: normalized.SysOrigin, OptionKey: item.OptionKey, Label: strings.TrimSpace(item.Label), Times: item.Times, PriceGold: item.PriceGold, Sort: sortValue, Enabled: item.Enabled, CreateTime: now, UpdateTime: now, }) } if len(optionRows) > 0 { if err := tx.Create(&optionRows).Error; err != nil { return err } } allRewards := make([]RewardConfigInput, 0, len(normalized.Rewards)+len(normalized.NewbieRewards)) allRewards = append(allRewards, normalized.Rewards...) allRewards = append(allRewards, normalized.NewbieRewards...) rewardRows := make([]model.SmashEggRewardConfig, 0, len(allRewards)) for index, item := range allRewards { nextID, idErr := utils.NextID() if idErr != nil { return idErr } sortValue := item.Sort if sortValue <= 0 { sortValue = index + 1 } rewardRows = append(rewardRows, model.SmashEggRewardConfig{ ID: nextID, ConfigID: configRow.ID, SysOrigin: normalized.SysOrigin, PoolType: normalizePoolType(item.PoolType), RewardType: item.RewardType, RewardGroupID: item.RewardGroupID, RewardGroupName: strings.TrimSpace(item.RewardGroupName), ResourceType: strings.TrimSpace(item.ResourceType), ResourceURL: strings.TrimSpace(item.ResourceURL), CoverURL: strings.TrimSpace(item.CoverURL), AnimationURL: strings.TrimSpace(item.AnimationURL), DurationDays: item.DurationDays, DisplayGoldAmount: item.DisplayGoldAmount, GoldAmount: item.GoldAmount, RewardValueGold: item.RewardValueGold, Probability: item.Probability, Sort: sortValue, Enabled: item.Enabled, CreateTime: now, UpdateTime: now, }) } if len(rewardRows) > 0 { return tx.Create(&rewardRows).Error } return nil }); err != nil { return nil, err } return s.GetConfig(ctx, normalized.SysOrigin) } func normalizeSaveRequest(req SaveConfigRequest) (SaveConfigRequest, error) { sysOrigin := normalizeSysOrigin(req.SysOrigin) if sysOrigin == "" { return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required") } timezone := normalizeTimezone(req.Timezone) if _, err := time.LoadLocation(timezone); err != nil { return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error()) } rtp := req.RTPBasisPoints if rtp <= 0 { rtp = defaultRTPBasisPoints } if rtp > 100000 { return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_rtp", "rtpBasisPoints must be less than or equal to 100000") } options := append([]DrawOptionInput(nil), req.DrawOptions...) if len(options) == 0 { options = defaultDrawOptionInputs() } sortOptionInputs(options) enabledOptionCount := 0 seenTimes := map[int]struct{}{} for index := range options { item := &options[index] item.OptionKey = normalizeOptionKey(item.OptionKey, index) item.Label = strings.TrimSpace(item.Label) if item.Label == "" { item.Label = item.OptionKey } if item.Sort <= 0 { item.Sort = index + 1 } if item.Times <= 0 { return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_draw_times", "draw option times must be greater than 0") } if item.PriceGold <= 0 { return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_draw_price", "draw option priceGold must be greater than 0") } if _, exists := seenTimes[item.Times]; exists { return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "duplicate_draw_times", "draw option times must be unique") } seenTimes[item.Times] = struct{}{} if item.Enabled { enabledOptionCount++ } } rewards, enabledRewardCount, err := normalizeRewardInputs(req.Rewards, poolTypeGeneral) if err != nil { return SaveConfigRequest{}, err } newbieRewards, newbieEnabledRewardCount, err := normalizeRewardInputs(req.NewbieRewards, poolTypeNewbie) if err != nil { return SaveConfigRequest{}, err } newbieWindowDays := req.NewbieWindowDays if newbieWindowDays <= 0 { newbieWindowDays = defaultNewbieWindowDays } if newbieWindowDays > 90 { return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_newbie_window_days", "newbieWindowDays must be less than or equal to 90") } newbieMaxDrawCount := req.NewbieMaxDrawCount if req.NewbiePoolEnabled && newbieMaxDrawCount <= 0 { newbieMaxDrawCount = defaultNewbieMaxDrawCount } if newbieMaxDrawCount < 0 { return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_newbie_max_draw_count", "newbieMaxDrawCount must be greater than or equal to 0") } if req.NewbieMinRechargeAmount < 0 { return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_newbie_min_recharge_amount", "newbieMinRechargeAmount must be greater than or equal to 0") } if req.Enabled && enabledOptionCount == 0 { return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "empty_draw_options", "enabled smash egg requires at least one enabled draw option") } if req.Enabled && enabledRewardCount == 0 { return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "empty_rewards", "enabled smash egg requires at least one enabled reward") } if enabledOptionCount > 0 && enabledRewardCount > 0 { rewards, err = applyAutoProbabilitiesToInputs(rtp, options, rewards, "general") if err != nil { return SaveConfigRequest{}, err } } if req.Enabled && req.NewbiePoolEnabled && newbieEnabledRewardCount == 0 { return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "empty_newbie_rewards", "enabled newbie pool requires at least one enabled reward") } if req.NewbiePoolEnabled && enabledOptionCount > 0 && newbieEnabledRewardCount > 0 { newbieRewards, err = applyAutoProbabilitiesToInputs(rtp, options, newbieRewards, "newbie") if err != nil { return SaveConfigRequest{}, err } } return SaveConfigRequest{ SysOrigin: sysOrigin, Enabled: req.Enabled, Timezone: timezone, RTPBasisPoints: rtp, NewbiePoolEnabled: req.NewbiePoolEnabled, NewbieWindowDays: newbieWindowDays, NewbieMaxDrawCount: newbieMaxDrawCount, NewbieMinRechargeAmount: req.NewbieMinRechargeAmount, DrawOptions: options, Rewards: rewards, NewbieRewards: newbieRewards, }, nil } func normalizeRewardInputs(input []RewardConfigInput, poolType string) ([]RewardConfigInput, int, error) { rewards := append([]RewardConfigInput(nil), input...) sortRewardInputs(rewards) enabledRewardCount := 0 for index := range rewards { item := &rewards[index] item.PoolType = normalizePoolType(poolType) item.RewardType = normalizeRewardType(item.RewardType) if item.RewardType == "" { item.RewardType = rewardTypeGold } item.ResourceType = strings.ToUpper(strings.TrimSpace(item.ResourceType)) item.ResourceName = strings.TrimSpace(firstNonEmpty(item.ResourceName, item.RewardGroupName)) item.ResourceURL = strings.TrimSpace(item.ResourceURL) item.RewardGroupName = strings.TrimSpace(item.RewardGroupName) item.CoverURL = strings.TrimSpace(item.CoverURL) item.AnimationURL = strings.TrimSpace(item.AnimationURL) if item.Sort <= 0 { item.Sort = index + 1 } switch item.RewardType { case rewardTypeGold: if item.GoldAmount <= 0 { return nil, 0, NewAppError(http.StatusBadRequest, "invalid_gold_amount", "gold reward requires goldAmount") } item.ResourceID = 0 item.ResourceType = "" item.ResourceName = "" item.ResourceURL = "" item.RewardGroupID = nil item.RewardGroupName = "金币" item.DurationDays = 0 item.DisplayGoldAmount = 0 item.RewardValueGold = item.GoldAmount case rewardTypeResource: if item.ResourceID.Int64() <= 0 && item.RewardGroupID != nil { item.ResourceID = ResourceID(*item.RewardGroupID) } if item.ResourceID.Int64() <= 0 { return nil, 0, NewAppError(http.StatusBadRequest, "invalid_resource", "resource reward requires resourceId") } if item.ResourceType == "" { return nil, 0, NewAppError(http.StatusBadRequest, "invalid_resource_type", "resource reward requires resourceType") } if item.DurationDays <= 0 { return nil, 0, NewAppError(http.StatusBadRequest, "invalid_duration_days", "resource reward requires durationDays greater than 0") } if item.DisplayGoldAmount <= 0 { if item.RewardValueGold > 0 { item.DisplayGoldAmount = item.RewardValueGold } else { return nil, 0, NewAppError(http.StatusBadRequest, "invalid_display_gold_amount", "resource reward displayGoldAmount must be greater than 0") } } item.RewardGroupID = resourceIDPtr(item.ResourceID) if item.ResourceName == "" { item.ResourceName = "资源 " + strconv.FormatInt(item.ResourceID.Int64(), 10) } item.RewardGroupName = item.ResourceName item.GoldAmount = 0 item.RewardValueGold = item.DisplayGoldAmount if item.AnimationURL == "" { item.AnimationURL = item.ResourceURL } default: return nil, 0, NewAppError(http.StatusBadRequest, "invalid_reward_type", "rewardType must be RESOURCE or GOLD") } if item.RewardValueGold <= 0 { return nil, 0, NewAppError(http.StatusBadRequest, "invalid_reward_value", "reward value must be greater than 0") } item.Probability = 0 if item.Enabled { enabledRewardCount++ } } return rewards, enabledRewardCount, nil } func weightedRewardValueFromInputs(rewards []RewardConfigInput) int64 { var total int64 for _, reward := range rewards { if !reward.Enabled || reward.Probability <= 0 { continue } total += reward.RewardValueGold * int64(reward.Probability) } return total } func drawOptionRTPBasisPointsFromWeightedValue(weightedValue int64, times int, priceGold int64) int { if weightedValue <= 0 || times <= 0 || priceGold <= 0 { return 0 } numerator := weightedValue * int64(times) * 10000 denominator := priceGold * int64(probabilityTotal) return int(roundDiv(numerator, denominator)) } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } return "" } func effectiveNewbieWindowDays(value int) int { if value <= 0 { return defaultNewbieWindowDays } return value } func effectiveNewbieMaxDrawCount(value int, enabled bool) int { if value <= 0 && enabled { return defaultNewbieMaxDrawCount } return value } func (s *Service) loadConfigBundle(ctx context.Context, sysOrigin string, onlyEnabled bool) (*configBundle, error) { sysOrigin = normalizeSysOrigin(sysOrigin) if sysOrigin == "" { return nil, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required") } var configRow model.SmashEggConfig err := s.db.WithContext(ctx).Where("sys_origin = ?", sysOrigin).First(&configRow).Error if errors.Is(err, gorm.ErrRecordNotFound) { return &configBundle{}, nil } if err != nil { return nil, err } optionQuery := s.db.WithContext(ctx).Where("config_id = ?", configRow.ID).Order("sort asc, id asc") rewardQuery := s.db.WithContext(ctx).Where("config_id = ?", configRow.ID).Order("sort asc, id asc") if onlyEnabled { optionQuery = optionQuery.Where("enabled = ?", true) rewardQuery = rewardQuery.Where("enabled = ?", true) } var options []model.SmashEggDrawOptionConfig if err := optionQuery.Find(&options).Error; err != nil { return nil, err } var rewards []model.SmashEggRewardConfig if err := rewardQuery.Find(&rewards).Error; err != nil { return nil, err } return &configBundle{Config: &configRow, DrawOptions: options, Rewards: rewards}, nil } func defaultConfigResponse(sysOrigin string) *ConfigResponse { return &ConfigResponse{ Configured: false, SysOrigin: normalizeSysOrigin(sysOrigin), Enabled: false, Timezone: defaultTimezone, RTPBasisPoints: defaultRTPBasisPoints, RTPPercent: basisPointsPercent(defaultRTPBasisPoints), ProbabilityTotal: probabilityTotal, ExpectedValueGold: "0.00", NewbieWindowDays: defaultNewbieWindowDays, NewbieMaxDrawCount: defaultNewbieMaxDrawCount, ActivePoolType: poolTypeGeneral, DrawOptions: defaultDrawOptionPayloads(0), Rewards: []RewardConfigPayload{}, NewbieRewards: []RewardConfigPayload{}, } } func (s *Service) buildConfigResponse(ctx context.Context, bundle *configBundle, includeRewardItems bool) (*ConfigResponse, error) { configRow := bundle.Config generalRewards := rewardsForPool(bundle.Rewards, poolTypeGeneral) newbieRewards := rewardsForPool(bundle.Rewards, poolTypeNewbie) expectedValue := expectedRewardValue(generalRewards) newbieExpectedValue := expectedRewardValue(newbieRewards) options := make([]DrawOptionPayload, 0, len(bundle.DrawOptions)) for _, row := range bundle.DrawOptions { optionExpectedValue := expectedRewardValueForOption(configRow.RTPBasisPoints, row, generalRewards) options = append(options, drawOptionPayload(row, optionExpectedValue)) } rewards := rewardPayloadsFromConfig(generalRewards) newbieRewardPayloads := rewardPayloadsFromConfig(newbieRewards) return &ConfigResponse{ Configured: true, ID: configRow.ID, SysOrigin: configRow.SysOrigin, Enabled: configRow.Enabled, Timezone: normalizeTimezone(configRow.Timezone), RTPBasisPoints: configRow.RTPBasisPoints, RTPPercent: basisPointsPercent(configRow.RTPBasisPoints), ProbabilityTotal: probabilityTotal, ExpectedValueGold: amount2(expectedValue), NewbiePoolEnabled: configRow.NewbiePoolEnabled, NewbieWindowDays: effectiveNewbieWindowDays(configRow.NewbieWindowDays), NewbieMaxDrawCount: effectiveNewbieMaxDrawCount(configRow.NewbieMaxDrawCount, configRow.NewbiePoolEnabled), NewbieMinRechargeAmount: configRow.NewbieMinRechargeAmount, NewbieExpectedValueGold: amount2(newbieExpectedValue), ActivePoolType: poolTypeGeneral, DrawOptions: options, Rewards: rewards, NewbieRewards: newbieRewardPayloads, UpdateTime: formatDateTime(configRow.UpdateTime), }, nil } func rewardPayloadsFromConfig(rewards []model.SmashEggRewardConfig) []RewardConfigPayload { payloads := make([]RewardConfigPayload, 0, len(rewards)) for _, row := range rewards { payloads = append(payloads, rewardPayloadFromConfig(row)) } return payloads } func drawOptionPayload(row model.SmashEggDrawOptionConfig, expectedValuePerDraw float64) DrawOptionPayload { rtp := 0 if row.PriceGold > 0 { rtp = int((expectedValuePerDraw * float64(row.Times) / float64(row.PriceGold) * 10000) + 0.5) } return DrawOptionPayload{ ID: row.ID, Enabled: row.Enabled, Sort: row.Sort, OptionKey: row.OptionKey, Label: row.Label, Times: row.Times, PriceGold: row.PriceGold, RTPBasisPoints: rtp, RTPPercent: basisPointsPercent(rtp), ExpectedValueGold: amount2(expectedValuePerDraw * float64(row.Times)), } } func rewardPayloadFromConfig(row model.SmashEggRewardConfig) RewardConfigPayload { return RewardConfigPayload{ ID: row.ID, Enabled: row.Enabled, Sort: row.Sort, PoolType: normalizePoolType(row.PoolType), RewardType: row.RewardType, ResourceID: ResourceID(int64PtrValue(row.RewardGroupID)), ResourceType: row.ResourceType, ResourceName: row.RewardGroupName, ResourceURL: row.ResourceURL, RewardGroupID: row.RewardGroupID, RewardGroupName: row.RewardGroupName, CoverURL: row.CoverURL, AnimationURL: row.AnimationURL, DurationDays: row.DurationDays, DisplayGoldAmount: row.DisplayGoldAmount, GoldAmount: row.GoldAmount, RewardValueGold: row.RewardValueGold, Probability: row.Probability, ProbabilityPercent: probabilityPercent(row.Probability), } } func rewardsForPool(rewards []model.SmashEggRewardConfig, poolType string) []model.SmashEggRewardConfig { poolType = normalizePoolType(poolType) result := make([]model.SmashEggRewardConfig, 0, len(rewards)) for _, reward := range rewards { reward.RewardType = normalizeRewardType(reward.RewardType) if normalizePoolType(reward.PoolType) == poolType { result = append(result, reward) } } return result } func expectedRewardValue(rewards []model.SmashEggRewardConfig) float64 { var total float64 for _, reward := range rewards { if !reward.Enabled || reward.Probability <= 0 { continue } total += float64(reward.RewardValueGold) * float64(reward.Probability) / probabilityTotal } return total } func defaultDrawOptionInputs() []DrawOptionInput { return []DrawOptionInput{ {Enabled: true, Sort: 1, OptionKey: optionKeyOne, Label: "Smash 1", Times: 1, PriceGold: 500}, {Enabled: true, Sort: 2, OptionKey: optionKeyTen, Label: "Smash 10", Times: 10, PriceGold: 5000}, {Enabled: true, Sort: 3, OptionKey: optionKeyHundred, Label: "Smash 100", Times: 100, PriceGold: 50000}, } } func defaultDrawOptionPayloads(expectedValuePerDraw float64) []DrawOptionPayload { defaults := defaultDrawOptionInputs() rows := make([]DrawOptionPayload, 0, len(defaults)) for _, item := range defaults { rows = append(rows, drawOptionPayload(model.SmashEggDrawOptionConfig{ Enabled: item.Enabled, Sort: item.Sort, OptionKey: item.OptionKey, Label: item.Label, Times: item.Times, PriceGold: item.PriceGold, }, expectedValuePerDraw)) } return rows }