2026-05-15 09:58:01 +08:00

625 lines
22 KiB
Go

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
}