403 lines
15 KiB
Go
403 lines
15 KiB
Go
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")
|
||
}
|