package yumigiftchallenge import ( "context" "errors" "net/http" "strings" "sync" "time" "chatapp3-golang/internal/model" "gorm.io/gorm" ) type rawRankRow struct { UserID int64 Score model.Decimal24_2 ScoreReachedTime time.Time Rank int } // GetRanking 返回 H5 排行榜;神秘人不出现在他人的公开榜,但本人仍可查看真实名次。 func (s *Service) GetRanking(ctx context.Context, user AuthUser, activityID int64, period, statDate string, limit int) (*RankingResponse, error) { origin, err := s.requireYumiOrigin(user.SysOrigin) if err != nil { return nil, err } activity, err := s.selectActivity(ctx, origin, activityID, true) if err != nil { return nil, err } return s.ranking(ctx, *activity, user.UserID, period, statDate, limit, false) } // GetAdminRanking 返回运营榜单,不应用用户公开隐身过滤,但仍保持同一确定性排序。 func (s *Service) GetAdminRanking(ctx context.Context, activityID int64, period, statDate string, limit int) (*RankingResponse, error) { activity, err := s.loadActivity(ctx, activityID) if err != nil { return nil, err } return s.ranking(ctx, *activity, 0, period, statDate, limit, true) } func (s *Service) ranking(ctx context.Context, activity model.YumiGiftChallengeActivity, viewerID int64, period, statDate string, limit int, admin bool) (*RankingResponse, error) { period = strings.ToUpper(strings.TrimSpace(period)) if period == "" { period = PeriodDaily } if period != PeriodDaily && period != PeriodOverall { return nil, NewAppError(http.StatusBadRequest, "invalid_period", "period must be DAILY or OVERALL") } if limit < 1 { if admin { limit = 100 } else { limit = maxPublicRankingLimit } } if limit > activity.DisplayTopN { limit = activity.DisplayTopN } if limit > maxDisplayTopN { limit = maxDisplayTopN } if !admin && limit > maxPublicRankingLimit { // Java 目前只提供单用户 VIP ability 接口,公开榜必须逐人按隐私优先核验; // 因而在服务端而非仅 H5 端限流,避免恶意 limit=500 放大为 500 次下游 HTTP。 limit = maxPublicRankingLimit } location, err := resolveLocation(activity.Timezone) if err != nil { return nil, err } var date *time.Time periodKey := PeriodOverall if period == PeriodDaily { selected, err := selectRankingDate(activity, statDate, location) if err != nil { return nil, err } date, periodKey = &selected, dateKey(selected, location) } var header model.YumiGiftChallengePeriodSettlement err = s.db.WithContext(ctx).Where("activity_id = ? AND period_type = ? AND period_key = ?", activity.ID, period, periodKey).First(&header).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, NewAppError(http.StatusConflict, "settlement_gate_missing", "period settlement gate is missing") } if err != nil { return nil, err } settled := header.Status != StatusNotStarted // 与 Aslan 一致:只读取请求的原始 Top N,再过滤神秘人;被隐藏的名次不从 N 之后补位。 fetchLimit := limit rows, err := s.loadRankRows(ctx, activity.ID, period, periodKey, date, settled, fetchLimit) if err != nil { return nil, err } entries := make([]RankEntryView, 0, limit) var my *RankEntryView decorated := s.decorateRankEntries(ctx, activity.SysOrigin, rows, viewerID, admin) for index, row := range rows { view, visible := decorated[index].view, decorated[index].visible if row.UserID == viewerID { copy := view my = © } if visible && len(entries) < limit { entries = append(entries, view) } } if viewerID > 0 && my == nil { myRow, err := s.loadMyRankRow(ctx, activity.ID, period, periodKey, date, settled, viewerID) if err != nil { return nil, err } if myRow != nil { view, _ := s.decorateRankEntry(ctx, activity.SysOrigin, *myRow, viewerID, false) my = &view } } s.hydrateRankProfiles(ctx, entries, my) response := &RankingResponse{PeriodType: period, Settled: settled, Entries: entries, My: my} if date != nil { response.StatDate = dateKey(*date, location) } return response, nil } type decoratedRankEntry struct { view RankEntryView visible bool } func (s *Service) decorateRankEntries(ctx context.Context, origin string, rows []rawRankRow, viewerID int64, admin bool) []decoratedRankEntry { results := make([]decoratedRankEntry, len(rows)) if len(rows) == 0 { return results } workerCount := 8 if len(rows) < workerCount { workerCount = len(rows) } jobs := make(chan int, len(rows)) var workers sync.WaitGroup workers.Add(workerCount) for worker := 0; worker < workerCount; worker++ { go func() { defer workers.Done() for index := range jobs { if ctx.Err() != nil { continue } view, visible := s.decorateRankEntry(ctx, origin, rows[index], viewerID, admin) results[index] = decoratedRankEntry{view: view, visible: visible} } }() } for index := range rows { jobs <- index } close(jobs) workers.Wait() return results } func selectRankingDate(activity model.YumiGiftChallengeActivity, value string, location *time.Location) (time.Time, error) { if strings.TrimSpace(value) != "" { date, err := parseDateKey(value, location) if err != nil { return time.Time{}, err } if date.Before(dateOnly(activity.StartTime, location)) || !date.Before(activity.EndTime) { return time.Time{}, NewAppError(http.StatusBadRequest, "stat_date_out_of_range", "statDate is outside activity window") } if date.After(dateOnly(time.Now(), location)) { return time.Time{}, NewAppError(http.StatusBadRequest, "future_stat_date", "future daily ranking is not available") } return date, nil } now := time.Now().In(location) if now.Before(activity.StartTime) { return dateOnly(activity.StartTime, location), nil } if !now.Before(activity.EndTime) { return dateOnly(activity.EndTime.Add(-time.Millisecond), location), nil } return dateOnly(now, location), nil } func (s *Service) loadRankRows(ctx context.Context, activityID int64, period, periodKey string, statDate *time.Time, settled bool, limit int) ([]rawRankRow, error) { rows := make([]rawRankRow, 0, limit) _ = periodKey _ = settled _ = statDate // 门闩进入 PROCESSING 后对应 score 聚合表不再写入,因此它本身就是完整冻结榜; // settlement 只包含命中奖励区间的获奖 parent,绝不能拿它替代完整排行榜。 if period == PeriodDaily { var live []model.YumiGiftChallengeUserDailyScore if err := s.db.WithContext(ctx).Where("activity_id = ? AND stat_date = ? AND score > 0", activityID, periodKey). Order("score DESC, score_reached_time ASC, user_id ASC").Limit(limit).Find(&live).Error; err != nil { return nil, err } for index, row := range live { rows = append(rows, rawRankRow{UserID: row.UserID, Score: row.Score, ScoreReachedTime: row.ScoreReachedTime, Rank: index + 1}) } return rows, nil } var live []model.YumiGiftChallengeUserScore if err := s.db.WithContext(ctx).Where("activity_id = ? AND score > 0", activityID). Order("score DESC, score_reached_time ASC, user_id ASC").Limit(limit).Find(&live).Error; err != nil { return nil, err } for index, row := range live { rows = append(rows, rawRankRow{UserID: row.UserID, Score: row.Score, ScoreReachedTime: row.ScoreReachedTime, Rank: index + 1}) } return rows, nil } func (s *Service) loadMyRankRow(ctx context.Context, activityID int64, period, periodKey string, statDate *time.Time, settled bool, userID int64) (*rawRankRow, error) { _ = settled _ = statDate if period == PeriodDaily { var row model.YumiGiftChallengeUserDailyScore err := s.db.WithContext(ctx).Where("activity_id = ? AND stat_date = ? AND user_id = ? AND score > 0", activityID, periodKey, userID).First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } rank, err := s.currentUserRank(ctx, activityID, &periodKey, userID, row.Score, row.ScoreReachedTime) if err != nil || rank == nil { return nil, err } return &rawRankRow{UserID: userID, Score: row.Score, ScoreReachedTime: row.ScoreReachedTime, Rank: *rank}, nil } var row model.YumiGiftChallengeUserScore err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ? AND score > 0", activityID, userID).First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } rank, err := s.currentUserRank(ctx, activityID, nil, userID, row.Score, row.ScoreReachedTime) if err != nil || rank == nil { return nil, err } return &rawRankRow{UserID: userID, Score: row.Score, ScoreReachedTime: row.ScoreReachedTime, Rank: *rank}, nil } func (s *Service) decorateRankEntry(ctx context.Context, origin string, row rawRankRow, viewerID int64, admin bool) (RankEntryView, bool) { view := RankEntryView{Rank: row.Rank, UserID: row.UserID, Score: row.Score, Me: row.UserID == viewerID} if !admin && row.UserID != viewerID { hidden, err := s.userHidden(ctx, origin, row.UserID) if err != nil || hidden { // 隐身能力下游不可用时按隐私优先隐藏候选;先判断可见性再取 profile, // 避免对不会展示的用户产生多余资料请求。 return view, false } } return view, true } func (s *Service) hydrateRankProfiles(ctx context.Context, entries []RankEntryView, my *RankEntryView) { userIDs := make([]int64, 0, len(entries)+1) seen := make(map[int64]struct{}, len(entries)+1) for _, entry := range entries { if _, exists := seen[entry.UserID]; !exists { seen[entry.UserID] = struct{}{} userIDs = append(userIDs, entry.UserID) } } if my != nil { if _, exists := seen[my.UserID]; !exists { userIDs = append(userIDs, my.UserID) } } profiles, err := s.gateway.MapUserProfiles(ctx, userIDs) if err != nil { return } for index := range entries { if profile, exists := profiles[entries[index].UserID]; exists { entries[index].Account, entries[index].Nickname, entries[index].Avatar = profile.Account, profile.UserNickname, profile.UserAvatar } } if my != nil { if profile, exists := profiles[my.UserID]; exists { my.Account, my.Nickname, my.Avatar = profile.Account, profile.UserNickname, profile.UserAvatar } } } func (s *Service) userHidden(ctx context.Context, origin string, userID int64) (bool, error) { now := time.Now() s.visibilityMu.Lock() for cachedUserID, item := range s.visibility { if !now.Before(item.expiresAt) { delete(s.visibility, cachedUserID) } } if item, ok := s.visibility[userID]; ok && now.Before(item.expiresAt) { s.visibilityMu.Unlock() return item.hidden, nil } s.visibilityMu.Unlock() hidden, err := s.gateway.GetUserMysteriousInvisibility(ctx, origin, userID) if err != nil { return false, err } if hidden { // 只缓存“隐藏”结论;公开状态必须实时复核,用户刚开启神秘人后不会继续沿用旧 false 暴露资料。 s.visibilityMu.Lock() s.visibility[userID] = visibilityCacheEntry{hidden: true, expiresAt: now.Add(2 * time.Minute)} s.visibilityMu.Unlock() } return hidden, nil }