2026-07-18 15:32:58 +08:00

321 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = &copy
}
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
}