321 lines
11 KiB
Go
321 lines
11 KiB
Go
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
|
||
}
|