diff --git a/controller/promo_code.go b/controller/promo_code.go new file mode 100644 index 00000000..3977fb57 --- /dev/null +++ b/controller/promo_code.go @@ -0,0 +1,237 @@ +package controller + +import ( + "errors" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type createPromoCodeRequest struct { + Code string `json:"code"` + BonusAmount int `json:"bonus_amount"` + MaxUses int `json:"max_uses"` + MinRechargeAmount int `json:"min_recharge_amount"` + ExpiresAt string `json:"expires_at"` + Notes string `json:"notes"` +} + +type updatePromoCodeRequest struct { + Status *int `json:"status"` + MaxUses *int `json:"max_uses"` + MinRechargeAmount *int `json:"min_recharge_amount"` + ExpiresAt *string `json:"expires_at"` + Notes *string `json:"notes"` +} + +func GetPromoCodes(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + keyword := strings.TrimSpace(c.Query("keyword")) + + var status *int + if statusText := strings.TrimSpace(c.Query("status")); statusText != "" { + statusValue, err := strconv.Atoi(statusText) + if err != nil { + common.ApiErrorMsg(c, "status 参数格式不正确") + return + } + status = &statusValue + } + + promoCodes, total, err := model.GetPromoCodes(pageInfo.GetPage(), pageInfo.GetPageSize(), status, keyword) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(promoCodes) + common.ApiSuccess(c, pageInfo) +} + +func CreatePromoCode(c *gin.Context) { + var req createPromoCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + + code := normalizePromoCode(req.Code) + if code == "" { + common.ApiErrorMsg(c, "优惠码不能为空") + return + } + if len(code) > 32 { + common.ApiErrorMsg(c, "优惠码长度不能超过 32 个字符") + return + } + if req.BonusAmount <= 0 { + common.ApiErrorMsg(c, "赠送额度必须大于 0") + return + } + if req.MaxUses < 0 { + common.ApiErrorMsg(c, "最大使用次数不能小于 0") + return + } + if req.MinRechargeAmount < 0 { + common.ApiErrorMsg(c, "最低充值金额不能小于 0") + return + } + + if _, err := model.GetPromoCodeByCode(code); err == nil { + common.ApiErrorMsg(c, "优惠码已存在") + return + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + common.ApiError(c, err) + return + } + + promoCode := &model.PromoCode{ + Code: code, + BonusAmount: req.BonusAmount, + MaxUses: req.MaxUses, + MinRechargeAmount: req.MinRechargeAmount, + Notes: req.Notes, + Status: 1, + } + if strings.TrimSpace(req.ExpiresAt) != "" { + expiresAt, err := parsePromoCodeExpiresAt(req.ExpiresAt) + if err != nil { + common.ApiErrorMsg(c, "expires_at 必须是 RFC3339 格式") + return + } + promoCode.ExpiresAt = expiresAt + } + + if err := promoCode.Insert(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, promoCode) +} + +func GetPromoCode(c *gin.Context) { + promoCode, ok := getPromoCodeFromParam(c) + if !ok { + return + } + common.ApiSuccess(c, promoCode) +} + +func UpdatePromoCode(c *gin.Context) { + promoCode, ok := getPromoCodeFromParam(c) + if !ok { + return + } + + var req updatePromoCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + + if req.Status != nil { + promoCode.Status = *req.Status + } + if req.MaxUses != nil { + if *req.MaxUses < 0 { + common.ApiErrorMsg(c, "最大使用次数不能小于 0") + return + } + promoCode.MaxUses = *req.MaxUses + } + if req.MinRechargeAmount != nil { + if *req.MinRechargeAmount < 0 { + common.ApiErrorMsg(c, "最低充值金额不能小于 0") + return + } + promoCode.MinRechargeAmount = *req.MinRechargeAmount + } + if req.ExpiresAt != nil { + expiresAtText := strings.TrimSpace(*req.ExpiresAt) + if expiresAtText == "" { + promoCode.ExpiresAt = nil + } else { + expiresAt, err := parsePromoCodeExpiresAt(expiresAtText) + if err != nil { + common.ApiErrorMsg(c, "expires_at 必须是 RFC3339 格式") + return + } + promoCode.ExpiresAt = expiresAt + } + } + if req.Notes != nil { + promoCode.Notes = *req.Notes + } + + if err := promoCode.Update(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, promoCode) +} + +func DeletePromoCode(c *gin.Context) { + promoCode, ok := getPromoCodeFromParam(c) + if !ok { + return + } + promoCode.Status = 0 + if err := promoCode.Update(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} + +func GetPromoCodeUsages(c *gin.Context) { + promoCode, ok := getPromoCodeFromParam(c) + if !ok { + return + } + + pageInfo := common.GetPageQuery(c) + usages, total, err := model.GetPromoCodeUsages(pageInfo.GetPage(), pageInfo.GetPageSize(), promoCode.Id) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(usages) + common.ApiSuccess(c, pageInfo) +} + +func getPromoCodeFromParam(c *gin.Context) (*model.PromoCode, bool) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return nil, false + } + promoCode, err := model.GetPromoCodeByID(id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + common.ApiErrorMsg(c, "优惠码不存在") + return nil, false + } + common.ApiError(c, err) + return nil, false + } + return promoCode, true +} + +func normalizePromoCode(code string) string { + return strings.ToUpper(strings.TrimSpace(code)) +} + +func parsePromoCodeExpiresAt(value string) (*time.Time, error) { + expiresAt, err := time.Parse(time.RFC3339, strings.TrimSpace(value)) + if err != nil { + return nil, err + } + return &expiresAt, nil +} diff --git a/controller/promo_code_user.go b/controller/promo_code_user.go new file mode 100644 index 00000000..d19aebc3 --- /dev/null +++ b/controller/promo_code_user.go @@ -0,0 +1,96 @@ +package controller + +import ( + "errors" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type validatePromoCodeRequest struct { + Code string `json:"code"` + Amount int `json:"amount"` +} + +type validatePromoCodeResponse struct { + Valid bool `json:"valid"` + Reason string `json:"reason"` + PromoCodeId int `json:"promo_code_id"` + BonusAmount int `json:"bonus_amount"` + Code string `json:"code"` +} + +func ValidatePromoCode(c *gin.Context) { + var req validatePromoCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + + code := normalizeUserPromoCode(req.Code) + if code == "" { + common.ApiErrorMsg(c, "优惠码不能为空") + return + } + + promoCode, err := model.GetPromoCodeByCode(code) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "not_found")) + return + } + common.ApiError(c, err) + return + } + + if promoCode.Status != 1 { + common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "disabled")) + return + } + if promoCode.ExpiresAt != nil && promoCode.ExpiresAt.Before(time.Now()) { + common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "expired")) + return + } + if promoCode.MaxUses > 0 && promoCode.UsedCount >= promoCode.MaxUses { + common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "max_uses_reached")) + return + } + if req.Amount < promoCode.MinRechargeAmount { + common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "below_min_recharge")) + return + } + + userId := c.GetInt("id") + if _, err := model.GetPromoCodeUsageByCodeAndUser(promoCode.Id, userId); err == nil { + common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "already_used")) + return + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + common.ApiError(c, err) + return + } + + common.ApiSuccess(c, validatePromoCodeResponse{ + Valid: true, + Reason: "", + PromoCodeId: promoCode.Id, + BonusAmount: promoCode.BonusAmount, + Code: promoCode.Code, + }) +} + +func normalizeUserPromoCode(code string) string { + return strings.ToUpper(strings.TrimSpace(code)) +} + +func newInvalidPromoCodeResponse(code string, reason string) validatePromoCodeResponse { + return validatePromoCodeResponse{ + Valid: false, + Reason: reason, + Code: code, + } +} diff --git a/controller/topup.go b/controller/topup.go index 1de19679..a293fcfb 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -125,12 +125,24 @@ func GetTopUpInfo(c *gin.Context) { type EpayRequest struct { Amount int64 `json:"amount"` PaymentMethod string `json:"payment_method"` + PromoCodeId int `json:"promo_code_id"` } type AmountRequest struct { Amount int64 `json:"amount"` } +func validateTopUpPromoCodeForOrder(c *gin.Context, userId int, promoCodeId int, amount int64) bool { + if promoCodeId <= 0 { + return true + } + if err := model.ValidateTopUpPromoCode(userId, promoCodeId, amount); err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()}) + return false + } + return true +} + func GetEpayClient() *epay.Client { if operation_setting.PayAddress == "" || operation_setting.EpayId == "" || operation_setting.EpayKey == "" { return nil @@ -214,6 +226,16 @@ func RequestEpay(c *gin.Context) { return } + amount := req.Amount + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + dAmount := decimal.NewFromInt(int64(amount)) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + amount = dAmount.Div(dQuotaPerUnit).IntPart() + } + if !validateTopUpPromoCodeForOrder(c, id, req.PromoCodeId, amount) { + return + } + callBackAddress := service.GetCallbackAddress() returnUrl, _ := url.Parse(paymentReturnPath("/console/log")) notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify") @@ -238,12 +260,6 @@ func RequestEpay(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"}) return } - amount := req.Amount - if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { - dAmount := decimal.NewFromInt(int64(amount)) - dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) - amount = dAmount.Div(dQuotaPerUnit).IntPart() - } topUp := &model.TopUp{ UserId: id, Amount: amount, @@ -251,6 +267,7 @@ func RequestEpay(c *gin.Context) { TradeNo: tradeNo, PaymentMethod: req.PaymentMethod, PaymentProvider: model.PaymentProviderEpay, + PromoCodeId: req.PromoCodeId, CreateTime: time.Now().Unix(), Status: common.TopUpStatusPending, } @@ -372,39 +389,11 @@ func EpayNotify(c *gin.Context) { if verifyInfo.TradeStatus == epay.StatusTradeSuccess { LockOrder(verifyInfo.ServiceTradeNo) defer UnlockOrder(verifyInfo.ServiceTradeNo) - topUp := model.GetTopUpByTradeNo(verifyInfo.ServiceTradeNo) - if topUp == nil { - logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 回调订单不存在 trade_no=%s callback_type=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP(), common.GetJsonString(verifyInfo))) + if err := model.RechargeEpay(verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()); err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 充值处理失败 trade_no=%s callback_type=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP(), err.Error())) return } - if topUp.PaymentProvider != model.PaymentProviderEpay { - logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 订单支付网关不匹配 trade_no=%s order_provider=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, topUp.PaymentProvider, verifyInfo.Type, c.ClientIP())) - return - } - if topUp.Status == common.TopUpStatusPending { - if topUp.PaymentMethod != verifyInfo.Type { - logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 实际支付方式与订单不同 trade_no=%s order_payment_method=%s actual_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, topUp.PaymentMethod, verifyInfo.Type, c.ClientIP())) - topUp.PaymentMethod = verifyInfo.Type - } - topUp.Status = common.TopUpStatusSuccess - err := topUp.Update() - if err != nil { - logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 更新充值订单失败 trade_no=%s user_id=%d client_ip=%s error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), err.Error(), common.GetJsonString(topUp))) - return - } - //user, _ := model.GetUserById(topUp.UserId, false) - //user.Quota += topUp.Amount * 500000 - dAmount := decimal.NewFromInt(int64(topUp.Amount)) - dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) - quotaToAdd := int(dAmount.Mul(dQuotaPerUnit).IntPart()) - err = model.IncreaseUserQuota(topUp.UserId, quotaToAdd, true) - if err != nil { - logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 更新用户额度失败 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, err.Error(), common.GetJsonString(topUp))) - return - } - logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值成功 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d money=%.2f topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, topUp.Money, common.GetJsonString(topUp))) - model.RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money), c.ClientIP(), topUp.PaymentMethod, "epay") - } + logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值成功 trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP())) } else { logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 忽略事件 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo))) } diff --git a/controller/topup_creem.go b/controller/topup_creem.go index 7472690e..ba84eae9 100644 --- a/controller/topup_creem.go +++ b/controller/topup_creem.go @@ -50,6 +50,7 @@ func verifyCreemSignature(payload string, signature string, secret string) bool type CreemPayRequest struct { ProductId string `json:"product_id"` PaymentMethod string `json:"payment_method"` + PromoCodeId int `json:"promo_code_id"` } type CreemProduct struct { @@ -99,6 +100,9 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) { id := c.GetInt("id") user, _ := model.GetUserById(id, false) + if !validateTopUpPromoCodeForOrder(c, id, req.PromoCodeId, selectedProduct.Quota) { + return + } // 生成唯一的订单引用ID reference := fmt.Sprintf("creem-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4)) @@ -112,6 +116,7 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) { TradeNo: referenceId, PaymentMethod: model.PaymentMethodCreem, PaymentProvider: model.PaymentProviderCreem, + PromoCodeId: req.PromoCodeId, CreateTime: time.Now().Unix(), Status: common.TopUpStatusPending, } diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index bcae201e..6c8eb78e 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -37,6 +37,8 @@ type StripePayRequest struct { // CancelURL is the optional custom URL to redirect when payment is canceled. // If empty, defaults to the server's console topup page. CancelURL string `json:"cancel_url,omitempty"` + // PromoCodeId is the optional internal promo code selected by the user. + PromoCodeId int `json:"promo_code_id"` } type StripeAdaptor struct { @@ -88,6 +90,9 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) { id := c.GetInt("id") user, _ := model.GetUserById(id, false) chargedMoney := GetChargedAmount(float64(req.Amount), *user) + if !validateTopUpPromoCodeForOrder(c, id, req.PromoCodeId, req.Amount) { + return + } reference := fmt.Sprintf("new-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4)) referenceId := "ref_" + common.Sha1([]byte(reference)) @@ -106,6 +111,7 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) { TradeNo: referenceId, PaymentMethod: model.PaymentMethodStripe, PaymentProvider: model.PaymentProviderStripe, + PromoCodeId: req.PromoCodeId, CreateTime: time.Now().Unix(), Status: common.TopUpStatusPending, } diff --git a/controller/topup_waffo.go b/controller/topup_waffo.go index 344630f7..fd65ff21 100644 --- a/controller/topup_waffo.go +++ b/controller/topup_waffo.go @@ -97,6 +97,7 @@ type WaffoPayRequest struct { PayMethodIndex *int `json:"pay_method_index"` // 服务端支付方式列表的索引,nil 表示由 Waffo 自动选择 PayMethodType string `json:"pay_method_type"` // Deprecated: 兼容旧前端,优先使用 pay_method_index PayMethodName string `json:"pay_method_name"` // Deprecated: 兼容旧前端,优先使用 pay_method_index + PromoCodeId int `json:"promo_code_id"` } func RequestWaffoAmount(c *gin.Context) { @@ -204,6 +205,9 @@ func RequestWaffoPay(c *gin.Context) { amount = 1 } } + if !validateTopUpPromoCodeForOrder(c, id, req.PromoCodeId, amount) { + return + } // 创建本地订单 topUp := &model.TopUp{ @@ -213,6 +217,7 @@ func RequestWaffoPay(c *gin.Context) { TradeNo: merchantOrderId, PaymentMethod: model.PaymentMethodWaffo, PaymentProvider: model.PaymentProviderWaffo, + PromoCodeId: req.PromoCodeId, CreateTime: time.Now().Unix(), Status: common.TopUpStatusPending, } diff --git a/controller/topup_waffo_pancake.go b/controller/topup_waffo_pancake.go index 11c581fa..ebffbc11 100644 --- a/controller/topup_waffo_pancake.go +++ b/controller/topup_waffo_pancake.go @@ -19,7 +19,8 @@ import ( ) type WaffoPancakePayRequest struct { - Amount int64 `json:"amount"` + Amount int64 `json:"amount"` + PromoCodeId int `json:"promo_code_id"` } func RequestWaffoPancakeAmount(c *gin.Context) { @@ -157,13 +158,18 @@ func RequestWaffoPancakePay(c *gin.Context) { } tradeNo := fmt.Sprintf("WAFFO_PANCAKE-%d-%d-%s", id, time.Now().UnixMilli(), randstr.String(6)) + amount := normalizeWaffoPancakeTopUpAmount(req.Amount) + if !validateTopUpPromoCodeForOrder(c, id, req.PromoCodeId, amount) { + return + } topUp := &model.TopUp{ UserId: id, - Amount: normalizeWaffoPancakeTopUpAmount(req.Amount), + Amount: amount, Money: payMoney, TradeNo: tradeNo, PaymentMethod: model.PaymentMethodWaffoPancake, PaymentProvider: model.PaymentProviderWaffoPancake, + PromoCodeId: req.PromoCodeId, CreateTime: time.Now().Unix(), Status: common.TopUpStatusPending, } diff --git a/model/main.go b/model/main.go index 16cd373f..815e1bff 100644 --- a/model/main.go +++ b/model/main.go @@ -281,6 +281,8 @@ func migrateDB() error { &CustomOAuthProvider{}, &UserOAuthBinding{}, &PerfMetric{}, + &PromoCode{}, + &PromoCodeUsage{}, ) if err != nil { return err @@ -330,6 +332,8 @@ func migrateDBFast() error { {&CustomOAuthProvider{}, "CustomOAuthProvider"}, {&UserOAuthBinding{}, "UserOAuthBinding"}, {&PerfMetric{}, "PerfMetric"}, + {&PromoCode{}, "PromoCode"}, + {&PromoCodeUsage{}, "PromoCodeUsage"}, } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) diff --git a/model/payment_method_guard_test.go b/model/payment_method_guard_test.go index 7f4f15cc..26518713 100644 --- a/model/payment_method_guard_test.go +++ b/model/payment_method_guard_test.go @@ -5,6 +5,7 @@ import ( "time" "github.com/QuantumNous/new-api/common" + "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -87,6 +88,33 @@ func getUserQuotaForPaymentGuardTest(t *testing.T, userID int) int { return user.Quota } +func insertPromoCodeForPaymentGuardTest(t *testing.T, code string, bonusAmount int, minRechargeAmount int, maxUses int) *PromoCode { + t.Helper() + promoCode := &PromoCode{ + Code: code, + BonusAmount: bonusAmount, + MaxUses: maxUses, + Status: 1, + MinRechargeAmount: minRechargeAmount, + } + require.NoError(t, DB.Create(promoCode).Error) + return promoCode +} + +func getPromoCodeUsedCountForPaymentGuardTest(t *testing.T, promoCodeID int) int { + t.Helper() + var promoCode PromoCode + require.NoError(t, DB.Select("used_count").Where("id = ?", promoCodeID).First(&promoCode).Error) + return promoCode.UsedCount +} + +func countPromoCodeUsagesForPaymentGuardTest(t *testing.T, promoCodeID int, userID int) int64 { + t.Helper() + var count int64 + require.NoError(t, DB.Model(&PromoCodeUsage{}).Where("promo_code_id = ? AND user_id = ?", promoCodeID, userID).Count(&count).Error) + return count +} + func TestRechargeWaffoPancake_RejectsMismatchedPaymentMethod(t *testing.T) { truncateTables(t) @@ -139,6 +167,60 @@ func TestUpdatePendingTopUpStatus_RejectsMismatchedPaymentProvider(t *testing.T) } } +func TestValidateTopUpPromoCode_RejectsBelowMinimumAndUsedCode(t *testing.T) { + truncateTables(t) + + insertUserForPaymentGuardTest(t, 180, 0) + promoCode := insertPromoCodeForPaymentGuardTest(t, "PROMO_MIN", 123, 10, 0) + + err := ValidateTopUpPromoCode(180, promoCode.Id, 9) + require.Error(t, err) + assert.Contains(t, err.Error(), "最低要求") + + require.NoError(t, ValidateTopUpPromoCode(180, promoCode.Id, 10)) + require.NoError(t, DB.Create(&PromoCodeUsage{ + PromoCodeId: promoCode.Id, + UserId: 180, + UsedAt: time.Now(), + }).Error) + + err = ValidateTopUpPromoCode(180, promoCode.Id, 10) + require.Error(t, err) + assert.Contains(t, err.Error(), "已使用") +} + +func TestRechargeWaffo_AppliesPromoCodeBonusIdempotently(t *testing.T) { + truncateTables(t) + + insertUserForPaymentGuardTest(t, 181, 10) + promoCode := insertPromoCodeForPaymentGuardTest(t, "WAFFO_BONUS", 123, 2, 1) + topUp := &TopUp{ + UserId: 181, + Amount: 2, + Money: 2, + TradeNo: "waffo-promo-guard", + PaymentMethod: PaymentMethodWaffo, + PaymentProvider: PaymentProviderWaffo, + PromoCodeId: promoCode.Id, + Status: common.TopUpStatusPending, + CreateTime: time.Now().Unix(), + } + require.NoError(t, topUp.Insert()) + + require.NoError(t, RechargeWaffo("waffo-promo-guard", "127.0.0.1")) + + baseQuota := int(decimal.NewFromInt(2).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).IntPart()) + assert.Equal(t, 10+baseQuota+123, getUserQuotaForPaymentGuardTest(t, 181)) + assert.Equal(t, common.TopUpStatusSuccess, getTopUpStatusForPaymentGuardTest(t, "waffo-promo-guard")) + assert.Equal(t, 1, getPromoCodeUsedCountForPaymentGuardTest(t, promoCode.Id)) + assert.Equal(t, int64(1), countPromoCodeUsagesForPaymentGuardTest(t, promoCode.Id, 181)) + + require.NoError(t, RechargeWaffo("waffo-promo-guard", "127.0.0.1")) + assert.Equal(t, 10+baseQuota+123, getUserQuotaForPaymentGuardTest(t, 181)) + assert.Equal(t, 1, getPromoCodeUsedCountForPaymentGuardTest(t, promoCode.Id)) + assert.Equal(t, int64(1), countPromoCodeUsagesForPaymentGuardTest(t, promoCode.Id, 181)) +} + func TestCompleteSubscriptionOrder_RejectsMismatchedPaymentProvider(t *testing.T) { truncateTables(t) diff --git a/model/promo_code.go b/model/promo_code.go new file mode 100644 index 00000000..c5fe70a1 --- /dev/null +++ b/model/promo_code.go @@ -0,0 +1,76 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +type PromoCode struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + Code string `json:"code" gorm:"type:varchar(32);uniqueIndex;not null"` + BonusAmount int `json:"bonus_amount" gorm:"not null;default:0"` + MaxUses int `json:"max_uses" gorm:"not null;default:0"` + UsedCount int `json:"used_count" gorm:"not null;default:0"` + Status int `json:"status" gorm:"not null;default:1"` + MinRechargeAmount int `json:"min_recharge_amount" gorm:"not null;default:0"` + ExpiresAt *time.Time `json:"expires_at"` + Notes string `json:"notes" gorm:"type:varchar(255);default:''"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (PromoCode) TableName() string { + return "promo_codes" +} + +func (p *PromoCode) Insert() error { + return DB.Create(p).Error +} + +func (p *PromoCode) Update() error { + return DB.Save(p).Error +} + +func (p *PromoCode) IncrementUsed() error { + return DB.Model(&PromoCode{}).Where("id = ?", p.Id).UpdateColumn("used_count", gorm.Expr("used_count + 1")).Error +} + +func GetPromoCodeByID(id int) (*PromoCode, error) { + var promoCode PromoCode + err := DB.First(&promoCode, "id = ?", id).Error + return &promoCode, err +} + +func GetPromoCodeByCode(code string) (*PromoCode, error) { + var promoCode PromoCode + err := DB.Where("code = ?", code).First(&promoCode).Error + return &promoCode, err +} + +func GetPromoCodes(page int, pageSize int, status *int, keyword string) ([]PromoCode, int64, error) { + var promoCodes []PromoCode + var total int64 + query := DB.Model(&PromoCode{}) + if status != nil { + query = query.Where("status = ?", *status) + } + if keyword != "" { + query = query.Where("code LIKE ?", "%"+keyword+"%") + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + err := query.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&promoCodes).Error + return promoCodes, total, err +} + +func DeletePromoCodeByID(id int) error { + return DB.Delete(&PromoCode{}, id).Error +} diff --git a/model/promo_code_usage.go b/model/promo_code_usage.go new file mode 100644 index 00000000..361095dc --- /dev/null +++ b/model/promo_code_usage.go @@ -0,0 +1,41 @@ +package model + +import "time" + +type PromoCodeUsage struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + PromoCodeId int `json:"promo_code_id" gorm:"not null;index:idx_promo_code_user,unique"` + UserId int `json:"user_id" gorm:"not null;index:idx_promo_code_user,unique"` + UsedAt time.Time `json:"used_at" gorm:"not null"` +} + +func (PromoCodeUsage) TableName() string { + return "promo_code_usages" +} + +func (u *PromoCodeUsage) Insert() error { + return DB.Create(u).Error +} + +func GetPromoCodeUsageByCodeAndUser(promoCodeId int, userId int) (*PromoCodeUsage, error) { + var usage PromoCodeUsage + err := DB.Where("promo_code_id = ? AND user_id = ?", promoCodeId, userId).First(&usage).Error + return &usage, err +} + +func GetPromoCodeUsages(page int, pageSize int, promoCodeId int) ([]PromoCodeUsage, int64, error) { + var usages []PromoCodeUsage + var total int64 + query := DB.Model(&PromoCodeUsage{}).Where("promo_code_id = ?", promoCodeId) + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + err := query.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&usages).Error + return usages, total, err +} diff --git a/model/task_cas_test.go b/model/task_cas_test.go index ba34a732..d9025a17 100644 --- a/model/task_cas_test.go +++ b/model/task_cas_test.go @@ -43,6 +43,8 @@ func TestMain(m *testing.M) { &SubscriptionPlan{}, &SubscriptionOrder{}, &UserSubscription{}, + &PromoCode{}, + &PromoCodeUsage{}, ); err != nil { panic("failed to migrate: " + err.Error()) } @@ -62,6 +64,8 @@ func truncateTables(t *testing.T) { DB.Exec("DELETE FROM subscription_orders") DB.Exec("DELETE FROM subscription_plans") DB.Exec("DELETE FROM user_subscriptions") + DB.Exec("DELETE FROM promo_code_usages") + DB.Exec("DELETE FROM promo_codes") }) } diff --git a/model/topup.go b/model/topup.go index c071b77b..ef8e2308 100644 --- a/model/topup.go +++ b/model/topup.go @@ -3,12 +3,15 @@ package model import ( "errors" "fmt" + "strings" + "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/logger" "github.com/shopspring/decimal" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type TopUp struct { @@ -19,6 +22,7 @@ type TopUp struct { TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"` PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"` PaymentProvider string `json:"payment_provider" gorm:"type:varchar(50);default:''"` + PromoCodeId int `json:"promo_code_id" gorm:"default:0"` CreateTime int64 `json:"create_time"` CompleteTime int64 `json:"complete_time"` Status string `json:"status"` @@ -45,6 +49,128 @@ var ( ErrTopUpStatusInvalid = errors.New("topup status invalid") ) +type promoCodeBonusInfo struct { + UserId int + PromoCode string + BonusAmount int +} + +func lockPromoCodeQuery(tx *gorm.DB) *gorm.DB { + if common.UsingSQLite { + return tx + } + return tx.Clauses(clause.Locking{Strength: "UPDATE"}) +} + +func isUniqueConstraintError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "unique") || + strings.Contains(msg, "duplicate") || + strings.Contains(msg, "duplicated") || + strings.Contains(msg, "constraint failed") +} + +func validateTopUpPromoCodeTx(tx *gorm.DB, userId int, promoCodeId int, amount int64, lock bool) (*PromoCode, error) { + if promoCodeId <= 0 { + return nil, nil + } + + var promoCode PromoCode + query := tx + if lock { + query = lockPromoCodeQuery(tx) + } + if err := query.Where("id = ?", promoCodeId).First(&promoCode).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("优惠码不存在") + } + return nil, err + } + if promoCode.Status != 1 { + return nil, errors.New("优惠码已停用") + } + if promoCode.ExpiresAt != nil && promoCode.ExpiresAt.Before(time.Now()) { + return nil, errors.New("优惠码已过期") + } + if promoCode.MaxUses > 0 && promoCode.UsedCount >= promoCode.MaxUses { + return nil, errors.New("优惠码使用次数已达上限") + } + if amount < int64(promoCode.MinRechargeAmount) { + return nil, errors.New("充值金额未达到优惠码最低要求") + } + + var usage PromoCodeUsage + if err := tx.Where("promo_code_id = ? AND user_id = ?", promoCodeId, userId).First(&usage).Error; err == nil { + return nil, errors.New("当前用户已使用过该优惠码") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + return &promoCode, nil +} + +func ValidateTopUpPromoCode(userId int, promoCodeId int, amount int64) error { + _, err := validateTopUpPromoCodeTx(DB, userId, promoCodeId, amount, false) + return err +} + +func applyPromoCodeBonusTx(tx *gorm.DB, topUp *TopUp) (*promoCodeBonusInfo, error) { + if topUp.PromoCodeId <= 0 { + return nil, nil + } + + promoCode, err := validateTopUpPromoCodeTx(tx, topUp.UserId, topUp.PromoCodeId, topUp.Amount, true) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) || strings.HasPrefix(err.Error(), "优惠码") || strings.HasPrefix(err.Error(), "当前用户") || strings.HasPrefix(err.Error(), "充值金额") { + return nil, nil + } + return nil, err + } + if promoCode == nil { + return nil, nil + } + + usage := &PromoCodeUsage{ + PromoCodeId: promoCode.Id, + UserId: topUp.UserId, + UsedAt: time.Now(), + } + if err := tx.Create(usage).Error; err != nil { + if isUniqueConstraintError(err) { + return nil, nil + } + return nil, err + } + + if err := tx.Model(&PromoCode{}).Where("id = ?", promoCode.Id).UpdateColumn("used_count", gorm.Expr("used_count + ?", 1)).Error; err != nil { + return nil, err + } + if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", promoCode.BonusAmount)).Error; err != nil { + return nil, err + } + + return &promoCodeBonusInfo{ + UserId: topUp.UserId, + PromoCode: promoCode.Code, + BonusAmount: promoCode.BonusAmount, + }, nil +} + +func recordPromoCodeBonusLog(info *promoCodeBonusInfo, callerIp string, paymentMethod string, callbackPaymentMethod string) { + if info == nil { + return + } + RecordTopupLog( + info.UserId, + fmt.Sprintf("优惠码加赠成功,promo_code:%s bonus:%d", info.PromoCode, info.BonusAmount), + callerIp, + paymentMethod, + callbackPaymentMethod, + ) +} + func (topUp *TopUp) Insert() error { var err error err = DB.Create(topUp).Error @@ -104,6 +230,74 @@ func UpdatePendingTopUpStatus(tradeNo string, expectedPaymentProvider string, ta }) } +func RechargeEpay(tradeNo string, actualPaymentMethod string, callerIp string) (err error) { + if tradeNo == "" { + return errors.New("未提供支付单号") + } + + var quotaToAdd int + topUp := &TopUp{} + var promoBonus *promoCodeBonusInfo + + refCol := "`trade_no`" + if common.UsingPostgreSQL { + refCol = `"trade_no"` + } + + err = DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil { + return errors.New("充值订单不存在") + } + + if topUp.PaymentProvider != PaymentProviderEpay { + return ErrPaymentMethodMismatch + } + + if topUp.Status == common.TopUpStatusSuccess { + return nil + } + + if topUp.Status != common.TopUpStatusPending { + return errors.New("充值订单状态错误") + } + + if actualPaymentMethod != "" && topUp.PaymentMethod != actualPaymentMethod { + topUp.PaymentMethod = actualPaymentMethod + } + topUp.CompleteTime = common.GetTimestamp() + topUp.Status = common.TopUpStatusSuccess + if err := tx.Save(topUp).Error; err != nil { + return err + } + + dAmount := decimal.NewFromInt(topUp.Amount) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + quotaToAdd = int(dAmount.Mul(dQuotaPerUnit).IntPart()) + if quotaToAdd <= 0 { + return errors.New("无效的充值额度") + } + if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil { + return err + } + + bonus, bonusErr := applyPromoCodeBonusTx(tx, topUp) + promoBonus = bonus + return bonusErr + }) + + if err != nil { + common.SysError("epay topup failed: " + err.Error()) + return errors.New("充值失败,请稍后重试") + } + + if quotaToAdd > 0 { + RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money), callerIp, topUp.PaymentMethod, PaymentProviderEpay) + recordPromoCodeBonusLog(promoBonus, callerIp, topUp.PaymentMethod, PaymentProviderEpay) + } + + return nil +} + func Recharge(referenceId string, customerId string, callerIp string) (err error) { if referenceId == "" { return errors.New("未提供支付单号") @@ -111,6 +305,7 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error var quota float64 topUp := &TopUp{} + var promoBonus *promoCodeBonusInfo refCol := "`trade_no`" if common.UsingPostgreSQL { @@ -144,7 +339,9 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error return err } - return nil + bonus, bonusErr := applyPromoCodeBonusTx(tx, topUp) + promoBonus = bonus + return bonusErr }) if err != nil { @@ -152,7 +349,10 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error return errors.New("充值失败,请稍后重试") } - RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%d", logger.FormatQuota(int(quota)), topUp.Amount), callerIp, topUp.PaymentMethod, PaymentMethodStripe) + if quota > 0 { + RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%d", logger.FormatQuota(int(quota)), topUp.Amount), callerIp, topUp.PaymentMethod, PaymentMethodStripe) + recordPromoCodeBonusLog(promoBonus, callerIp, topUp.PaymentMethod, PaymentMethodStripe) + } return nil } @@ -329,6 +529,7 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error { var quotaToAdd int var payMoney float64 var paymentMethod string + var promoBonus *promoCodeBonusInfo err := DB.Transaction(func(tx *gorm.DB) error { topUp := &TopUp{} @@ -373,6 +574,12 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error { return err } + bonus, bonusErr := applyPromoCodeBonusTx(tx, topUp) + if bonusErr != nil { + return bonusErr + } + promoBonus = bonus + userId = topUp.UserId payMoney = topUp.Money paymentMethod = topUp.PaymentMethod @@ -383,8 +590,11 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error { return err } - // 事务外记录日志,避免阻塞 - RecordTopupLog(userId, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney), callerIp, paymentMethod, "admin") + if quotaToAdd > 0 { + // 事务外记录日志,避免阻塞 + RecordTopupLog(userId, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney), callerIp, paymentMethod, "admin") + recordPromoCodeBonusLog(promoBonus, callerIp, paymentMethod, "admin") + } return nil } func RechargeCreem(referenceId string, customerEmail string, customerName string, callerIp string) (err error) { @@ -394,6 +604,7 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string var quota int64 topUp := &TopUp{} + var promoBonus *promoCodeBonusInfo refCol := "`trade_no`" if common.UsingPostgreSQL { @@ -410,6 +621,10 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string return ErrPaymentMethodMismatch } + if topUp.Status == common.TopUpStatusSuccess { + return nil + } + if topUp.Status != common.TopUpStatusPending { return errors.New("充值订单状态错误") } @@ -449,7 +664,9 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string return err } - return nil + bonus, bonusErr := applyPromoCodeBonusTx(tx, topUp) + promoBonus = bonus + return bonusErr }) if err != nil { @@ -457,7 +674,10 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string return errors.New("充值失败,请稍后重试") } - RecordTopupLog(topUp.UserId, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", quota, topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodCreem) + if quota > 0 { + RecordTopupLog(topUp.UserId, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", quota, topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodCreem) + recordPromoCodeBonusLog(promoBonus, callerIp, topUp.PaymentMethod, PaymentMethodCreem) + } return nil } @@ -469,6 +689,7 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) { var quotaToAdd int topUp := &TopUp{} + var promoBonus *promoCodeBonusInfo refCol := "`trade_no`" if common.UsingPostgreSQL { @@ -510,7 +731,9 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) { return err } - return nil + bonus, bonusErr := applyPromoCodeBonusTx(tx, topUp) + promoBonus = bonus + return bonusErr }) if err != nil { @@ -520,6 +743,7 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) { if quotaToAdd > 0 { RecordTopupLog(topUp.UserId, fmt.Sprintf("Waffo充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodWaffo) + recordPromoCodeBonusLog(promoBonus, callerIp, topUp.PaymentMethod, PaymentMethodWaffo) } return nil @@ -532,6 +756,7 @@ func RechargeWaffoPancake(tradeNo string) (err error) { var quotaToAdd int topUp := &TopUp{} + var promoBonus *promoCodeBonusInfo refCol := "`trade_no`" if common.UsingPostgreSQL { @@ -571,7 +796,9 @@ func RechargeWaffoPancake(tradeNo string) (err error) { return err } - return nil + bonus, bonusErr := applyPromoCodeBonusTx(tx, topUp) + promoBonus = bonus + return bonusErr }) if err != nil { @@ -581,6 +808,7 @@ func RechargeWaffoPancake(tradeNo string) (err error) { if quotaToAdd > 0 { RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("Waffo Pancake充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money)) + recordPromoCodeBonusLog(promoBonus, "", topUp.PaymentMethod, PaymentMethodWaffoPancake) } return nil diff --git a/router/api-router.go b/router/api-router.go index da026ed9..e32f7a90 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -93,6 +93,7 @@ func SetApiRouter(router *gin.Engine) { selfRoute.GET("/topup/info", controller.GetTopUpInfo) selfRoute.GET("/topup/self", controller.GetUserTopUps) selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp) + selfRoute.POST("/promo-codes/validate", controller.ValidatePromoCode) selfRoute.POST("/pay", middleware.CriticalRateLimit(), controller.RequestEpay) selfRoute.POST("/amount", controller.RequestAmount) selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay) @@ -293,6 +294,16 @@ func SetApiRouter(router *gin.Engine) { redemptionRoute.DELETE("/invalid", controller.DeleteInvalidRedemption) redemptionRoute.DELETE("/:id", controller.DeleteRedemption) } + promoCodeRoute := apiRouter.Group("/promo-codes") + promoCodeRoute.Use(middleware.AdminAuth()) + { + promoCodeRoute.GET("", controller.GetPromoCodes) + promoCodeRoute.POST("", controller.CreatePromoCode) + promoCodeRoute.GET("/:id", controller.GetPromoCode) + promoCodeRoute.PUT("/:id", controller.UpdatePromoCode) + promoCodeRoute.DELETE("/:id", controller.DeletePromoCode) + promoCodeRoute.GET("/:id/usages", controller.GetPromoCodeUsages) + } logRoute := apiRouter.Group("/log") logRoute.GET("/", middleware.AdminAuth(), controller.GetAllLogs) logRoute.DELETE("/", middleware.AdminAuth(), controller.DeleteHistoryLogs)