403 lines
15 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 gameking
import (
"context"
"errors"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/model"
"gorm.io/gorm"
)
// Me 返回机会账本和活动默认榜单中的当前名次。
func (s *Service) Me(ctx context.Context, user AuthUser, activityID int64) (*UserStateResponse, error) {
if user.UserID <= 0 {
return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required")
}
activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), activityID, time.Now())
if err != nil {
return nil, err
}
var row model.YumiGameKingUser
err = s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, user.UserID).First(&row).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if errors.Is(err, gorm.ErrRecordNotFound) {
row = model.YumiGameKingUser{ActivityID: activity.ID, UserID: user.UserID}
}
earned, dailyEarned, dailyConsumed, err := s.earnedChances(ctx, activity, user.UserID, time.Now())
if err != nil {
return nil, err
}
available := earned - row.UsedChances
if available < 0 {
available = 0
}
remainder := dailyConsumed % activity.CoinPerDraw
next := activity.CoinPerDraw
// 达到日上限后,下一次资格只能从次日重新累计;进度也必须按当日消费计算。
if dailyEarned < int64(activity.DailyChanceLimit) && remainder > 0 {
next -= remainder
}
var rank *int
if row.TotalConsumed > 0 {
value, rankErr := s.userRank(ctx, activity, user.UserID, rankingTypeTycoon, activity.RankingPeriod, time.Now())
if rankErr != nil {
return nil, rankErr
}
rank = value
}
return &UserStateResponse{
ActivityID: activity.ID, TotalConsumed: row.TotalConsumed, EarnedChances: earned,
UsedChances: row.UsedChances, AvailableChances: available, DailyEarnedChances: dailyEarned,
DailyChanceLimit: activity.DailyChanceLimit, NextChanceRemaining: next, Rank: rank,
}, nil
}
// earnedChances 按活动时区日聚合逐日封顶;只读取该用户参与过的活动日期,不扫描事件账本。
func (s *Service) earnedChances(ctx context.Context, activity model.YumiGameKingActivity, userID int64, now time.Time) (int64, int64, int64, error) {
var rows []model.YumiGameKingUserDaily
if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, userID).Find(&rows).Error; err != nil {
return 0, 0, 0, err
}
_, location, err := normalizeTimezone(activity.Timezone)
if err != nil {
return 0, 0, 0, err
}
today := dateKey(now, location)
var total, daily, dailyConsumed int64
for _, row := range rows {
earned := row.TotalConsumed / activity.CoinPerDraw
if earned > int64(activity.DailyChanceLimit) {
earned = int64(activity.DailyChanceLimit)
}
total += earned
if row.StatDate.Equal(today) {
daily = earned
dailyConsumed = row.TotalConsumed
}
}
return total, daily, dailyConsumed, nil
}
// Ranking 返回活动时区日榜或总榜;榜单类型只映射到服务端固定列名,不接受任意 SQL 字段。
func (s *Service) Ranking(ctx context.Context, user AuthUser, activityID int64, rankingType, period string, limit int) (*RankingResponse, error) {
activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), activityID, time.Now())
if err != nil {
return nil, err
}
rankingType = strings.ToUpper(strings.TrimSpace(rankingType))
if rankingType == "" {
rankingType = activity.RankingType
}
period = strings.ToUpper(strings.TrimSpace(period))
if period == "" {
period = activity.RankingPeriod
}
if period != "DAILY" && period != rankingPeriodOverall {
return nil, NewAppError(http.StatusBadRequest, "invalid_ranking_period", "period must be DAILY or OVERALL")
}
if rankingType != rankingTypeTycoon && rankingType != rankingTypeVictorious {
return nil, NewAppError(http.StatusBadRequest, "invalid_ranking_type", "rankingType must be TYCOON or VICTORIOUS")
}
if limit <= 0 {
limit = defaultRankingLimit
}
if limit > maxRankingLimit {
limit = maxRankingLimit
}
response := &RankingResponse{RankingType: rankingType, Period: period, Entries: []RankEntryView{}}
now := time.Now()
scoreColumn, reachedColumn := "total_consumed", "consume_reached_time"
if rankingType == rankingTypeVictorious {
scoreColumn, reachedColumn = "total_won", "win_reached_time"
}
if period == "DAILY" {
_, location, timezoneErr := normalizeTimezone(activity.Timezone)
if timezoneErr != nil {
return nil, timezoneErr
}
statDate := dateKey(now, location)
response.StatDate = now.In(location).Format("2006-01-02")
var rows []model.YumiGameKingUserDaily
if err := s.db.WithContext(ctx).
Where("activity_id = ? AND stat_date = ? AND "+scoreColumn+" > 0", activity.ID, statDate).
Order(scoreColumn + " DESC, " + reachedColumn + " ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil {
return nil, err
}
for index, row := range rows {
score := row.TotalConsumed
entry := RankEntryView{Rank: index + 1, UserID: row.UserID, Score: score, TotalConsumed: score, Me: row.UserID == user.UserID}
if rankingType == rankingTypeVictorious {
entry.Score, entry.TotalConsumed, entry.TotalWon = row.TotalWon, 0, row.TotalWon
}
response.Entries = append(response.Entries, entry)
}
} else {
var rows []model.YumiGameKingUser
if err := s.db.WithContext(ctx).
Where("activity_id = ? AND "+scoreColumn+" > 0", activity.ID).
Order(scoreColumn + " DESC, " + reachedColumn + " ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil {
return nil, err
}
for index, row := range rows {
score := row.TotalConsumed
entry := RankEntryView{Rank: index + 1, UserID: row.UserID, Score: score, TotalConsumed: score, Me: row.UserID == user.UserID}
if rankingType == rankingTypeVictorious {
entry.Score, entry.TotalConsumed, entry.TotalWon = row.TotalWon, 0, row.TotalWon
}
response.Entries = append(response.Entries, entry)
}
}
s.hydrateRankProfiles(ctx, response.Entries)
if user.UserID > 0 {
response.My, err = s.myRankEntry(ctx, activity, user.UserID, rankingType, period, now)
if err != nil {
return nil, err
}
s.hydrateMyRankProfile(ctx, response.My, response.Entries)
}
return response, nil
}
func (s *Service) myRankEntry(ctx context.Context, activity model.YumiGameKingActivity, userID int64, rankingType, period string, now time.Time) (*RankEntryView, error) {
rank, err := s.userRank(ctx, activity, userID, rankingType, period, now)
if err != nil || rank == nil {
return nil, err
}
entry := &RankEntryView{Rank: *rank, UserID: userID, Me: true}
if period == "DAILY" {
_, location, _ := normalizeTimezone(activity.Timezone)
var row model.YumiGameKingUserDaily
if err := s.db.WithContext(ctx).Where("activity_id = ? AND stat_date = ? AND user_id = ?", activity.ID, dateKey(now, location), userID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
entry.Score, entry.TotalConsumed = row.TotalConsumed, row.TotalConsumed
if rankingType == rankingTypeVictorious {
entry.Score, entry.TotalConsumed, entry.TotalWon = row.TotalWon, 0, row.TotalWon
}
} else {
var row model.YumiGameKingUser
if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, userID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
entry.Score, entry.TotalConsumed = row.TotalConsumed, row.TotalConsumed
if rankingType == rankingTypeVictorious {
entry.Score, entry.TotalConsumed, entry.TotalWon = row.TotalWon, 0, row.TotalWon
}
}
return entry, nil
}
func (s *Service) userRank(ctx context.Context, activity model.YumiGameKingActivity, userID int64, rankingType, period string, now time.Time) (*int, error) {
scoreColumn, reachedColumn := "total_consumed", "consume_reached_time"
if rankingType == rankingTypeVictorious {
scoreColumn, reachedColumn = "total_won", "win_reached_time"
}
if period == "DAILY" {
_, location, _ := normalizeTimezone(activity.Timezone)
statDate := dateKey(now, location)
var row model.YumiGameKingUserDaily
if err := s.db.WithContext(ctx).Where("activity_id = ? AND stat_date = ? AND user_id = ?", activity.ID, statDate, userID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
score, reached := row.TotalConsumed, row.ConsumeReachedTime
if rankingType == rankingTypeVictorious {
score, reached = row.TotalWon, row.WinReachedTime
}
if score <= 0 {
return nil, nil
}
var ahead int64
if err := s.db.WithContext(ctx).Model(&model.YumiGameKingUserDaily{}).
Where("activity_id = ? AND stat_date = ? AND ("+scoreColumn+" > ? OR ("+scoreColumn+" = ? AND ("+reachedColumn+" < ? OR ("+reachedColumn+" = ? AND user_id < ?))))",
activity.ID, statDate, score, score, reached, reached, userID).
Count(&ahead).Error; err != nil {
return nil, err
}
value := int(ahead) + 1
return &value, nil
}
var row model.YumiGameKingUser
if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, userID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
score, reached := row.TotalConsumed, row.ConsumeReachedTime
if rankingType == rankingTypeVictorious {
score, reached = row.TotalWon, row.WinReachedTime
}
if score <= 0 {
return nil, nil
}
var ahead int64
if err := s.db.WithContext(ctx).Model(&model.YumiGameKingUser{}).
Where("activity_id = ? AND ("+scoreColumn+" > ? OR ("+scoreColumn+" = ? AND ("+reachedColumn+" < ? OR ("+reachedColumn+" = ? AND user_id < ?))))",
activity.ID, score, score, reached, reached, userID).
Count(&ahead).Error; err != nil {
return nil, err
}
value := int(ahead) + 1
return &value, nil
}
func (s *Service) hydrateRankProfiles(ctx context.Context, entries []RankEntryView) {
if s.gateway == nil || len(entries) == 0 {
return
}
seen := make(map[int64]struct{}, len(entries))
userIDs := make([]int64, 0, len(entries))
for _, entry := range entries {
if entry.UserID <= 0 {
continue
}
if _, exists := seen[entry.UserID]; exists {
continue
}
seen[entry.UserID] = struct{}{}
userIDs = append(userIDs, entry.UserID)
}
profiles, err := s.gateway.MapUserProfiles(ctx, userIDs)
if err != nil {
// 排行主数据来自本库;资料服务失败时退化为 userId不阻断榜单。
return
}
for index := range entries {
if profile, exists := profiles[entries[index].UserID]; exists {
integrationProfile(&entries[index], profile)
}
}
}
func (s *Service) hydrateMyRankProfile(ctx context.Context, my *RankEntryView, entries []RankEntryView) {
if my == nil {
return
}
for _, entry := range entries {
if entry.UserID == my.UserID {
my.Account, my.Nickname, my.Avatar = entry.Account, entry.Nickname, entry.Avatar
return
}
}
values := []RankEntryView{*my}
s.hydrateRankProfiles(ctx, values)
*my = values[0]
}
func (s *Service) DrawRecords(ctx context.Context, user AuthUser, activityID int64, limit int) ([]DrawRecordView, error) {
if user.UserID <= 0 {
return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required")
}
activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), activityID, time.Now())
if err != nil {
return nil, err
}
return s.listDrawRecords(ctx, activity.ID, user.UserID, "", limit, false)
}
func (s *Service) ManageDrawRecords(ctx context.Context, activityID int64, status string, limit int) ([]DrawRecordView, error) {
if _, err := s.loadActivity(ctx, activityID); err != nil {
return nil, err
}
return s.listDrawRecords(ctx, activityID, 0, status, limit, true)
}
func (s *Service) listDrawRecords(ctx context.Context, activityID, userID int64, status string, limit int, includeDelivery bool) ([]DrawRecordView, error) {
if limit <= 0 {
limit = defaultRecordLimit
}
if limit > maxRecordLimit {
limit = maxRecordLimit
}
query := s.db.WithContext(ctx).Where("activity_id = ?", activityID)
if userID > 0 {
query = query.Where("user_id = ?", userID)
}
if strings.TrimSpace(status) != "" {
query = query.Where("delivery_status = ?", strings.ToUpper(strings.TrimSpace(status)))
}
var rows []model.YumiGameKingDrawRecord
if err := query.Order("id DESC").Limit(limit).Find(&rows).Error; err != nil {
return nil, err
}
result := make([]DrawRecordView, 0, len(rows))
for _, row := range rows {
view := drawRecordView(row)
if includeDelivery {
var deliveryErr error
view.DeliveryItems, deliveryErr = s.deliveryItems(ctx, OwnerDraw, row.ID)
if deliveryErr != nil {
return nil, deliveryErr
}
}
result = append(result, view)
}
return result, nil
}
func (s *Service) SettlementRecords(ctx context.Context, activityID int64) ([]SettlementRecordView, error) {
if _, err := s.loadActivity(ctx, activityID); err != nil {
return nil, err
}
var rows []model.YumiGameKingSettlementRecord
if err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).
Order("ranking_period ASC, period_key DESC, ranking_type ASC, rank_no ASC").Find(&rows).Error; err != nil {
return nil, err
}
result := make([]SettlementRecordView, 0, len(rows))
for _, row := range rows {
view := settlementRecordView(row)
var deliveryErr error
view.DeliveryItems, deliveryErr = s.deliveryItems(ctx, OwnerSettlement, row.ID)
if deliveryErr != nil {
return nil, deliveryErr
}
result = append(result, view)
}
return result, nil
}
func drawRecordView(row model.YumiGameKingDrawRecord) DrawRecordView {
return DrawRecordView{
ID: row.ID, ActivityID: row.ActivityID, UserID: row.UserID, RequestID: row.RequestID,
PrizeID: row.PrizeID, PrizeName: row.PrizeName, PrizeImage: row.PrizeImage,
ResourceGroupID: row.ResourceGroupID, DeliveryStatus: row.DeliveryStatus,
RetryCount: row.RetryCount, FailureReason: row.FailureReason,
DrawTime: unixMilli(row.DrawTime), DeliverTime: unixMilliPtr(row.DeliverTime),
}
}
func settlementRecordView(row model.YumiGameKingSettlementRecord) SettlementRecordView {
return SettlementRecordView{
ID: row.ID, ActivityID: row.ActivityID, UserID: row.UserID, Rank: row.RankNo,
RankingType: row.RankingType, RankingPeriod: row.RankingPeriod, PeriodKey: row.PeriodKey,
StatDate: formatStatDate(row.StatDate), Score: row.Score,
TotalConsumed: row.TotalConsumed, RankRewardID: row.RankRewardID,
ResourceGroupID: row.ResourceGroupID, BusinessNo: row.BusinessNo,
DeliveryStatus: row.DeliveryStatus, RetryCount: row.RetryCount,
FailureReason: row.FailureReason, CreateTime: unixMilli(row.CreateTime), DeliverTime: unixMilliPtr(row.DeliverTime),
}
}
func formatStatDate(value *time.Time) string {
if value == nil || value.IsZero() {
return ""
}
return value.Format("2006-01-02")
}