diff --git a/controller/subscription.go b/controller/subscription.go index 106ed8c87..9b0924ce7 100644 --- a/controller/subscription.go +++ b/controller/subscription.go @@ -207,21 +207,23 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) { 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, - "max_purchase_per_user": req.Plan.MaxPurchasePerUser, - "total_amount": req.Plan.TotalAmount, - "upgrade_group": req.Plan.UpgradeGroup, - "updated_at": common.GetTimestamp(), + "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, + "max_purchase_per_user": req.Plan.MaxPurchasePerUser, + "total_amount": req.Plan.TotalAmount, + "upgrade_group": req.Plan.UpgradeGroup, + "quota_reset_period": req.Plan.QuotaResetPeriod, + "quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds, + "updated_at": common.GetTimestamp(), } if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil { return err diff --git a/controller/subscription_payment_creem.go b/controller/subscription_payment_creem.go index 740ff65e7..258d4fb35 100644 --- a/controller/subscription_payment_creem.go +++ b/controller/subscription_payment_creem.go @@ -54,7 +54,15 @@ func SubscriptionRequestCreemPay(c *gin.Context) { } userId := c.GetInt("id") - user, _ := model.GetUserById(userId, false) + user, err := model.GetUserById(userId, false) + if err != nil { + common.ApiError(c, err) + return + } + if user == nil { + common.ApiErrorMsg(c, "用户不存在") + return + } if plan.MaxPurchasePerUser > 0 { count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id) diff --git a/controller/subscription_payment_epay.go b/controller/subscription_payment_epay.go index 830159ba3..a0e4b9bfe 100644 --- a/controller/subscription_payment_epay.go +++ b/controller/subscription_payment_epay.go @@ -61,8 +61,16 @@ func SubscriptionRequestEpay(c *gin.Context) { } callBackAddress := service.GetCallbackAddress() - returnUrl, _ := url.Parse(callBackAddress + "/api/subscription/epay/return") - notifyUrl, _ := url.Parse(callBackAddress + "/api/subscription/epay/notify") + returnUrl, err := url.Parse(callBackAddress + "/api/subscription/epay/return") + if err != nil { + common.ApiErrorMsg(c, "回调地址配置错误") + return + } + notifyUrl, err := url.Parse(callBackAddress + "/api/subscription/epay/notify") + if err != nil { + common.ApiErrorMsg(c, "回调地址配置错误") + return + } tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix()) tradeNo = fmt.Sprintf("SUBUSR%dNO%s", userId, tradeNo) diff --git a/controller/subscription_payment_stripe.go b/controller/subscription_payment_stripe.go index 80a49739f..2603a8280 100644 --- a/controller/subscription_payment_stripe.go +++ b/controller/subscription_payment_stripe.go @@ -51,7 +51,15 @@ func SubscriptionRequestStripePay(c *gin.Context) { } userId := c.GetInt("id") - user, _ := model.GetUserById(userId, false) + user, err := model.GetUserById(userId, false) + if err != nil { + common.ApiError(c, err) + return + } + if user == nil { + common.ApiErrorMsg(c, "用户不存在") + return + } if plan.MaxPurchasePerUser > 0 { count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id) @@ -110,7 +118,7 @@ func genStripeSubscriptionLink(referenceId string, customerId string, email stri Quantity: stripe.Int64(1), }, }, - Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), } if "" == customerId { diff --git a/model/db_time.go b/model/db_time.go index a01a33187..dca14292d 100644 --- a/model/db_time.go +++ b/model/db_time.go @@ -9,7 +9,7 @@ func GetDBTimestamp() int64 { var err error switch { case common.UsingPostgreSQL: - err = DB.Raw("SELECT EXTRACT(EPOCH FROM NOW())").Scan(&ts).Error + err = DB.Raw("SELECT EXTRACT(EPOCH FROM NOW())::bigint").Scan(&ts).Error case common.UsingSQLite: err = DB.Raw("SELECT strftime('%s','now')").Scan(&ts).Error default: diff --git a/service/quota.go b/service/quota.go index e012e345a..951eecec5 100644 --- a/service/quota.go +++ b/service/quota.go @@ -508,7 +508,7 @@ func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQu if relayInfo.SubscriptionId == 0 { return errors.New("subscription id is missing") } - delta := int64(quota) - relayInfo.SubscriptionPreConsumed + delta := int64(quota) if delta != 0 { if err := model.PostConsumeUserSubscriptionDelta(relayInfo.SubscriptionId, delta); err != nil { return err diff --git a/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx b/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx index 6bf18ba51..222b05126 100644 --- a/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx +++ b/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx @@ -202,9 +202,10 @@ const renderResetPeriod = (text, record, t) => { ); }; -const renderPaymentConfig = (text, record, t) => { +const renderPaymentConfig = (text, record, t, enableEpay) => { const hasStripe = !!record?.plan?.stripe_price_id; const hasCreem = !!record?.plan?.creem_product_id; + const hasEpay = !!enableEpay; return ( @@ -218,9 +219,11 @@ const renderPaymentConfig = (text, record, t) => { Creem )} - - {t('易支付')} - + {hasEpay && ( + + {t('易支付')} + + )} ); }; @@ -274,7 +277,12 @@ const renderOperations = (text, record, { openEdit, setPlanEnabled, t }) => { ); }; -export const getSubscriptionsColumns = ({ t, openEdit, setPlanEnabled }) => { +export const getSubscriptionsColumns = ({ + t, + openEdit, + setPlanEnabled, + enableEpay, +}) => { return [ { title: 'ID', @@ -324,7 +332,8 @@ export const getSubscriptionsColumns = ({ t, openEdit, setPlanEnabled }) => { { title: t('支付渠道'), width: 180, - render: (text, record) => renderPaymentConfig(text, record, t), + render: (text, record) => + renderPaymentConfig(text, record, t, enableEpay), }, { title: t('总额度'), diff --git a/web/src/components/table/subscriptions/SubscriptionsTable.jsx b/web/src/components/table/subscriptions/SubscriptionsTable.jsx index 32d530e55..4892f66ca 100644 --- a/web/src/components/table/subscriptions/SubscriptionsTable.jsx +++ b/web/src/components/table/subscriptions/SubscriptionsTable.jsx @@ -27,16 +27,24 @@ import { import { getSubscriptionsColumns } from './SubscriptionsColumnDefs'; const SubscriptionsTable = (subscriptionsData) => { - const { plans, loading, compactMode, openEdit, setPlanEnabled, t } = - subscriptionsData; + const { + plans, + loading, + compactMode, + openEdit, + setPlanEnabled, + t, + enableEpay, + } = subscriptionsData; const columns = useMemo(() => { return getSubscriptionsColumns({ t, openEdit, setPlanEnabled, + enableEpay, }); - }, [t, openEdit, setPlanEnabled]); + }, [t, openEdit, setPlanEnabled, enableEpay]); const tableColumns = useMemo(() => { return compactMode diff --git a/web/src/components/table/subscriptions/index.jsx b/web/src/components/table/subscriptions/index.jsx index 52595a60b..865044826 100644 --- a/web/src/components/table/subscriptions/index.jsx +++ b/web/src/components/table/subscriptions/index.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React from 'react'; +import React, { useContext } from 'react'; import { Banner } from '@douyinfe/semi-ui'; import CardPro from '../../common/ui/CardPro'; import SubscriptionsTable from './SubscriptionsTable'; @@ -27,10 +27,13 @@ import AddEditSubscriptionModal from './modals/AddEditSubscriptionModal'; import { useSubscriptionsData } from '../../../hooks/subscriptions/useSubscriptionsData'; import { useIsMobile } from '../../../hooks/common/useIsMobile'; import { createCardProPagination } from '../../../helpers/utils'; +import { StatusContext } from '../../../context/Status'; const SubscriptionsPage = () => { const subscriptionsData = useSubscriptionsData(); const isMobile = useIsMobile(); + const [statusState] = useContext(StatusContext); + const enableEpay = !!statusState?.status?.enable_online_topup; const { showEdit, @@ -91,7 +94,7 @@ const SubscriptionsPage = () => { })} t={t} > - + ); diff --git a/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx b/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx index e57f94c9c..73aef7317 100644 --- a/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx +++ b/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx @@ -423,7 +423,7 @@ const AddEditSubscriptionModal = ({ field='custom_seconds' label={t('自定义秒数')} required - min={0} + min={1} precision={0} rules={[{ required: true, message: t('请输入秒数') }]} style={{ width: '100%' }} diff --git a/web/src/components/topup/SubscriptionPlansCard.jsx b/web/src/components/topup/SubscriptionPlansCard.jsx index 701f958e6..a3a223ba4 100644 --- a/web/src/components/topup/SubscriptionPlansCard.jsx +++ b/web/src/components/topup/SubscriptionPlansCard.jsx @@ -35,45 +35,13 @@ import { API, showError, showSuccess, renderQuota } from '../../helpers'; import { getCurrencyConfig } from '../../helpers/render'; import { Crown, RefreshCw, Sparkles } from 'lucide-react'; import SubscriptionPurchaseModal from './modals/SubscriptionPurchaseModal'; +import { + formatSubscriptionDuration, + formatSubscriptionResetPeriod, +} from '../../helpers/subscriptionFormat'; 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 formatResetPeriod(plan, t) { - const period = plan?.quota_reset_period || 'never'; - if (period === 'never') return t('不重置'); - 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('不重置'); -} - // 过滤易支付方式 function getEpayMethods(payMethods = []) { return (payMethods || []).filter( @@ -473,8 +441,9 @@ const SubscriptionPlansCard = ({ const totalAmount = Number(plan?.total_amount || 0); const { symbol, rate } = getCurrencyConfig(); const price = Number(plan?.price_amount || 0); - const displayPrice = (price * rate).toFixed( - price % 1 === 0 ? 0 : 2, + const convertedPrice = price * rate; + const displayPrice = convertedPrice.toFixed( + Number.isInteger(convertedPrice) ? 0 : 2, ); const isPopular = index === 0 && plans.length > 1; const limit = Number(plan?.max_purchase_per_user || 0); @@ -487,11 +456,13 @@ const SubscriptionPlansCard = ({ ? `${t('升级分组')}: ${plan.upgrade_group}` : null; const resetLabel = - formatResetPeriod(plan, t) === t('不重置') + formatSubscriptionResetPeriod(plan, t) === t('不重置') ? null - : `${t('额度重置')}: ${formatResetPeriod(plan, t)}`; + : `${t('额度重置')}: ${formatSubscriptionResetPeriod(plan, t)}`; const planBenefits = [ - { label: `${t('有效期')}: ${formatDuration(plan, t)}` }, + { + label: `${t('有效期')}: ${formatSubscriptionDuration(plan, t)}`, + }, resetLabel ? { label: resetLabel } : null, totalAmount > 0 ? { diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index 04ef8935a..03e12a941 100644 --- a/web/src/components/topup/index.jsx +++ b/web/src/components/topup/index.jsx @@ -356,6 +356,7 @@ const TopUp = () => { }; const updateBillingPreference = async (pref) => { + const previousPref = billingPreference; setBillingPreference(pref); try { const res = await API.put('/api/subscription/self/preference', { @@ -363,11 +364,16 @@ const TopUp = () => { }); if (res.data?.success) { showSuccess(t('更新成功')); + const normalizedPref = + res.data?.data?.billing_preference || pref || previousPref; + setBillingPreference(normalizedPref); } else { showError(res.data?.message || t('更新失败')); + setBillingPreference(previousPref); } } catch (e) { showError(t('请求失败')); + setBillingPreference(previousPref); } }; diff --git a/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx b/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx index 7f0c71c5e..8bd861ee3 100644 --- a/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx +++ b/web/src/components/topup/modals/SubscriptionPurchaseModal.jsx @@ -33,45 +33,13 @@ import { SiStripe } from 'react-icons/si'; import { IconCreditCard } from '@douyinfe/semi-icons'; import { renderQuota } from '../../../helpers'; import { getCurrencyConfig } from '../../../helpers/render'; +import { + formatSubscriptionDuration, + formatSubscriptionResetPeriod, +} from '../../../helpers/subscriptionFormat'; 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 formatResetPeriod(plan, t) { - const period = plan?.quota_reset_period || 'never'; - if (period === 'never') return t('不重置'); - 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 SubscriptionPurchaseModal = ({ t, visible, @@ -93,7 +61,10 @@ const SubscriptionPurchaseModal = ({ const totalAmount = Number(plan?.total_amount || 0); const { symbol, rate } = getCurrencyConfig(); const price = plan ? Number(plan.price_amount || 0) : 0; - const displayPrice = (price * rate).toFixed(price % 1 === 0 ? 0 : 2); + const convertedPrice = price * rate; + const displayPrice = convertedPrice.toFixed( + Number.isInteger(convertedPrice) ? 0 : 2, + ); // 只有当管理员开启支付网关 AND 套餐配置了对应的支付ID时才显示 const hasStripe = enableStripeTopUp && !!plan?.stripe_price_id; const hasCreem = enableCreemTopUp && !!plan?.creem_product_id; @@ -142,17 +113,17 @@ const SubscriptionPurchaseModal = ({
- {formatDuration(plan, t)} + {formatSubscriptionDuration(plan, t)}
- {formatResetPeriod(plan, t) !== t('不重置') && ( + {formatSubscriptionResetPeriod(plan, t) !== t('不重置') && (
{t('重置周期')}: - {formatResetPeriod(plan, t)} + {formatSubscriptionResetPeriod(plan, t)}
)} diff --git a/web/src/helpers/subscriptionFormat.js b/web/src/helpers/subscriptionFormat.js new file mode 100644 index 000000000..6e49a839a --- /dev/null +++ b/web/src/helpers/subscriptionFormat.js @@ -0,0 +1,34 @@ +export function formatSubscriptionDuration(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}`; +} + +export function formatSubscriptionResetPeriod(plan, t) { + const period = plan?.quota_reset_period || 'never'; + if (period === 'never') return t('不重置'); + 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('不重置'); +} diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 95fa80b6f..58448d129 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -2660,7 +2660,7 @@ "推荐": "おすすめ", "已达到购买上限": "購入上限に達しました", "已达上限": "上限に達しました", - "立即订阅": "今すぐ購読", + "立即订阅": "今すぐサブスクリプション", "暂无可购买套餐": "購入可能なプランがありません", "该套餐未配置 Stripe": "このプランには Stripe が設定されていません", "已打开支付页面": "決済ページを開きました", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 858235b99..eec313d23 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -2616,7 +2616,7 @@ "格式化 JSON": "Форматировать JSON", "关闭提示": "Закрыть уведомление", "说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "Примечание: тесты на этой странице используют нестриминговые запросы. Если канал поддерживает только стриминговые ответы, тест может завершиться неудачей. Ориентируйтесь на реальное использование.", - "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Примечание: сопоставление endpoint'ов используется только для отображения в «Маркетплейсе моделей» и не влияет на реальный вызов. Чтобы настроить реальное поведение вызовов, перейдите в «Управление каналами».", + "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Примечание: сопоставление эндпоинтов используется только для отображения в «Маркетплейсе моделей» и не влияет на реальный вызов. Чтобы настроить реальное поведение вызовов, перейдите в «Управление каналами».", "Stripe/Creem 需在第三方平台创建商品并填入 ID": "Товары Stripe/Creem нужно создать на сторонней платформе и указать их ID", "暂无订阅套餐": "Нет тарифных планов", "订阅管理": "Управление подписками", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 65adbc0e6..aa152114d 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -3228,9 +3228,9 @@ "仅用订阅": "Chỉ dùng đăng ký", "仅用钱包": "Chỉ dùng ví", "我的订阅": "Đăng ký của tôi", - "个生效中": "đang hiệu lực", - "无生效": "Không có hiệu lực", - "个已过期": "đã hết hạn", + "个生效中": "gói đăng ký đang hiệu lực", + "无生效": "Không có gói đăng ký hiệu lực", + "个已过期": "gói đăng ký đã hết hạn", "订阅": "Đăng ký", "过期于": "Hết hạn vào", "购买套餐后即可享受模型权益": "Mua gói để nhận quyền lợi mô hình", diff --git a/web/src/pages/Subscription/index.jsx b/web/src/pages/Subscription/index.jsx index 94ef750c9..623c293e1 100644 --- a/web/src/pages/Subscription/index.jsx +++ b/web/src/pages/Subscription/index.jsx @@ -18,12 +18,12 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import SubscriptionsTable from '../../components/table/subscriptions'; +import SubscriptionsPage from '../../components/table/subscriptions'; const Subscription = () => { return (
- +
); };