feat: add promo code bonus flow

Add promo code models and APIs, persist selected promo codes on top-up orders, and apply bonus quota idempotently when payments complete.
This commit is contained in:
zizi 2026-05-20 12:57:54 +08:00
parent 059512f6ef
commit 1de1cdb14f
14 changed files with 837 additions and 47 deletions

237
controller/promo_code.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

76
model/promo_code.go Normal file
View File

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

41
model/promo_code_usage.go Normal file
View File

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

View File

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

View File

@ -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

View File

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