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 }