This commit is contained in:
邹方成 2026-02-02 23:56:01 +08:00
parent 55e22086e8
commit 25c44c2064
2 changed files with 325 additions and 38 deletions

View File

@ -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,
})
}
}

View File

@ -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. 事务:扣减库存 + 记录中奖