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 }