mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-06-07 22:09:57 +00:00
✨ feat: add subscription billing system with admin management and user purchase flow
Implement a new subscription-based billing model alongside existing metered/per-request billing: Backend: - Add subscription plan models (SubscriptionPlan, SubscriptionPlanItem, UserSubscription, etc.) - Implement CRUD APIs for subscription plan management (admin only) - Add user subscription queries with support for multiple active/expired subscriptions - Integrate payment gateways (Stripe, Creem, Epay) for subscription purchases - Implement pre-consume and post-consume billing logic for subscription quota tracking - Add billing preference settings (subscription_first, wallet_first, etc.) - Enhance usage logs with subscription deduction details Frontend - Admin: - Add subscription management page with table view and drawer-based edit form - Match UI/UX style with existing admin pages (redemption codes, users) - Support enabling/disabling plans, configuring payment IDs, and model quotas - Add user subscription binding modal in user management Frontend - Wallet: - Add subscription plans card with current subscription status display - Show all subscriptions (active and expired) with remaining days/usage percentage - Display purchasable plans with pricing cards following SaaS best practices - Extract purchase modal to separate component matching payment confirm modal style - Add skeleton loading states with active animation - Implement billing preference selector in card header - Handle payment gateway availability based on admin configuration Frontend - Usage Logs: - Display subscription deduction details in log entries - Show step-by-step breakdown of subscription usage (pre-consumed, delta, final, remaining) - Add subscription deduction tag for subscription-covered requests
This commit is contained in:
@@ -159,7 +159,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
||||
if priceData.FreeModel {
|
||||
logger.LogInfo(c, fmt.Sprintf("模型 %s 免费,跳过预扣费", relayInfo.OriginModelName))
|
||||
} else {
|
||||
newAPIError = service.PreConsumeQuota(c, priceData.QuotaToPreConsume, relayInfo)
|
||||
newAPIError = service.PreConsumeBilling(c, priceData.QuotaToPreConsume, relayInfo)
|
||||
if newAPIError != nil {
|
||||
return
|
||||
}
|
||||
|
||||
303
controller/subscription.go
Normal file
303
controller/subscription.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ---- Shared types ----
|
||||
|
||||
type SubscriptionPlanDTO struct {
|
||||
Plan model.SubscriptionPlan `json:"plan"`
|
||||
Items []model.SubscriptionPlanItem `json:"items"`
|
||||
}
|
||||
|
||||
type BillingPreferenceRequest struct {
|
||||
BillingPreference string `json:"billing_preference"`
|
||||
}
|
||||
|
||||
func normalizeBillingPreference(pref string) string {
|
||||
switch strings.TrimSpace(pref) {
|
||||
case "subscription_first", "wallet_first", "subscription_only", "wallet_only":
|
||||
return strings.TrimSpace(pref)
|
||||
default:
|
||||
return "subscription_first"
|
||||
}
|
||||
}
|
||||
|
||||
// ---- User APIs ----
|
||||
|
||||
func GetSubscriptionPlans(c *gin.Context) {
|
||||
var plans []model.SubscriptionPlan
|
||||
if err := model.DB.Where("enabled = ?", true).Order("sort_order desc, id desc").Find(&plans).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
result := make([]SubscriptionPlanDTO, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
items, _ := model.GetSubscriptionPlanItems(p.Id)
|
||||
result = append(result, SubscriptionPlanDTO{
|
||||
Plan: p,
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
common.ApiSuccess(c, result)
|
||||
}
|
||||
|
||||
func GetSubscriptionSelf(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
settingMap, _ := model.GetUserSetting(userId, false)
|
||||
pref := normalizeBillingPreference(settingMap.BillingPreference)
|
||||
|
||||
// Get all subscriptions (including expired)
|
||||
allSubscriptions, err := model.GetAllUserSubscriptions(userId)
|
||||
if err != nil {
|
||||
allSubscriptions = []model.SubscriptionSummary{}
|
||||
}
|
||||
|
||||
// Get active subscriptions for backward compatibility
|
||||
activeSubscriptions, err := model.GetAllActiveUserSubscriptions(userId)
|
||||
if err != nil {
|
||||
activeSubscriptions = []model.SubscriptionSummary{}
|
||||
}
|
||||
|
||||
// For backward compatibility, also return the first active subscription as "subscription"
|
||||
var summary *model.SubscriptionSummary
|
||||
if len(activeSubscriptions) > 0 {
|
||||
summary = &activeSubscriptions[0]
|
||||
}
|
||||
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"billing_preference": pref,
|
||||
"subscription": summary, // backward compatibility (first active)
|
||||
"subscriptions": activeSubscriptions, // all active subscriptions
|
||||
"all_subscriptions": allSubscriptions, // all subscriptions including expired
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateSubscriptionPreference(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
var req BillingPreferenceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
pref := normalizeBillingPreference(req.BillingPreference)
|
||||
|
||||
user, err := model.GetUserById(userId, true)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
current := user.GetSetting()
|
||||
current.BillingPreference = pref
|
||||
user.SetSetting(current)
|
||||
if err := user.Update(false); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{"billing_preference": pref})
|
||||
}
|
||||
|
||||
// ---- Admin APIs ----
|
||||
|
||||
func AdminListSubscriptionPlans(c *gin.Context) {
|
||||
var plans []model.SubscriptionPlan
|
||||
if err := model.DB.Order("sort_order desc, id desc").Find(&plans).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
result := make([]SubscriptionPlanDTO, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
items, _ := model.GetSubscriptionPlanItems(p.Id)
|
||||
result = append(result, SubscriptionPlanDTO{
|
||||
Plan: p,
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
common.ApiSuccess(c, result)
|
||||
}
|
||||
|
||||
type AdminUpsertSubscriptionPlanRequest struct {
|
||||
Plan model.SubscriptionPlan `json:"plan"`
|
||||
Items []model.SubscriptionPlanItem `json:"items"`
|
||||
}
|
||||
|
||||
func AdminCreateSubscriptionPlan(c *gin.Context) {
|
||||
var req AdminUpsertSubscriptionPlanRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
req.Plan.Id = 0
|
||||
if strings.TrimSpace(req.Plan.Title) == "" {
|
||||
common.ApiErrorMsg(c, "套餐标题不能为空")
|
||||
return
|
||||
}
|
||||
if req.Plan.Currency == "" {
|
||||
req.Plan.Currency = "USD"
|
||||
}
|
||||
if req.Plan.DurationUnit == "" {
|
||||
req.Plan.DurationUnit = model.SubscriptionDurationMonth
|
||||
}
|
||||
if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom {
|
||||
req.Plan.DurationValue = 1
|
||||
}
|
||||
|
||||
if len(req.Items) == 0 {
|
||||
common.ApiErrorMsg(c, "套餐至少需要配置一个模型权益")
|
||||
return
|
||||
}
|
||||
|
||||
db := model.DB
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&req.Plan).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
items := make([]model.SubscriptionPlanItem, 0, len(req.Items))
|
||||
for _, it := range req.Items {
|
||||
if strings.TrimSpace(it.ModelName) == "" {
|
||||
continue
|
||||
}
|
||||
if it.AmountTotal <= 0 {
|
||||
continue
|
||||
}
|
||||
it.Id = 0
|
||||
it.PlanId = req.Plan.Id
|
||||
items = append(items, it)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return errors.New("无有效的模型权益配置")
|
||||
}
|
||||
return tx.Create(&items).Error
|
||||
})
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, req.Plan)
|
||||
}
|
||||
|
||||
func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
if id <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的ID")
|
||||
return
|
||||
}
|
||||
var req AdminUpsertSubscriptionPlanRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Plan.Title) == "" {
|
||||
common.ApiErrorMsg(c, "套餐标题不能为空")
|
||||
return
|
||||
}
|
||||
req.Plan.Id = id
|
||||
if req.Plan.Currency == "" {
|
||||
req.Plan.Currency = "USD"
|
||||
}
|
||||
if req.Plan.DurationUnit == "" {
|
||||
req.Plan.DurationUnit = model.SubscriptionDurationMonth
|
||||
}
|
||||
if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom {
|
||||
req.Plan.DurationValue = 1
|
||||
}
|
||||
|
||||
if len(req.Items) == 0 {
|
||||
common.ApiErrorMsg(c, "套餐至少需要配置一个模型权益")
|
||||
return
|
||||
}
|
||||
|
||||
err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// update plan (allow zero values updates with map)
|
||||
updateMap := map[string]interface{}{
|
||||
"title": req.Plan.Title,
|
||||
"subtitle": req.Plan.Subtitle,
|
||||
"price_amount": req.Plan.PriceAmount,
|
||||
"currency": req.Plan.Currency,
|
||||
"duration_unit": req.Plan.DurationUnit,
|
||||
"duration_value": req.Plan.DurationValue,
|
||||
"custom_seconds": req.Plan.CustomSeconds,
|
||||
"enabled": req.Plan.Enabled,
|
||||
"sort_order": req.Plan.SortOrder,
|
||||
"stripe_price_id": req.Plan.StripePriceId,
|
||||
"creem_product_id": req.Plan.CreemProductId,
|
||||
"updated_at": common.GetTimestamp(),
|
||||
}
|
||||
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// replace items
|
||||
if err := tx.Where("plan_id = ?", id).Delete(&model.SubscriptionPlanItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
items := make([]model.SubscriptionPlanItem, 0, len(req.Items))
|
||||
for _, it := range req.Items {
|
||||
if strings.TrimSpace(it.ModelName) == "" {
|
||||
continue
|
||||
}
|
||||
if it.AmountTotal <= 0 {
|
||||
continue
|
||||
}
|
||||
it.Id = 0
|
||||
it.PlanId = id
|
||||
items = append(items, it)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return errors.New("无有效的模型权益配置")
|
||||
}
|
||||
return tx.Create(&items).Error
|
||||
})
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
func AdminDeleteSubscriptionPlan(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
if id <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的ID")
|
||||
return
|
||||
}
|
||||
// best practice: disable instead of hard delete to avoid breaking past orders
|
||||
if err := model.DB.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Update("enabled", false).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
type AdminBindSubscriptionRequest struct {
|
||||
UserId int `json:"user_id"`
|
||||
PlanId int `json:"plan_id"`
|
||||
}
|
||||
|
||||
func AdminBindSubscription(c *gin.Context) {
|
||||
var req AdminBindSubscriptionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.UserId <= 0 || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
if err := model.AdminBindSubscription(req.UserId, req.PlanId, ""); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
// ---- Helper: serialize provider payload safely ----
|
||||
|
||||
func jsonString(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
95
controller/subscription_payment_creem.go
Normal file
95
controller/subscription_payment_creem.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/thanhpk/randstr"
|
||||
)
|
||||
|
||||
type SubscriptionCreemPayRequest struct {
|
||||
PlanId int `json:"plan_id"`
|
||||
}
|
||||
|
||||
func SubscriptionRequestCreemPay(c *gin.Context) {
|
||||
var req SubscriptionCreemPayRequest
|
||||
|
||||
// Keep body for debugging consistency (like RequestCreemPay)
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
log.Printf("read subscription creem pay req body err: %v", err)
|
||||
c.JSON(200, gin.H{"message": "error", "data": "read query error"})
|
||||
return
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := model.GetSubscriptionPlanById(req.PlanId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if !plan.Enabled {
|
||||
common.ApiErrorMsg(c, "套餐未启用")
|
||||
return
|
||||
}
|
||||
if plan.CreemProductId == "" {
|
||||
common.ApiErrorMsg(c, "该套餐未配置 CreemProductId")
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
user, _ := model.GetUserById(userId, false)
|
||||
|
||||
reference := "sub-creem-ref-" + randstr.String(6)
|
||||
referenceId := "sub_ref_" + common.Sha1([]byte(reference+time.Now().String()+user.Username))
|
||||
|
||||
// create pending order first
|
||||
order := &model.SubscriptionOrder{
|
||||
UserId: userId,
|
||||
PlanId: plan.Id,
|
||||
Money: plan.PriceAmount,
|
||||
TradeNo: referenceId,
|
||||
PaymentMethod: PaymentMethodCreem,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
if err := order.Insert(); err != nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// Reuse Creem checkout generator by building a lightweight product reference.
|
||||
product := &CreemProduct{
|
||||
ProductId: plan.CreemProductId,
|
||||
Name: plan.Title,
|
||||
Price: plan.PriceAmount,
|
||||
Currency: plan.Currency,
|
||||
Quota: 0,
|
||||
}
|
||||
|
||||
checkoutUrl, err := genCreemLink(referenceId, product, user.Email, user.Username)
|
||||
if err != nil {
|
||||
log.Printf("获取Creem支付链接失败: %v", err)
|
||||
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"checkout_url": checkoutUrl,
|
||||
"order_id": referenceId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
126
controller/subscription_payment_epay.go
Normal file
126
controller/subscription_payment_epay.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/Calcium-Ion/go-epay/epay"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type SubscriptionEpayPayRequest struct {
|
||||
PlanId int `json:"plan_id"`
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
}
|
||||
|
||||
func SubscriptionRequestEpay(c *gin.Context) {
|
||||
var req SubscriptionEpayPayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := model.GetSubscriptionPlanById(req.PlanId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if !plan.Enabled {
|
||||
common.ApiErrorMsg(c, "套餐未启用")
|
||||
return
|
||||
}
|
||||
if plan.PriceAmount < 0.01 {
|
||||
common.ApiErrorMsg(c, "套餐金额过低")
|
||||
return
|
||||
}
|
||||
if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
|
||||
common.ApiErrorMsg(c, "支付方式不存在")
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
|
||||
callBackAddress := service.GetCallbackAddress()
|
||||
returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/topup")
|
||||
notifyUrl, _ := url.Parse(callBackAddress + "/api/subscription/epay/notify")
|
||||
|
||||
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
|
||||
tradeNo = fmt.Sprintf("SUBUSR%dNO%s", userId, tradeNo)
|
||||
|
||||
client := GetEpayClient()
|
||||
if client == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "当前管理员未配置支付信息"})
|
||||
return
|
||||
}
|
||||
|
||||
uri, params, err := client.Purchase(&epay.PurchaseArgs{
|
||||
Type: req.PaymentMethod,
|
||||
ServiceTradeNo: tradeNo,
|
||||
Name: fmt.Sprintf("SUB:%s", plan.Title),
|
||||
Money: strconv.FormatFloat(plan.PriceAmount, 'f', 2, 64),
|
||||
Device: epay.PC,
|
||||
NotifyUrl: notifyUrl,
|
||||
ReturnUrl: returnUrl,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
return
|
||||
}
|
||||
|
||||
order := &model.SubscriptionOrder{
|
||||
UserId: userId,
|
||||
PlanId: plan.Id,
|
||||
Money: plan.PriceAmount,
|
||||
TradeNo: tradeNo,
|
||||
PaymentMethod: req.PaymentMethod,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
if err := order.Insert(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success", "data": params, "url": uri})
|
||||
}
|
||||
|
||||
func SubscriptionEpayNotify(c *gin.Context) {
|
||||
params := lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string {
|
||||
r[t] = c.Request.URL.Query().Get(t)
|
||||
return r
|
||||
}, map[string]string{})
|
||||
|
||||
client := GetEpayClient()
|
||||
if client == nil {
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
verifyInfo, err := client.Verify(params)
|
||||
if err == nil && verifyInfo.VerifyStatus {
|
||||
_, _ = c.Writer.Write([]byte("success"))
|
||||
} else {
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
|
||||
if verifyInfo.TradeStatus != epay.StatusTradeSuccess {
|
||||
return
|
||||
}
|
||||
|
||||
LockOrder(verifyInfo.ServiceTradeNo)
|
||||
defer UnlockOrder(verifyInfo.ServiceTradeNo)
|
||||
|
||||
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, jsonString(verifyInfo)); err != nil {
|
||||
// do not fail webhook response after signature verified
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
115
controller/subscription_payment_stripe.go
Normal file
115
controller/subscription_payment_stripe.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stripe/stripe-go/v81"
|
||||
"github.com/stripe/stripe-go/v81/checkout/session"
|
||||
"github.com/thanhpk/randstr"
|
||||
)
|
||||
|
||||
type SubscriptionStripePayRequest struct {
|
||||
PlanId int `json:"plan_id"`
|
||||
}
|
||||
|
||||
func SubscriptionRequestStripePay(c *gin.Context) {
|
||||
var req SubscriptionStripePayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := model.GetSubscriptionPlanById(req.PlanId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if !plan.Enabled {
|
||||
common.ApiErrorMsg(c, "套餐未启用")
|
||||
return
|
||||
}
|
||||
if plan.StripePriceId == "" {
|
||||
common.ApiErrorMsg(c, "该套餐未配置 StripePriceId")
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(setting.StripeApiSecret, "sk_") && !strings.HasPrefix(setting.StripeApiSecret, "rk_") {
|
||||
common.ApiErrorMsg(c, "Stripe 未配置或密钥无效")
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
user, _ := model.GetUserById(userId, false)
|
||||
|
||||
reference := fmt.Sprintf("sub-stripe-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
|
||||
referenceId := "sub_ref_" + common.Sha1([]byte(reference))
|
||||
|
||||
payLink, err := genStripeSubscriptionLink(referenceId, user.StripeCustomer, user.Email, plan.StripePriceId)
|
||||
if err != nil {
|
||||
log.Println("获取Stripe Checkout支付链接失败", err)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
return
|
||||
}
|
||||
|
||||
order := &model.SubscriptionOrder{
|
||||
UserId: userId,
|
||||
PlanId: plan.Id,
|
||||
Money: plan.PriceAmount,
|
||||
TradeNo: referenceId,
|
||||
PaymentMethod: PaymentMethodStripe,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
if err := order.Insert(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"pay_link": payLink,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func genStripeSubscriptionLink(referenceId string, customerId string, email string, priceId string) (string, error) {
|
||||
stripe.Key = setting.StripeApiSecret
|
||||
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
ClientReferenceID: stripe.String(referenceId),
|
||||
SuccessURL: stripe.String(system_setting.ServerAddress + "/console/topup"),
|
||||
CancelURL: stripe.String(system_setting.ServerAddress + "/console/topup"),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(priceId),
|
||||
Quantity: stripe.Int64(1),
|
||||
},
|
||||
},
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
|
||||
}
|
||||
|
||||
if "" == customerId {
|
||||
if "" != email {
|
||||
params.CustomerEmail = stripe.String(email)
|
||||
}
|
||||
params.CustomerCreation = stripe.String(string(stripe.CheckoutSessionCustomerCreationAlways))
|
||||
} else {
|
||||
params.Customer = stripe.String(customerId)
|
||||
}
|
||||
|
||||
result, err := session.New(params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.URL, nil
|
||||
}
|
||||
|
||||
@@ -308,7 +308,18 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证订单类型,目前只处理一次性付款
|
||||
// Subscription order takes precedence (accept both onetime/subscription types)
|
||||
if model.GetSubscriptionOrderByTradeNo(referenceId) != nil {
|
||||
if err := model.CompleteSubscriptionOrder(referenceId, jsonString(event)); err != nil {
|
||||
log.Printf("Creem订阅订单处理失败: %s, 订单号: %s", err.Error(), referenceId)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证订单类型,目前只处理一次性付款(充值)
|
||||
if event.Object.Order.Type != "onetime" {
|
||||
log.Printf("暂不支持的订单类型: %s, 跳过处理", event.Object.Order.Type)
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
@@ -166,6 +166,20 @@ func sessionCompleted(event stripe.Event) {
|
||||
return
|
||||
}
|
||||
|
||||
// Subscription order takes precedence
|
||||
if model.GetSubscriptionOrderByTradeNo(referenceId) != nil {
|
||||
payload := map[string]any{
|
||||
"customer": customerId,
|
||||
"amount_total": event.GetObjectValue("amount_total"),
|
||||
"currency": strings.ToUpper(event.GetObjectValue("currency")),
|
||||
"event_type": string(event.Type),
|
||||
}
|
||||
if err := model.CompleteSubscriptionOrder(referenceId, jsonString(payload)); err != nil {
|
||||
log.Println("complete subscription order failed:", err.Error(), referenceId)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err := model.Recharge(referenceId, customerId)
|
||||
if err != nil {
|
||||
log.Println(err.Error(), referenceId)
|
||||
@@ -190,6 +204,14 @@ func sessionExpired(event stripe.Event) {
|
||||
return
|
||||
}
|
||||
|
||||
// Subscription order expiration
|
||||
if model.GetSubscriptionOrderByTradeNo(referenceId) != nil {
|
||||
if err := model.ExpireSubscriptionOrder(referenceId); err != nil {
|
||||
log.Println("过期订阅订单失败", referenceId, ", err:", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
topUp := model.GetTopUpByTradeNo(referenceId)
|
||||
if topUp == nil {
|
||||
log.Println("充值订单不存在", referenceId)
|
||||
|
||||
Reference in New Issue
Block a user