package gameking import ( "context" "errors" "net/http" "strings" "time" "chatapp3-golang/internal/model" "gorm.io/gorm" ) // Me 返回机会账本和活动默认榜单中的当前名次。 func (s *Service) Me(ctx context.Context, user AuthUser, activityID int64) (*UserStateResponse, error) { if user.UserID <= 0 { return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required") } activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), activityID, time.Now()) if err != nil { return nil, err } var row model.YumiGameKingUser err = s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, user.UserID).First(&row).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } if errors.Is(err, gorm.ErrRecordNotFound) { row = model.YumiGameKingUser{ActivityID: activity.ID, UserID: user.UserID} } earned, dailyEarned, dailyConsumed, err := s.earnedChances(ctx, activity, user.UserID, time.Now()) if err != nil { return nil, err } available := earned - row.UsedChances if available < 0 { available = 0 } remainder := dailyConsumed % activity.CoinPerDraw next := activity.CoinPerDraw // 达到日上限后,下一次资格只能从次日重新累计;进度也必须按当日消费计算。 if dailyEarned < int64(activity.DailyChanceLimit) && remainder > 0 { next -= remainder } var rank *int if row.TotalConsumed > 0 { value, rankErr := s.userRank(ctx, activity, user.UserID, rankingTypeTycoon, activity.RankingPeriod, time.Now()) if rankErr != nil { return nil, rankErr } rank = value } return &UserStateResponse{ ActivityID: activity.ID, TotalConsumed: row.TotalConsumed, EarnedChances: earned, UsedChances: row.UsedChances, AvailableChances: available, DailyEarnedChances: dailyEarned, DailyChanceLimit: activity.DailyChanceLimit, NextChanceRemaining: next, Rank: rank, }, nil } // earnedChances 按活动时区日聚合逐日封顶;只读取该用户参与过的活动日期,不扫描事件账本。 func (s *Service) earnedChances(ctx context.Context, activity model.YumiGameKingActivity, userID int64, now time.Time) (int64, int64, int64, error) { var rows []model.YumiGameKingUserDaily if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, userID).Find(&rows).Error; err != nil { return 0, 0, 0, err } _, location, err := normalizeTimezone(activity.Timezone) if err != nil { return 0, 0, 0, err } today := dateKey(now, location) var total, daily, dailyConsumed int64 for _, row := range rows { earned := row.TotalConsumed / activity.CoinPerDraw if earned > int64(activity.DailyChanceLimit) { earned = int64(activity.DailyChanceLimit) } total += earned if row.StatDate.Equal(today) { daily = earned dailyConsumed = row.TotalConsumed } } return total, daily, dailyConsumed, nil } // Ranking 返回活动时区日榜或总榜;榜单类型只映射到服务端固定列名,不接受任意 SQL 字段。 func (s *Service) Ranking(ctx context.Context, user AuthUser, activityID int64, rankingType, period string, limit int) (*RankingResponse, error) { activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), activityID, time.Now()) if err != nil { return nil, err } rankingType = strings.ToUpper(strings.TrimSpace(rankingType)) if rankingType == "" { rankingType = activity.RankingType } period = strings.ToUpper(strings.TrimSpace(period)) if period == "" { period = activity.RankingPeriod } if period != "DAILY" && period != rankingPeriodOverall { return nil, NewAppError(http.StatusBadRequest, "invalid_ranking_period", "period must be DAILY or OVERALL") } if rankingType != rankingTypeTycoon && rankingType != rankingTypeVictorious { return nil, NewAppError(http.StatusBadRequest, "invalid_ranking_type", "rankingType must be TYCOON or VICTORIOUS") } if limit <= 0 { limit = defaultRankingLimit } if limit > maxRankingLimit { limit = maxRankingLimit } response := &RankingResponse{RankingType: rankingType, Period: period, Entries: []RankEntryView{}} now := time.Now() scoreColumn, reachedColumn := "total_consumed", "consume_reached_time" if rankingType == rankingTypeVictorious { scoreColumn, reachedColumn = "total_won", "win_reached_time" } if period == "DAILY" { _, location, timezoneErr := normalizeTimezone(activity.Timezone) if timezoneErr != nil { return nil, timezoneErr } statDate := dateKey(now, location) response.StatDate = now.In(location).Format("2006-01-02") var rows []model.YumiGameKingUserDaily if err := s.db.WithContext(ctx). Where("activity_id = ? AND stat_date = ? AND "+scoreColumn+" > 0", activity.ID, statDate). Order(scoreColumn + " DESC, " + reachedColumn + " ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil { return nil, err } for index, row := range rows { score := row.TotalConsumed entry := RankEntryView{Rank: index + 1, UserID: row.UserID, Score: score, TotalConsumed: score, Me: row.UserID == user.UserID} if rankingType == rankingTypeVictorious { entry.Score, entry.TotalConsumed, entry.TotalWon = row.TotalWon, 0, row.TotalWon } response.Entries = append(response.Entries, entry) } } else { var rows []model.YumiGameKingUser if err := s.db.WithContext(ctx). Where("activity_id = ? AND "+scoreColumn+" > 0", activity.ID). Order(scoreColumn + " DESC, " + reachedColumn + " ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil { return nil, err } for index, row := range rows { score := row.TotalConsumed entry := RankEntryView{Rank: index + 1, UserID: row.UserID, Score: score, TotalConsumed: score, Me: row.UserID == user.UserID} if rankingType == rankingTypeVictorious { entry.Score, entry.TotalConsumed, entry.TotalWon = row.TotalWon, 0, row.TotalWon } response.Entries = append(response.Entries, entry) } } s.hydrateRankProfiles(ctx, response.Entries) if user.UserID > 0 { response.My, err = s.myRankEntry(ctx, activity, user.UserID, rankingType, period, now) if err != nil { return nil, err } s.hydrateMyRankProfile(ctx, response.My, response.Entries) } return response, nil } func (s *Service) myRankEntry(ctx context.Context, activity model.YumiGameKingActivity, userID int64, rankingType, period string, now time.Time) (*RankEntryView, error) { rank, err := s.userRank(ctx, activity, userID, rankingType, period, now) if err != nil || rank == nil { return nil, err } entry := &RankEntryView{Rank: *rank, UserID: userID, Me: true} if period == "DAILY" { _, location, _ := normalizeTimezone(activity.Timezone) var row model.YumiGameKingUserDaily if err := s.db.WithContext(ctx).Where("activity_id = ? AND stat_date = ? AND user_id = ?", activity.ID, dateKey(now, location), userID).First(&row).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, err } entry.Score, entry.TotalConsumed = row.TotalConsumed, row.TotalConsumed if rankingType == rankingTypeVictorious { entry.Score, entry.TotalConsumed, entry.TotalWon = row.TotalWon, 0, row.TotalWon } } else { var row model.YumiGameKingUser if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, userID).First(&row).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, err } entry.Score, entry.TotalConsumed = row.TotalConsumed, row.TotalConsumed if rankingType == rankingTypeVictorious { entry.Score, entry.TotalConsumed, entry.TotalWon = row.TotalWon, 0, row.TotalWon } } return entry, nil } func (s *Service) userRank(ctx context.Context, activity model.YumiGameKingActivity, userID int64, rankingType, period string, now time.Time) (*int, error) { scoreColumn, reachedColumn := "total_consumed", "consume_reached_time" if rankingType == rankingTypeVictorious { scoreColumn, reachedColumn = "total_won", "win_reached_time" } if period == "DAILY" { _, location, _ := normalizeTimezone(activity.Timezone) statDate := dateKey(now, location) var row model.YumiGameKingUserDaily if err := s.db.WithContext(ctx).Where("activity_id = ? AND stat_date = ? AND user_id = ?", activity.ID, statDate, userID).First(&row).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, err } score, reached := row.TotalConsumed, row.ConsumeReachedTime if rankingType == rankingTypeVictorious { score, reached = row.TotalWon, row.WinReachedTime } if score <= 0 { return nil, nil } var ahead int64 if err := s.db.WithContext(ctx).Model(&model.YumiGameKingUserDaily{}). Where("activity_id = ? AND stat_date = ? AND ("+scoreColumn+" > ? OR ("+scoreColumn+" = ? AND ("+reachedColumn+" < ? OR ("+reachedColumn+" = ? AND user_id < ?))))", activity.ID, statDate, score, score, reached, reached, userID). Count(&ahead).Error; err != nil { return nil, err } value := int(ahead) + 1 return &value, nil } var row model.YumiGameKingUser if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, userID).First(&row).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, err } score, reached := row.TotalConsumed, row.ConsumeReachedTime if rankingType == rankingTypeVictorious { score, reached = row.TotalWon, row.WinReachedTime } if score <= 0 { return nil, nil } var ahead int64 if err := s.db.WithContext(ctx).Model(&model.YumiGameKingUser{}). Where("activity_id = ? AND ("+scoreColumn+" > ? OR ("+scoreColumn+" = ? AND ("+reachedColumn+" < ? OR ("+reachedColumn+" = ? AND user_id < ?))))", activity.ID, score, score, reached, reached, userID). Count(&ahead).Error; err != nil { return nil, err } value := int(ahead) + 1 return &value, nil } func (s *Service) hydrateRankProfiles(ctx context.Context, entries []RankEntryView) { if s.gateway == nil || len(entries) == 0 { return } seen := make(map[int64]struct{}, len(entries)) userIDs := make([]int64, 0, len(entries)) for _, entry := range entries { if entry.UserID <= 0 { continue } if _, exists := seen[entry.UserID]; exists { continue } seen[entry.UserID] = struct{}{} userIDs = append(userIDs, entry.UserID) } profiles, err := s.gateway.MapUserProfiles(ctx, userIDs) if err != nil { // 排行主数据来自本库;资料服务失败时退化为 userId,不阻断榜单。 return } for index := range entries { if profile, exists := profiles[entries[index].UserID]; exists { integrationProfile(&entries[index], profile) } } } func (s *Service) hydrateMyRankProfile(ctx context.Context, my *RankEntryView, entries []RankEntryView) { if my == nil { return } for _, entry := range entries { if entry.UserID == my.UserID { my.Account, my.Nickname, my.Avatar = entry.Account, entry.Nickname, entry.Avatar return } } values := []RankEntryView{*my} s.hydrateRankProfiles(ctx, values) *my = values[0] } func (s *Service) DrawRecords(ctx context.Context, user AuthUser, activityID int64, limit int) ([]DrawRecordView, error) { if user.UserID <= 0 { return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required") } activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), activityID, time.Now()) if err != nil { return nil, err } return s.listDrawRecords(ctx, activity.ID, user.UserID, "", limit, false) } func (s *Service) ManageDrawRecords(ctx context.Context, activityID int64, status string, limit int) ([]DrawRecordView, error) { if _, err := s.loadActivity(ctx, activityID); err != nil { return nil, err } return s.listDrawRecords(ctx, activityID, 0, status, limit, true) } func (s *Service) listDrawRecords(ctx context.Context, activityID, userID int64, status string, limit int, includeDelivery bool) ([]DrawRecordView, error) { if limit <= 0 { limit = defaultRecordLimit } if limit > maxRecordLimit { limit = maxRecordLimit } query := s.db.WithContext(ctx).Where("activity_id = ?", activityID) if userID > 0 { query = query.Where("user_id = ?", userID) } if strings.TrimSpace(status) != "" { query = query.Where("delivery_status = ?", strings.ToUpper(strings.TrimSpace(status))) } var rows []model.YumiGameKingDrawRecord if err := query.Order("id DESC").Limit(limit).Find(&rows).Error; err != nil { return nil, err } result := make([]DrawRecordView, 0, len(rows)) for _, row := range rows { view := drawRecordView(row) if includeDelivery { var deliveryErr error view.DeliveryItems, deliveryErr = s.deliveryItems(ctx, OwnerDraw, row.ID) if deliveryErr != nil { return nil, deliveryErr } } result = append(result, view) } return result, nil } func (s *Service) SettlementRecords(ctx context.Context, activityID int64) ([]SettlementRecordView, error) { if _, err := s.loadActivity(ctx, activityID); err != nil { return nil, err } var rows []model.YumiGameKingSettlementRecord if err := s.db.WithContext(ctx).Where("activity_id = ?", activityID). Order("ranking_period ASC, period_key DESC, ranking_type ASC, rank_no ASC").Find(&rows).Error; err != nil { return nil, err } result := make([]SettlementRecordView, 0, len(rows)) for _, row := range rows { view := settlementRecordView(row) var deliveryErr error view.DeliveryItems, deliveryErr = s.deliveryItems(ctx, OwnerSettlement, row.ID) if deliveryErr != nil { return nil, deliveryErr } result = append(result, view) } return result, nil } func drawRecordView(row model.YumiGameKingDrawRecord) DrawRecordView { return DrawRecordView{ ID: row.ID, ActivityID: row.ActivityID, UserID: row.UserID, RequestID: row.RequestID, PrizeID: row.PrizeID, PrizeName: row.PrizeName, PrizeImage: row.PrizeImage, ResourceGroupID: row.ResourceGroupID, DeliveryStatus: row.DeliveryStatus, RetryCount: row.RetryCount, FailureReason: row.FailureReason, DrawTime: unixMilli(row.DrawTime), DeliverTime: unixMilliPtr(row.DeliverTime), } } func settlementRecordView(row model.YumiGameKingSettlementRecord) SettlementRecordView { return SettlementRecordView{ ID: row.ID, ActivityID: row.ActivityID, UserID: row.UserID, Rank: row.RankNo, RankingType: row.RankingType, RankingPeriod: row.RankingPeriod, PeriodKey: row.PeriodKey, StatDate: formatStatDate(row.StatDate), Score: row.Score, TotalConsumed: row.TotalConsumed, RankRewardID: row.RankRewardID, ResourceGroupID: row.ResourceGroupID, BusinessNo: row.BusinessNo, DeliveryStatus: row.DeliveryStatus, RetryCount: row.RetryCount, FailureReason: row.FailureReason, CreateTime: unixMilli(row.CreateTime), DeliverTime: unixMilliPtr(row.DeliverTime), } } func formatStatDate(value *time.Time) string { if value == nil || value.IsZero() { return "" } return value.Format("2006-01-02") }