2026-07-22 18:54:53 +08:00

280 lines
10 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"
cryptorand "crypto/rand"
"errors"
"math"
"math/big"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Draw 消耗一次由游戏金币支出获得的机会,并在同一事务冻结奖项和奖励组。
func (s *Service) Draw(ctx context.Context, user AuthUser, req DrawRequest) (*DrawResult, error) {
if user.UserID <= 0 {
return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required")
}
if req.ActivityID.Int64() <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_activity_id", "activityId is required")
}
req.RequestID = strings.TrimSpace(req.RequestID)
if req.RequestID == "" || len(req.RequestID) > 64 {
return nil, NewAppError(http.StatusBadRequest, "invalid_request_id", "requestId is required and must not exceed 64 characters")
}
// 网络超时后的同 requestId 重放必须先于 ACTIVE 校验;已提交的结果即使活动已经结束、
// 关闭或进入结算,也要原样返回,且绝不再次发奖。
if replay, err := s.findDrawReplay(ctx, user, req); err != nil || replay != nil {
return replay, err
}
now := time.Now()
activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), req.ActivityID.Int64(), now)
if err != nil {
return nil, err
}
if activityStatus(activity, now) != activityActive {
return nil, NewAppError(http.StatusConflict, "activity_not_active", "game king activity is not active")
}
var record model.YumiGameKingDrawRecord
var prize model.YumiGameKingPrize
var remaining int64
replayed := false
var deliveryItemID int64
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var lockedActivity model.YumiGameKingActivity
// 与事件入账相同,抽奖使用共享活动锁;结算排他锁会等待已进入抽奖完成。
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where("id = ?", activity.ID).First(&lockedActivity).Error; err != nil {
return err
}
txNow := time.Now()
if !lockedActivity.Enabled || lockedActivity.SettlementStatus != SettlementNotStarted ||
txNow.Before(lockedActivity.StartTime) || !txNow.Before(lockedActivity.EndTime) {
return NewAppError(http.StatusConflict, "activity_not_active", "game king activity is not active")
}
var userRow model.YumiGameKingUser
if err := withWriteLock(tx).Where("activity_id = ? AND user_id = ?", activity.ID, user.UserID).First(&userRow).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return NewAppError(http.StatusConflict, "draw_chance_unavailable", "no draw chance is available")
}
return err
}
// 同一用户的请求都由聚合行串行化,因此并发复用 requestId 不会重复扣机会。
if err := tx.Where("activity_id = ? AND user_id = ? AND request_id = ?", activity.ID, user.UserID, req.RequestID).First(&record).Error; err == nil {
if err := tx.Where("id = ?", record.PrizeID).First(&prize).Error; err != nil {
return err
}
replayed = true
earned, _, _, earnedErr := earnedChancesTx(tx, lockedActivity, user.UserID)
if earnedErr != nil {
return earnedErr
}
remaining = earned - userRow.UsedChances
if remaining < 0 {
remaining = 0
}
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
earned, _, _, earnedErr := earnedChancesTx(tx, lockedActivity, user.UserID)
if earnedErr != nil {
return earnedErr
}
if userRow.UsedChances >= earned {
return NewAppError(http.StatusConflict, "draw_chance_unavailable", "no draw chance is available")
}
selected, err := selectAndReservePrizeTx(tx, activity.ID)
if err != nil {
return err
}
prize = selected
recordID, err := utils.NextID()
if err != nil {
return err
}
deliveryItemID, err = utils.NextID()
if err != nil {
return err
}
record = model.YumiGameKingDrawRecord{
ID: recordID, ActivityID: activity.ID, UserID: user.UserID, RequestID: req.RequestID,
PrizeID: prize.ID, PrizeName: prize.PrizeName, PrizeImage: prize.PrizeImage,
ResourceGroupID: prize.ResourceGroupID, DeliveryStatus: DeliveryPending,
DrawTime: txNow, CreateTime: txNow, UpdateTime: txNow,
}
if err := tx.Create(&record).Error; err != nil {
return err
}
if err := tx.Create(&model.YumiGameKingDeliveryItem{
ID: deliveryItemID, OwnerType: OwnerDraw, OwnerID: record.ID, ActivityID: activity.ID,
UserID: user.UserID, ResourceGroupID: prize.ResourceGroupID, DeliveryStatus: DeliveryPending,
CreateTime: txNow, UpdateTime: txNow,
}).Error; err != nil {
return err
}
userRow.UsedChances++
userRow.UpdateTime = txNow
if err := tx.Save(&userRow).Error; err != nil {
return err
}
remaining = earned - userRow.UsedChances
return nil
}); err != nil {
return nil, err
}
if !replayed {
// 发奖在提交后执行,外部超时不会回滚已经向 H5 展示的抽奖结果,也不会再次扣机会。
_ = s.dispatchDeliveryItem(ctx, deliveryItemID, false)
_ = s.db.WithContext(ctx).Where("id = ?", record.ID).First(&record).Error
}
return &DrawResult{
Record: drawRecordView(record), Prize: prizeView(prize), RemainingChances: remaining, IdempotentReplay: replayed,
}, nil
}
func (s *Service) findDrawReplay(ctx context.Context, user AuthUser, req DrawRequest) (*DrawResult, error) {
var record model.YumiGameKingDrawRecord
query := s.db.WithContext(ctx).Table("yumi_game_king_draw_record AS draw").
Select("draw.*").
Joins("JOIN yumi_game_king_activity AS activity ON activity.id = draw.activity_id").
Where("draw.user_id = ? AND draw.request_id = ? AND activity.sys_origin = ?", user.UserID, req.RequestID, normalizeSysOrigin(user.SysOrigin))
if req.ActivityID.Int64() > 0 {
query = query.Where("draw.activity_id = ?", req.ActivityID.Int64())
}
result := query.Order("draw.id DESC").Limit(1).Scan(&record)
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected == 0 || record.ID <= 0 {
return nil, nil
}
var prize model.YumiGameKingPrize
if err := s.db.WithContext(ctx).Where("id = ? AND activity_id = ?", record.PrizeID, record.ActivityID).First(&prize).Error; err != nil {
return nil, err
}
var activity model.YumiGameKingActivity
if err := s.db.WithContext(ctx).Where("id = ?", record.ActivityID).First(&activity).Error; err != nil {
return nil, err
}
var aggregate model.YumiGameKingUser
if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", record.ActivityID, user.UserID).First(&aggregate).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
earned, _, _, err := s.earnedChances(ctx, activity, user.UserID, time.Now())
if err != nil {
return nil, err
}
remaining := earned - aggregate.UsedChances
if remaining < 0 {
remaining = 0
}
return &DrawResult{Record: drawRecordView(record), Prize: prizeView(prize), RemainingChances: remaining, IdempotentReplay: true}, nil
}
// 用户汇总行已在调用方 FOR UPDATE事件入账先更新同一汇总行再更新日表因此这里读取到
// 的日聚合与汇总消费属于同一个已提交边界,并发抽奖不会突破每日次数上限。
func earnedChancesTx(tx *gorm.DB, activity model.YumiGameKingActivity, userID int64) (int64, int64, int64, error) {
var rows []model.YumiGameKingUserDaily
if err := tx.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(time.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
}
func selectAndReservePrizeTx(tx *gorm.DB, activityID int64) (model.YumiGameKingPrize, error) {
for attempt := 0; attempt < 20; attempt++ {
var rows []model.YumiGameKingPrize
if err := tx.Where("activity_id = ? AND enabled = ? AND weight > 0 AND stock <> 0", activityID, true).
Order("sort_order ASC").Find(&rows).Error; err != nil {
return model.YumiGameKingPrize{}, err
}
if len(rows) != 7 {
// 配置固定七个,但有限库存耗尽后允许从剩余奖项继续抽;只有配置本身缺失才报错。
var configured int64
if err := tx.Model(&model.YumiGameKingPrize{}).Where("activity_id = ? AND enabled = ? AND weight > 0", activityID, true).Count(&configured).Error; err != nil {
return model.YumiGameKingPrize{}, err
}
if configured != 7 {
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_config_invalid", "seven enabled positive-weight prizes are required")
}
}
if len(rows) == 0 {
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_stock_empty", "all prize stock is exhausted")
}
selected, err := weightedPrize(rows)
if err != nil {
return model.YumiGameKingPrize{}, err
}
if selected.Stock < 0 {
return selected, nil
}
result := tx.Model(&model.YumiGameKingPrize{}).
Where("id = ? AND stock > 0", selected.ID).
Updates(map[string]any{"stock": gorm.Expr("stock - 1"), "update_time": time.Now()})
if result.Error != nil {
return model.YumiGameKingPrize{}, result.Error
}
if result.RowsAffected == 1 {
selected.Stock--
return selected, nil
}
// 另一个抽奖刚好抢完有限库存,重新读取候选并按剩余权重抽取。
}
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_stock_busy", "prize stock changed concurrently; please retry")
}
func weightedPrize(rows []model.YumiGameKingPrize) (model.YumiGameKingPrize, error) {
var total int64
for _, row := range rows {
if row.Weight <= 0 || total > math.MaxInt64-row.Weight {
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_weight_invalid", "prize weights are invalid")
}
total += row.Weight
}
if total <= 0 {
return model.YumiGameKingPrize{}, NewAppError(http.StatusConflict, "prize_weight_invalid", "prize weights are invalid")
}
randomValue, err := cryptorand.Int(cryptorand.Reader, big.NewInt(total))
if err != nil {
return model.YumiGameKingPrize{}, err
}
needle := randomValue.Int64()
var cursor int64
for _, row := range rows {
cursor += row.Weight
if needle < cursor {
return row, nil
}
}
return rows[len(rows)-1], nil
}