513 lines
15 KiB
Go
513 lines
15 KiB
Go
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.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,
|
||
})
|
||
}
|
||
}
|