2026-04-28 18:05:13 +08:00

415 lines
14 KiB
Go

package weekstar
import (
"context"
"errors"
"math"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/model"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// GetPublicPage 返回 H5 聚合页数据,优先读取当前生效配置,其次回退到最近未来周配置。
func (s *WeekStarService) GetPublicPage(ctx context.Context, sysOrigin string) (*WeekStarPageResponse, error) {
sysOrigin = s.normalizeSysOrigin(sysOrigin)
now := time.Now().In(s.location)
activeConfig, giftRows, rewardRows, err := s.loadActiveConfigBundle(ctx, sysOrigin, now)
if err != nil {
return nil, err
}
activityStatus := weekStarActivityStatusDisabled
displayConfig := activeConfig
if displayConfig == nil {
displayConfig, giftRows, rewardRows, err = s.loadUpcomingConfigBundle(ctx, sysOrigin, now)
if err != nil {
return nil, err
}
if displayConfig == nil {
return &WeekStarPageResponse{
ActivityStatus: weekStarActivityStatusDisabled,
QualifyingGifts: []WeekStarGiftConfigPayload{},
LastWeekTop3: []WeekStarRankingUserView{},
ThisWeekTop10: []WeekStarRankingUserView{},
RewardPreview: []WeekStarRewardConfigPayload{},
CurrentPeriodText: "",
LastPeriodText: "",
CountdownMillis: 0,
}, nil
}
activityStatus = weekStarActivityStatusNotStarted
} else {
activityStatus = weekStarActivityStatusOngoing
}
// 页面优先围绕当前生效周期展示;未开始时改为围绕 startAt 推导周期。
referenceTime := now
if activityStatus == weekStarActivityStatusNotStarted {
referenceTime = s.fromStorageWallClock(displayConfig.StartAt)
}
cycleStart, cycleEnd := s.cycleBounds(referenceTime)
currentPeriodStart := maxTime(cycleStart, s.fromStorageWallClock(displayConfig.StartAt))
currentPeriodEnd := minTime(cycleEnd, s.fromStorageWallClock(displayConfig.EndAt))
// 历史前三优先走最近一次已结算快照,避免受实时榜单波动影响。
lastAnchor, err := s.loadLatestSettledCycle(ctx, sysOrigin, currentPeriodStart)
if err != nil {
return nil, err
}
lastWeekTop3 := []WeekStarRankingUserView{}
lastPeriodText := ""
if lastAnchor != nil {
lastWeekTop3, err = s.listSnapshotRanking(ctx, lastAnchor.ConfigID, lastAnchor.CycleKey)
if err != nil {
return nil, err
}
lastPeriodText = formatPeriodText(
s.fromStorageWallClock(lastAnchor.PeriodStartAt),
s.fromStorageWallClock(lastAnchor.PeriodEndAt),
)
}
// 本周榜只在活动进行中展示实时榜。
thisWeekTop10 := []WeekStarRankingUserView{}
if activityStatus == weekStarActivityStatusOngoing {
thisWeekTop10, err = s.loadLiveRanking(ctx, displayConfig.ID, s.cycleKey(cycleStart), 10)
if err != nil {
return nil, err
}
}
rewardPreview, err := s.buildRewardPreview(ctx, rewardRows)
if err != nil {
return nil, err
}
countdownTarget := currentPeriodEnd
if activityStatus == weekStarActivityStatusNotStarted {
countdownTarget = s.fromStorageWallClock(displayConfig.StartAt)
}
countdownMillis := maxInt64(0, countdownTarget.UnixMilli()-now.UnixMilli())
return &WeekStarPageResponse{
ActivityStatus: activityStatus,
CurrentPeriodText: formatPeriodText(currentPeriodStart, currentPeriodEnd),
LastPeriodText: lastPeriodText,
CountdownMillis: countdownMillis,
QualifyingGifts: buildGiftPayloads(giftRows),
LastWeekTop3: lastWeekTop3,
ThisWeekTop10: thisWeekTop10,
RewardPreview: rewardPreview,
}, nil
}
// CurrentRanking 返回后台查看用的当前实时榜,实时榜只在当前有生效配置时读取。
func (s *WeekStarService) CurrentRanking(ctx context.Context, sysOrigin string, limit int) (*WeekStarCurrentRankResponse, error) {
sysOrigin = s.normalizeSysOrigin(sysOrigin)
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
now := time.Now().In(s.location)
configRow, giftRows, _, err := s.loadActiveConfigBundle(ctx, sysOrigin, now)
if err != nil {
return nil, err
}
if configRow == nil {
return &WeekStarCurrentRankResponse{
SysOrigin: sysOrigin,
ActivityStatus: weekStarActivityStatusDisabled,
Timezone: s.cfg.WeekStar.Timezone,
GiftConfigs: []WeekStarGiftConfigPayload{},
Records: []WeekStarRankingUserView{},
}, nil
}
cycleStart, cycleEnd := s.cycleBounds(now)
periodStart := maxTime(cycleStart, s.fromStorageWallClock(configRow.StartAt))
periodEnd := minTime(cycleEnd, s.fromStorageWallClock(configRow.EndAt))
records, err := s.loadLiveRanking(ctx, configRow.ID, s.cycleKey(cycleStart), int64(limit))
if err != nil {
return nil, err
}
return &WeekStarCurrentRankResponse{
ConfigID: configRow.ID,
SysOrigin: sysOrigin,
ActivityStatus: weekStarActivityStatusOngoing,
CycleKey: s.cycleKey(cycleStart),
PeriodStartAt: formatDateTime(periodStart),
PeriodEndAt: formatDateTime(periodEnd),
Timezone: configRow.Timezone,
GiftConfigs: buildGiftPayloads(giftRows),
Records: records,
}, nil
}
// PageSnapshots 按系统或配置分页查询历史快照,支持跨多周活动查看冻结榜单。
func (s *WeekStarService) PageSnapshots(ctx context.Context, sysOrigin string, configID int64, cycleKey string, cursor, limit int) (map[string]any, error) {
page, size := normalizePage(cursor, limit)
query := s.repo.DB.WithContext(ctx).
Table("week_star_rank_snapshot AS snapshot").
Joins("JOIN week_star_activity_config AS config ON config.id = snapshot.config_id")
if configID > 0 {
query = query.Where("snapshot.config_id = ?", configID)
} else {
query = query.Where("config.sys_origin = ?", s.normalizeSysOrigin(sysOrigin))
}
if strings.TrimSpace(cycleKey) != "" {
query = query.Where("snapshot.cycle_key = ?", strings.TrimSpace(cycleKey))
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, err
}
var rows []model.WeekStarRankSnapshot
if total > 0 {
if err := query.
Select("snapshot.*").
Order("snapshot.period_end_at desc").
Order("snapshot.rank asc").
Offset((page - 1) * size).
Limit(size).
Find(&rows).Error; err != nil {
return nil, err
}
}
records := make([]WeekStarSnapshotView, 0, len(rows))
for _, row := range rows {
records = append(records, WeekStarSnapshotView{
ID: row.ID,
ConfigID: row.ConfigID,
CycleKey: row.CycleKey,
PeriodStartAt: formatDateTime(s.fromStorageWallClock(row.PeriodStartAt)),
PeriodEndAt: formatDateTime(s.fromStorageWallClock(row.PeriodEndAt)),
UserID: row.UserID,
UserAvatar: row.UserAvatar,
UserNickname: row.UserNickname,
Account: row.Account,
CountryCode: row.CountryCode,
CountryName: row.CountryName,
Rank: row.Rank,
ScoreGold: row.ScoreGold,
})
}
return map[string]any{"records": records, "total": total}, nil
}
// PageRewardRecords 按系统或配置分页查询发奖记录,支持失败补发前的筛选和定位。
func (s *WeekStarService) PageRewardRecords(ctx context.Context, sysOrigin string, configID int64, cycleKey, status string, cursor, limit int) (map[string]any, error) {
page, size := normalizePage(cursor, limit)
query := s.repo.DB.WithContext(ctx).
Table("week_star_reward_record AS record").
Joins("JOIN week_star_activity_config AS config ON config.id = record.config_id")
if configID > 0 {
query = query.Where("record.config_id = ?", configID)
} else {
query = query.Where("config.sys_origin = ?", s.normalizeSysOrigin(sysOrigin))
}
if strings.TrimSpace(cycleKey) != "" {
query = query.Where("record.cycle_key = ?", strings.TrimSpace(cycleKey))
}
if strings.TrimSpace(status) != "" {
query = query.Where("record.status = ?", strings.ToUpper(strings.TrimSpace(status)))
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, err
}
var rows []model.WeekStarRewardRecord
if total > 0 {
if err := query.
Select("record.*").
Order("record.create_time desc").
Order("record.rank asc").
Offset((page - 1) * size).
Limit(size).
Find(&rows).Error; err != nil {
return nil, err
}
}
records := make([]WeekStarRewardRecordView, 0, len(rows))
for _, row := range rows {
records = append(records, toWeekStarRewardRecordView(row))
}
return map[string]any{"records": records, "total": total}, nil
}
// RetryRewardRecord 对失败的发奖记录提交一次后台补发任务。
func (s *WeekStarService) RetryRewardRecord(ctx context.Context, id int64) (*WeekStarRewardRecordView, error) {
if id <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_id", "id is required")
}
var record model.WeekStarRewardRecord
err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&record).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, NewAppError(http.StatusNotFound, "reward_record_not_found", "reward record not found")
}
if err != nil {
return nil, err
}
if record.Status != weekStarRewardStatusFailed {
return nil, NewAppError(http.StatusBadRequest, "reward_record_not_retryable", "only failed records can be retried")
}
if err := s.enqueueRewardRetryTask(ctx, record.ID); err != nil {
return nil, err
}
result := toWeekStarRewardRecordView(record)
return &result, nil
}
// listSnapshotRanking 读取某个配置某个周期已经冻结的榜单快照。
func (s *WeekStarService) listSnapshotRanking(ctx context.Context, configID int64, cycleKey string) ([]WeekStarRankingUserView, error) {
if strings.TrimSpace(cycleKey) == "" {
return []WeekStarRankingUserView{}, nil
}
var rows []model.WeekStarRankSnapshot
if err := s.repo.DB.WithContext(ctx).
Where("config_id = ? AND cycle_key = ?", configID, cycleKey).
Order("`rank` asc").
Find(&rows).Error; err != nil {
return nil, err
}
result := make([]WeekStarRankingUserView, 0, len(rows))
for _, row := range rows {
result = append(result, WeekStarRankingUserView{
UserID: row.UserID,
UserAvatar: row.UserAvatar,
UserNickname: row.UserNickname,
Account: row.Account,
CountryCode: row.CountryCode,
CountryName: row.CountryName,
Rank: row.Rank,
ScoreGold: row.ScoreGold,
})
}
return result, nil
}
// loadLiveRanking 从 Redis 实时榜读取前 N 名,并补齐用户资料用于 H5 和结算展示。
func (s *WeekStarService) loadLiveRanking(ctx context.Context, configID int64, cycleKey string, limit int64) ([]WeekStarRankingUserView, error) {
if limit <= 0 {
return []WeekStarRankingUserView{}, nil
}
zs, err := s.repo.Redis.ZRevRangeWithScores(ctx, s.rankRedisKey(configID, cycleKey), 0, limit-1).Result()
if err != nil && !errors.Is(err, redis.Nil) {
return nil, err
}
if len(zs) == 0 {
return []WeekStarRankingUserView{}, nil
}
userIDs := make([]int64, 0, len(zs))
rankScores := make([]weekStarRankScore, 0, len(zs))
for index, item := range zs {
userID, ok := parseRedisMemberInt64(item.Member)
if !ok || userID <= 0 {
continue
}
userIDs = append(userIDs, userID)
rankScores = append(rankScores, weekStarRankScore{
UserID: userID,
ScoreGold: int64(math.Round(item.Score)),
Rank: index + 1,
})
}
profileMap, err := s.lookupUserProfiles(ctx, userIDs)
if err != nil {
return nil, err
}
result := make([]WeekStarRankingUserView, 0, len(rankScores))
for _, item := range rankScores {
profile := profileMap[item.UserID]
result = append(result, WeekStarRankingUserView{
UserID: item.UserID,
UserAvatar: profile.UserAvatar,
UserNickname: profile.UserNickname,
Account: profile.Account,
CountryCode: profile.CountryCode,
CountryName: profile.CountryName,
Rank: item.Rank,
ScoreGold: item.ScoreGold,
})
}
return result, nil
}
// lookupUserProfiles 优先查本地资料,查不到时回源 Java 用户资料接口兜底。
func (s *WeekStarService) lookupUserProfiles(ctx context.Context, userIDs []int64) (map[int64]weekStarUserProfile, error) {
result := make(map[int64]weekStarUserProfile, len(userIDs))
if len(userIDs) == 0 {
return result, nil
}
var rows []model.UserBaseInfo
if err := s.repo.DB.WithContext(ctx).Where("id IN ?", userIDs).Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
result[row.ID] = weekStarUserProfile{
UserID: row.ID,
Account: row.Account,
UserAvatar: row.UserAvatar,
UserNickname: row.UserNickname,
CountryCode: row.CountryCode,
CountryName: row.CountryName,
}
}
for _, userID := range userIDs {
if _, exists := result[userID]; exists {
continue
}
profile, err := s.java.GetUserProfile(ctx, userID)
if err != nil {
result[userID] = weekStarUserProfile{UserID: userID}
continue
}
result[userID] = weekStarUserProfile{
UserID: userID,
UserAvatar: profile.UserAvatar,
UserNickname: profile.UserNickname,
}
}
return result, nil
}
// loadLatestSettledCycle 读取当前展示配置之前最近一次已结算周期,用于展示历史前三。
func (s *WeekStarService) loadLatestSettledCycle(ctx context.Context, sysOrigin string, before time.Time) (*weekStarSettledCycleAnchor, error) {
sysOrigin = s.normalizeSysOrigin(sysOrigin)
var anchor weekStarSettledCycleAnchor
result := s.repo.DB.WithContext(ctx).
Table("week_star_rank_snapshot AS snapshot").
Select("snapshot.config_id, snapshot.cycle_key, snapshot.period_start_at, snapshot.period_end_at").
Joins("JOIN week_star_activity_config AS config ON config.id = snapshot.config_id").
Where("config.sys_origin = ?", sysOrigin).
Where("snapshot.period_end_at <= ?", s.toStorageWallClock(before)).
Order("snapshot.period_end_at desc").
Order("snapshot.config_id desc").
Limit(1).
Scan(&anchor)
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected == 0 {
return nil, nil
}
return &anchor, nil
}