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 ( -
-
{text}
- {record?.plan?.subtitle ? ( -
{record.plan.subtitle}
- ) : null} +function formatResetPeriod(plan, t) { + const period = plan?.quota_reset_period || 'never'; + if (period === 'daily') return t('每天'); + if (period === 'weekly') return t('每周'); + if (period === 'monthly') return t('每月'); + if (period === 'custom') { + const seconds = Number(plan?.quota_reset_custom_seconds || 0); + if (seconds >= 86400) return `${Math.floor(seconds / 86400)} ${t('天')}`; + if (seconds >= 3600) return `${Math.floor(seconds / 3600)} ${t('小时')}`; + if (seconds >= 60) return `${Math.floor(seconds / 60)} ${t('分钟')}`; + return `${seconds} ${t('秒')}`; + } + return t('不重置'); +} + +const renderPlanTitle = (text, record, t) => { + const subtitle = record?.plan?.subtitle; + const plan = record?.plan; + const items = record?.items || []; + + const popoverContent = ( +
+ {text} + {subtitle && ( + + {subtitle} + + )} + +
+ {t('价格')} + + {convertUSDToCurrency(Number(plan?.price_amount || 0), 2)} + + {t('有效期')} + {formatDuration(plan, t)} + {t('重置')} + {formatResetPeriod(plan, t)} + {t('模型')} + + {items.length} {t('个')} + +
); + + return ( + +
+ + {text} + + {subtitle && ( + + {subtitle} + + )} +
+
+ ); }; const renderPrice = (text) => { - return convertUSDToCurrency(Number(text || 0), 2); + return ( + + {convertUSDToCurrency(Number(text || 0), 2)} + + ); }; const renderDuration = (text, record, t) => { - return formatDuration(record?.plan, t); + return {formatDuration(record?.plan, t)}; }; -const renderEnabled = (text, record) => { +const renderEnabled = (text, record, t) => { return text ? ( - 启用 + {t('启用')} ) : ( - 禁用 + {t('禁用')} ); }; @@ -72,93 +142,203 @@ const renderEnabled = (text, record) => { const renderModels = (text, record, t) => { const items = record?.items || []; if (items.length === 0) { - return
{t('无模型')}
; + return ; } - 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 popoverContent = ( +
+ + {t('模型权益')} ({items.length}) + + + + {items.map((it, idx) => ( +
+ + {it.model_name} + + + + {quotaTypeLabel(it.quota_type)} + + {it.amount_total} + +
+ ))} +
); + + return ( + + + {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), - }); - }; +const renderResetPeriod = (text, record, t) => { + const period = record?.plan?.quota_reset_period || 'never'; + const isNever = period === 'never'; + return ( + + {formatResetPeriod(record?.plan, t)} + + ); +}; + +const renderPaymentConfig = (text, record, t) => { + const hasStripe = !!record?.plan?.stripe_price_id; + const hasCreem = !!record?.plan?.creem_product_id; return ( - - - + + {hasStripe && ( + + Stripe + + )} + {hasCreem && ( + + Creem + + )} + + {t('易支付')} + ); }; -export const getSubscriptionsColumns = ({ t, openEdit, disablePlan }) => { +const renderOperations = (text, record, { openEdit, setPlanEnabled, t }) => { + const isEnabled = record?.plan?.enabled; + + const handleToggle = () => { + if (isEnabled) { + Modal.confirm({ + title: t('确认禁用'), + content: t('禁用后用户端不再展示,但历史订单不受影响。是否继续?'), + centered: true, + onOk: () => setPlanEnabled(record, false), + }); + } else { + Modal.confirm({ + title: t('确认启用'), + content: t('启用后套餐将在用户端展示。是否继续?'), + centered: true, + onOk: () => setPlanEnabled(record, true), + }); + } + }; + + return ( + + + {isEnabled ? ( + + ) : ( + + )} + + ); +}; + +export const getSubscriptionsColumns = ({ t, openEdit, setPlanEnabled }) => { return [ { title: 'ID', dataIndex: ['plan', 'id'], - width: 80, + width: 60, + render: (text) => #{text}, }, { - title: t('标题'), + title: t('套餐'), dataIndex: ['plan', 'title'], - render: (text, record) => renderPlanTitle(text, record), + width: 200, + render: (text, record) => renderPlanTitle(text, record, t), }, { title: t('价格'), dataIndex: ['plan', 'price_amount'], - width: 140, - render: (text, record) => renderPrice(text, record), + width: 100, + render: (text) => renderPrice(text), + }, + { + title: t('优先级'), + dataIndex: ['plan', 'sort_order'], + width: 80, + render: (text) => {Number(text || 0)}, }, { title: t('有效期'), - width: 140, + width: 80, render: (text, record) => renderDuration(text, record, t), }, + { + title: t('重置'), + width: 80, + render: (text, record) => renderResetPeriod(text, record, t), + }, { title: t('状态'), dataIndex: ['plan', 'enabled'], - width: 90, - render: (text, record) => renderEnabled(text, record), + width: 80, + render: (text, record) => renderEnabled(text, record, t), }, { - title: t('模型权益'), - width: 200, + title: t('支付渠道'), + width: 180, + render: (text, record) => renderPaymentConfig(text, record, t), + }, + { + title: t('模型'), + width: 100, render: (text, record) => renderModels(text, record, t), }, { - title: '', + title: t('操作'), dataIndex: 'operate', fixed: 'right', - width: 180, + width: 160, render: (text, record) => - renderOperations(text, record, { openEdit, disablePlan, t }), + renderOperations(text, record, { openEdit, setPlanEnabled, t }), }, ]; }; diff --git a/web/src/components/table/subscriptions/SubscriptionsTable.jsx b/web/src/components/table/subscriptions/SubscriptionsTable.jsx index cc54b9dc0..32d530e55 100644 --- a/web/src/components/table/subscriptions/SubscriptionsTable.jsx +++ b/web/src/components/table/subscriptions/SubscriptionsTable.jsx @@ -27,16 +27,16 @@ import { import { getSubscriptionsColumns } from './SubscriptionsColumnDefs'; const SubscriptionsTable = (subscriptionsData) => { - const { plans, loading, compactMode, openEdit, disablePlan, t } = + const { plans, loading, compactMode, openEdit, setPlanEnabled, t } = subscriptionsData; const columns = useMemo(() => { return getSubscriptionsColumns({ t, openEdit, - disablePlan, + setPlanEnabled, }); - }, [t, openEdit, disablePlan]); + }, [t, openEdit, setPlanEnabled]); const tableColumns = useMemo(() => { return compactMode diff --git a/web/src/hooks/subscriptions/useSubscriptionsData.jsx b/web/src/hooks/subscriptions/useSubscriptionsData.jsx index 2fc0703b1..72058cda4 100644 --- a/web/src/hooks/subscriptions/useSubscriptionsData.jsx +++ b/web/src/hooks/subscriptions/useSubscriptionsData.jsx @@ -88,14 +88,20 @@ export const useSubscriptionsData = () => { setActivePage(1); }; - // Disable plan - const disablePlan = async (planId) => { + // Update plan enabled status (single endpoint) + const setPlanEnabled = async (planRecordOrId, enabled) => { + const planId = + typeof planRecordOrId === 'number' + ? planRecordOrId + : planRecordOrId?.plan?.id; if (!planId) return; setLoading(true); try { - const res = await API.delete(`/api/subscription/admin/plans/${planId}`); + const res = await API.patch(`/api/subscription/admin/plans/${planId}`, { + enabled: !!enabled, + }); if (res.data?.success) { - showSuccess(t('已禁用')); + showSuccess(enabled ? t('已启用') : t('已禁用')); await loadPlans(); } else { showError(res.data?.message || t('操作失败')); @@ -163,7 +169,7 @@ export const useSubscriptionsData = () => { // Actions loadPlans, - disablePlan, + setPlanEnabled, refresh, closeEdit, openCreate,