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:
t0ng7u
2026-01-30 05:31:10 +08:00
parent c6c12d340f
commit 009910b960
36 changed files with 3872 additions and 181 deletions

View File

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

View 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,
},
})
}

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

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

View File

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

View File

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