diff --git a/internal/api/user/game_passes_app.go b/internal/api/user/game_passes_app.go index ea44ba4..f777e7d 100644 --- a/internal/api/user/game_passes_app.go +++ b/internal/api/user/game_passes_app.go @@ -9,6 +9,7 @@ import ( "bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/validation" "bindbox-game/internal/repository/mysql/model" + "gorm.io/gorm/clause" ) // ==================== 用户次数卡 API ==================== @@ -197,8 +198,9 @@ func (h *handler) GetGamePassPackages() core.HandlerFunc { // ==================== 购买套餐 API ==================== type purchasePackageRequest struct { - PackageID int64 `json:"package_id" binding:"required"` - Count int32 `json:"count"` // 购买数量 + PackageID int64 `json:"package_id" binding:"required"` + Count int32 `json:"count"` // 购买数量 + CouponIDs []int64 `json:"coupon_ids"` // 优惠券ID列表 } type purchasePackageResponse struct { @@ -208,7 +210,7 @@ type purchasePackageResponse struct { // PurchaseGamePassPackage 购买次数卡套餐(创建订单) // @Summary 购买次数卡套餐 -// @Description 购买次数卡套餐,创建订单等待支付 +// @Description 购买次数卡套餐,创建订单等待支付,支持使用优惠券 // @Tags APP端.用户 // @Accept json // @Produce json @@ -245,7 +247,7 @@ func (h *handler) PurchaseGamePassPackage() core.HandlerFunc { // Calculate total price totalPrice := pkg.Price * int64(req.Count) - // 创建订单 + // 创建订单 (支持优惠券) now := time.Now() orderNo := now.Format("20060102150405") + fmt.Sprintf("%04d", now.UnixNano()%10000) order := &model.Orders{ @@ -255,11 +257,33 @@ func (h *handler) PurchaseGamePassPackage() core.HandlerFunc { TotalAmount: totalPrice, ActualAmount: totalPrice, Status: 1, // 待支付 - Remark: fmt.Sprintf("game_pass_package:%s|count:%d", pkg.Name, req.Count), + Remark: fmt.Sprintf("game_pass_package:%s|pkg_id:%d|count:%d", pkg.Name, pkg.ID, req.Count), CreatedAt: now, UpdatedAt: now, } + // 应用优惠券 (如果有) + var appliedCouponVal int64 + var couponID int64 + + if len(req.CouponIDs) > 0 { + couponID = req.CouponIDs[0] + // 调用优惠券应用函数 + applied, err := h.applyCouponToGamePassOrder(ctx, order, userID, couponID) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error())) + return + } + appliedCouponVal = applied + + // 记录优惠券到订单 + if appliedCouponVal > 0 { + order.CouponID = couponID + order.Remark += fmt.Sprintf("|coupon:%d", couponID) + } + } + + // 保存订单 if err := h.writeDB.Orders.WithContext(ctx.RequestContext()). Omit(h.writeDB.Orders.PaidAt, h.writeDB.Orders.CancelledAt). Create(order); err != nil { @@ -267,14 +291,222 @@ func (h *handler) PurchaseGamePassPackage() core.HandlerFunc { return } - // 在备注中记录套餐ID和数量 - remark := fmt.Sprintf("%s|pkg_id:%d|count:%d", order.Remark, pkg.ID, req.Count) - h.writeDB.Orders.WithContext(ctx.RequestContext()). - Where(h.writeDB.Orders.ID.Eq(order.ID)). - Updates(map[string]any{"remark": remark}) + // 如果使用了优惠券,记录到order_coupons表 + if appliedCouponVal > 0 { + _ = h.writeDB.OrderCoupons.WithContext(ctx.RequestContext()).UnderlyingDB().Exec( + "INSERT IGNORE INTO order_coupons (order_id, user_coupon_id, applied_amount, created_at) VALUES (?,?,?,NOW(3))", + order.ID, couponID, appliedCouponVal) + } + + // 处理0元订单 + if order.ActualAmount == 0 { + order.Status = 2 + order.PaidAt = now + h.writeDB.Orders.WithContext(ctx.RequestContext()). + Where(h.writeDB.Orders.OrderNo.Eq(orderNo)). + Updates(map[string]any{ + h.writeDB.Orders.Status.ColumnName().String(): 2, + h.writeDB.Orders.PaidAt.ColumnName().String(): now, + }) + + // 0元订单确认优惠券扣减 + if appliedCouponVal > 0 { + h.confirmCouponUsage(ctx, couponID, order.ID, now) + } + } res.OrderNo = order.OrderNo res.Message = "订单创建成功,请完成支付" ctx.Payload(res) } } + +// ==================== 优惠券辅助函数 ==================== + +// applyCouponToGamePassOrder 应用优惠券到次卡购买订单 +// 返回应用的优惠金额 (分) +func (h *handler) applyCouponToGamePassOrder(ctx core.Context, order *model.Orders, userID int64, userCouponID int64) (int64, error) { + // 使用 SELECT ... FOR UPDATE 锁定行 + uc, _ := h.writeDB.UserCoupons.WithContext(ctx.RequestContext()). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where( + h.writeDB.UserCoupons.ID.Eq(userCouponID), + h.writeDB.UserCoupons.UserID.Eq(userID), + ).First() + + if uc == nil { + return 0, nil + } + + // 检查状态 (必须是可用状态) + if uc.Status != 1 { + return 0, fmt.Errorf("优惠券不可用") + } + + // 获取优惠券模板 + sc, _ := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()). + Where(h.readDB.SystemCoupons.ID.Eq(uc.CouponID), h.readDB.SystemCoupons.Status.Eq(1)). + First() + + now := time.Now() + if sc == nil { + return 0, fmt.Errorf("优惠券模板不存在") + } + + // 验证有效期 + if uc.ValidStart.After(now) { + return 0, fmt.Errorf("优惠券未到开始时间") + } + if !uc.ValidEnd.IsZero() && uc.ValidEnd.Before(now) { + return 0, fmt.Errorf("优惠券已过期") + } + + // 验证使用范围 (次卡购买只支持全场券 scope_type=1) + if sc.ScopeType != 1 { + return 0, fmt.Errorf("次卡购买仅支持全场通用优惠券") + } + + // 验证门槛 + if order.TotalAmount < sc.MinSpend { + return 0, fmt.Errorf("未达优惠券使用门槛") + } + + // 50% 封顶 + cap := order.TotalAmount / 2 + remainingCap := cap - order.DiscountAmount + if remainingCap <= 0 { + return 0, fmt.Errorf("已达优惠封顶限制") + } + + // 计算优惠金额 + applied := int64(0) + switch sc.DiscountType { + case 1: // 金额券 + bal := uc.BalanceAmount + if bal > 0 { + if bal > remainingCap { + applied = remainingCap + } else { + applied = bal + } + } + case 2: // 满减券 + applied = sc.DiscountValue + if applied > remainingCap { + applied = remainingCap + } + case 3: // 折扣券 + rate := sc.DiscountValue + if rate < 0 { + rate = 0 + } + if rate > 1000 { + rate = 1000 + } + newAmt := order.ActualAmount * rate / 1000 + d := order.ActualAmount - newAmt + if d > remainingCap { + applied = remainingCap + } else { + applied = d + } + } + + if applied > order.ActualAmount { + applied = order.ActualAmount + } + if applied <= 0 { + return 0, nil + } + + // 更新订单金额 + order.ActualAmount -= applied + order.DiscountAmount += applied + + // 扣减优惠券余额或标记为冻结 + if sc.DiscountType == 1 { + // 金额券:直接扣余额 + newBal := uc.BalanceAmount - applied + newStatus := int32(1) + if newBal <= 0 { + newBal = 0 + newStatus = 2 // 已使用 + } + res := h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).UnderlyingDB().Exec( + "UPDATE user_coupons SET balance_amount = ?, status = ? WHERE id = ?", + newBal, newStatus, userCouponID) + if res.Error != nil { + return 0, res.Error + } + + // 记录流水 + _ = h.writeDB.UserCouponLedger.WithContext(ctx.RequestContext()).Create(&model.UserCouponLedger{ + UserID: userID, + UserCouponID: userCouponID, + ChangeAmount: -applied, + BalanceAfter: newBal, + OrderID: order.ID, + Action: "reserve", // 预扣 + CreatedAt: time.Now(), + }) + } else { + // 满减/折扣券:标记为冻结 (状态4) + res := h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).UnderlyingDB().Exec( + "UPDATE user_coupons SET status = 4 WHERE id = ? AND status = 1", userCouponID) + if res.Error != nil { + return 0, res.Error + } + if res.RowsAffected == 0 { + return 0, fmt.Errorf("优惠券已被使用") + } + + // 记录流水 + _ = h.writeDB.UserCouponLedger.WithContext(ctx.RequestContext()).Create(&model.UserCouponLedger{ + UserID: userID, + UserCouponID: userCouponID, + ChangeAmount: 0, + BalanceAfter: 0, + OrderID: order.ID, + Action: "reserve", + CreatedAt: time.Now(), + }) + } + + return applied, nil +} + +// confirmCouponUsage 确认优惠券使用 (支付成功后调用) +func (h *handler) confirmCouponUsage(ctx core.Context, userCouponID int64, orderID int64, paidAt time.Time) { + uc, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()). + Where(h.readDB.UserCoupons.ID.Eq(userCouponID)). + First() + if uc == nil { + return + } + + sc, _ := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()). + Where(h.readDB.SystemCoupons.ID.Eq(uc.CouponID)). + First() + if sc == nil { + return + } + + // 金额券:余额已经扣减,只需记录used_order_id + if sc.DiscountType == 1 { + _, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()). + Where(h.writeDB.UserCoupons.ID.Eq(userCouponID)). + Updates(map[string]any{ + h.writeDB.UserCoupons.UsedOrderID.ColumnName().String(): orderID, + h.writeDB.UserCoupons.UsedAt.ColumnName().String(): paidAt, + }) + } else { + // 满减/折扣券:状态从冻结(4)改为已使用(2) + _, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()). + Where(h.writeDB.UserCoupons.ID.Eq(userCouponID)). + Updates(map[string]any{ + h.writeDB.UserCoupons.Status.ColumnName().String(): 2, + h.writeDB.UserCoupons.UsedOrderID.ColumnName().String(): orderID, + h.writeDB.UserCoupons.UsedAt.ColumnName().String(): paidAt, + }) + } +} diff --git a/internal/service/livestream/livestream.go b/internal/service/livestream/livestream.go index d58fc31..5eceb1d 100644 --- a/internal/service/livestream/livestream.go +++ b/internal/service/livestream/livestream.go @@ -378,6 +378,69 @@ func (s *service) DeletePrize(ctx context.Context, prizeID int64) error { // ========== 抽奖逻辑 ========== +// selectPrizeByWeight 根据随机值和权重选择奖品(不考虑库存) +func selectPrizeByWeight(prizes []*model.LivestreamPrizes, randValue int64) *model.LivestreamPrizes { + var cumulative int64 + for _, p := range prizes { + cumulative += int64(p.Weight) + if randValue < cumulative { + return p + } + } + if len(prizes) > 0 { + return prizes[len(prizes)-1] + } + return nil +} + +// findFallbackPrize 找到权重最大的有库存奖品作为兜底 +func findFallbackPrize(prizes []*model.LivestreamPrizes) *model.LivestreamPrizes { + var fallback *model.LivestreamPrizes + for _, p := range prizes { + if p.Remaining == 0 { + continue + } + if fallback == nil || p.Weight > fallback.Weight { + fallback = p + } + } + return fallback +} + +// drawWithFallback 抽奖:抽中售罄奖品时,穿透到权重最大的有库存奖品 +func (s *service) drawWithFallback(prizes []*model.LivestreamPrizes, totalWeight int64) (*model.LivestreamPrizes, int64, error) { + randBig, err := rand.Int(rand.Reader, big.NewInt(totalWeight)) + if err != nil { + return nil, 0, fmt.Errorf("生成随机数失败: %w", err) + } + randValue := randBig.Int64() + + selected := selectPrizeByWeight(prizes, randValue) + if selected == nil { + return nil, 0, fmt.Errorf("奖品选择失败") + } + + // 有库存,直接返回 + if selected.Remaining == -1 || selected.Remaining > 0 { + return selected, randValue, nil + } + + // 售罄,穿透到权重最大的有库存奖品 + fallback := findFallbackPrize(prizes) + if fallback == nil { + return nil, 0, fmt.Errorf("没有可用奖品") + } + + s.logger.Info("抽中售罄奖品,穿透到兜底奖品", + zap.Int64("original_prize_id", selected.ID), + zap.String("original_prize_name", selected.Name), + zap.Int64("fallback_prize_id", fallback.ID), + zap.String("fallback_prize_name", fallback.Name), + ) + + return fallback, randValue, nil +} + func (s *service) Draw(ctx context.Context, input DrawInput) (*DrawResult, error) { // 0. 检查黑名单 if input.DouyinUserID != "" { @@ -390,27 +453,35 @@ func (s *service) Draw(ctx context.Context, input DrawInput) (*DrawResult, error } } - // 1. 获取可用奖品 + // 1. 获取所有奖品 prizes, err := s.ListPrizes(ctx, input.ActivityID) if err != nil { return nil, fmt.Errorf("获取奖品列表失败: %w", err) } - // 2. 过滤有库存的奖品 - var availablePrizes []*model.LivestreamPrizes + if len(prizes) == 0 { + return nil, fmt.Errorf("没有配置奖品") + } + + // 2. 计算总权重(所有奖品都参与,保持概率恒定) var totalWeight int64 + var hasAvailable bool for _, p := range prizes { - if p.Remaining != 0 { // -1 表示无限 - availablePrizes = append(availablePrizes, p) - totalWeight += int64(p.Weight) + totalWeight += int64(p.Weight) + if p.Remaining != 0 { // -1 表示无限,>0 表示有库存 + hasAvailable = true } } - if len(availablePrizes) == 0 { - return nil, fmt.Errorf("没有可用奖品") + if totalWeight == 0 { + return nil, fmt.Errorf("奖品权重配置异常") } - // 3. 生成随机种子 + if !hasAvailable { + return nil, fmt.Errorf("没有可用奖品(全部售罄)") + } + + // 3. 生成随机种子(用于凭证) seedBytes := make([]byte, 32) if _, err := rand.Read(seedBytes); err != nil { return nil, fmt.Errorf("生成随机种子失败: %w", err) @@ -418,26 +489,10 @@ func (s *service) Draw(ctx context.Context, input DrawInput) (*DrawResult, error seedHash := sha256.Sum256(seedBytes) seedHex := hex.EncodeToString(seedHash[:]) - // 4. 计算随机值 - randBig, err := rand.Int(rand.Reader, big.NewInt(totalWeight)) + // 4. 穿透抽奖:抽中售罄奖品时给权重最大的有库存奖品 + selectedPrize, randValue, err := s.drawWithFallback(prizes, totalWeight) if err != nil { - return nil, fmt.Errorf("生成随机数失败: %w", err) - } - randValue := randBig.Int64() - - // 5. 按权重选择奖品 - var selectedPrize *model.LivestreamPrizes - var cumulative int64 - for _, p := range availablePrizes { - cumulative += int64(p.Weight) - if randValue < cumulative { - selectedPrize = p - break - } - } - - if selectedPrize == nil { - selectedPrize = availablePrizes[len(availablePrizes)-1] + return nil, err } // 6. 事务:扣减库存 + 记录中奖