331 lines
12 KiB
Go
331 lines
12 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 := row.TotalConsumed / activity.CoinPerDraw
|
||
available := earned - row.UsedChances
|
||
if available < 0 {
|
||
available = 0
|
||
}
|
||
remainder := row.TotalConsumed % activity.CoinPerDraw
|
||
next := activity.CoinPerDraw
|
||
if remainder > 0 {
|
||
next -= remainder
|
||
}
|
||
var rank *int
|
||
if row.TotalConsumed > 0 {
|
||
value, rankErr := s.userRank(ctx, activity, user.UserID, 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, NextChanceRemaining: next, Rank: rank,
|
||
}, nil
|
||
}
|
||
|
||
// Ranking 返回活动时区日榜或总榜。VICTORIOUS 是旧 H5 兼容维度;当前活动没有胜利事件,
|
||
// 因此显式请求时返回空榜而不是 400,supportedRankingTypes 会让新 H5 隐藏该页签。
|
||
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 != "VICTORIOUS" {
|
||
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{}}
|
||
if rankingType == "VICTORIOUS" {
|
||
return response, nil
|
||
}
|
||
|
||
now := time.Now()
|
||
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 total_consumed > 0", activity.ID, statDate).
|
||
Order("total_consumed DESC, consume_reached_time ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
for index, row := range rows {
|
||
entry := RankEntryView{Rank: index + 1, UserID: row.UserID, Score: row.TotalConsumed, TotalConsumed: row.TotalConsumed, Me: row.UserID == user.UserID}
|
||
response.Entries = append(response.Entries, entry)
|
||
}
|
||
} else {
|
||
var rows []model.YumiGameKingUser
|
||
if err := s.db.WithContext(ctx).
|
||
Where("activity_id = ? AND total_consumed > 0", activity.ID).
|
||
Order("total_consumed DESC, consume_reached_time ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
for index, row := range rows {
|
||
entry := RankEntryView{Rank: index + 1, UserID: row.UserID, Score: row.TotalConsumed, TotalConsumed: row.TotalConsumed, Me: row.UserID == user.UserID}
|
||
response.Entries = append(response.Entries, entry)
|
||
}
|
||
}
|
||
s.hydrateRankProfiles(ctx, response.Entries)
|
||
if user.UserID > 0 {
|
||
response.My, err = s.myRankEntry(ctx, activity, user.UserID, 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, period string, now time.Time) (*RankEntryView, error) {
|
||
rank, err := s.userRank(ctx, activity, userID, 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
|
||
} 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
|
||
}
|
||
return entry, nil
|
||
}
|
||
|
||
func (s *Service) userRank(ctx context.Context, activity model.YumiGameKingActivity, userID int64, period string, now time.Time) (*int, error) {
|
||
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
|
||
}
|
||
var ahead int64
|
||
if err := s.db.WithContext(ctx).Model(&model.YumiGameKingUserDaily{}).
|
||
Where("activity_id = ? AND stat_date = ? AND (total_consumed > ? OR (total_consumed = ? AND (consume_reached_time < ? OR (consume_reached_time = ? AND user_id < ?))))",
|
||
activity.ID, statDate, row.TotalConsumed, row.TotalConsumed, row.ConsumeReachedTime, row.ConsumeReachedTime, 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
|
||
}
|
||
var ahead int64
|
||
if err := s.db.WithContext(ctx).Model(&model.YumiGameKingUser{}).
|
||
Where("activity_id = ? AND (total_consumed > ? OR (total_consumed = ? AND (consume_reached_time < ? OR (consume_reached_time = ? AND user_id < ?))))",
|
||
activity.ID, row.TotalConsumed, row.TotalConsumed, row.ConsumeReachedTime, row.ConsumeReachedTime, 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("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,
|
||
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),
|
||
}
|
||
}
|