Zuncle 3db52af4b6 feat(activity): 重构福利活动并支持统一奖池
对齐福利活动新库表结构,支持商品、道具卡和优惠券统一建奖、开奖与中奖记录。
同时新增福利活动测试命令行工具,便于模拟消费、参与活动并验证完整开奖链路。
2026-04-29 17:21:11 +08:00

330 lines
11 KiB
Go

package welfare_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("welfare 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("WA%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("活动不允许开奖或已开奖")
}
err := s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var participants []Participant
if err := tx.Where("activity_id = ?", activityID).Find(&participants).Error; err != nil {
return err
}
if len(participants) == 0 {
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
}
if err := s.repo.GetDbW().WithContext(ctx).Model(&Activity{}).Where("id = ?", activityID).Updates(map[string]interface{}{"status": StatusFinished, "draw_batch": batch}).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("WA%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())
}
}()
}