330 lines
11 KiB
Go
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())
|
|
}
|
|
}()
|
|
}
|