242 lines
9.2 KiB
Go
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
|
|
}
|