package app import ( "fmt" "net/http" "time" "bindbox-game/internal/code" "bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/validation" "bindbox-game/internal/repository/mysql/model" "gorm.io/gorm/clause" ) // ==================== 用户次数卡 API ==================== type getGamePassesRequest struct { ActivityID *int64 `form:"activity_id"` } type userGamePassItem struct { ID int64 `json:"id"` ActivityID int64 `json:"activity_id"` ActivityName string `json:"activity_name"` Remaining int32 `json:"remaining"` ExpiredAt string `json:"expired_at"` Source string `json:"source"` } type getGamePassesResponse struct { TotalRemaining int32 `json:"total_remaining"` GlobalRemaining int32 `json:"global_remaining"` // 全局通用次数 Passes []userGamePassItem `json:"passes"` } // GetGamePasses 获取用户可用的游戏次数卡 // @Summary 获取用户次数卡 // @Description 查询当前用户可用的游戏次数卡 // @Tags APP端.用户 // @Accept json // @Produce json // @Security LoginVerifyToken // @Param activity_id query integer false "活动ID,不传返回所有可用次数卡" // @Success 200 {object} getGamePassesResponse // @Failure 400 {object} code.Failure // @Router /api/app/game-passes/available [get] func (h *handler) GetGamePasses() core.HandlerFunc { return func(ctx core.Context) { req := new(getGamePassesRequest) res := new(getGamePassesResponse) if err := ctx.ShouldBindForm(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } userID := int64(ctx.SessionUserInfo().Id) now := time.Now() // 查询用户的次数卡(已过滤过期和剩余为0的) q := h.readDB.UserGamePasses.WithContext(ctx.RequestContext()). Where(h.readDB.UserGamePasses.UserID.Eq(userID)). Where(h.readDB.UserGamePasses.Remaining.Gt(0)) passes, err := q.Find() if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error())) return } // 获取活动名称 activityIDs := make([]int64, 0) for _, p := range passes { if p.ActivityID > 0 { activityIDs = append(activityIDs, p.ActivityID) } } activityMap := make(map[int64]string) if len(activityIDs) > 0 { activities, _ := h.readDB.Activities.WithContext(ctx.RequestContext()). Where(h.readDB.Activities.ID.In(activityIDs...)).Find() for _, a := range activities { activityMap[a.ID] = a.Name } } res.Passes = make([]userGamePassItem, 0) for _, p := range passes { // 检查是否过期 if !p.ExpiredAt.IsZero() && p.ExpiredAt.Before(now) { continue } // 如果指定了活动ID,只返回全局通用的和该活动的 if req.ActivityID != nil && *req.ActivityID > 0 { if p.ActivityID != 0 && p.ActivityID != *req.ActivityID { continue } } expiredAt := "" if !p.ExpiredAt.IsZero() { expiredAt = p.ExpiredAt.Format("2006-01-02 15:04:05") } item := userGamePassItem{ ID: p.ID, ActivityID: p.ActivityID, ActivityName: activityMap[p.ActivityID], Remaining: p.Remaining, ExpiredAt: expiredAt, Source: p.Source, } res.TotalRemaining += p.Remaining if p.ActivityID == 0 { res.GlobalRemaining += p.Remaining } res.Passes = append(res.Passes, item) } ctx.Payload(res) } } // ==================== 套餐列表 API ==================== type getPackagesRequest struct { ActivityID *int64 `form:"activity_id"` } type packageItem struct { ID int64 `json:"id"` Name string `json:"name"` PassCount int32 `json:"pass_count"` Price int64 `json:"price"` OriginalPrice int64 `json:"original_price"` ValidDays int32 `json:"valid_days"` ActivityID int64 `json:"activity_id"` } type getPackagesResponse struct { Packages []packageItem `json:"packages"` } // GetGamePassPackages 获取可购买的套餐列表 // @Summary 获取次数卡套餐 // @Description 获取可购买的次数卡套餐列表 // @Tags APP端.用户 // @Accept json // @Produce json // @Param activity_id query integer false "活动ID,不传返回全局套餐" // @Success 200 {object} getPackagesResponse // @Failure 400 {object} code.Failure // @Router /api/app/game-passes/packages [get] func (h *handler) GetGamePassPackages() core.HandlerFunc { return func(ctx core.Context) { req := new(getPackagesRequest) res := new(getPackagesResponse) if err := ctx.ShouldBindForm(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } q := h.readDB.GamePassPackages.WithContext(ctx.RequestContext()). Where(h.readDB.GamePassPackages.Status.Eq(1)) // 只返回上架的 // 如果指定了活动ID,返回全局套餐(activity_id=0)和该活动的专属套餐 if req.ActivityID != nil && *req.ActivityID > 0 { q = q.Where(h.readDB.GamePassPackages.ActivityID.In(0, *req.ActivityID)) } else { // 只返回全局套餐 q = q.Where(h.readDB.GamePassPackages.ActivityID.Eq(0)) } packages, err := q.Order(h.readDB.GamePassPackages.SortOrder.Desc(), h.readDB.GamePassPackages.ID.Asc()).Find() if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error())) return } res.Packages = make([]packageItem, len(packages)) for i, p := range packages { res.Packages[i] = packageItem{ ID: p.ID, Name: p.Name, PassCount: p.PassCount, Price: p.Price, OriginalPrice: p.OriginalPrice, ValidDays: p.ValidDays, ActivityID: p.ActivityID, } } ctx.Payload(res) } } // ==================== 购买套餐 API ==================== type purchasePackageRequest struct { PackageID int64 `json:"package_id" binding:"required"` Count int32 `json:"count"` // 购买数量 CouponIDs []int64 `json:"coupon_ids"` // 优惠券ID列表 } type purchasePackageResponse struct { OrderNo string `json:"order_no"` Message string `json:"message"` } // PurchaseGamePassPackage 购买次数卡套餐(创建订单) // @Summary 购买次数卡套餐 // @Description 购买次数卡套餐,创建订单等待支付,支持使用优惠券 // @Tags APP端.用户 // @Accept json // @Produce json // @Security LoginVerifyToken // @Param RequestBody body purchasePackageRequest true "请求参数" // @Success 200 {object} purchasePackageResponse // @Failure 400 {object} code.Failure // @Router /api/app/game-passes/purchase [post] func (h *handler) PurchaseGamePassPackage() core.HandlerFunc { return func(ctx core.Context) { req := new(purchasePackageRequest) res := new(purchasePackageResponse) if err := ctx.ShouldBindJSON(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } if req.Count <= 0 { req.Count = 1 } userID := int64(ctx.SessionUserInfo().Id) // 查询套餐信息 pkg, err := h.readDB.GamePassPackages.WithContext(ctx.RequestContext()). Where(h.readDB.GamePassPackages.ID.Eq(req.PackageID)). Where(h.readDB.GamePassPackages.Status.Eq(1)). First() if err != nil || pkg == nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "套餐不存在或已下架")) return } // 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{ UserID: userID, OrderNo: "GP" + orderNo, SourceType: 4, // 次数卡购买 TotalAmount: totalPrice, ActualAmount: totalPrice, Status: 1, // 待支付 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 { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error())) return } // 如果使用了优惠券,记录到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.TotalAmount * rate / 1000 d := order.TotalAmount - 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, }) } }