bindbox-game/internal/api/user/game_passes_app.go

513 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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