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:
parent
059512f6ef
commit
1de1cdb14f
237
controller/promo_code.go
Normal file
237
controller/promo_code.go
Normal 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
|
||||
}
|
||||
96
controller/promo_code_user.go
Normal file
96
controller/promo_code_user.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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)))
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
76
model/promo_code.go
Normal 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
41
model/promo_code_usage.go
Normal 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
|
||||
}
|
||||
@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
244
model/topup.go
244
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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user