package threshold_activity import ( "context" crand "crypto/rand" "encoding/binary" "errors" "fmt" "math/rand" "time" "bindbox-game/internal/pkg/logger" "bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql/model" "go.uber.org/zap" "gorm.io/gorm" ) type prizeGrantResult struct { RewardType string RewardRefID int64 PrizeNameSnapshot string PrizeImageSnapshot string PrizeValueSnapshotCents int64 GrantRecordType string GrantRecordID int64 CostCents int64 } func (s *service) DrawDueActivities(ctx context.Context) error { var ids []int64 if err := s.repo.GetDbR().WithContext(ctx).Model(&Activity{}). Where("deleted_at IS NULL AND status = ? AND draw_time <= ?", StatusActive, time.Now()). Pluck("id", &ids).Error; err != nil { return err } for _, id := range ids { if err := s.Draw(ctx, id); err != nil && s.logger != nil { s.logger.Warn("threshold activity draw failed", zap.Int64("activity_id", id), zap.Error(err)) } } return nil } func (s *service) Draw(ctx context.Context, activityID int64) error { if activityID <= 0 { return errors.New("活动ID无效") } batch := fmt.Sprintf("TA%d-%d", activityID, time.Now().UnixNano()) updated := s.repo.GetDbW().WithContext(ctx).Model(&Activity{}). Where("id = ? AND deleted_at IS NULL AND status = ?", activityID, StatusActive). Update("draw_batch", batch).RowsAffected if updated == 0 { return errors.New("活动不允许开奖或已开奖") } var finalStatus = StatusFinished var abortReason string err := s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error { var activity Activity if err := tx.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", activityID).First(&activity).Error; err != nil { return err } var participants []Participant if err := tx.Where("activity_id = ?", activityID).Find(&participants).Error; err != nil { return err } if int64(len(participants)) < activity.MinParticipants { finalStatus = StatusAborted abortReason = "min_participants_not_met" return nil } var prizes []Prize if err := tx.Where("activity_id = ? AND remaining_quantity > 0", activityID).Order("sort ASC, id ASC").Find(&prizes).Error; err != nil { return err } if len(prizes) == 0 { return errors.New("未配置可发放奖品") } shuffleParticipants(participants) prizePool := buildPrizePool(prizes) shufflePrizes(prizePool) used := map[int64]bool{} idx := 0 for _, prize := range prizePool { for idx < len(participants) && used[participants[idx].UserID] { idx++ } if idx >= len(participants) { break } userID := participants[idx].UserID idx++ used[userID] = true grantResult, err := s.grantPrizeInTx(ctx, tx, activityID, userID, prize) if err != nil { return err } winner := &Winner{ ActivityID: activityID, PrizeID: prize.ID, RewardType: grantResult.RewardType, RewardRefID: grantResult.RewardRefID, PrizeNameSnapshot: grantResult.PrizeNameSnapshot, PrizeImageSnapshot: grantResult.PrizeImageSnapshot, PrizeValueSnapshotCents: grantResult.PrizeValueSnapshotCents, UserID: userID, GrantRecordType: grantResult.GrantRecordType, GrantRecordID: grantResult.GrantRecordID, CostCents: grantResult.CostCents, DrawBatch: batch, } if err := tx.Create(winner).Error; err != nil { return err } if err := tx.Model(&Prize{}).Where("id = ? AND remaining_quantity > 0", prize.ID).Update("remaining_quantity", gorm.Expr("remaining_quantity - 1")).Error; err != nil { return err } } return nil }) if err != nil { return err } updates := map[string]interface{}{ "status": finalStatus, "draw_batch": batch, } if finalStatus == StatusAborted { now := time.Now() updates["abort_reason"] = abortReason updates["aborted_at"] = now } if err := s.repo.GetDbW().WithContext(ctx).Model(&Activity{}).Where("id = ?", activityID).Updates(updates).Error; err != nil { return err } return nil } func (s *service) grantPrizeInTx(ctx context.Context, tx *gorm.DB, activityID int64, userID int64, prize Prize) (*prizeGrantResult, error) { switch prize.RewardType { case RewardTypeItemCard: return s.grantItemCardPrizeInTx(ctx, tx, activityID, userID, prize) case RewardTypeCoupon: return s.grantCouponPrizeInTx(ctx, tx, activityID, userID, prize) default: return s.grantProductPrizeInTx(ctx, tx, activityID, userID, prize) } } func (s *service) grantProductPrizeInTx(ctx context.Context, tx *gorm.DB, activityID int64, userID int64, prize Prize) (*prizeGrantResult, error) { var product model.Products if err := tx.WithContext(ctx).Where("id = ?", prize.RewardRefID).First(&product).Error; err != nil { return nil, err } if product.Stock <= 0 { return nil, fmt.Errorf("商品库存不足: %s", product.Name) } result := tx.WithContext(ctx).Model(&model.Products{}).Where("id = ? AND stock > 0", product.ID).Update("stock", gorm.Expr("stock - 1")) if result.Error != nil { return nil, result.Error } if result.RowsAffected == 0 { return nil, fmt.Errorf("商品库存不足: %s", product.Name) } now := time.Now() minValidTime := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) order := &model.Orders{OrderNo: fmt.Sprintf("TA%d%d", activityID, now.UnixNano()), UserID: userID, SourceType: 6, Status: 2, PaidAt: now, CancelledAt: minValidTime, Remark: "门槛活动中奖发放", CreatedAt: now, UpdatedAt: now} if err := tx.WithContext(ctx).Create(order).Error; err != nil { return nil, err } orderItem := &model.OrderItems{OrderID: order.ID, ProductID: product.ID, Title: product.Name, Quantity: 1, ProductImages: product.ImagesJSON, Status: 1} if err := tx.WithContext(ctx).Create(orderItem).Error; err != nil { return nil, err } value := prize.CostSnapshotCents if value <= 0 { value = product.CostPrice } if value <= 0 { value = product.Price } inventory := &model.UserInventory{UserID: userID, ProductID: product.ID, ValueCents: value, ValueSource: 1, ValueSnapshotAt: now, OrderID: order.ID, ActivityID: activityID, RewardID: prize.ID, Status: 1, Remark: "门槛活动中奖发放"} if err := tx.WithContext(ctx).Create(inventory).Error; err != nil { return nil, err } return &prizeGrantResult{ RewardType: RewardTypeProduct, RewardRefID: prize.RewardRefID, PrizeNameSnapshot: fallbackRewardName(prize.RewardNameSnapshot, product.Name), PrizeImageSnapshot: fallbackRewardImage(prize.RewardImageSnapshot, firstProductImage(product.ImagesJSON)), PrizeValueSnapshotCents: fallbackRewardValue(prize.RewardValueSnapshotCents, product.Price), GrantRecordType: GrantRecordTypeInventory, GrantRecordID: inventory.ID, CostCents: prize.CostSnapshotCents, }, nil } func (s *service) grantItemCardPrizeInTx(ctx context.Context, tx *gorm.DB, activityID int64, userID int64, prize Prize) (*prizeGrantResult, error) { var card model.SystemItemCards if err := tx.WithContext(ctx).Where("id = ? AND status = 1", prize.RewardRefID).First(&card).Error; err != nil { return nil, err } now := time.Now() item := &model.UserItemCards{UserID: userID, CardID: card.ID, Status: 1, Remark: "门槛活动中奖发放"} if !card.ValidStart.IsZero() { item.ValidStart = card.ValidStart } else { item.ValidStart = now } if !card.ValidEnd.IsZero() { item.ValidEnd = card.ValidEnd } do := tx.WithContext(ctx).Omit("used_at", "used_draw_log_id", "used_activity_id", "used_issue_id") if card.ValidEnd.IsZero() { do = do.Omit("valid_end") } if err := do.Create(item).Error; err != nil { return nil, err } return &prizeGrantResult{ RewardType: RewardTypeItemCard, RewardRefID: prize.RewardRefID, PrizeNameSnapshot: fallbackRewardName(prize.RewardNameSnapshot, card.Name), PrizeImageSnapshot: fallbackRewardImage(prize.RewardImageSnapshot, ""), PrizeValueSnapshotCents: fallbackRewardValue(prize.RewardValueSnapshotCents, card.Price), GrantRecordType: GrantRecordTypeItemCard, GrantRecordID: item.ID, CostCents: prize.CostSnapshotCents, }, nil } func (s *service) grantCouponPrizeInTx(ctx context.Context, tx *gorm.DB, activityID int64, userID int64, prize Prize) (*prizeGrantResult, error) { var tpl model.SystemCoupons if err := tx.WithContext(ctx).Where("id = ? AND status = 1", prize.RewardRefID).First(&tpl).Error; err != nil { return nil, err } if !tpl.ValidEnd.IsZero() && tpl.ValidEnd.Before(time.Now()) { return nil, errors.New("coupon template expired") } if tpl.TotalQuantity > 0 { var issued int64 if err := tx.WithContext(ctx).Model(&model.UserCoupons{}).Where("coupon_id = ?", tpl.ID).Count(&issued).Error; err != nil { return nil, err } if issued >= tpl.TotalQuantity { return nil, gorm.ErrInvalidData } } item := &model.UserCoupons{UserID: userID, CouponID: tpl.ID, Status: 1} if !tpl.ValidStart.IsZero() { item.ValidStart = tpl.ValidStart } else { item.ValidStart = time.Now() } if !tpl.ValidEnd.IsZero() { item.ValidEnd = tpl.ValidEnd } do := tx.WithContext(ctx).Omit("used_at", "used_order_id") if tpl.ValidEnd.IsZero() { do = do.Omit("valid_end") } if err := do.Create(item).Error; err != nil { return nil, err } balance := int64(0) if tpl.DiscountType == 1 && tpl.DiscountValue > 0 { balance = tpl.DiscountValue } if err := tx.WithContext(ctx).Model(&model.UserCoupons{}).Where("id = ?", item.ID).Update("balance_amount", balance).Error; err != nil { return nil, err } return &prizeGrantResult{ RewardType: RewardTypeCoupon, RewardRefID: prize.RewardRefID, PrizeNameSnapshot: fallbackRewardName(prize.RewardNameSnapshot, tpl.Name), PrizeImageSnapshot: fallbackRewardImage(prize.RewardImageSnapshot, ""), PrizeValueSnapshotCents: fallbackRewardValue(prize.RewardValueSnapshotCents, tpl.DiscountValue), GrantRecordType: GrantRecordTypeCoupon, GrantRecordID: item.ID, CostCents: prize.CostSnapshotCents, }, nil } func fallbackRewardName(snapshot string, fallback string) string { if snapshot != "" { return snapshot } return fallback } func fallbackRewardImage(snapshot string, fallback string) string { if snapshot != "" { return snapshot } return fallback } func fallbackRewardValue(snapshot int64, fallback int64) int64 { if snapshot > 0 { return snapshot } return fallback } func shuffleParticipants(list []Participant) { seed := time.Now().UnixNano() var b [8]byte if _, err := crand.Read(b[:]); err == nil { seed = int64(binary.LittleEndian.Uint64(b[:])) } r := rand.New(rand.NewSource(seed)) r.Shuffle(len(list), func(i, j int) { list[i], list[j] = list[j], list[i] }) } func buildPrizePool(prizes []Prize) []Prize { pool := make([]Prize, 0) for _, prize := range prizes { for i := 0; i < prize.RemainingQuantity; i++ { pool = append(pool, prize) } } return pool } func shufflePrizes(list []Prize) { seed := time.Now().UnixNano() var b [8]byte if _, err := crand.Read(b[:]); err == nil { seed = int64(binary.LittleEndian.Uint64(b[:])) } r := rand.New(rand.NewSource(seed)) r.Shuffle(len(list), func(i, j int) { list[i], list[j] = list[j], list[i] }) } func StartScheduledDraw(log logger.CustomLogger, repo mysql.Repo) { svc := New(log, repo) go func() { ticker := time.NewTicker(time.Minute) defer ticker.Stop() for range ticker.C { _ = svc.DrawDueActivities(context.Background()) } }() }