From 009910b9602a74f3a933c79a358d95f7bd2bc4d0 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 30 Jan 2026 05:31:10 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20subscription=20billin?= =?UTF-8?q?g=20system=20with=20admin=20management=20and=20user=20purchase?= =?UTF-8?q?=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- controller/relay.go | 2 +- controller/subscription.go | 303 ++++++++++ controller/subscription_payment_creem.go | 95 +++ controller/subscription_payment_epay.go | 126 ++++ controller/subscription_payment_stripe.go | 115 ++++ controller/topup_creem.go | 13 +- controller/topup_stripe.go | 22 + dto/user_settings.go | 1 + model/main.go | 10 + model/subscription.go | 515 +++++++++++++++++ relay/common/relay_info.go | 18 + router/api-router.go | 24 + service/billing.go | 117 ++++ service/log_info_generate.go | 68 +++ service/pre_consume_quota.go | 33 +- service/quota.go | 28 +- web/src/App.jsx | 9 + web/src/components/layout/SiderBar.jsx | 9 +- .../subscriptions/SubscriptionsActions.jsx | 38 ++ .../subscriptions/SubscriptionsColumnDefs.jsx | 161 ++++++ .../SubscriptionsDescription.jsx | 44 ++ .../subscriptions/SubscriptionsTable.jsx | 84 +++ .../components/table/subscriptions/index.jsx | 90 +++ .../modals/AddEditSubscriptionModal.jsx | 542 ++++++++++++++++++ .../table/usage-logs/UsageLogsColumnDefs.jsx | 187 +++--- .../table/users/UsersColumnDefs.jsx | 11 + web/src/components/table/users/UsersTable.jsx | 18 + .../users/modals/BindSubscriptionModal.jsx | 124 ++++ .../topup/SubscriptionPlansCard.jsx | 537 +++++++++++++++++ web/src/components/topup/index.jsx | 175 ++++-- .../modals/SubscriptionPurchaseModal.jsx | 236 ++++++++ web/src/helpers/render.jsx | 3 + web/src/hooks/common/useSidebar.js | 1 + .../subscriptions/useSubscriptionsData.jsx | 144 +++++ web/src/hooks/usage-logs/useUsageLogsData.jsx | 118 ++-- web/src/pages/Subscription/index.jsx | 32 ++ 36 files changed, 3872 insertions(+), 181 deletions(-) create mode 100644 controller/subscription.go create mode 100644 controller/subscription_payment_creem.go create mode 100644 controller/subscription_payment_epay.go create mode 100644 controller/subscription_payment_stripe.go create mode 100644 model/subscription.go create mode 100644 service/billing.go create mode 100644 web/src/components/table/subscriptions/SubscriptionsActions.jsx create mode 100644 web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx create mode 100644 web/src/components/table/subscriptions/SubscriptionsDescription.jsx create mode 100644 web/src/components/table/subscriptions/SubscriptionsTable.jsx create mode 100644 web/src/components/table/subscriptions/index.jsx create mode 100644 web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx create mode 100644 web/src/components/table/users/modals/BindSubscriptionModal.jsx create mode 100644 web/src/components/topup/SubscriptionPlansCard.jsx create mode 100644 web/src/components/topup/modals/SubscriptionPurchaseModal.jsx create mode 100644 web/src/hooks/subscriptions/useSubscriptionsData.jsx create mode 100644 web/src/pages/Subscription/index.jsx diff --git a/controller/relay.go b/controller/relay.go index 387fe47f8..3a929d8d7 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -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 } diff --git a/controller/subscription.go b/controller/subscription.go new file mode 100644 index 000000000..85c26b7fd --- /dev/null +++ b/controller/subscription.go @@ -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) +} diff --git a/controller/subscription_payment_creem.go b/controller/subscription_payment_creem.go new file mode 100644 index 000000000..4856155b5 --- /dev/null +++ b/controller/subscription_payment_creem.go @@ -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, + }, + }) +} + diff --git a/controller/subscription_payment_epay.go b/controller/subscription_payment_epay.go new file mode 100644 index 000000000..c8ae49502 --- /dev/null +++ b/controller/subscription_payment_epay.go @@ -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 + } +} + diff --git a/controller/subscription_payment_stripe.go b/controller/subscription_payment_stripe.go new file mode 100644 index 000000000..8547d97e0 --- /dev/null +++ b/controller/subscription_payment_stripe.go @@ -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 +} + diff --git a/controller/topup_creem.go b/controller/topup_creem.go index 80a869673..5f636f881 100644 --- a/controller/topup_creem.go +++ b/controller/topup_creem.go @@ -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) diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index 337ff8e73..3fa605f17 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -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) diff --git a/dto/user_settings.go b/dto/user_settings.go index 16ce7b985..aba6ba511 100644 --- a/dto/user_settings.go +++ b/dto/user_settings.go @@ -13,6 +13,7 @@ type UserSetting struct { AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型 RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置 + BillingPreference string `json:"billing_preference,omitempty"` // BillingPreference 扣费策略(订阅/钱包) } var ( diff --git a/model/main.go b/model/main.go index 586eaa353..dcd6c8841 100644 --- a/model/main.go +++ b/model/main.go @@ -268,6 +268,11 @@ func migrateDB() error { &TwoFA{}, &TwoFABackupCode{}, &Checkin{}, + &SubscriptionPlan{}, + &SubscriptionPlanItem{}, + &SubscriptionOrder{}, + &UserSubscription{}, + &UserSubscriptionItem{}, ) if err != nil { return err @@ -302,6 +307,11 @@ func migrateDBFast() error { {&TwoFA{}, "TwoFA"}, {&TwoFABackupCode{}, "TwoFABackupCode"}, {&Checkin{}, "Checkin"}, + {&SubscriptionPlan{}, "SubscriptionPlan"}, + {&SubscriptionPlanItem{}, "SubscriptionPlanItem"}, + {&SubscriptionOrder{}, "SubscriptionOrder"}, + {&UserSubscription{}, "UserSubscription"}, + {&UserSubscriptionItem{}, "UserSubscriptionItem"}, } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) diff --git a/model/subscription.go b/model/subscription.go new file mode 100644 index 000000000..75aebc1ed --- /dev/null +++ b/model/subscription.go @@ -0,0 +1,515 @@ +package model + +import ( + "errors" + "fmt" + "time" + + "github.com/QuantumNous/new-api/common" + "gorm.io/gorm" +) + +// Subscription duration units +const ( + SubscriptionDurationYear = "year" + SubscriptionDurationMonth = "month" + SubscriptionDurationDay = "day" + SubscriptionDurationHour = "hour" + SubscriptionDurationCustom = "custom" +) + +// Subscription plan +type SubscriptionPlan struct { + Id int `json:"id"` + + Title string `json:"title" gorm:"type:varchar(128);not null"` + Subtitle string `json:"subtitle" gorm:"type:varchar(255);default:''"` + + // Display money amount (follow existing code style: float64 for money) + PriceAmount float64 `json:"price_amount" gorm:"type:double;not null;default:0"` + Currency string `json:"currency" gorm:"type:varchar(8);not null;default:'USD'"` + + DurationUnit string `json:"duration_unit" gorm:"type:varchar(16);not null;default:'month'"` + DurationValue int `json:"duration_value" gorm:"type:int;not null;default:1"` + CustomSeconds int64 `json:"custom_seconds" gorm:"type:bigint;not null;default:0"` + + Enabled bool `json:"enabled" gorm:"default:true"` + SortOrder int `json:"sort_order" gorm:"type:int;default:0"` + + StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"` + CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"` + + CreatedAt int64 `json:"created_at" gorm:"bigint"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} + +func (p *SubscriptionPlan) BeforeCreate(tx *gorm.DB) error { + now := common.GetTimestamp() + p.CreatedAt = now + p.UpdatedAt = now + return nil +} + +func (p *SubscriptionPlan) BeforeUpdate(tx *gorm.DB) error { + p.UpdatedAt = common.GetTimestamp() + return nil +} + +type SubscriptionPlanItem struct { + Id int `json:"id"` + PlanId int `json:"plan_id" gorm:"index"` + + ModelName string `json:"model_name" gorm:"type:varchar(128);index"` + // 0=按量(额度), 1=按次(次数) + QuotaType int `json:"quota_type" gorm:"type:int;index"` + + // If quota_type=0 => amount in quota units; if quota_type=1 => request count. + AmountTotal int64 `json:"amount_total" gorm:"type:bigint;not null;default:0"` +} + +// Subscription order (payment -> webhook -> create UserSubscription) +type SubscriptionOrder struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index"` + PlanId int `json:"plan_id" gorm:"index"` + Money float64 `json:"money"` + + TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"` + PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"` + Status string `json:"status"` + CreateTime int64 `json:"create_time"` + CompleteTime int64 `json:"complete_time"` + + ProviderPayload string `json:"provider_payload" gorm:"type:text"` +} + +func (o *SubscriptionOrder) Insert() error { + if o.CreateTime == 0 { + o.CreateTime = common.GetTimestamp() + } + return DB.Create(o).Error +} + +func (o *SubscriptionOrder) Update() error { + return DB.Save(o).Error +} + +func GetSubscriptionOrderByTradeNo(tradeNo string) *SubscriptionOrder { + if tradeNo == "" { + return nil + } + var order SubscriptionOrder + if err := DB.Where("trade_no = ?", tradeNo).First(&order).Error; err != nil { + return nil + } + return &order +} + +// User subscription instance +type UserSubscription struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index"` + PlanId int `json:"plan_id" gorm:"index"` + + StartTime int64 `json:"start_time" gorm:"bigint"` + EndTime int64 `json:"end_time" gorm:"bigint;index"` + Status string `json:"status" gorm:"type:varchar(32);index"` // active/expired/cancelled + + Source string `json:"source" gorm:"type:varchar(32);default:'order'"` // order/admin + + CreatedAt int64 `json:"created_at" gorm:"bigint"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} + +func (s *UserSubscription) BeforeCreate(tx *gorm.DB) error { + now := common.GetTimestamp() + s.CreatedAt = now + s.UpdatedAt = now + return nil +} + +func (s *UserSubscription) BeforeUpdate(tx *gorm.DB) error { + s.UpdatedAt = common.GetTimestamp() + return nil +} + +type UserSubscriptionItem struct { + Id int `json:"id"` + UserSubscriptionId int `json:"user_subscription_id" gorm:"index"` + ModelName string `json:"model_name" gorm:"type:varchar(128);index"` + QuotaType int `json:"quota_type" gorm:"type:int;index"` + AmountTotal int64 `json:"amount_total" gorm:"type:bigint;not null;default:0"` + AmountUsed int64 `json:"amount_used" gorm:"type:bigint;not null;default:0"` +} + +type SubscriptionSummary struct { + Subscription *UserSubscription `json:"subscription"` + Items []UserSubscriptionItem `json:"items"` +} + +func calcPlanEndTime(start time.Time, plan *SubscriptionPlan) (int64, error) { + if plan == nil { + return 0, errors.New("plan is nil") + } + if plan.DurationValue <= 0 && plan.DurationUnit != SubscriptionDurationCustom { + return 0, errors.New("duration_value must be > 0") + } + switch plan.DurationUnit { + case SubscriptionDurationYear: + return start.AddDate(plan.DurationValue, 0, 0).Unix(), nil + case SubscriptionDurationMonth: + return start.AddDate(0, plan.DurationValue, 0).Unix(), nil + case SubscriptionDurationDay: + return start.Add(time.Duration(plan.DurationValue) * 24 * time.Hour).Unix(), nil + case SubscriptionDurationHour: + return start.Add(time.Duration(plan.DurationValue) * time.Hour).Unix(), nil + case SubscriptionDurationCustom: + if plan.CustomSeconds <= 0 { + return 0, errors.New("custom_seconds must be > 0") + } + return start.Add(time.Duration(plan.CustomSeconds) * time.Second).Unix(), nil + default: + return 0, fmt.Errorf("invalid duration_unit: %s", plan.DurationUnit) + } +} + +func GetSubscriptionPlanById(id int) (*SubscriptionPlan, error) { + if id <= 0 { + return nil, errors.New("invalid plan id") + } + var plan SubscriptionPlan + if err := DB.Where("id = ?", id).First(&plan).Error; err != nil { + return nil, err + } + return &plan, nil +} + +func GetSubscriptionPlanItems(planId int) ([]SubscriptionPlanItem, error) { + if planId <= 0 { + return nil, errors.New("invalid plan id") + } + var items []SubscriptionPlanItem + if err := DB.Where("plan_id = ?", planId).Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *SubscriptionPlan, source string) (*UserSubscription, error) { + if tx == nil { + return nil, errors.New("tx is nil") + } + if plan == nil || plan.Id == 0 { + return nil, errors.New("invalid plan") + } + if userId <= 0 { + return nil, errors.New("invalid user id") + } + now := time.Now() + endUnix, err := calcPlanEndTime(now, plan) + if err != nil { + return nil, err + } + sub := &UserSubscription{ + UserId: userId, + PlanId: plan.Id, + StartTime: now.Unix(), + EndTime: endUnix, + Status: "active", + Source: source, + CreatedAt: common.GetTimestamp(), + UpdatedAt: common.GetTimestamp(), + } + if err := tx.Create(sub).Error; err != nil { + return nil, err + } + items, err := GetSubscriptionPlanItems(plan.Id) + if err != nil { + return nil, err + } + if len(items) == 0 { + return nil, errors.New("plan has no items") + } + userItems := make([]UserSubscriptionItem, 0, len(items)) + for _, it := range items { + userItems = append(userItems, UserSubscriptionItem{ + UserSubscriptionId: sub.Id, + ModelName: it.ModelName, + QuotaType: it.QuotaType, + AmountTotal: it.AmountTotal, + AmountUsed: 0, + }) + } + if err := tx.Create(&userItems).Error; err != nil { + return nil, err + } + return sub, nil +} + +// Complete a subscription order (idempotent). Creates a UserSubscription snapshot from the plan. +func CompleteSubscriptionOrder(tradeNo string, providerPayload string) error { + if tradeNo == "" { + return errors.New("tradeNo is empty") + } + refCol := "`trade_no`" + if common.UsingPostgreSQL { + refCol = `"trade_no"` + } + return DB.Transaction(func(tx *gorm.DB) error { + var order SubscriptionOrder + if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil { + return errors.New("subscription order not found") + } + if order.Status == common.TopUpStatusSuccess { + return nil + } + if order.Status != common.TopUpStatusPending { + return errors.New("subscription order status invalid") + } + plan, err := GetSubscriptionPlanById(order.PlanId) + if err != nil { + return err + } + if !plan.Enabled { + // still allow completion for already purchased orders + } + _, err = CreateUserSubscriptionFromPlanTx(tx, order.UserId, plan, "order") + if err != nil { + return err + } + order.Status = common.TopUpStatusSuccess + order.CompleteTime = common.GetTimestamp() + if providerPayload != "" { + order.ProviderPayload = providerPayload + } + return tx.Save(&order).Error + }) +} + +func ExpireSubscriptionOrder(tradeNo string) error { + if tradeNo == "" { + return errors.New("tradeNo is empty") + } + refCol := "`trade_no`" + if common.UsingPostgreSQL { + refCol = `"trade_no"` + } + return DB.Transaction(func(tx *gorm.DB) error { + var order SubscriptionOrder + if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil { + return errors.New("subscription order not found") + } + if order.Status != common.TopUpStatusPending { + return nil + } + order.Status = common.TopUpStatusExpired + order.CompleteTime = common.GetTimestamp() + return tx.Save(&order).Error + }) +} + +// Admin bind (no payment). Creates a UserSubscription from a plan. +func AdminBindSubscription(userId int, planId int, sourceNote string) error { + if userId <= 0 || planId <= 0 { + return errors.New("invalid userId or planId") + } + plan, err := GetSubscriptionPlanById(planId) + if err != nil { + return err + } + return DB.Transaction(func(tx *gorm.DB) error { + _, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, "admin") + return err + }) +} + +// Get current active subscription (best-effort: latest end_time) +func GetActiveUserSubscription(userId int) (*SubscriptionSummary, error) { + if userId <= 0 { + return nil, errors.New("invalid userId") + } + now := common.GetTimestamp() + var sub UserSubscription + err := DB.Where("user_id = ? AND status = ? AND end_time > ?", userId, "active", now). + Order("end_time desc, id desc"). + First(&sub).Error + if err != nil { + return nil, err + } + var items []UserSubscriptionItem + if err := DB.Where("user_subscription_id = ?", sub.Id).Find(&items).Error; err != nil { + return nil, err + } + return &SubscriptionSummary{Subscription: &sub, Items: items}, nil +} + +// GetAllActiveUserSubscriptions returns all active subscriptions for a user. +func GetAllActiveUserSubscriptions(userId int) ([]SubscriptionSummary, error) { + if userId <= 0 { + return nil, errors.New("invalid userId") + } + now := common.GetTimestamp() + var subs []UserSubscription + err := DB.Where("user_id = ? AND status = ? AND end_time > ?", userId, "active", now). + Order("end_time desc, id desc"). + Find(&subs).Error + if err != nil { + return nil, err + } + result := make([]SubscriptionSummary, 0, len(subs)) + for _, sub := range subs { + var items []UserSubscriptionItem + if err := DB.Where("user_subscription_id = ?", sub.Id).Find(&items).Error; err != nil { + continue + } + subCopy := sub + result = append(result, SubscriptionSummary{Subscription: &subCopy, Items: items}) + } + return result, nil +} + +// GetAllUserSubscriptions returns all subscriptions (active and expired) for a user. +func GetAllUserSubscriptions(userId int) ([]SubscriptionSummary, error) { + if userId <= 0 { + return nil, errors.New("invalid userId") + } + var subs []UserSubscription + err := DB.Where("user_id = ?", userId). + Order("end_time desc, id desc"). + Find(&subs).Error + if err != nil { + return nil, err + } + result := make([]SubscriptionSummary, 0, len(subs)) + for _, sub := range subs { + var items []UserSubscriptionItem + if err := DB.Where("user_subscription_id = ?", sub.Id).Find(&items).Error; err != nil { + continue + } + subCopy := sub + result = append(result, SubscriptionSummary{Subscription: &subCopy, Items: items}) + } + return result, nil +} + +type SubscriptionPreConsumeResult struct { + UserSubscriptionId int + ItemId int + QuotaType int + PreConsumed int64 + AmountTotal int64 + AmountUsedBefore int64 + AmountUsedAfter int64 +} + +// PreConsumeUserSubscription finds a valid active subscription item and increments amount_used. +// quotaType=0 => consume quota units; quotaType=1 => consume request count (usually 1). +func PreConsumeUserSubscription(userId int, modelName string, quotaType int, amount int64) (*SubscriptionPreConsumeResult, error) { + if userId <= 0 { + return nil, errors.New("invalid userId") + } + if modelName == "" { + return nil, errors.New("modelName is empty") + } + if amount <= 0 { + return nil, errors.New("amount must be > 0") + } + now := common.GetTimestamp() + + returnValue := &SubscriptionPreConsumeResult{} + err := DB.Transaction(func(tx *gorm.DB) error { + var item UserSubscriptionItem + // lock item row; join to ensure subscription still active + q := tx.Set("gorm:query_option", "FOR UPDATE"). + Table("user_subscription_items"). + Select("user_subscription_items.*"). + Joins("JOIN user_subscriptions ON user_subscriptions.id = user_subscription_items.user_subscription_id"). + Where("user_subscriptions.user_id = ? AND user_subscriptions.status = ? AND user_subscriptions.end_time > ?", userId, "active", now). + Where("user_subscription_items.model_name = ? AND user_subscription_items.quota_type = ?", modelName, quotaType). + Order("user_subscriptions.end_time desc, user_subscriptions.id desc, user_subscription_items.id desc") + if err := q.First(&item).Error; err != nil { + return errors.New("no active subscription item for this model") + } + usedBefore := item.AmountUsed + remain := item.AmountTotal - usedBefore + if remain < amount { + return fmt.Errorf("subscription quota insufficient, remain=%d need=%d", remain, amount) + } + item.AmountUsed += amount + if err := tx.Save(&item).Error; err != nil { + return err + } + returnValue.UserSubscriptionId = item.UserSubscriptionId + returnValue.ItemId = item.Id + returnValue.QuotaType = item.QuotaType + returnValue.PreConsumed = amount + returnValue.AmountTotal = item.AmountTotal + returnValue.AmountUsedBefore = usedBefore + returnValue.AmountUsedAfter = item.AmountUsed + return nil + }) + if err != nil { + return nil, err + } + return returnValue, nil +} + +type SubscriptionPlanInfo struct { + PlanId int + PlanTitle string +} + +func GetSubscriptionPlanInfoByUserSubscriptionId(userSubscriptionId int) (*SubscriptionPlanInfo, error) { + if userSubscriptionId <= 0 { + return nil, errors.New("invalid userSubscriptionId") + } + var sub UserSubscription + if err := DB.Where("id = ?", userSubscriptionId).First(&sub).Error; err != nil { + return nil, err + } + var plan SubscriptionPlan + if err := DB.Where("id = ?", sub.PlanId).First(&plan).Error; err != nil { + return nil, err + } + return &SubscriptionPlanInfo{ + PlanId: sub.PlanId, + PlanTitle: plan.Title, + }, nil +} + +func GetSubscriptionPlanInfoBySubscriptionItemId(itemId int) (*SubscriptionPlanInfo, error) { + if itemId <= 0 { + return nil, errors.New("invalid itemId") + } + var item UserSubscriptionItem + if err := DB.Where("id = ?", itemId).First(&item).Error; err != nil { + return nil, err + } + return GetSubscriptionPlanInfoByUserSubscriptionId(item.UserSubscriptionId) +} + +// Update subscription used amount by delta (positive consume more, negative refund). +func PostConsumeUserSubscriptionDelta(itemId int, delta int64) error { + if itemId <= 0 { + return errors.New("invalid itemId") + } + if delta == 0 { + return nil + } + return DB.Transaction(func(tx *gorm.DB) error { + var item UserSubscriptionItem + if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("id = ?", itemId).First(&item).Error; err != nil { + return err + } + newUsed := item.AmountUsed + delta + if newUsed < 0 { + newUsed = 0 + } + if newUsed > item.AmountTotal { + return fmt.Errorf("subscription used exceeds total, used=%d total=%d", newUsed, item.AmountTotal) + } + item.AmountUsed = newUsed + return tx.Save(&item).Error + }) +} + diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index f4365c126..8363b6040 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -114,6 +114,24 @@ type RelayInfo struct { RelayFormat types.RelayFormat SendResponseCount int FinalPreConsumedQuota int // 最终预消耗的配额 + // BillingSource indicates whether this request is billed from wallet quota or subscription. + // "" or "wallet" => wallet; "subscription" => subscription + BillingSource string + // SubscriptionItemId is the user_subscription_items.id used when BillingSource == "subscription" + SubscriptionItemId int + // SubscriptionQuotaType is the plan item quota type: 0=quota units, 1=request count + SubscriptionQuotaType int + // SubscriptionPreConsumed is the amount pre-consumed on subscription item (quota units or 1) + SubscriptionPreConsumed int64 + // SubscriptionPostDelta is the post-consume delta applied to amount_used (quota units; can be negative). + // Only meaningful when SubscriptionQuotaType == 0. + SubscriptionPostDelta int64 + // SubscriptionPlanId / SubscriptionPlanTitle are used for logging/UI display. + SubscriptionPlanId int + SubscriptionPlanTitle string + // SubscriptionAmountTotal / SubscriptionAmountUsedAfterPreConsume are used to compute remaining in logs. + SubscriptionAmountTotal int64 + SubscriptionAmountUsedAfterPreConsume int64 IsClaudeBetaQuery bool // /v1/messages?beta=true IsChannelTest bool // channel test request diff --git a/router/api-router.go b/router/api-router.go index 973684958..9b2f20b55 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -119,6 +119,30 @@ func SetApiRouter(router *gin.Engine) { adminRoute.DELETE("/:id/2fa", controller.AdminDisable2FA) } } + + // Subscription billing (plans, purchase, admin management) + subscriptionRoute := apiRouter.Group("/subscription") + subscriptionRoute.Use(middleware.UserAuth()) + { + subscriptionRoute.GET("/plans", controller.GetSubscriptionPlans) + subscriptionRoute.GET("/self", controller.GetSubscriptionSelf) + subscriptionRoute.PUT("/self/preference", controller.UpdateSubscriptionPreference) + subscriptionRoute.POST("/epay/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestEpay) + subscriptionRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestStripePay) + subscriptionRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestCreemPay) + } + subscriptionAdminRoute := apiRouter.Group("/subscription/admin") + subscriptionAdminRoute.Use(middleware.AdminAuth()) + { + subscriptionAdminRoute.GET("/plans", controller.AdminListSubscriptionPlans) + subscriptionAdminRoute.POST("/plans", controller.AdminCreateSubscriptionPlan) + subscriptionAdminRoute.PUT("/plans/:id", controller.AdminUpdateSubscriptionPlan) + subscriptionAdminRoute.DELETE("/plans/:id", controller.AdminDeleteSubscriptionPlan) + subscriptionAdminRoute.POST("/bind", controller.AdminBindSubscription) + } + + // Subscription payment callbacks (no auth) + apiRouter.GET("/subscription/epay/notify", controller.SubscriptionEpayNotify) optionRoute := apiRouter.Group("/option") optionRoute.Use(middleware.RootAuth()) { diff --git a/service/billing.go b/service/billing.go new file mode 100644 index 000000000..7a7be40fc --- /dev/null +++ b/service/billing.go @@ -0,0 +1,117 @@ +package service + +import ( + "fmt" + "net/http" + + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" +) + +const ( + BillingSourceWallet = "wallet" + BillingSourceSubscription = "subscription" +) + +func normalizeBillingPreference(pref string) string { + switch pref { + case "subscription_first", "wallet_first", "subscription_only", "wallet_only": + return pref + default: + return "subscription_first" + } +} + +// PreConsumeBilling decides whether to pre-consume from subscription or wallet based on user preference. +// It also always pre-consumes token quota in quota units (same as legacy flow). +func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError { + if relayInfo == nil { + return types.NewError(fmt.Errorf("relayInfo is nil"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + pref := normalizeBillingPreference(relayInfo.UserSetting.BillingPreference) + trySubscription := func() *types.NewAPIError { + quotaTypes := model.GetModelQuotaTypes(relayInfo.OriginModelName) + quotaType := 0 + if len(quotaTypes) > 0 { + quotaType = quotaTypes[0] + } + + // For subscription item: per-request consumes 1, per-quota consumes preConsumedQuota quota units. + subConsume := int64(preConsumedQuota) + if quotaType == 1 { + subConsume = 1 + } + if subConsume <= 0 { + subConsume = 1 + } + + // Pre-consume token quota in quota units to keep token limits consistent. + if preConsumedQuota > 0 { + if err := PreConsumeTokenQuota(relayInfo, preConsumedQuota); err != nil { + return types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) + } + } + + res, err := model.PreConsumeUserSubscription(relayInfo.UserId, relayInfo.OriginModelName, quotaType, subConsume) + if err != nil { + // revert token pre-consume when subscription fails + if preConsumedQuota > 0 && !relayInfo.IsPlayground { + _ = model.IncreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, preConsumedQuota) + } + return types.NewErrorWithStatusCode(fmt.Errorf("订阅额度不足或未配置订阅: %s", err.Error()), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) + } + + relayInfo.BillingSource = BillingSourceSubscription + relayInfo.SubscriptionItemId = res.ItemId + relayInfo.SubscriptionQuotaType = quotaType + relayInfo.SubscriptionPreConsumed = res.PreConsumed + relayInfo.SubscriptionPostDelta = 0 + relayInfo.SubscriptionAmountTotal = res.AmountTotal + relayInfo.SubscriptionAmountUsedAfterPreConsume = res.AmountUsedAfter + if planInfo, err := model.GetSubscriptionPlanInfoByUserSubscriptionId(res.UserSubscriptionId); err == nil && planInfo != nil { + relayInfo.SubscriptionPlanId = planInfo.PlanId + relayInfo.SubscriptionPlanTitle = planInfo.PlanTitle + } + relayInfo.FinalPreConsumedQuota = preConsumedQuota + + logger.LogInfo(c, fmt.Sprintf("用户 %d 使用订阅计费预扣:订阅=%d,token_quota=%d", relayInfo.UserId, res.PreConsumed, preConsumedQuota)) + return nil + } + + tryWallet := func() *types.NewAPIError { + relayInfo.BillingSource = BillingSourceWallet + relayInfo.SubscriptionItemId = 0 + relayInfo.SubscriptionQuotaType = 0 + relayInfo.SubscriptionPreConsumed = 0 + return PreConsumeQuota(c, preConsumedQuota, relayInfo) + } + + switch pref { + case "subscription_only": + return trySubscription() + case "wallet_only": + return tryWallet() + case "wallet_first": + if err := tryWallet(); err != nil { + // only fallback for insufficient wallet quota + if err.GetErrorCode() == types.ErrorCodeInsufficientUserQuota { + return trySubscription() + } + return err + } + return nil + case "subscription_first": + fallthrough + default: + if err := trySubscription(); err != nil { + // fallback only when subscription not available/insufficient + return tryWallet() + } + return nil + } +} + diff --git a/service/log_info_generate.go b/service/log_info_generate.go index 71dd22f47..1bba8ea55 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -6,6 +6,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/types" @@ -73,9 +74,76 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m other["admin_info"] = adminInfo appendRequestPath(ctx, relayInfo, other) appendRequestConversionChain(relayInfo, other) + appendBillingInfo(relayInfo, other) return other } +func appendBillingInfo(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) { + if relayInfo == nil || other == nil { + return + } + // billing_source: "wallet" or "subscription" + if relayInfo.BillingSource != "" { + other["billing_source"] = relayInfo.BillingSource + } + if relayInfo.UserSetting.BillingPreference != "" { + other["billing_preference"] = relayInfo.UserSetting.BillingPreference + } + if relayInfo.BillingSource == "subscription" { + if relayInfo.SubscriptionItemId != 0 { + other["subscription_item_id"] = relayInfo.SubscriptionItemId + } + other["subscription_quota_type"] = relayInfo.SubscriptionQuotaType + if relayInfo.SubscriptionPreConsumed > 0 { + other["subscription_pre_consumed"] = relayInfo.SubscriptionPreConsumed + } + // post_delta: settlement delta applied after actual usage is known (can be negative for refund) + if relayInfo.SubscriptionQuotaType == 0 && relayInfo.SubscriptionPostDelta != 0 { + other["subscription_post_delta"] = relayInfo.SubscriptionPostDelta + } + if relayInfo.SubscriptionPlanId != 0 { + other["subscription_plan_id"] = relayInfo.SubscriptionPlanId + } + if relayInfo.SubscriptionPlanTitle != "" { + other["subscription_plan_title"] = relayInfo.SubscriptionPlanTitle + } + // Compute "this request" subscription consumed + remaining + consumed := relayInfo.SubscriptionPreConsumed + usedFinal := relayInfo.SubscriptionAmountUsedAfterPreConsume + if relayInfo.SubscriptionQuotaType == 0 { + consumed = relayInfo.SubscriptionPreConsumed + relayInfo.SubscriptionPostDelta + usedFinal = relayInfo.SubscriptionAmountUsedAfterPreConsume + relayInfo.SubscriptionPostDelta + } + if consumed < 0 { + consumed = 0 + } + if usedFinal < 0 { + usedFinal = 0 + } + if relayInfo.SubscriptionAmountTotal > 0 { + remain := relayInfo.SubscriptionAmountTotal - usedFinal + if remain < 0 { + remain = 0 + } + other["subscription_total"] = relayInfo.SubscriptionAmountTotal + other["subscription_used"] = usedFinal + other["subscription_remain"] = remain + } + if consumed > 0 { + other["subscription_consumed"] = consumed + } + // Fallback: if plan info missing (older requests), best-effort fetch by item id. + if relayInfo.SubscriptionPlanId == 0 && relayInfo.SubscriptionItemId != 0 { + if info, err := model.GetSubscriptionPlanInfoBySubscriptionItemId(relayInfo.SubscriptionItemId); err == nil && info != nil { + other["subscription_plan_id"] = info.PlanId + other["subscription_plan_title"] = info.PlanTitle + } + } + // Wallet quota is not deducted when billed from subscription. + other["wallet_quota_deducted"] = 0 + } +} + func appendRequestConversionChain(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) { if relayInfo == nil || other == nil { return diff --git a/service/pre_consume_quota.go b/service/pre_consume_quota.go index 995a3f971..4a5edc499 100644 --- a/service/pre_consume_quota.go +++ b/service/pre_consume_quota.go @@ -15,17 +15,38 @@ import ( ) func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) { - if relayInfo.FinalPreConsumedQuota != 0 { - logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费额度 %s", relayInfo.UserId, logger.FormatQuota(relayInfo.FinalPreConsumedQuota))) - gopool.Go(func() { - relayInfoCopy := *relayInfo + // Always refund subscription pre-consumed (can be non-zero even when FinalPreConsumedQuota is 0) + needRefundSub := relayInfo.BillingSource == BillingSourceSubscription && relayInfo.SubscriptionItemId != 0 && relayInfo.SubscriptionPreConsumed > 0 + needRefundToken := relayInfo.FinalPreConsumedQuota != 0 + if !needRefundSub && !needRefundToken { + return + } + logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费(token_quota=%s, subscription=%d)", + relayInfo.UserId, + logger.FormatQuota(relayInfo.FinalPreConsumedQuota), + relayInfo.SubscriptionPreConsumed, + )) + gopool.Go(func() { + relayInfoCopy := *relayInfo + if relayInfoCopy.BillingSource == BillingSourceSubscription { + if needRefundSub { + _ = model.PostConsumeUserSubscriptionDelta(relayInfoCopy.SubscriptionItemId, -relayInfoCopy.SubscriptionPreConsumed) + } + // refund token quota only + if needRefundToken && !relayInfoCopy.IsPlayground { + _ = model.IncreaseTokenQuota(relayInfoCopy.TokenId, relayInfoCopy.TokenKey, relayInfoCopy.FinalPreConsumedQuota) + } + return + } + // wallet refund uses existing path (user quota + token quota) + if needRefundToken { err := PostConsumeQuota(&relayInfoCopy, -relayInfoCopy.FinalPreConsumedQuota, 0, false) if err != nil { common.SysLog("error return pre-consumed quota: " + err.Error()) } - }) - } + } + }) } // PreConsumeQuota checks if the user has enough quota to pre-consume. diff --git a/service/quota.go b/service/quota.go index 23ae60c1f..a2178fec0 100644 --- a/service/quota.go +++ b/service/quota.go @@ -503,13 +503,29 @@ func PreConsumeTokenQuota(relayInfo *relaycommon.RelayInfo, quota int) error { func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQuota int, sendEmail bool) (err error) { - if quota > 0 { - err = model.DecreaseUserQuota(relayInfo.UserId, quota) + // 1) Consume from wallet quota OR subscription item + if relayInfo != nil && relayInfo.BillingSource == BillingSourceSubscription { + // For subscription: quotaType=0 uses quota units delta; quotaType=1 uses fixed 0 delta (pre-consumed 1 on request begin) + if relayInfo.SubscriptionQuotaType == 0 { + if relayInfo.SubscriptionItemId == 0 { + return errors.New("subscription item id is missing") + } + if err := model.PostConsumeUserSubscriptionDelta(relayInfo.SubscriptionItemId, int64(quota)); err != nil { + return err + } + // Track delta for logging/UI (net consumed = preConsumed + postDelta) + relayInfo.SubscriptionPostDelta += int64(quota) + } } else { - err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false) - } - if err != nil { - return err + // Wallet + if quota > 0 { + err = model.DecreaseUserQuota(relayInfo.UserId, quota) + } else { + err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false) + } + if err != nil { + return err + } } if !relayInfo.IsPlayground { diff --git a/web/src/App.jsx b/web/src/App.jsx index 995c64499..91c4d1a1e 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -44,6 +44,7 @@ import Task from './pages/Task'; import ModelPage from './pages/Model'; import ModelDeploymentPage from './pages/ModelDeployment'; import Playground from './pages/Playground'; +import Subscription from './pages/Subscription'; import OAuth2Callback from './components/auth/OAuth2Callback'; import PersonalSetting from './components/settings/PersonalSetting'; import Setup from './pages/Setup'; @@ -117,6 +118,14 @@ function App() { } /> + + + + } + /> {} }) => { +const SiderBar = ({ onNavigate = () => { } }) => { const { t } = useTranslation(); const [collapsed, toggleCollapsed] = useSidebarCollapsed(); const { @@ -152,6 +153,12 @@ const SiderBar = ({ onNavigate = () => {} }) => { to: '/channel', className: isAdmin() ? '' : 'tableHiddle', }, + { + text: t('订阅管理'), + itemKey: 'subscription', + to: '/subscription', + className: isAdmin() ? '' : 'tableHiddle', + }, { text: t('模型管理'), itemKey: 'models', diff --git a/web/src/components/table/subscriptions/SubscriptionsActions.jsx b/web/src/components/table/subscriptions/SubscriptionsActions.jsx new file mode 100644 index 000000000..3d3296250 --- /dev/null +++ b/web/src/components/table/subscriptions/SubscriptionsActions.jsx @@ -0,0 +1,38 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Button } from '@douyinfe/semi-ui'; + +const SubscriptionsActions = ({ openCreate, t }) => { + return ( +
+ +
+ ); +}; + +export default SubscriptionsActions; diff --git a/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx b/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx new file mode 100644 index 000000000..ee27a690c --- /dev/null +++ b/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx @@ -0,0 +1,161 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Button, Modal, Space, Tag } from '@douyinfe/semi-ui'; + +const quotaTypeLabel = (quotaType) => (quotaType === 1 ? '按次' : '按量'); + +function formatDuration(plan, t) { + if (!plan) return ''; + const u = plan.duration_unit || 'month'; + if (u === 'custom') { + return `${t('自定义')} ${plan.custom_seconds || 0}s`; + } + const unitMap = { + year: t('年'), + month: t('月'), + day: t('日'), + hour: t('小时'), + }; + return `${plan.duration_value || 0}${unitMap[u] || u}`; +} + +const renderPlanTitle = (text, record) => { + return ( +
+
{text}
+ {record?.plan?.subtitle ? ( +
{record.plan.subtitle}
+ ) : null} +
+ ); +}; + +const renderPrice = (text, record) => { + return `${record?.plan?.currency || 'USD'} ${Number(text || 0).toFixed(2)}`; +}; + +const renderDuration = (text, record, t) => { + return formatDuration(record?.plan, t); +}; + +const renderEnabled = (text, record) => { + return text ? ( + + 启用 + + ) : ( + + 禁用 + + ); +}; + +const renderModels = (text, record, t) => { + const items = record?.items || []; + if (items.length === 0) { + return
{t('无模型')}
; + } + return ( +
+ {items.slice(0, 3).map((it, idx) => ( +
+ {it.model_name} ({quotaTypeLabel(it.quota_type)}: {it.amount_total}) +
+ ))} + {items.length > 3 && ( +
...{t('共')} {items.length} {t('个模型')}
+ )} +
+ ); +}; + +const renderOperations = (text, record, { openEdit, disablePlan, t }) => { + const handleDisable = () => { + Modal.confirm({ + title: t('确认禁用'), + content: t('禁用后用户端不再展示,但历史订单不受影响。是否继续?'), + centered: true, + onOk: () => disablePlan(record?.plan?.id), + }); + }; + + return ( + + + + + ); +}; + +export const getSubscriptionsColumns = ({ t, openEdit, disablePlan }) => { + return [ + { + title: 'ID', + dataIndex: ['plan', 'id'], + width: 80, + }, + { + title: t('标题'), + dataIndex: ['plan', 'title'], + render: (text, record) => renderPlanTitle(text, record), + }, + { + title: t('价格'), + dataIndex: ['plan', 'price_amount'], + width: 140, + render: (text, record) => renderPrice(text, record), + }, + { + title: t('有效期'), + width: 140, + render: (text, record) => renderDuration(text, record, t), + }, + { + title: t('状态'), + dataIndex: ['plan', 'enabled'], + width: 90, + render: (text, record) => renderEnabled(text, record), + }, + { + title: t('模型权益'), + width: 200, + render: (text, record) => renderModels(text, record, t), + }, + { + title: '', + dataIndex: 'operate', + fixed: 'right', + width: 180, + render: (text, record) => + renderOperations(text, record, { openEdit, disablePlan, t }), + }, + ]; +}; diff --git a/web/src/components/table/subscriptions/SubscriptionsDescription.jsx b/web/src/components/table/subscriptions/SubscriptionsDescription.jsx new file mode 100644 index 000000000..a2d2c73b8 --- /dev/null +++ b/web/src/components/table/subscriptions/SubscriptionsDescription.jsx @@ -0,0 +1,44 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Typography } from '@douyinfe/semi-ui'; +import { CalendarClock } from 'lucide-react'; +import CompactModeToggle from '../../common/ui/CompactModeToggle'; + +const { Text } = Typography; + +const SubscriptionsDescription = ({ compactMode, setCompactMode, t }) => { + return ( +
+
+ + {t('订阅管理')} +
+ + +
+ ); +}; + +export default SubscriptionsDescription; diff --git a/web/src/components/table/subscriptions/SubscriptionsTable.jsx b/web/src/components/table/subscriptions/SubscriptionsTable.jsx new file mode 100644 index 000000000..ad6bfa6b5 --- /dev/null +++ b/web/src/components/table/subscriptions/SubscriptionsTable.jsx @@ -0,0 +1,84 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useMemo } from 'react'; +import { Empty } from '@douyinfe/semi-ui'; +import CardTable from '../../common/ui/CardTable'; +import { + IllustrationNoResult, + IllustrationNoResultDark, +} from '@douyinfe/semi-illustrations'; +import { getSubscriptionsColumns } from './SubscriptionsColumnDefs'; + +const SubscriptionsTable = (subscriptionsData) => { + const { + plans, + loading, + compactMode, + openEdit, + disablePlan, + t, + } = subscriptionsData; + + const columns = useMemo(() => { + return getSubscriptionsColumns({ + t, + openEdit, + disablePlan, + }); + }, [t, openEdit, disablePlan]); + + const tableColumns = useMemo(() => { + return compactMode + ? columns.map((col) => { + if (col.dataIndex === 'operate') { + const { fixed, ...rest } = col; + return rest; + } + return col; + }) + : columns; + }, [compactMode, columns]); + + return ( + row?.plan?.id} + empty={ + } + darkModeImage={ + + } + description={t('暂无订阅套餐')} + style={{ padding: 30 }} + /> + } + className='overflow-hidden' + size='middle' + /> + ); +}; + +export default SubscriptionsTable; diff --git a/web/src/components/table/subscriptions/index.jsx b/web/src/components/table/subscriptions/index.jsx new file mode 100644 index 000000000..74df1a85b --- /dev/null +++ b/web/src/components/table/subscriptions/index.jsx @@ -0,0 +1,90 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Banner } from '@douyinfe/semi-ui'; +import CardPro from '../../common/ui/CardPro'; +import SubscriptionsTable from './SubscriptionsTable'; +import SubscriptionsActions from './SubscriptionsActions'; +import SubscriptionsDescription from './SubscriptionsDescription'; +import AddEditSubscriptionModal from './modals/AddEditSubscriptionModal'; +import { useSubscriptionsData } from '../../../hooks/subscriptions/useSubscriptionsData'; + +const SubscriptionsPage = () => { + const subscriptionsData = useSubscriptionsData(); + + const { + showEdit, + editingPlan, + sheetPlacement, + closeEdit, + refresh, + openCreate, + compactMode, + setCompactMode, + pricingModels, + t, + } = subscriptionsData; + + return ( + <> + + + + } + actionsArea={ +
+ {/* Mobile: actions first; Desktop: actions left */} +
+ +
+ +
+ } + t={t} + > + +
+ + ); +}; + +export default SubscriptionsPage; diff --git a/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx b/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx new file mode 100644 index 000000000..129012f18 --- /dev/null +++ b/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx @@ -0,0 +1,542 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useMemo, useState, useRef } from 'react'; +import { + Avatar, + Button, + Card, + Col, + Form, + Input, + InputNumber, + Row, + Select, + SideSheet, + Space, + Spin, + Switch, + Table, + Tag, + Typography, +} from '@douyinfe/semi-ui'; +import { + IconCalendarClock, + IconClose, + IconCreditCard, + IconSave, +} from '@douyinfe/semi-icons'; +import { Trash2, Clock } from 'lucide-react'; +import { API, showError, showSuccess } from '../../../../helpers'; +import { useIsMobile } from '../../../../hooks/common/useIsMobile'; + +const { Text, Title } = Typography; + +const durationUnitOptions = [ + { value: 'year', label: '年' }, + { value: 'month', label: '月' }, + { value: 'day', label: '日' }, + { value: 'hour', label: '小时' }, + { value: 'custom', label: '自定义(秒)' }, +]; + +const quotaTypeLabel = (quotaType) => (quotaType === 1 ? '按次' : '按量'); + +const AddEditSubscriptionModal = ({ + visible, + handleClose, + editingPlan, + placement = 'left', + pricingModels = [], + refresh, + t, +}) => { + const [loading, setLoading] = useState(false); + const isMobile = useIsMobile(); + const formApiRef = useRef(null); + const isEdit = editingPlan?.plan?.id !== undefined; + const formKey = isEdit ? `edit-${editingPlan?.plan?.id}` : 'create'; + + const getInitValues = () => ({ + title: '', + subtitle: '', + price_amount: 0, + currency: 'USD', + duration_unit: 'month', + duration_value: 1, + custom_seconds: 0, + enabled: true, + sort_order: 0, + stripe_price_id: '', + creem_product_id: '', + }); + + const [items, setItems] = useState([]); + + const buildFormValues = () => { + const base = getInitValues(); + if (editingPlan?.plan?.id === undefined) return base; + const p = editingPlan.plan || {}; + return { + ...base, + title: p.title || '', + subtitle: p.subtitle || '', + price_amount: Number(p.price_amount || 0), + currency: p.currency || 'USD', + duration_unit: p.duration_unit || 'month', + duration_value: Number(p.duration_value || 1), + custom_seconds: Number(p.custom_seconds || 0), + enabled: p.enabled !== false, + sort_order: Number(p.sort_order || 0), + stripe_price_id: p.stripe_price_id || '', + creem_product_id: p.creem_product_id || '', + }; + }; + + useEffect(() => { + // 1) always keep items in sync + if (visible && isEdit && editingPlan) { + setItems((editingPlan.items || []).map((it) => ({ ...it }))); + } else if (visible && !isEdit) { + setItems([]); + } + }, [visible, editingPlan]); + + const modelOptions = useMemo(() => { + return (pricingModels || []).map((m) => ({ + label: `${m.model_name} (${quotaTypeLabel(m.quota_type)})`, + value: m.model_name, + quota_type: m.quota_type, + })); + }, [pricingModels]); + + const addItem = (modelName) => { + const modelMeta = modelOptions.find((m) => m.value === modelName); + if (!modelMeta) return; + if (items.some((it) => it.model_name === modelName)) { + showError(t('该模型已添加')); + return; + } + setItems([ + ...items, + { + model_name: modelName, + quota_type: modelMeta.quota_type, + amount_total: 0, + }, + ]); + }; + + const updateItem = (idx, patch) => { + const next = [...items]; + next[idx] = { ...next[idx], ...patch }; + setItems(next); + }; + + const removeItem = (idx) => { + const next = [...items]; + next.splice(idx, 1); + setItems(next); + }; + + const submit = async (values) => { + if (!values.title || values.title.trim() === '') { + showError(t('套餐标题不能为空')); + return; + } + const cleanedItems = items + .filter((it) => it.model_name && Number(it.amount_total) > 0) + .map((it) => ({ + model_name: it.model_name, + quota_type: Number(it.quota_type || 0), + amount_total: Number(it.amount_total), + })); + if (cleanedItems.length === 0) { + showError(t('请至少配置一个模型权益(且数量>0)')); + return; + } + + setLoading(true); + try { + const payload = { + plan: { + ...values, + price_amount: Number(values.price_amount || 0), + duration_value: Number(values.duration_value || 0), + custom_seconds: Number(values.custom_seconds || 0), + sort_order: Number(values.sort_order || 0), + }, + items: cleanedItems, + }; + if (editingPlan?.plan?.id) { + const res = await API.put( + `/api/subscription/admin/plans/${editingPlan.plan.id}`, + payload, + ); + if (res.data?.success) { + showSuccess(t('更新成功')); + handleClose(); + refresh?.(); + } else { + showError(res.data?.message || t('更新失败')); + } + } else { + const res = await API.post('/api/subscription/admin/plans', payload); + if (res.data?.success) { + showSuccess(t('创建成功')); + handleClose(); + refresh?.(); + } else { + showError(res.data?.message || t('创建失败')); + } + } + } catch (e) { + showError(t('请求失败')); + } finally { + setLoading(false); + } + }; + + const itemColumns = [ + { + title: t('模型'), + dataIndex: 'model_name', + render: (v, row) => ( +
+
{v}
+
+ {t('计费')}: {quotaTypeLabel(row.quota_type)} +
+
+ ), + }, + { + title: t('数量'), + dataIndex: 'amount_total', + width: 220, + render: (v, row, idx) => ( + updateItem(idx, { amount_total: val })} + placeholder={row.quota_type === 1 ? t('次数') : t('额度')} + style={{ width: '100%' }} + /> + ), + }, + { + title: '', + width: 60, + render: (_, __, idx) => ( + + + + + } + closeIcon={null} + onCancel={handleClose} + > + +
(formApiRef.current = api)} + onSubmit={submit} + > + {({ values }) => ( +
+ {/* 基本信息 */} + +
+ + + +
+ + {t('基本信息')} + +
+ {t('套餐的基本信息和定价')} +
+
+
+ + + + + + + + + + + + + + + + + USD + EUR + CNY + + + + + + + + + + + +
+ + {/* 有效期设置 */} + +
+ + + +
+ + {t('有效期设置')} + +
+ {t('配置套餐的有效时长')} +
+
+
+ + + + + {durationUnitOptions.map((o) => ( + + {o.label} + + ))} + + + + + {values.duration_unit === 'custom' ? ( + + ) : ( + + )} + + +
+ + {/* 第三方支付配置 */} + +
+ + + +
+ + {t('第三方支付配置')} + +
+ {t('Stripe/Creem 商品ID(可选)')} +
+
+
+ + + + + + + + + + +
+ + {/* 模型权益 */} + +
+
+ + {t('模型权益')} + +
+ {t('配置套餐可使用的模型及额度')} +
+
+ +
+ `${row.model_name}-${row.quota_type}`} + empty={ +
+ {t('尚未添加任何模型')} +
+ } + /> + + + )} + + + + + ); +}; + +export default AddEditSubscriptionModal; diff --git a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx index 2fb0cde8b..f28d06a1b 100644 --- a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx +++ b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx @@ -182,6 +182,18 @@ function renderFirstUseTime(type, t) { } } +function renderBillingTag(record, t) { + const other = getLogOther(record.other); + if (other?.billing_source === 'subscription') { + return ( + + {t('订阅抵扣')} + + ); + } + return null; +} + function renderModelName(record, copyText, t) { let other = getLogOther(record.other); let modelMapped = @@ -191,7 +203,7 @@ function renderModelName(record, copyText, t) { if (!modelMapped) { return renderModelTag(record.model_name, { onClick: (event) => { - copyText(event, record.model_name).then((r) => {}); + copyText(event, record.model_name).then((r) => { }); }, }); } else { @@ -208,7 +220,7 @@ function renderModelName(record, copyText, t) { {renderModelTag(record.model_name, { onClick: (event) => { - copyText(event, record.model_name).then((r) => {}); + copyText(event, record.model_name).then((r) => { }); }, })} @@ -219,7 +231,7 @@ function renderModelName(record, copyText, t) { {renderModelTag(other.upstream_model_name, { onClick: (event) => { copyText(event, other.upstream_model_name).then( - (r) => {}, + (r) => { }, ); }, })} @@ -230,7 +242,7 @@ function renderModelName(record, copyText, t) { > {renderModelTag(record.model_name, { onClick: (event) => { - copyText(event, record.model_name).then((r) => {}); + copyText(event, record.model_name).then((r) => { }); }, suffixIcon: ( { - return record.type === 0 || record.type === 2 || record.type === 5 ? ( - <>{renderQuota(text, 6)} - ) : ( - <> - ); + if (!(record.type === 0 || record.type === 2 || record.type === 5)) { + return <>; + } + const other = getLogOther(record.other); + const isSubscription = other?.billing_source === 'subscription'; + if (isSubscription) { + // Subscription billed: show only tag (no $0), but keep tooltip for equivalent cost. + return ( + + {renderBillingTag(record, t)} + + ); + } + return <>{renderQuota(text, 6)}; }, }, { @@ -532,42 +553,42 @@ export const getLogsColumns = ({ return isAdminUser ? (
{content}
- {affinity ? ( - - {t('渠道亲和性')} -
- - {t('规则')}:{affinity.rule_name || '-'} - -
-
- - {t('分组')}:{affinity.selected_group || '-'} - -
-
- - {t('Key')}: - {(affinity.key_source || '-') + - ':' + - (affinity.key_path || affinity.key_key || '-') + + {affinity ? ( + + {t('渠道亲和性')} +
+ + {t('规则')}:{affinity.rule_name || '-'} + +
+
+ + {t('分组')}:{affinity.selected_group || '-'} + +
+
+ + {t('Key')}: + {(affinity.key_source || '-') + + ':' + + (affinity.key_path || affinity.key_key || '-') + (affinity.key_fp ? `#${affinity.key_fp}` : '')}
-
- } - > - - - - - {t('优选')} - - - -
+ + } + > + + + + + {t('优选')} + + + + ) : null}
) : ( @@ -632,45 +653,49 @@ export const getLogsColumns = ({ let content = other?.claude ? renderModelPriceSimple( - other.model_ratio, - other.model_price, - other.group_ratio, - other?.user_group_ratio, - other.cache_tokens || 0, - other.cache_ratio || 1.0, - other.cache_creation_tokens || 0, - other.cache_creation_ratio || 1.0, - other.cache_creation_tokens_5m || 0, - other.cache_creation_ratio_5m || - other.cache_creation_ratio || - 1.0, - other.cache_creation_tokens_1h || 0, - other.cache_creation_ratio_1h || - other.cache_creation_ratio || - 1.0, - false, - 1.0, - other?.is_system_prompt_overwritten, - 'claude', - ) + other.model_ratio, + other.model_price, + other.group_ratio, + other?.user_group_ratio, + other.cache_tokens || 0, + other.cache_ratio || 1.0, + other.cache_creation_tokens || 0, + other.cache_creation_ratio || 1.0, + other.cache_creation_tokens_5m || 0, + other.cache_creation_ratio_5m || + other.cache_creation_ratio || + 1.0, + other.cache_creation_tokens_1h || 0, + other.cache_creation_ratio_1h || + other.cache_creation_ratio || + 1.0, + false, + 1.0, + other?.is_system_prompt_overwritten, + 'claude', + ) : renderModelPriceSimple( - other.model_ratio, - other.model_price, - other.group_ratio, - other?.user_group_ratio, - other.cache_tokens || 0, - other.cache_ratio || 1.0, - 0, - 1.0, - 0, - 1.0, - 0, - 1.0, - false, - 1.0, - other?.is_system_prompt_overwritten, - 'openai', - ); + other.model_ratio, + other.model_price, + other.group_ratio, + other?.user_group_ratio, + other.cache_tokens || 0, + other.cache_ratio || 1.0, + 0, + 1.0, + 0, + 1.0, + 0, + 1.0, + false, + 1.0, + other?.is_system_prompt_overwritten, + 'openai', + ); + // Do not add billing source here; keep details clean. + const summary = [content, text ? `${t('详情')}:${text}` : null] + .filter(Boolean) + .join('\n'); return ( - {content} + {summary} ); }, diff --git a/web/src/components/table/users/UsersColumnDefs.jsx b/web/src/components/table/users/UsersColumnDefs.jsx index 9348d5603..17d0a6324 100644 --- a/web/src/components/table/users/UsersColumnDefs.jsx +++ b/web/src/components/table/users/UsersColumnDefs.jsx @@ -208,6 +208,7 @@ const renderOperations = ( showDeleteModal, showResetPasskeyModal, showResetTwoFAModal, + showBindSubscriptionModal, t, }, ) => { @@ -216,6 +217,14 @@ const renderOperations = ( } const moreMenu = [ + { + node: 'item', + name: t('绑定订阅套餐'), + onClick: () => showBindSubscriptionModal(record), + }, + { + node: 'divider', + }, { node: 'item', name: t('重置 Passkey'), @@ -299,6 +308,7 @@ export const getUsersColumns = ({ showDeleteModal, showResetPasskeyModal, showResetTwoFAModal, + showBindSubscriptionModal, }) => { return [ { @@ -355,6 +365,7 @@ export const getUsersColumns = ({ showDeleteModal, showResetPasskeyModal, showResetTwoFAModal, + showBindSubscriptionModal, t, }), }, diff --git a/web/src/components/table/users/UsersTable.jsx b/web/src/components/table/users/UsersTable.jsx index e31a63c7f..525b11682 100644 --- a/web/src/components/table/users/UsersTable.jsx +++ b/web/src/components/table/users/UsersTable.jsx @@ -31,6 +31,7 @@ import EnableDisableUserModal from './modals/EnableDisableUserModal'; import DeleteUserModal from './modals/DeleteUserModal'; import ResetPasskeyModal from './modals/ResetPasskeyModal'; import ResetTwoFAModal from './modals/ResetTwoFAModal'; +import BindSubscriptionModal from './modals/BindSubscriptionModal'; const UsersTable = (usersData) => { const { @@ -61,6 +62,8 @@ const UsersTable = (usersData) => { const [enableDisableAction, setEnableDisableAction] = useState(''); const [showResetPasskeyModal, setShowResetPasskeyModal] = useState(false); const [showResetTwoFAModal, setShowResetTwoFAModal] = useState(false); + const [showBindSubscriptionModal, setShowBindSubscriptionModal] = + useState(false); // Modal handlers const showPromoteUserModal = (user) => { @@ -94,6 +97,11 @@ const UsersTable = (usersData) => { setShowResetTwoFAModal(true); }; + const showBindSubscriptionUserModal = (user) => { + setModalUser(user); + setShowBindSubscriptionModal(true); + }; + // Modal confirm handlers const handlePromoteConfirm = () => { manageUser(modalUser.id, 'promote', modalUser); @@ -132,6 +140,7 @@ const UsersTable = (usersData) => { showDeleteModal: showDeleteUserModal, showResetPasskeyModal: showResetPasskeyUserModal, showResetTwoFAModal: showResetTwoFAUserModal, + showBindSubscriptionModal: showBindSubscriptionUserModal, }); }, [ t, @@ -143,6 +152,7 @@ const UsersTable = (usersData) => { showDeleteUserModal, showResetPasskeyUserModal, showResetTwoFAUserModal, + showBindSubscriptionUserModal, ]); // Handle compact mode by removing fixed positioning @@ -242,6 +252,14 @@ const UsersTable = (usersData) => { user={modalUser} t={t} /> + + setShowBindSubscriptionModal(false)} + user={modalUser} + t={t} + onSuccess={() => refresh?.()} + /> ); }; diff --git a/web/src/components/table/users/modals/BindSubscriptionModal.jsx b/web/src/components/table/users/modals/BindSubscriptionModal.jsx new file mode 100644 index 000000000..cff91e4fd --- /dev/null +++ b/web/src/components/table/users/modals/BindSubscriptionModal.jsx @@ -0,0 +1,124 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useMemo, useState } from 'react'; +import { Modal, Select, Space, Typography } from '@douyinfe/semi-ui'; +import { API, showError, showSuccess } from '../../../../helpers'; + +const { Text } = Typography; + +const BindSubscriptionModal = ({ visible, onCancel, user, t, onSuccess }) => { + const [loading, setLoading] = useState(false); + const [plans, setPlans] = useState([]); + const [selectedPlanId, setSelectedPlanId] = useState(null); + + const loadPlans = async () => { + setLoading(true); + try { + const res = await API.get('/api/subscription/admin/plans'); + if (res.data?.success) { + setPlans(res.data.data || []); + } else { + showError(res.data?.message || t('加载失败')); + } + } catch (e) { + showError(t('请求失败')); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (visible) { + setSelectedPlanId(null); + loadPlans(); + } + }, [visible]); + + const planOptions = useMemo(() => { + return (plans || []).map((p) => ({ + label: `${p?.plan?.title || ''} (${p?.plan?.currency || 'USD'} ${Number(p?.plan?.price_amount || 0)})`, + value: p?.plan?.id, + })); + }, [plans]); + + const bind = async () => { + if (!user?.id) { + showError(t('用户信息缺失')); + return; + } + if (!selectedPlanId) { + showError(t('请选择订阅套餐')); + return; + } + setLoading(true); + try { + const res = await API.post('/api/subscription/admin/bind', { + user_id: user.id, + plan_id: selectedPlanId, + }); + if (res.data?.success) { + showSuccess(t('绑定成功')); + onSuccess?.(); + onCancel?.(); + } else { + showError(res.data?.message || t('绑定失败')); + } + } catch (e) { + showError(t('请求失败')); + } finally { + setLoading(false); + } + }; + + return ( + + +
+ {t('用户')}: + {user?.username} + (ID: {user?.id}) +
+ + + + {loading ? ( +
+ {/* 我的订阅骨架屏 */} + +
+ + +
+
+ +
+
+ {/* 套餐列表骨架屏 */} +
+ {[1, 2, 3].map((i) => ( + + + +
+ +
+ + +
+ ))} +
+
+ ) : ( + + {/* 当前订阅状态 */} + +
+
+ {t('我的订阅')} + {hasActiveSubscription ? ( + + {activeSubscriptions.length} {t('个生效中')} + + ) : ( + {t('无生效')} + )} + {allSubscriptions.length > activeSubscriptions.length && ( + + {allSubscriptions.length - activeSubscriptions.length} {t('个已过期')} + + )} +
+
+ + {hasAnySubscription ? ( +
+ {allSubscriptions.map((sub, subIndex) => { + const subscription = sub.subscription; + const items = sub.items || []; + const remainDays = getRemainingDays(sub); + const usagePercent = getUsagePercent(sub); + const now = Date.now() / 1000; + const isExpired = (subscription?.end_time || 0) < now; + const isActive = subscription?.status === 'active' && !isExpired; + + return ( +
+ {/* 订阅概要 */} +
+
+ + {t('订阅')} #{subscription?.id} + + {isActive ? ( + {t('生效')} + ) : ( + {t('已过期')} + )} +
+ {isActive && ( + + {t('剩余')} {remainDays} {t('天')} · {t('已用')} {usagePercent}% + + )} +
+
+ {isActive ? t('至') : t('过期于')} {new Date((subscription?.end_time || 0) * 1000).toLocaleString()} +
+ {/* 权益列表 */} + {items.length > 0 && ( +
+ {items.slice(0, 4).map((it) => { + const used = Number(it.amount_used || 0); + const total = Number(it.amount_total || 0); + const remain = total - used; + const percent = total > 0 ? Math.round((used / total) * 100) : 0; + const label = it.quota_type === 1 ? t('次') : ''; + + return ( + 80 ? 'red' : 'blue') : 'grey'} + type='light' + shape='circle' + > + {it.model_name}: {remain}{label} + + ); + })} + {items.length > 4 && ( + + +{items.length - 4} + + )} +
+ )} +
+ ); + })} +
+ ) : ( +
+ {t('购买套餐后即可享受模型权益')} +
+ )} +
+ + {/* 可购买套餐 - 标准定价卡片 */} + {plans.length > 0 ? ( +
+ {plans.map((p, index) => { + const plan = p?.plan; + const planItems = p?.items || []; + const currency = getCurrencySymbol(plan?.currency || 'USD'); + const price = Number(plan?.price_amount || 0); + const isPopular = index === 0 && plans.length > 1; + + return ( + +
+ {/* 推荐标签 */} + {isPopular && ( +
+ + + {t('推荐')} + +
+ )} + {/* 套餐名称 */} +
+ + {plan?.title || t('订阅套餐')} + + {plan?.subtitle && ( + + {plan.subtitle} + + )} +
+ + {/* 价格区域 */} +
+
+ + {currency} + + + {price.toFixed(price % 1 === 0 ? 0 : 2)} + +
+
+ + {formatDuration(plan, t)} +
+
+ + + + {/* 权益列表 */} +
+ {planItems.slice(0, 5).map((it, idx) => ( +
+ + {it.model_name} + + {it.amount_total} + {it.quota_type === 1 ? t('次') : ''} + +
+ ))} + {planItems.length > 5 && ( +
+ +{planItems.length - 5} {t('项更多权益')} +
+ )} + {planItems.length === 0 && ( +
+ {t('暂无权益配置')} +
+ )} +
+ + {/* 购买按钮 */} + +
+
+ ); + })} +
+ ) : ( +
+ {t('暂无可购买套餐')} +
+ )} +
+ )} + + {/* 购买确认弹窗 */} + + + ); +}; + +export default SubscriptionPlansCard; + diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index 7618d7778..0cfc431af 100644 --- a/web/src/components/topup/index.jsx +++ b/web/src/components/topup/index.jsx @@ -35,6 +35,7 @@ import { StatusContext } from '../../context/Status'; import RechargeCard from './RechargeCard'; import InvitationCard from './InvitationCard'; +import SubscriptionPlansCard from './SubscriptionPlansCard'; import TransferModal from './modals/TransferModal'; import PaymentConfirmModal from './modals/PaymentConfirmModal'; import TopupHistoryModal from './modals/TopupHistoryModal'; @@ -87,6 +88,13 @@ const TopUp = () => { // 账单Modal状态 const [openHistory, setOpenHistory] = useState(false); + // 订阅相关 + const [subscriptionPlans, setSubscriptionPlans] = useState([]); + const [subscriptionLoading, setSubscriptionLoading] = useState(true); + const [billingPreference, setBillingPreference] = useState('subscription_first'); + const [activeSubscriptions, setActiveSubscriptions] = useState([]); + const [allSubscriptions, setAllSubscriptions] = useState([]); + // 预设充值额度选项 const [presetAmounts, setPresetAmounts] = useState([]); const [selectedPreset, setSelectedPreset] = useState(null); @@ -313,6 +321,53 @@ const TopUp = () => { } }; + const getSubscriptionPlans = async () => { + setSubscriptionLoading(true); + try { + const res = await API.get('/api/subscription/plans'); + if (res.data?.success) { + setSubscriptionPlans(res.data.data || []); + } + } catch (e) { + setSubscriptionPlans([]); + } finally { + setSubscriptionLoading(false); + } + }; + + const getSubscriptionSelf = async () => { + try { + const res = await API.get('/api/subscription/self'); + if (res.data?.success) { + setBillingPreference(res.data.data?.billing_preference || 'subscription_first'); + // Active subscriptions + const activeSubs = res.data.data?.subscriptions || []; + setActiveSubscriptions(activeSubs); + // All subscriptions (including expired) + const allSubs = res.data.data?.all_subscriptions || []; + setAllSubscriptions(allSubs); + } + } catch (e) { + // ignore + } + }; + + const updateBillingPreference = async (pref) => { + setBillingPreference(pref); + try { + const res = await API.put('/api/subscription/self/preference', { + billing_preference: pref, + }); + if (res.data?.success) { + showSuccess(t('更新成功')); + } else { + showError(res.data?.message || t('更新失败')); + } + } catch (e) { + showError(t('请求失败')); + } + }; + // 获取充值配置信息 const getTopupInfo = async () => { try { @@ -479,6 +534,8 @@ const TopUp = () => { // 在 statusState 可用时获取充值信息 useEffect(() => { getTopupInfo().then(); + getSubscriptionPlans().then(); + getSubscriptionSelf().then(); }, []); useEffect(() => { @@ -661,60 +718,72 @@ const TopUp = () => { )}
- {/* 用户信息头部 */} -
-
- {/* 左侧充值区域 */} -
- -
+ {/* 主布局区域 */} +
+ {/* 左侧 - 订阅套餐 */} +
+ +
- {/* 右侧信息区域 */} -
- -
+ {/* 右侧 - 账户充值 + 邀请奖励 */} +
+ +
diff --git a/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx b/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx new file mode 100644 index 000000000..8ed377ff7 --- /dev/null +++ b/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx @@ -0,0 +1,236 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Modal, Typography, Card, Tag, Button, Select, Divider } from '@douyinfe/semi-ui'; +import { Crown, CalendarClock, Package, Check } from 'lucide-react'; +import { SiStripe } from 'react-icons/si'; +import { IconCreditCard } from '@douyinfe/semi-icons'; + +const { Text } = Typography; + +// 格式化有效期显示 +function formatDuration(plan, t) { + const unit = plan?.duration_unit || 'month'; + const value = plan?.duration_value || 1; + const unitLabels = { + year: t('年'), + month: t('个月'), + day: t('天'), + hour: t('小时'), + custom: t('自定义'), + }; + if (unit === 'custom') { + const seconds = plan?.custom_seconds || 0; + if (seconds >= 86400) return `${Math.floor(seconds / 86400)} ${t('天')}`; + if (seconds >= 3600) return `${Math.floor(seconds / 3600)} ${t('小时')}`; + return `${seconds} ${t('秒')}`; + } + return `${value} ${unitLabels[unit] || unit}`; +} + +// 获取货币符号 +function getCurrencySymbol(currency) { + const symbols = { USD: '$', EUR: '€', CNY: '¥', GBP: '£', JPY: '¥' }; + return symbols[currency] || currency + ' '; +} + +const SubscriptionPurchaseModal = ({ + t, + visible, + onCancel, + selectedPlan, + paying, + selectedEpayMethod, + setSelectedEpayMethod, + epayMethods = [], + enableOnlineTopUp = false, + enableStripeTopUp = false, + enableCreemTopUp = false, + onPayStripe, + onPayCreem, + onPayEpay, +}) => { + const plan = selectedPlan?.plan; + const items = selectedPlan?.items || []; + const currency = plan ? getCurrencySymbol(plan.currency || 'USD') : '$'; + const price = plan ? Number(plan.price_amount || 0) : 0; + // 只有当管理员开启支付网关 AND 套餐配置了对应的支付ID时才显示 + const hasStripe = enableStripeTopUp && !!plan?.stripe_price_id; + const hasCreem = enableCreemTopUp && !!plan?.creem_product_id; + const hasEpay = enableOnlineTopUp && epayMethods.length > 0; + const hasAnyPayment = hasStripe || hasCreem || hasEpay; + + return ( + + + {t('购买订阅套餐')} +
+ } + visible={visible} + onCancel={onCancel} + footer={null} + maskClosable={false} + size='small' + centered + > + {plan ? ( +
+ {/* 套餐信息 */} + +
+
+ + {t('套餐名称')}: + + + {plan.title} + +
+
+ + {t('有效期')}: + +
+ + + {formatDuration(plan, t)} + +
+
+
+ + {t('包含权益')}: + +
+ + + {items.length} {t('项')} + +
+
+ +
+ + {t('应付金额')}: + + + {currency}{price.toFixed(price % 1 === 0 ? 0 : 2)} + +
+
+
+ + {/* 权益列表 */} + {items.length > 0 && ( +
+ {t('权益明细')}: +
+ {items.slice(0, 6).map((it, idx) => ( + + + {it.model_name}: {it.amount_total}{it.quota_type === 1 ? t('次') : ''} + + ))} + {items.length > 6 && ( + + +{items.length - 6} + + )} +
+
+ )} + + {/* 支付方式 */} + {hasAnyPayment ? ( +
+ {t('选择支付方式')}: + + {/* Stripe / Creem */} + {(hasStripe || hasCreem) && ( +
+ {hasStripe && ( + + )} + {hasCreem && ( + + )} +
+ )} + + {/* 易支付 */} + {hasEpay && ( +
+