package voiceroomrocket import ( "context" "encoding/json" "errors" "net/http" "strings" "time" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "gorm.io/gorm" ) // GetConfig 返回 App 火箭配置、等级资源和当前等级奖励预览。 func (s *Service) GetConfig(ctx context.Context, user AuthUser) (*ConfigResponse, error) { return s.getConfigResponse(ctx, user.SysOrigin, 0) } // GetRule 返回火箭规则说明。 func (s *Service) GetRule(ctx context.Context, user AuthUser) (json.RawMessage, error) { snapshot, err := s.loadConfig(ctx, user.SysOrigin) if err != nil { return nil, err } if strings.TrimSpace(snapshot.RuleText) == "" { return json.RawMessage(`{}`), nil } return json.RawMessage(snapshot.RuleText), nil } // GetAdminConfig 返回后台配置总览。 func (s *Service) GetAdminConfig(ctx context.Context, sysOrigin string) (*ConfigResponse, error) { return s.getConfigResponse(ctx, sysOrigin, 0) } // SaveAdminConfig 保存后台系统配置。 func (s *Service) SaveAdminConfig(ctx context.Context, req SaveConfigRequest) (*ConfigResponse, error) { sysOrigin := s.normalizeSysOrigin(req.SysOrigin) timezone := defaultTimezone maxLevel := req.MaxLevel if maxLevel <= 0 { maxLevel = defaultMaxLevel } else if maxLevel > defaultMaxLevel { maxLevel = defaultMaxLevel } rankingLimit := req.RankingTopLimit if rankingLimit <= 0 || rankingLimit > 100 { rankingLimit = defaultRankingTopLimit } keepDays := req.RewardRecordKeepDays if keepDays <= 0 { keepDays = defaultRewardRecordKeepDays } broadcastSeconds := req.BroadcastDurationSeconds if broadcastSeconds <= 0 { broadcastSeconds = defaultBroadcastDurationSeconds } inRoomDelay := req.InRoomRewardDelaySeconds if inRoomDelay <= 0 { inRoomDelay = defaultInRoomRewardDelaySeconds } energyRule := strings.TrimSpace(string(req.EnergyRuleJSON)) if energyRule != "" && energyRule != "null" && !json.Valid([]byte(energyRule)) { return nil, NewAppError(http.StatusBadRequest, "invalid_energy_rule_json", "energyRuleJson must be valid JSON") } ruleText := strings.TrimSpace(string(req.RuleText)) if ruleText != "" && ruleText != "null" && !json.Valid([]byte(ruleText)) { return nil, NewAppError(http.StatusBadRequest, "invalid_rule_text", "ruleText must be valid JSON") } if err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { now := time.Now() var row model.VoiceRoomRocketConfig err := tx.Where("sys_origin = ?", sysOrigin).First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { id, idErr := utils.NextID() if idErr != nil { return idErr } row = model.VoiceRoomRocketConfig{ ID: id, SysOrigin: sysOrigin, CreateTime: now, } } else if err != nil { return err } row.Enabled = req.Enabled row.Timezone = timezone row.MaxLevel = maxLevel row.RankingTopLimit = rankingLimit row.RewardRecordKeepDays = keepDays row.BroadcastDurationSeconds = broadcastSeconds row.InRoomRewardDelaySeconds = inRoomDelay row.EnergyRuleJSON = nullJSONToEmpty(energyRule) row.RuleText = nullJSONToEmpty(ruleText) row.UpdateTime = now if row.CreateTime.IsZero() { row.CreateTime = now } return tx.Save(&row).Error }); err != nil { return nil, err } return s.GetAdminConfig(ctx, sysOrigin) } // ListLevelConfigs 返回后台等级配置。 func (s *Service) ListLevelConfigs(ctx context.Context, sysOrigin string) ([]LevelConfigPayload, error) { rows, err := s.loadLevelConfigs(ctx, s.normalizeSysOrigin(sysOrigin), false) if err != nil { return nil, err } result := make([]LevelConfigPayload, 0, len(rows)) for _, row := range rows { result = append(result, levelPayload(row)) } return result, nil } // SaveLevelConfigs 批量保存后台等级配置。 func (s *Service) SaveLevelConfigs(ctx context.Context, req SaveLevelConfigsRequest) ([]LevelConfigPayload, error) { sysOrigin := s.normalizeSysOrigin(req.SysOrigin) if len(req.Levels) == 0 { return nil, NewAppError(http.StatusBadRequest, "missing_level_configs", "levels is required") } seen := map[int]struct{}{} if err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { now := time.Now() for _, item := range req.Levels { if item.Level <= 0 || item.Level > defaultMaxLevel { return NewAppError(http.StatusBadRequest, "invalid_level", "level must be between 1 and 6") } if _, ok := seen[item.Level]; ok { return NewAppError(http.StatusBadRequest, "duplicate_level", "level must be unique") } seen[item.Level] = struct{}{} if item.NeedEnergy <= 0 { return NewAppError(http.StatusBadRequest, "invalid_need_energy", "needEnergy must be positive") } threshold := strings.TrimSpace(item.ShakeThresholdPercent) if threshold == "" { threshold = "0.0000" } var row model.VoiceRoomRocketLevelConfig err := tx.Where("sys_origin = ? AND level = ?", sysOrigin, item.Level).First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { id, idErr := utils.NextID() if idErr != nil { return idErr } row = model.VoiceRoomRocketLevelConfig{ ID: id, SysOrigin: sysOrigin, Level: item.Level, CreateTime: now, } } else if err != nil { return err } row.NeedEnergy = item.NeedEnergy row.ShakeThresholdPercent = threshold row.PreviewRocketURL = strings.TrimSpace(item.PreviewRocketURL) row.RocketIconURL = strings.TrimSpace(item.RocketIconURL) row.RocketAnimationURL = strings.TrimSpace(item.RocketAnimationURL) row.ProgressBarURL = strings.TrimSpace(item.ProgressBarURL) row.Enabled = item.Enabled row.Sort = item.Sort row.UpdateTime = now if row.CreateTime.IsZero() { row.CreateTime = now } if err := tx.Save(&row).Error; err != nil { return err } } return nil }); err != nil { return nil, err } return s.ListLevelConfigs(ctx, sysOrigin) } // ListRewardConfigs 返回后台奖励配置。 func (s *Service) ListRewardConfigs(ctx context.Context, sysOrigin string, level int) ([]RewardConfigPayload, error) { query := s.repo.DB.WithContext(ctx). Where("sys_origin = ?", s.normalizeSysOrigin(sysOrigin)). Order("level ASC, reward_scene ASC, sort ASC, id ASC") if level > 0 { query = query.Where("level = ?", level) } var rows []model.VoiceRoomRocketRewardConfig if err := query.Find(&rows).Error; err != nil { return nil, err } result := make([]RewardConfigPayload, 0, len(rows)) for _, row := range rows { result = append(result, rewardPayload(row)) } return result, nil } // SaveRewardConfigs 批量保存后台奖励配置。 func (s *Service) SaveRewardConfigs(ctx context.Context, req SaveRewardConfigsRequest) ([]RewardConfigPayload, error) { sysOrigin := s.normalizeSysOrigin(req.SysOrigin) targetLevels := map[int]struct{}{} if req.Level > 0 { if req.Level > defaultMaxLevel { return nil, NewAppError(http.StatusBadRequest, "invalid_level", "level must be between 1 and 6") } targetLevels[req.Level] = struct{}{} } if err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { now := time.Now() savedIDsByLevel := map[int][]int64{} if len(req.Rewards) == 0 { if len(targetLevels) == 0 { return NewAppError(http.StatusBadRequest, "missing_reward_configs", "rewards is required") } for level := range targetLevels { if err := tx.Where("sys_origin = ? AND level = ?", sysOrigin, level). Delete(&model.VoiceRoomRocketRewardConfig{}).Error; err != nil { return err } } return nil } for _, item := range req.Rewards { scene := strings.ToUpper(strings.TrimSpace(item.RewardScene)) if scene != rewardSceneTop1 && scene != rewardSceneIgnite && scene != rewardSceneInRoom { return NewAppError(http.StatusBadRequest, "invalid_reward_scene", "rewardScene is invalid") } if item.Level <= 0 || item.Level > defaultMaxLevel { return NewAppError(http.StatusBadRequest, "invalid_level", "level must be between 1 and 6") } if req.Level > 0 && item.Level != req.Level { return NewAppError(http.StatusBadRequest, "invalid_level", "reward level must match request level") } targetLevels[item.Level] = struct{}{} rewardType := strings.ToUpper(strings.TrimSpace(item.RewardType)) if rewardType == "" { return NewAppError(http.StatusBadRequest, "missing_reward_type", "rewardType is required") } if !isSupportedRewardType(rewardType) { return NewAppError(http.StatusBadRequest, "invalid_reward_type", "rewardType is invalid") } if rewardType == rewardTypeNone && scene != rewardSceneInRoom { return NewAppError(http.StatusBadRequest, "invalid_reward_type", "NONE reward is only allowed for IN_ROOM") } rewardName := strings.TrimSpace(item.RewardName) if rewardType == rewardTypeNone && rewardName == "" { rewardName = "轮空" } if rewardName == "" { return NewAppError(http.StatusBadRequest, "missing_reward_name", "rewardName is required") } amount := item.RewardAmount if rewardType == rewardTypeNone { amount = 0 item.RewardItemID = nil item.ExpireDays = nil } else if amount <= 0 { amount = 1 } rewardCopies := item.RewardCopies if scene != rewardSceneInRoom { rewardCopies = 1 } else if rewardCopies <= 0 { rewardCopies = 1 } var row model.VoiceRoomRocketRewardConfig if item.ID > 0 { if err := tx.Where("id = ? AND sys_origin = ?", item.ID, sysOrigin).First(&row).Error; err != nil { return err } } else { id, idErr := utils.NextID() if idErr != nil { return idErr } row = model.VoiceRoomRocketRewardConfig{ ID: id, SysOrigin: sysOrigin, CreateTime: now, } } row.Level = item.Level row.RewardScene = scene row.RewardType = rewardType row.RewardItemID = item.RewardItemID row.RewardName = rewardName row.RewardCover = strings.TrimSpace(item.RewardCover) row.RewardAmount = amount row.ExpireDays = item.ExpireDays row.RewardCopies = rewardCopies row.Weight = item.Weight row.Sort = item.Sort row.Enabled = item.Enabled row.UpdateTime = now if row.CreateTime.IsZero() { row.CreateTime = now } if err := tx.Save(&row).Error; err != nil { return err } savedIDsByLevel[item.Level] = append(savedIDsByLevel[item.Level], row.ID) } for level := range targetLevels { query := tx.Where("sys_origin = ? AND level = ?", sysOrigin, level) if ids := savedIDsByLevel[level]; len(ids) > 0 { query = query.Where("id NOT IN ?", ids) } if err := query.Delete(&model.VoiceRoomRocketRewardConfig{}).Error; err != nil { return err } } return nil }); err != nil { return nil, err } return s.ListRewardConfigs(ctx, sysOrigin, req.Level) } func (s *Service) getConfigResponse(ctx context.Context, sysOrigin string, currentLevel int) (*ConfigResponse, error) { snapshot, err := s.loadConfig(ctx, sysOrigin) if err != nil { return nil, err } levels, err := s.loadLevelConfigs(ctx, snapshot.SysOrigin, true) if err != nil { return nil, err } rewardLevel := currentLevel if rewardLevel <= 0 { rewardLevel = 1 } rewards, err := s.loadRewardConfigs(ctx, snapshot.SysOrigin, rewardLevel) if err != nil { return nil, err } return configResponse(snapshot, levels, rewards), nil } func (s *Service) loadConfig(ctx context.Context, sysOrigin string) (configSnapshot, error) { sysOrigin = s.normalizeSysOrigin(sysOrigin) var row model.VoiceRoomRocketConfig err := s.repo.DB.WithContext(ctx).Where("sys_origin = ?", sysOrigin).First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return configSnapshot{ SysOrigin: sysOrigin, Enabled: false, Timezone: defaultTimezone, MaxLevel: defaultMaxLevel, RankingTopLimit: defaultRankingTopLimit, RewardRecordKeepDays: defaultRewardRecordKeepDays, BroadcastDurationSeconds: defaultBroadcastDurationSeconds, InRoomRewardDelaySeconds: defaultInRoomRewardDelaySeconds, }, nil } if err != nil { return configSnapshot{}, err } return configSnapshot{ Configured: true, ID: row.ID, SysOrigin: row.SysOrigin, Enabled: row.Enabled, Timezone: defaultTimezone, MaxLevel: positiveOrDefault(row.MaxLevel, defaultMaxLevel), RankingTopLimit: clampPositive(row.RankingTopLimit, defaultRankingTopLimit, 100), RewardRecordKeepDays: positiveOrDefault(row.RewardRecordKeepDays, defaultRewardRecordKeepDays), BroadcastDurationSeconds: positiveOrDefault(row.BroadcastDurationSeconds, defaultBroadcastDurationSeconds), InRoomRewardDelaySeconds: positiveOrDefault(row.InRoomRewardDelaySeconds, defaultInRoomRewardDelaySeconds), EnergyRuleJSON: row.EnergyRuleJSON, RuleText: row.RuleText, UpdateTime: row.UpdateTime, }, nil } func (s *Service) loadLevelConfigs(ctx context.Context, sysOrigin string, enabledOnly bool) ([]model.VoiceRoomRocketLevelConfig, error) { query := s.repo.DB.WithContext(ctx). Where("sys_origin = ?", s.normalizeSysOrigin(sysOrigin)). Order("sort ASC, level ASC") if enabledOnly { query = query.Where("enabled = ?", true) } var rows []model.VoiceRoomRocketLevelConfig if err := query.Find(&rows).Error; err != nil { return nil, err } if len(rows) == 0 && enabledOnly { return defaultLevelRows(s.normalizeSysOrigin(sysOrigin)), nil } return rows, nil } func (s *Service) loadLevelConfig(ctx context.Context, sysOrigin string, level int) (model.VoiceRoomRocketLevelConfig, error) { var row model.VoiceRoomRocketLevelConfig err := s.repo.DB.WithContext(ctx). Where("sys_origin = ? AND level = ? AND enabled = ?", s.normalizeSysOrigin(sysOrigin), level, true). First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { defaults := defaultLevelRows(s.normalizeSysOrigin(sysOrigin)) for _, item := range defaults { if item.Level == level { return item, nil } } return model.VoiceRoomRocketLevelConfig{}, NewAppError(http.StatusBadRequest, "rocket_level_not_configured", "rocket level is not configured") } return row, err } func (s *Service) loadRewardConfigs(ctx context.Context, sysOrigin string, level int) ([]model.VoiceRoomRocketRewardConfig, error) { query := s.repo.DB.WithContext(ctx). Where("sys_origin = ? AND enabled = ?", s.normalizeSysOrigin(sysOrigin), true). Order("reward_scene ASC, sort ASC, id ASC") if level > 0 { query = query.Where("level = ?", level) } var rows []model.VoiceRoomRocketRewardConfig if err := query.Find(&rows).Error; err != nil { return nil, err } return rows, nil } func defaultLevelRows(sysOrigin string) []model.VoiceRoomRocketLevelConfig { rows := make([]model.VoiceRoomRocketLevelConfig, 0, defaultMaxLevel) for i := 1; i <= defaultMaxLevel; i++ { rows = append(rows, model.VoiceRoomRocketLevelConfig{ SysOrigin: sysOrigin, Level: i, NeedEnergy: int64(i * 100), ShakeThresholdPercent: "95.0000", Enabled: true, Sort: i, }) } return rows } func positiveOrDefault(value int, fallback int) int { if value <= 0 { return fallback } return value } func clampPositive(value int, fallback int, maxValue int) int { if value <= 0 { value = fallback } if maxValue > 0 && value > maxValue { return maxValue } return value } func nullJSONToEmpty(value string) string { value = strings.TrimSpace(value) if value == "" || value == "null" { return "{}" } return value }