2026-07-18 15:32:58 +08:00

242 lines
9.2 KiB
Go

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
remaining = userRow.TotalConsumed/lockedActivity.CoinPerDraw - userRow.UsedChances
if remaining < 0 {
remaining = 0
}
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
earned := userRow.TotalConsumed / lockedActivity.CoinPerDraw
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
}
remaining := aggregate.TotalConsumed/activity.CoinPerDraw - aggregate.UsedChances
if remaining < 0 {
remaining = 0
}
return &DrawResult{Record: drawRecordView(record), Prize: prizeView(prize), RemainingChances: remaining, IdempotentReplay: true}, 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
}