diff --git a/common/str.go b/common/str.go index 4f0e81466..71391f722 100644 --- a/common/str.go +++ b/common/str.go @@ -106,6 +106,16 @@ func GetJsonString(data any) string { return string(b) } +// NormalizeBillingPreference clamps the billing preference to valid values. +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" + } +} + // MaskEmail masks a user email to prevent PII leakage in logs // Returns "***masked***" if email is empty, otherwise shows only the domain part func MaskEmail(email string) string { diff --git a/controller/subscription.go b/controller/subscription.go index e8e8a82ac..4a4e86bfe 100644 --- a/controller/subscription.go +++ b/controller/subscription.go @@ -1,7 +1,6 @@ package controller import ( - "encoding/json" "errors" "strconv" "strings" @@ -23,27 +22,6 @@ 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" - } -} - -func normalizeQuotaResetPeriod(period string) string { - switch strings.TrimSpace(period) { - case model.SubscriptionResetDaily, - model.SubscriptionResetWeekly, - model.SubscriptionResetMonthly, - model.SubscriptionResetCustom: - return strings.TrimSpace(period) - default: - return model.SubscriptionResetNever - } -} - // ---- User APIs ---- func GetSubscriptionPlans(c *gin.Context) { @@ -66,7 +44,7 @@ func GetSubscriptionPlans(c *gin.Context) { func GetSubscriptionSelf(c *gin.Context) { userId := c.GetInt("id") settingMap, _ := model.GetUserSetting(userId, false) - pref := normalizeBillingPreference(settingMap.BillingPreference) + pref := common.NormalizeBillingPreference(settingMap.BillingPreference) // Get all subscriptions (including expired) allSubscriptions, err := model.GetAllUserSubscriptions(userId) @@ -94,7 +72,7 @@ func UpdateSubscriptionPreference(c *gin.Context) { common.ApiErrorMsg(c, "参数错误") return } - pref := normalizeBillingPreference(req.BillingPreference) + pref := common.NormalizeBillingPreference(req.BillingPreference) user, err := model.GetUserById(userId, true) if err != nil { @@ -156,7 +134,7 @@ func AdminCreateSubscriptionPlan(c *gin.Context) { if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom { req.Plan.DurationValue = 1 } - req.Plan.QuotaResetPeriod = normalizeQuotaResetPeriod(req.Plan.QuotaResetPeriod) + req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod) if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 { common.ApiErrorMsg(c, "自定义重置周期需大于0秒") return @@ -223,7 +201,7 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) { if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom { req.Plan.DurationValue = 1 } - req.Plan.QuotaResetPeriod = normalizeQuotaResetPeriod(req.Plan.QuotaResetPeriod) + req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod) if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 { common.ApiErrorMsg(c, "自定义重置周期需大于0秒") return @@ -282,14 +260,22 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) { common.ApiSuccess(c, nil) } -func AdminDeleteSubscriptionPlan(c *gin.Context) { +type AdminUpdateSubscriptionPlanStatusRequest struct { + Enabled *bool `json:"enabled"` +} + +func AdminUpdateSubscriptionPlanStatus(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 { + var req AdminUpdateSubscriptionPlanStatusRequest + if err := c.ShouldBindJSON(&req); err != nil || req.Enabled == nil { + common.ApiErrorMsg(c, "参数错误") + return + } + if err := model.DB.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Update("enabled", *req.Enabled).Error; err != nil { common.ApiError(c, err) return } @@ -323,7 +309,7 @@ func AdminListUserSubscriptions(c *gin.Context) { common.ApiErrorMsg(c, "无效的用户ID") return } - subs, err := model.AdminListUserSubscriptions(userId) + subs, err := model.GetAllUserSubscriptions(userId) if err != nil { common.ApiError(c, err) return @@ -381,10 +367,3 @@ func AdminDeleteUserSubscription(c *gin.Context) { } 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_epay.go b/controller/subscription_payment_epay.go index fc24337b1..b5031dcb0 100644 --- a/controller/subscription_payment_epay.go +++ b/controller/subscription_payment_epay.go @@ -118,7 +118,7 @@ func SubscriptionEpayNotify(c *gin.Context) { LockOrder(verifyInfo.ServiceTradeNo) defer UnlockOrder(verifyInfo.ServiceTradeNo) - if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, jsonString(verifyInfo)); err != nil { + if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo)); err != nil { // do not fail webhook response after signature verified return } @@ -145,7 +145,7 @@ func SubscriptionEpayReturn(c *gin.Context) { if verifyInfo.TradeStatus == epay.StatusTradeSuccess { LockOrder(verifyInfo.ServiceTradeNo) defer UnlockOrder(verifyInfo.ServiceTradeNo) - _ = model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, jsonString(verifyInfo)) + _ = model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo)) c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=success") return } diff --git a/controller/topup_creem.go b/controller/topup_creem.go index cd83af045..54b67b854 100644 --- a/controller/topup_creem.go +++ b/controller/topup_creem.go @@ -302,7 +302,7 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) { // Try complete subscription order first LockOrder(referenceId) defer UnlockOrder(referenceId) - if err := model.CompleteSubscriptionOrder(referenceId, jsonString(event)); err == nil { + if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(event)); err == nil { c.Status(http.StatusOK) return } else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) { diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index 995a50af3..01479e8a8 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -176,7 +176,7 @@ func sessionCompleted(event stripe.Event) { "currency": strings.ToUpper(event.GetObjectValue("currency")), "event_type": string(event.Type), } - if err := model.CompleteSubscriptionOrder(referenceId, jsonString(payload)); err == nil { + if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(payload)); err == nil { return } else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) { log.Println("complete subscription order failed:", err.Error(), referenceId) diff --git a/model/subscription.go b/model/subscription.go index f9e20da47..91a5d310c 100644 --- a/model/subscription.go +++ b/model/subscription.go @@ -344,7 +344,7 @@ func calcPlanEndTime(start time.Time, plan *SubscriptionPlan) (int64, error) { } } -func normalizeResetPeriod(period string) string { +func NormalizeResetPeriod(period string) string { switch strings.TrimSpace(period) { case SubscriptionResetDaily, SubscriptionResetWeekly, SubscriptionResetMonthly, SubscriptionResetCustom: return strings.TrimSpace(period) @@ -357,7 +357,7 @@ func calcNextResetTime(base time.Time, plan *SubscriptionPlan, endUnix int64) in if plan == nil { return 0 } - period := normalizeResetPeriod(plan.QuotaResetPeriod) + period := NormalizeResetPeriod(plan.QuotaResetPeriod) if period == SubscriptionResetNever { return 0 } @@ -689,13 +689,6 @@ func buildSubscriptionSummaries(subs []UserSubscription) ([]SubscriptionSummary, return result, nil } -// ---- Admin helpers for managing user subscriptions ---- - -// AdminListUserSubscriptions lists all subscriptions (including expired) for a user. -func AdminListUserSubscriptions(userId int) ([]SubscriptionSummary, error) { - return GetAllUserSubscriptions(userId) -} - // AdminInvalidateUserSubscription marks a user subscription as cancelled and ends it immediately. func AdminInvalidateUserSubscription(userSubscriptionId int) error { if userSubscriptionId <= 0 { @@ -786,7 +779,7 @@ func maybeResetSubscriptionItemWithPlanTx(tx *gorm.DB, item *UserSubscriptionIte if item.NextResetTime > 0 && item.NextResetTime > now { return nil } - if normalizeResetPeriod(plan.QuotaResetPeriod) == SubscriptionResetNever { + if NormalizeResetPeriod(plan.QuotaResetPeriod) == SubscriptionResetNever { return nil } diff --git a/router/api-router.go b/router/api-router.go index 037001159..5587ac7ad 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -137,7 +137,7 @@ func SetApiRouter(router *gin.Engine) { subscriptionAdminRoute.GET("/plans", controller.AdminListSubscriptionPlans) subscriptionAdminRoute.POST("/plans", controller.AdminCreateSubscriptionPlan) subscriptionAdminRoute.PUT("/plans/:id", controller.AdminUpdateSubscriptionPlan) - subscriptionAdminRoute.DELETE("/plans/:id", controller.AdminDeleteSubscriptionPlan) + subscriptionAdminRoute.PATCH("/plans/:id", controller.AdminUpdateSubscriptionPlanStatus) subscriptionAdminRoute.POST("/bind", controller.AdminBindSubscription) // User subscription management (admin) diff --git a/service/billing.go b/service/billing.go index 6e001bc6d..3b80feed8 100644 --- a/service/billing.go +++ b/service/billing.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" relaycommon "github.com/QuantumNous/new-api/relay/common" @@ -16,15 +17,6 @@ const ( 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 { @@ -32,7 +24,7 @@ func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycom return types.NewError(fmt.Errorf("relayInfo is nil"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) } - pref := normalizeBillingPreference(relayInfo.UserSetting.BillingPreference) + pref := common.NormalizeBillingPreference(relayInfo.UserSetting.BillingPreference) trySubscription := func() *types.NewAPIError { quotaTypes := model.GetModelQuotaTypes(relayInfo.OriginModelName) quotaType := 0 diff --git a/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx b/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx index 77685e06a..9d35af434 100644 --- a/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx +++ b/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx @@ -18,8 +18,19 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import { Button, Modal, Space, Tag } from '@douyinfe/semi-ui'; -import { convertUSDToCurrency } from '../../helpers/render'; +import { + Button, + Modal, + Space, + Tag, + Typography, + Popover, + Divider, +} from '@douyinfe/semi-ui'; +import { IconEdit, IconStop, IconPlay } from '@douyinfe/semi-icons'; +import { convertUSDToCurrency } from '../../../helpers/render'; + +const { Text } = Typography; const quotaTypeLabel = (quotaType) => (quotaType === 1 ? '按次' : '按量'); @@ -38,33 +49,92 @@ function formatDuration(plan, t) { return `${plan.duration_value || 0}${unitMap[u] || u}`; } -const renderPlanTitle = (text, record) => { - return ( -