package gameking import ( "context" "encoding/json" "errors" "fmt" "math" "net/http" "sort" "strings" "time" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "gorm.io/gorm" "gorm.io/gorm/clause" ) var fixedRankRanges = [][2]int{{1, 1}, {2, 2}, {3, 3}, {4, 7}, {8, 10}, {11, 30}} func normalizeSysOrigin(value string) string { value = strings.ToUpper(strings.TrimSpace(value)) if value == "" { return "LIKEI" } return value } func normalizeTimezone(value string) (string, *time.Location, error) { value = strings.TrimSpace(value) if value == "" { value = defaultTimezone } location, err := time.LoadLocation(value) if err != nil { return "", nil, NewAppError(http.StatusBadRequest, "invalid_timezone", "timeZone must be a valid IANA timezone") } return value, location, nil } func normalizeRankingType(value string) (string, error) { value = strings.ToUpper(strings.TrimSpace(value)) if value == "" { value = rankingTypeTycoon } if value != rankingTypeTycoon { return "", NewAppError(http.StatusBadRequest, "invalid_ranking_type", "rankingType must be TYCOON") } return value, nil } func normalizeRankingPeriod(value string) (string, error) { value = strings.ToUpper(strings.TrimSpace(value)) if value == "" { value = rankingPeriodOverall } if value != rankingPeriodOverall { return "", NewAppError(http.StatusBadRequest, "invalid_ranking_period", "configured rankingPeriod must be OVERALL") } return value, nil } func normalizeActivityInput(req SaveActivityRequest) (SaveActivityRequest, error) { req.ActivityCode = strings.TrimSpace(req.ActivityCode) req.ActivityName = strings.TrimSpace(req.ActivityName) req.ActivityDesc = strings.TrimSpace(req.ActivityDesc) if req.ActivityCode == "" || len(req.ActivityCode) > 64 { return req, NewAppError(http.StatusBadRequest, "invalid_activity_code", "activityCode is required and must not exceed 64 characters") } if req.ActivityName == "" || len(req.ActivityName) > 128 { return req, NewAppError(http.StatusBadRequest, "invalid_activity_name", "activityName is required and must not exceed 128 characters") } if len(req.ActivityDesc) > 1000 { return req, NewAppError(http.StatusBadRequest, "invalid_activity_desc", "activityDesc must not exceed 1000 characters") } req.SysOrigin = normalizeSysOrigin(req.SysOrigin) if req.SysOrigin != "LIKEI" { return req, NewAppError(http.StatusBadRequest, "invalid_sys_origin", "Yumi game king only supports LIKEI sysOrigin") } timezone, _, err := normalizeTimezone(req.Timezone) if err != nil { return req, err } req.Timezone = timezone if req.StartTime <= 0 || req.EndTime <= req.StartTime { return req, NewAppError(http.StatusBadRequest, "invalid_activity_time", "startTime and endTime are required and startTime must be before endTime") } if req.SettlementDelayMinutes == nil { value := defaultSettlementDelayMinutes req.SettlementDelayMinutes = &value } if *req.SettlementDelayMinutes < 0 || *req.SettlementDelayMinutes > maxSettlementDelayMinutes { return req, NewAppError(http.StatusBadRequest, "invalid_settlement_delay", "settlementDelayMinutes must be between 0 and 1440") } if req.CoinPerDraw <= 0 { return req, NewAppError(http.StatusBadRequest, "invalid_coin_per_draw", "coinPerDraw must be greater than zero") } req.RankingType, err = normalizeRankingType(req.RankingType) if err != nil { return req, err } req.RankingPeriod, err = normalizeRankingPeriod(req.RankingPeriod) return req, err } func (s *Service) normalizePrizes(ctx context.Context, sysOrigin string, values []PrizeInput) ([]normalizedPrize, error) { if len(values) != 7 { return nil, NewAppError(http.StatusBadRequest, "invalid_prize_count", "exactly seven prizes are required") } seenSort := make(map[int]struct{}, 7) result := make([]normalizedPrize, 0, 7) for _, value := range values { value.PrizeName = strings.TrimSpace(value.PrizeName) value.PrizeImage = strings.TrimSpace(value.PrizeImage) if value.PrizeName == "" || len(value.PrizeName) > 128 { return nil, NewAppError(http.StatusBadRequest, "invalid_prize_name", "every prizeName is required and must not exceed 128 characters") } if value.PrizeImage == "" || len(value.PrizeImage) > 500 { return nil, NewAppError(http.StatusBadRequest, "invalid_prize_image", "every prizeImage is required and must not exceed 500 characters") } if !value.Enabled { return nil, NewAppError(http.StatusBadRequest, "invalid_prize_enabled", "all seven prizes must be enabled") } if value.Weight <= 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_prize_weight", "every prize weight must be greater than zero") } if value.Stock < -1 { return nil, NewAppError(http.StatusBadRequest, "invalid_prize_stock", "prize stock must be -1 or greater") } if value.SortOrder < 1 || value.SortOrder > 7 { return nil, NewAppError(http.StatusBadRequest, "invalid_prize_sort", "prize sortOrder must cover 1 through 7") } if _, exists := seenSort[value.SortOrder]; exists { return nil, NewAppError(http.StatusBadRequest, "duplicate_prize_sort", "prize sortOrder must be unique") } seenSort[value.SortOrder] = struct{}{} if value.ResourceGroupID.Int64() <= 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_prize_reward_group", "every prize must reference a reward group") } snapshot, err := s.loadRewardGroupSnapshot(ctx, sysOrigin, value.ResourceGroupID.Int64()) if err != nil { return nil, err } result = append(result, normalizedPrize{Input: value, Snapshot: snapshot}) } sort.Slice(result, func(i, j int) bool { return result[i].Input.SortOrder < result[j].Input.SortOrder }) return result, nil } func (s *Service) normalizeRankRewards(ctx context.Context, sysOrigin string, values []RankRewardInput) ([]normalizedRankReward, error) { if len(values) != len(fixedRankRanges) { return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_count", "exactly six rank reward tiers are required") } byRange := make(map[string]RankRewardInput, len(values)) for _, value := range values { key := fmt.Sprintf("%d-%d", value.StartRank, value.EndRank) if _, exists := byRange[key]; exists { return nil, NewAppError(http.StatusBadRequest, "duplicate_rank_reward", "rank reward ranges must be unique") } byRange[key] = value } result := make([]normalizedRankReward, 0, len(fixedRankRanges)) for _, rankRange := range fixedRankRanges { key := fmt.Sprintf("%d-%d", rankRange[0], rankRange[1]) value, exists := byRange[key] if !exists { return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_ranges", "rank rewards must be 1, 2, 3, 4-7, 8-10 and 11-30") } if value.ResourceGroupID.Int64() <= 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_group", "every rank tier must reference a reward group") } value.RewardName = strings.TrimSpace(value.RewardName) snapshot, err := s.loadRewardGroupSnapshot(ctx, sysOrigin, value.ResourceGroupID.Int64()) if err != nil { return nil, err } if value.RewardName == "" { value.RewardName = snapshot.GroupName } if len(value.RewardName) > 128 { return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_name", "rewardName must not exceed 128 characters") } result = append(result, normalizedRankReward{Input: value, Snapshot: snapshot}) } return result, nil } func (s *Service) loadRewardGroupSnapshot(ctx context.Context, sysOrigin string, groupID int64) (rewardGroupSnapshot, error) { if s.gateway == nil { return rewardGroupSnapshot{}, NewAppError(http.StatusServiceUnavailable, "reward_gateway_unavailable", "reward gateway is unavailable") } detail, err := s.gateway.GetRewardGroupDetail(ctx, groupID) if err != nil { return rewardGroupSnapshot{}, NewAppError(http.StatusBadGateway, "reward_group_lookup_failed", err.Error()) } if int64(detail.ID) != groupID || len(detail.RewardConfigList) == 0 { return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "reward_group_empty", "reward group must exist and contain at least one reward") } // 租户信息缺失也必须 fail-closed;否则旧 Java DTO 或异常响应会让跨租户奖励组绕过校验。 if strings.TrimSpace(detail.SysOrigin) == "" || !strings.EqualFold(strings.TrimSpace(detail.SysOrigin), sysOrigin) { return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "reward_group_sys_origin_mismatch", "reward group sysOrigin does not match activity") } // 启用和每次发奖前都必须拿到明确的上架状态;缺失状态不能被当成“已上架”。 if detail.ShelfStatus == nil || !*detail.ShelfStatus { return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "reward_group_not_shelved", "reward group must be shelved") } items := make([]RewardItemView, 0, len(detail.RewardConfigList)) for _, item := range detail.RewardConfigList { if int64(item.ID) <= 0 || strings.TrimSpace(item.Type) == "" || int64(item.Quantity) <= 0 || int64(item.Quantity) > math.MaxInt32 { return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "reward_group_item_invalid", "reward group contains an invalid reward item") } items = append(items, RewardItemView{ ID: int64(item.ID), Type: strings.TrimSpace(item.Type), DetailType: strings.TrimSpace(item.DetailType), Content: strings.TrimSpace(item.Content), Quantity: int64(item.Quantity), Cover: strings.TrimSpace(item.Cover), SourceURL: strings.TrimSpace(item.SourceURL), Name: strings.TrimSpace(item.Name), Remark: strings.TrimSpace(item.Remark), }) } // 奖励组接口不承诺返回顺序;按配置 ID 及实际发奖字段规范化后再冻结/比对, // 避免单纯数组重排被误判,同时确保 type/detailType/content/quantity 全部参与校验。 sort.Slice(items, func(i, j int) bool { if items[i].ID != items[j].ID { return items[i].ID < items[j].ID } if items[i].Type != items[j].Type { return items[i].Type < items[j].Type } if items[i].DetailType != items[j].DetailType { return items[i].DetailType < items[j].DetailType } if items[i].Content != items[j].Content { return items[i].Content < items[j].Content } return items[i].Quantity < items[j].Quantity }) for index := range items { items[index].Sort = index + 1 } raw, err := json.Marshal(items) if err != nil { return rewardGroupSnapshot{}, err } return rewardGroupSnapshot{GroupID: groupID, GroupName: strings.TrimSpace(detail.Name), ItemsJSON: string(raw), Items: items}, nil } func rewardItems(raw string) []RewardItemView { items := make([]RewardItemView, 0) if strings.TrimSpace(raw) == "" { return items } if err := json.Unmarshal([]byte(raw), &items); err != nil { return []RewardItemView{} } return items } func activityView(row model.YumiGameKingActivity) ActivityView { return ActivityView{ ID: row.ID, ActivityCode: row.ActivityCode, ActivityName: row.ActivityName, ActivityDesc: row.ActivityDesc, SysOrigin: row.SysOrigin, Timezone: row.Timezone, StartTime: unixMilli(row.StartTime), EndTime: unixMilli(row.EndTime), SettlementDelayMinutes: row.SettlementDelayMinutes, SettlementTime: unixMilli(row.SettlementTime), CoinPerDraw: row.CoinPerDraw, EventTypes: []string{"GAME_CONSUME_GOLD"}, RankingType: row.RankingType, RankingPeriod: row.RankingPeriod, Enabled: row.Enabled, SettlementStatus: row.SettlementStatus, CreateTime: unixMilli(row.CreateTime), UpdateTime: unixMilli(row.UpdateTime), } } func prizeView(row model.YumiGameKingPrize) PrizeView { return PrizeView{ ID: row.ID, ActivityID: row.ActivityID, PrizeName: row.PrizeName, PrizeImage: row.PrizeImage, Weight: row.Weight, Stock: row.Stock, ResourceGroupID: row.ResourceGroupID, RewardGroupName: row.RewardGroupName, Enabled: row.Enabled, SortOrder: row.SortOrder, RewardItems: rewardItems(row.RewardItemsJSON), } } func rankRewardView(row model.YumiGameKingRankReward) RankRewardView { return RankRewardView{ ID: row.ID, ActivityID: row.ActivityID, StartRank: row.StartRank, EndRank: row.EndRank, ResourceGroupID: row.ResourceGroupID, RewardName: row.RewardName, RewardItems: rewardItems(row.RewardItemsJSON), } } func activityStatus(row model.YumiGameKingActivity, now time.Time) string { if !row.Enabled { return activityDisabled } if now.Before(row.StartTime) { return activityUpcoming } if now.Before(row.EndTime) { return activityActive } return activityEnded } func dateKey(at time.Time, location *time.Location) time.Time { local := at.In(location) return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.UTC) } func withWriteLock(db *gorm.DB) *gorm.DB { return db.Clauses(clause.Locking{Strength: "UPDATE"}) } func isRecordNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) } func truncateFailure(value string) string { value = strings.TrimSpace(value) if len(value) <= 500 { return value } return value[:500] } func integrationProfile(entry *RankEntryView, profile integration.UserProfile) { entry.Account = profile.Account entry.Nickname = profile.UserNickname entry.Avatar = profile.UserAvatar }