package wheel import ( "context" "errors" "fmt" "net/http" "strings" "time" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "gorm.io/gorm" ) type configBundle struct { Config *model.WheelConfig Categories []model.WheelPoolConfig Rewards []model.WheelRewardConfig } // GetConfig returns admin wheel 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(bundle) } // GetAppConfig returns enabled wheel 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 { return &ConfigResponse{ Configured: false, SysOrigin: normalizeSysOrigin(user.SysOrigin), Timezone: defaultTimezone, ProbabilityTotal: probabilityTotal, Categories: defaultCategoryPayloads(false), }, nil } return s.buildConfigResponse(bundle) } // SaveConfig saves admin wheel configuration. func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*ConfigResponse, error) { normalized, err := normalizeSaveRequest(req) if err != nil { return nil, err } configEnabled := false for _, category := range normalized.Categories { if category.Enabled { configEnabled = true break } } now := time.Now() if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var configRow model.WheelConfig 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.WheelConfig{ ID: nextID, SysOrigin: normalized.SysOrigin, CreateTime: now, } } else if err != nil { return err } configRow.Enabled = configEnabled configRow.Timezone = normalized.Timezone 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.WheelPoolConfig{}).Error; err != nil { return err } if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.WheelRewardConfig{}).Error; err != nil { return err } poolRows := make([]model.WheelPoolConfig, 0, len(normalized.Categories)) rewardRows := make([]model.WheelRewardConfig, 0, 28) for _, category := range normalized.Categories { meta, _ := categoryMeta(category.Category) poolID, idErr := utils.NextID() if idErr != nil { return idErr } poolRows = append(poolRows, model.WheelPoolConfig{ ID: poolID, ConfigID: configRow.ID, SysOrigin: normalized.SysOrigin, Category: category.Category, Enabled: category.Enabled, PriceOneGold: category.PriceOneGold, PriceTenGold: category.PriceTenGold, PriceFiftyGold: category.PriceFiftyGold, Sort: meta.Sort, CreateTime: now, UpdateTime: now, }) for index, item := range category.Rewards { nextID, rewardIDErr := utils.NextID() if rewardIDErr != nil { return rewardIDErr } sortValue := item.Sort if sortValue <= 0 { sortValue = index + 1 } rewardRows = append(rewardRows, model.WheelRewardConfig{ ID: nextID, ConfigID: configRow.ID, SysOrigin: normalized.SysOrigin, Category: category.Category, RewardType: item.RewardType, ResourceID: resourceIDPtr(item.ResourceID), ResourceType: item.ResourceType, ResourceName: strings.TrimSpace(item.ResourceName), ResourceURL: strings.TrimSpace(item.ResourceURL), CoverURL: strings.TrimSpace(item.CoverURL), DurationDays: item.DurationDays, DisplayGoldAmount: item.DisplayGoldAmount, GoldAmount: item.GoldAmount, Probability: item.Probability, Sort: sortValue, Enabled: item.Enabled, CreateTime: now, UpdateTime: now, }) } } if len(poolRows) > 0 { if err := tx.Create(&poolRows).Error; err != nil { return err } } if len(rewardRows) > 0 { return tx.Create(&rewardRows).Error } return nil }); err != nil { return nil, err } return s.GetConfig(ctx, normalized.SysOrigin) } // SaveCategoryConfig saves one admin wheel category without touching the other categories. func (s *Service) SaveCategoryConfig(ctx context.Context, req SaveCategoryConfigRequest) (*ConfigResponse, error) { normalized, err := normalizeSaveCategoryRequest(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.WheelConfig 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.WheelConfig{ ID: nextID, SysOrigin: normalized.SysOrigin, CreateTime: now, } } else if err != nil { return err } configRow.Timezone = normalized.Timezone configRow.UpdateTime = now if configRow.CreateTime.IsZero() { configRow.CreateTime = now } if err := tx.Save(&configRow).Error; err != nil { return err } category := normalized.Category meta, _ := categoryMeta(category.Category) if err := tx.Where("config_id = ? AND category = ?", configRow.ID, category.Category).Delete(&model.WheelPoolConfig{}).Error; err != nil { return err } if err := tx.Where("config_id = ? AND category = ?", configRow.ID, category.Category).Delete(&model.WheelRewardConfig{}).Error; err != nil { return err } poolID, idErr := utils.NextID() if idErr != nil { return idErr } poolRow := model.WheelPoolConfig{ ID: poolID, ConfigID: configRow.ID, SysOrigin: normalized.SysOrigin, Category: category.Category, Enabled: category.Enabled, PriceOneGold: category.PriceOneGold, PriceTenGold: category.PriceTenGold, PriceFiftyGold: category.PriceFiftyGold, Sort: meta.Sort, CreateTime: now, UpdateTime: now, } if err := tx.Create(&poolRow).Error; err != nil { return err } rewardRows := make([]model.WheelRewardConfig, 0, len(category.Rewards)) for index, item := range category.Rewards { nextID, rewardIDErr := utils.NextID() if rewardIDErr != nil { return rewardIDErr } sortValue := item.Sort if sortValue <= 0 { sortValue = index + 1 } rewardRows = append(rewardRows, model.WheelRewardConfig{ ID: nextID, ConfigID: configRow.ID, SysOrigin: normalized.SysOrigin, Category: category.Category, RewardType: item.RewardType, ResourceID: resourceIDPtr(item.ResourceID), ResourceType: item.ResourceType, ResourceName: strings.TrimSpace(item.ResourceName), ResourceURL: strings.TrimSpace(item.ResourceURL), CoverURL: strings.TrimSpace(item.CoverURL), DurationDays: item.DurationDays, DisplayGoldAmount: item.DisplayGoldAmount, GoldAmount: item.GoldAmount, Probability: item.Probability, Sort: sortValue, Enabled: item.Enabled, CreateTime: now, UpdateTime: now, }) } if len(rewardRows) > 0 { if err := tx.Create(&rewardRows).Error; err != nil { return err } } var enabledPools int64 if err := tx.Model(&model.WheelPoolConfig{}). Where("config_id = ? AND enabled = ?", configRow.ID, true). Count(&enabledPools).Error; err != nil { return err } return tx.Model(&model.WheelConfig{}). Where("id = ?", configRow.ID). Updates(map[string]any{ "enabled": enabledPools > 0, "timezone": normalized.Timezone, "update_time": now, }).Error }); 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()) } inputByCategory := make(map[string]CategoryConfigInput, len(req.Categories)) for _, category := range req.Categories { normalizedCategory := normalizeCategory(category.Category) if _, ok := categoryMeta(normalizedCategory); !ok { return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_category", "category must be CLASSIC, LUXURY, or ADVANCED") } category.Category = normalizedCategory inputByCategory[normalizedCategory] = category } categories := make([]CategoryConfigInput, 0, len(wheelCategories)) for _, meta := range wheelCategories { category, exists := inputByCategory[meta.Category] if !exists { category = defaultCategoryInput(meta) } category.Category = meta.Category if category.PriceOneGold <= 0 || category.PriceTenGold <= 0 || category.PriceFiftyGold <= 0 { return SaveConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_price", fmt.Sprintf("%s draw prices must be greater than 0", meta.Category)) } normalizedRewards, err := normalizeCategoryRewards(meta, category.Rewards, category.Enabled) if err != nil { return SaveConfigRequest{}, err } category.Rewards = normalizedRewards categories = append(categories, category) } return SaveConfigRequest{ SysOrigin: sysOrigin, Enabled: req.Enabled, Timezone: timezone, Categories: categories, }, nil } func normalizeSaveCategoryRequest(req SaveCategoryConfigRequest) (SaveCategoryConfigRequest, error) { sysOrigin := normalizeSysOrigin(req.SysOrigin) if sysOrigin == "" { return SaveCategoryConfigRequest{}, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required") } timezone := normalizeTimezone(req.Timezone) if _, err := time.LoadLocation(timezone); err != nil { return SaveCategoryConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error()) } category := req.Category category.Category = normalizeCategory(category.Category) meta, ok := categoryMeta(category.Category) if !ok { return SaveCategoryConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_category", "category must be CLASSIC, LUXURY, or ADVANCED") } if category.PriceOneGold <= 0 || category.PriceTenGold <= 0 || category.PriceFiftyGold <= 0 { return SaveCategoryConfigRequest{}, NewAppError(http.StatusBadRequest, "invalid_price", fmt.Sprintf("%s draw prices must be greater than 0", meta.Category)) } rewards, err := normalizeCategoryRewards(meta, category.Rewards, category.Enabled) if err != nil { return SaveCategoryConfigRequest{}, err } category.Rewards = rewards return SaveCategoryConfigRequest{ SysOrigin: sysOrigin, Timezone: timezone, Category: category, }, nil } func normalizeCategoryRewards(meta CategoryMeta, rewards []RewardConfigInput, requireEnabled bool) ([]RewardConfigInput, error) { if len(rewards) != meta.RewardCount { return nil, NewAppError(http.StatusBadRequest, "invalid_reward_count", fmt.Sprintf("%s rewards must contain exactly %d items", meta.Category, meta.RewardCount)) } normalized := append([]RewardConfigInput(nil), rewards...) sortRewardInputs(normalized) enabledProbability := 0 enabledCount := 0 for index := range normalized { item := &normalized[index] item.RewardType = normalizeRewardType(item.RewardType) item.ResourceType = strings.ToUpper(strings.TrimSpace(item.ResourceType)) item.ResourceName = strings.TrimSpace(item.ResourceName) item.ResourceURL = strings.TrimSpace(item.ResourceURL) item.CoverURL = strings.TrimSpace(item.CoverURL) if item.Sort <= 0 { item.Sort = index + 1 } switch item.RewardType { case rewardTypeResource: if requireEnabled && item.ResourceID.Int64() <= 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_resource", fmt.Sprintf("%s resource reward requires resourceId", meta.Category)) } if requireEnabled && item.ResourceType == "" { return nil, NewAppError(http.StatusBadRequest, "invalid_resource_type", fmt.Sprintf("%s resource reward requires resourceType", meta.Category)) } if requireEnabled && item.DurationDays <= 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_duration_days", fmt.Sprintf("%s resource reward requires durationDays greater than 0", meta.Category)) } if requireEnabled && item.DisplayGoldAmount <= 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_display_gold_amount", fmt.Sprintf("%s resource reward display gold amount must be greater than 0", meta.Category)) } if item.ResourceID.Int64() > 0 && item.ResourceName == "" { item.ResourceName = fmt.Sprintf("资源 %d", item.ResourceID.Int64()) } item.GoldAmount = 0 case rewardTypeGold: if requireEnabled && item.GoldAmount <= 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_gold_amount", fmt.Sprintf("%s gold reward requires goldAmount", meta.Category)) } item.ResourceID = 0 item.ResourceType = "" item.ResourceName = "金币" item.ResourceURL = "" item.CoverURL = "" item.DurationDays = 0 item.DisplayGoldAmount = 0 default: return nil, NewAppError(http.StatusBadRequest, "invalid_reward_type", "rewardType must be RESOURCE or GOLD") } if item.Probability < 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_probability", "probability must be greater than or equal to 0") } if item.Enabled { enabledCount++ if requireEnabled && item.Probability <= 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_probability", fmt.Sprintf("%s enabled reward probability must be greater than 0", meta.Category)) } enabledProbability += item.Probability } } if requireEnabled { if enabledCount != meta.RewardCount { return nil, NewAppError(http.StatusBadRequest, "invalid_enabled_reward_count", fmt.Sprintf("%s must enable exactly %d rewards", meta.Category, meta.RewardCount)) } if enabledProbability != probabilityTotal { return nil, NewAppError(http.StatusBadRequest, "invalid_probability_total", fmt.Sprintf("%s enabled reward probability total must equal 10000", meta.Category)) } } return normalized, nil } func (s *Service) loadConfigBundle(ctx context.Context, sysOrigin string, onlyEnabledRewards bool) (*configBundle, error) { sysOrigin = normalizeSysOrigin(sysOrigin) if sysOrigin == "" { return nil, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required") } var configRow model.WheelConfig 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 } var categoryRows []model.WheelPoolConfig categoryQuery := s.db.WithContext(ctx). Where("config_id = ?", configRow.ID). Order("sort asc, id asc") if err := categoryQuery.Find(&categoryRows).Error; err != nil { return nil, err } rewardQuery := s.db.WithContext(ctx). Where("config_id = ?", configRow.ID). Order("category asc, sort asc, id asc") if onlyEnabledRewards { rewardQuery = rewardQuery.Where("enabled = ?", true) } var rewardRows []model.WheelRewardConfig if err := rewardQuery.Find(&rewardRows).Error; err != nil { return nil, err } return &configBundle{Config: &configRow, Categories: categoryRows, Rewards: rewardRows}, nil } func defaultConfigResponse(sysOrigin string) *ConfigResponse { return &ConfigResponse{ Configured: false, SysOrigin: normalizeSysOrigin(sysOrigin), Enabled: false, Timezone: defaultTimezone, ProbabilityTotal: probabilityTotal, Categories: defaultCategoryPayloads(false), } } func defaultCategoryInput(meta CategoryMeta) CategoryConfigInput { return CategoryConfigInput{ Category: meta.Category, Enabled: false, PriceOneGold: meta.DefaultPriceOneGold, PriceTenGold: meta.DefaultPriceTenGold, PriceFiftyGold: meta.DefaultPriceFiftyGold, Rewards: defaultRewardInputs(meta.RewardCount), } } func defaultRewardInputs(count int) []RewardConfigInput { rewards := make([]RewardConfigInput, 0, count) for index := 0; index < count; index++ { rewards = append(rewards, RewardConfigInput{ Enabled: true, Sort: index + 1, RewardType: rewardTypeResource, Probability: 0, }) } return rewards } func defaultCategoryPayloads(enabled bool) []CategoryConfigPayload { result := make([]CategoryConfigPayload, 0, len(wheelCategories)) for _, meta := range wheelCategories { rewards := make([]RewardConfigPayload, 0, meta.RewardCount) for index := 0; index < meta.RewardCount; index++ { rewards = append(rewards, RewardConfigPayload{ Enabled: true, Sort: index + 1, RewardType: rewardTypeResource, Probability: 0, ProbabilityPercent: "0.00", }) } result = append(result, CategoryConfigPayload{ Category: meta.Category, Label: meta.Label, Enabled: enabled, RewardCount: meta.RewardCount, PriceOneGold: meta.DefaultPriceOneGold, PriceTenGold: meta.DefaultPriceTenGold, PriceFiftyGold: meta.DefaultPriceFiftyGold, ProbabilityTotal: probabilityTotal, Rewards: rewards, }) } return result } func (s *Service) buildConfigResponse(bundle *configBundle) (*ConfigResponse, error) { configRow := bundle.Config categoryByName := make(map[string]model.WheelPoolConfig, len(bundle.Categories)) for _, row := range bundle.Categories { categoryByName[normalizeCategory(row.Category)] = row } rewardsByCategory := make(map[string][]model.WheelRewardConfig, len(wheelCategories)) for _, row := range bundle.Rewards { category := normalizeCategory(row.Category) rewardsByCategory[category] = append(rewardsByCategory[category], row) } categories := make([]CategoryConfigPayload, 0, len(wheelCategories)) for _, meta := range wheelCategories { pool, exists := categoryByName[meta.Category] if !exists { pool = model.WheelPoolConfig{ Category: meta.Category, Enabled: false, PriceOneGold: meta.DefaultPriceOneGold, PriceTenGold: meta.DefaultPriceTenGold, PriceFiftyGold: meta.DefaultPriceFiftyGold, Sort: meta.Sort, } } rewards := make([]RewardConfigPayload, 0, len(rewardsByCategory[meta.Category])) for _, row := range rewardsByCategory[meta.Category] { rewards = append(rewards, rewardPayloadFromConfig(row)) } for len(rewards) < meta.RewardCount { rewards = append(rewards, RewardConfigPayload{ Enabled: true, Sort: len(rewards) + 1, RewardType: rewardTypeResource, Probability: 0, ProbabilityPercent: "0.00", }) } if len(rewards) > meta.RewardCount { rewards = rewards[:meta.RewardCount] } categories = append(categories, CategoryConfigPayload{ Category: meta.Category, Label: meta.Label, Enabled: pool.Enabled, RewardCount: meta.RewardCount, PriceOneGold: pool.PriceOneGold, PriceTenGold: pool.PriceTenGold, PriceFiftyGold: pool.PriceFiftyGold, ProbabilityTotal: probabilityTotal, Rewards: rewards, }) } return &ConfigResponse{ Configured: true, ID: configRow.ID, SysOrigin: configRow.SysOrigin, Enabled: configRow.Enabled, Timezone: normalizeTimezone(configRow.Timezone), ProbabilityTotal: probabilityTotal, Categories: categories, UpdateTime: formatDateTime(configRow.UpdateTime), }, nil } func rewardPayloadFromConfig(row model.WheelRewardConfig) RewardConfigPayload { return RewardConfigPayload{ ID: row.ID, Enabled: row.Enabled, Sort: row.Sort, RewardType: row.RewardType, ResourceID: resourceIDValue(row.ResourceID), ResourceType: row.ResourceType, ResourceName: row.ResourceName, ResourceURL: row.ResourceURL, CoverURL: row.CoverURL, DurationDays: row.DurationDays, DisplayGoldAmount: row.DisplayGoldAmount, GoldAmount: row.GoldAmount, Probability: row.Probability, ProbabilityPercent: probabilityPercent(row.Probability), } }