diff --git a/controller/channel_pricing.go b/controller/channel_pricing.go index aa424b1..4b54317 100644 --- a/controller/channel_pricing.go +++ b/controller/channel_pricing.go @@ -301,7 +301,23 @@ func GetChannelPricingByModelWithChannelInfo(c *gin.Context) { return } - common.ApiSuccess(c, list) + loggedIn, userId, userGroup, groupRatio := resolveCurrentUser(c) + + if loggedIn { + for _, item := range list { + userChannelRatio := model.GetUserChannelRatio(userId, modelName, item.ChannelId) + item.UserRatio = groupRatio * userChannelRatio + } + } + + c.JSON(200, gin.H{ + "success": true, + "message": "", + "data": list, + "logged_in": loggedIn, + "group": userGroup, + "group_ratio": groupRatio, + }) } // GetChannelPricingWithTags 获取渠道定价(带标签详情) diff --git a/controller/pricing.go b/controller/pricing.go index 9d1191f..4d69b02 100644 --- a/controller/pricing.go +++ b/controller/pricing.go @@ -1,6 +1,10 @@ package controller import ( + "fmt" + "math" + "strings" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" @@ -9,6 +13,25 @@ import ( "github.com/gin-gonic/gin" ) +// resolveCurrentUser 从 gin.Context 解析当前登录用户信息。 +// 未登录或查询失败时 loggedIn=false,groupRatio=1.0。 +func resolveCurrentUser(c *gin.Context) (loggedIn bool, userId int, userGroup string, groupRatio float64) { + groupRatio = 1.0 + raw, exists := c.Get("id") + if !exists { + return + } + userId = raw.(int) + user, err := model.GetUserCache(userId) + if err != nil { + return + } + loggedIn = true + userGroup = user.Group + groupRatio = service.GetUserGroupRatio(userGroup, userGroup) + return +} + func filterPricingByUsableGroups(pricing []model.Pricing, usableGroup map[string]string) []model.Pricing { if len(pricing) == 0 { return pricing @@ -76,6 +99,95 @@ func GetPricing(c *gin.Context) { }) } +// GetUserPricing 获取用户对指定模型的价格信息(原价 vs 用户价) +func GetUserPricing(c *gin.Context) { + modelName := c.Param("model") + // *model 通配符返回值带前导 /,需要去掉 + modelName = strings.TrimPrefix(modelName, "/") + if modelName == "" { + common.ApiErrorMsg(c, "模型名不能为空") + return + } + + pricingData := model.GetPricingByModel(modelName) + if pricingData == nil { + common.ApiErrorMsg(c, "未找到该模型的定价信息") + return + } + + loggedIn, userId, userGroup, groupRatio := resolveCurrentUser(c) + if !loggedIn { + respondOriginalPrice(c, pricingData) + return + } + + bestUserChannelRatio := model.GetBestUserChannelRatio(userId, modelName) + totalRatio := groupRatio * bestUserChannelRatio + savingsPercent := int(math.Round((1 - totalRatio) * 100)) + + result := gin.H{ + "success": true, + "model_name": pricingData.ModelName, + "quota_type": pricingData.QuotaType, + "group": userGroup, + "group_ratio": groupRatio, + "user_channel_ratio": bestUserChannelRatio, + "savings_percent": savingsPercent, + "logged_in": true, + } + + if pricingData.QuotaType == model.QuotaTypeByTokens { + originalInput := pricingData.ModelRatio * 2 + originalOutput := pricingData.ModelRatio * pricingData.CompletionRatio * 2 + result["original_input"] = originalInput + result["original_output"] = originalOutput + result["user_input"] = originalInput * totalRatio + result["user_output"] = originalOutput * totalRatio + } else { + result["original_price"] = pricingData.ModelPrice + result["user_price"] = pricingData.ModelPrice * totalRatio + } + + if savingsPercent > 0 { + result["discount"] = formatDiscount(totalRatio) + } + + c.JSON(200, result) +} + +// respondOriginalPrice 未登录或用户查询失败时,只返回原价 +func respondOriginalPrice(c *gin.Context, pricingData *model.Pricing) { + result := gin.H{ + "success": true, + "model_name": pricingData.ModelName, + "quota_type": pricingData.QuotaType, + "logged_in": false, + } + if pricingData.QuotaType == model.QuotaTypeByTokens { + result["original_input"] = pricingData.ModelRatio * 2 + result["original_output"] = pricingData.ModelRatio * pricingData.CompletionRatio * 2 + } else { + result["original_price"] = pricingData.ModelPrice + } + c.JSON(200, result) +} + +// formatDiscount 将倍率转换为中文折扣格式 +func formatDiscount(ratio float64) string { + if ratio <= 0 { + return "免费" + } + discount := int(math.Round(ratio * 10)) + if discount >= 10 { + return "" + } + remainder := int(math.Round(ratio*100)) % 10 + if remainder == 0 { + return fmt.Sprintf("%d折", discount) + } + return fmt.Sprintf("%.1f折", ratio*10) +} + func ResetModelRatio(c *gin.Context) { defaultStr := ratio_setting.DefaultModelRatio2JSONString() err := model.UpdateOption("ModelRatio", defaultStr) diff --git a/model/channel_pricing.go b/model/channel_pricing.go index 2707a9a..545d734 100644 --- a/model/channel_pricing.go +++ b/model/channel_pricing.go @@ -267,6 +267,7 @@ type ChannelPricingWithChannel struct { AudioRatio float64 `json:"audio_ratio"` AudioCompletionRatio float64 `json:"audio_completion_ratio"` IsDefault bool `json:"is_default"` + UserRatio float64 `json:"user_ratio"` } // GetChannelPricingByModelWithChannelInfo 获取指定模型的渠道定价(带渠道信息) diff --git a/model/pricing.go b/model/pricing.go index 13d7d9f..991d89f 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -44,6 +44,7 @@ type PricingVendor struct { var ( pricingMap []Pricing + pricingByModel map[string]*Pricing vendorsList []PricingVendor supportedEndpointMap map[string]common.EndpointInfo lastGetPricingTime time.Time @@ -60,6 +61,19 @@ var ( modelSupportEndpointsLock = sync.RWMutex{} ) +// SetTestPricing 设置测试用的定价缓存(仅用于测试) +func SetTestPricing(pricing []Pricing) { + updatePricingLock.Lock() + defer updatePricingLock.Unlock() + pricingMap = pricing + idx := make(map[string]*Pricing, len(pricing)) + for i := range pricing { + idx[pricing[i].ModelName] = &pricingMap[i] + } + pricingByModel = idx + lastGetPricingTime = time.Now() +} + func GetPricing() []Pricing { if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 { updatePricingLock.Lock() @@ -74,6 +88,15 @@ func GetPricing() []Pricing { return pricingMap } +// GetPricingByModel 从缓存中查找指定模型的定价信息 +func GetPricingByModel(modelName string) *Pricing { + GetPricing() // 确保缓存已刷新 + modelEnableGroupsLock.RLock() + p := pricingByModel[modelName] + modelEnableGroupsLock.RUnlock() + return p +} + // GetVendors 返回当前定价接口使用到的供应商信息 func GetVendors() []PricingVendor { if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 { @@ -364,10 +387,14 @@ func updatePricing() { modelEnableGroupsLock.Lock() modelEnableGroups = make(map[string][]string) modelQuotaTypeMap = make(map[string]int) - for _, p := range pricingMap { + byModel := make(map[string]*Pricing, len(pricingMap)) + for i := range pricingMap { + p := &pricingMap[i] modelEnableGroups[p.ModelName] = p.EnableGroup modelQuotaTypeMap[p.ModelName] = p.QuotaType + byModel[p.ModelName] = p } + pricingByModel = byModel modelEnableGroupsLock.Unlock() lastGetPricingTime = time.Now() diff --git a/model/user_channel_ratio.go b/model/user_channel_ratio.go index e430013..d722a91 100644 --- a/model/user_channel_ratio.go +++ b/model/user_channel_ratio.go @@ -2,6 +2,7 @@ package model import ( "strconv" + "strings" "sync" "github.com/QuantumNous/new-api/common" @@ -112,3 +113,17 @@ func GetUserChannelRatiosByUserId(userId int) ([]*UserChannelRatio, error) { err := DB.Where("user_id = ?", userId).Find(&list).Error return list, err } + +// GetBestUserChannelRatio 从内存缓存中查找指定用户+模型的最低渠道倍率(未命中返回 1.0) +func GetBestUserChannelRatio(userId int, modelName string) float64 { + best := 1.0 + prefix := strconv.Itoa(userId) + ":" + modelName + ":" + userChannelRatioCacheLock.RLock() + for key, ratio := range userChannelRatioCache { + if len(key) > len(prefix) && strings.HasPrefix(key, prefix) && ratio < best { + best = ratio + } + } + userChannelRatioCacheLock.RUnlock() + return best +} diff --git a/router/api-router.go b/router/api-router.go index 43b618c..dcbf578 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -32,6 +32,7 @@ func SetApiRouter(router *gin.Engine) { //apiRouter.GET("/midjourney", controller.GetMidjourney) apiRouter.GET("/home_page_content", controller.GetHomePageContent) apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing) + apiRouter.GET("/pricing/user/*model", middleware.TryUserAuth(), controller.GetUserPricing) apiRouter.GET("/channel-pricing/model/*name", middleware.TryUserAuth(), controller.GetChannelPricingByModelWithChannelInfo) apiRouter.GET("/captcha", controller.GetCaptcha) apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification) diff --git a/web/src/components/table/model-pricing/layout/PricingPage.jsx b/web/src/components/table/model-pricing/layout/PricingPage.jsx index 615df08..a8f19cd 100644 --- a/web/src/components/table/model-pricing/layout/PricingPage.jsx +++ b/web/src/components/table/model-pricing/layout/PricingPage.jsx @@ -37,6 +37,7 @@ const PricingPage = () => { setShowRatio, viewMode, setViewMode, + showTryButton: true, }; return ( diff --git a/web/src/components/table/model-pricing/layout/PricingSidebar.jsx b/web/src/components/table/model-pricing/layout/PricingSidebar.jsx index f466816..0b43a25 100644 --- a/web/src/components/table/model-pricing/layout/PricingSidebar.jsx +++ b/web/src/components/table/model-pricing/layout/PricingSidebar.jsx @@ -122,7 +122,7 @@ const PricingSidebar = ({ t={t} /> - + /> */} . For commercial licensing, please contact support@quantumnous.com */ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect } from 'react'; import { Card, Avatar, Typography, Table, Tag, Spin, Banner, Tooltip } from '@douyinfe/semi-ui'; -import { IconServer, IconCopy } from '@douyinfe/semi-icons'; -import { API, copy, showSuccess } from '../../../../../helpers'; +import { IconServer, IconCopy, IconArrowDown } from '@douyinfe/semi-icons'; +import { API, copy, showSuccess, getCurrencyConfig } from '../../../../../helpers'; const { Text } = Typography; @@ -33,6 +33,7 @@ const ChannelPricingCard = ({ }) => { const [loading, setLoading] = useState(false); const [channelPricingData, setChannelPricingData] = useState([]); + const [priceMeta, setPriceMeta] = useState({ loggedIn: false, group: '', groupRatio: 1.0 }); useEffect(() => { const fetchChannelPricing = async () => { @@ -41,14 +42,18 @@ const ChannelPricingCard = ({ setLoading(true); try { const res = await API.get(`/api/channel-pricing/model/${encodeURIComponent(modelName)}`); - const { success, data } = res.data; - if (success && Array.isArray(data)) { - setChannelPricingData(data); + const result = res.data; + if (result.success && Array.isArray(result.data)) { + setChannelPricingData(result.data); + setPriceMeta({ + loggedIn: result.logged_in || false, + group: result.group || '', + groupRatio: result.group_ratio || 1.0, + }); } else { setChannelPricingData([]); } } catch (error) { - // 静默处理错误,不显示给用户 setChannelPricingData([]); } finally { setLoading(false); @@ -58,23 +63,7 @@ const ChannelPricingCard = ({ fetchChannelPricing(); }, [modelName]); - // 获取货币符号 - const currencySymbol = useMemo(() => { - if (currency === 'CNY') return '¥'; - if (currency === 'CUSTOM') { - try { - const statusStr = localStorage.getItem('status'); - if (statusStr) { - const s = JSON.parse(statusStr); - return s?.custom_currency_symbol || '¤'; - } - } catch (e) { - // ignore - } - return '¤'; - } - return '$'; - }, [currency]); + const currencySymbol = getCurrencyConfig().symbol; // 格式化价格显示 const formatPrice = (price) => { @@ -105,7 +94,7 @@ const ChannelPricingCard = ({ key: item.channel_id || index, channelId: item.channel_id, channelName: item.channel_public_name || ('通道' + (index + 1)), - channelTags: item.tags || [], // 渠道定价的标签列表 + channelTags: item.tags || [], channelType: item.channel_type, quotaType: item.quota_type, modelRatio: item.model_ratio, @@ -115,6 +104,7 @@ const ChannelPricingCard = ({ cacheRatio: item.cache_ratio, cacheCreationRatio: item.cache_creation_ratio, isDefault: item.is_default || false, + userRatio: item.user_ratio ?? 1.0, })); // 判断是否存在按次计费的渠道 @@ -180,7 +170,55 @@ const ChannelPricingCard = ({ }, ]; + // 渲染带折扣的价格 + const renderDiscountedPrice = (originalPrice, record, unit) => { + const { userRatio } = record; + const hasDiscount = priceMeta.loggedIn && userRatio < 1; + + if (hasDiscount) { + const discountedPrice = originalPrice * userRatio; + return ( +
+
+ {formatPrice(originalPrice)} / {unit} +
+
+ {formatPrice(discountedPrice)} / {unit} +
+
+ ); + } + + return ( +
+ + {formatPrice(originalPrice)} + + / {unit} +
+ ); + }; + + // 折扣列(仅登录且有折扣时显示) + const discountColumn = priceMeta.loggedIn + ? [ + { + title: t('折扣'), + dataIndex: 'userRatio', + render: (userRatio) => { + if (userRatio >= 1) return '-'; + return ( + + {Math.round((1 - userRatio) * 100)}% + + ); + }, + }, + ] + : []; + // 只有存在按量计费的渠道时才显示输入/输出价格列 + const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'; const tokenBasedColumns = hasTokenBased ? [ { @@ -188,18 +226,8 @@ const ChannelPricingCard = ({ dataIndex: 'modelRatio', render: (ratio, record) => { if (record.quotaType !== 0) return '-'; - // 输入价格 = model_ratio * 2 (渠道定价不乘分组倍率) const inputPrice = ratio * 2; - return ( - <> -
- {formatPrice(inputPrice)} -
-
- / {tokenUnit === 'K' ? '1K' : '1M'} tokens -
- - ); + return renderDiscountedPrice(inputPrice, record, `${tokenUnitLabel} tokens`); }, }, { @@ -207,18 +235,8 @@ const ChannelPricingCard = ({ dataIndex: 'completionRatio', render: (completionRatio, record) => { if (record.quotaType !== 0) return '-'; - // 输出价格 = model_ratio * completion_ratio * 2 const outputPrice = record.modelRatio * completionRatio * 2; - return ( - <> -
- {formatPrice(outputPrice)} -
-
- / {tokenUnit === 'K' ? '1K' : '1M'} tokens -
- - ); + return renderDiscountedPrice(outputPrice, record, `${tokenUnitLabel} tokens`); }, }, ] @@ -231,16 +249,8 @@ const ChannelPricingCard = ({ const renderCachePrice = (v, record) => { if (record.quotaType !== 0) return '-'; - return ( - <> -
- {formatPrice(record.modelRatio * v * 2)} -
-
- / {tokenUnit === 'K' ? '1K' : '1M'} tokens -
- - ); + const cachePrice = record.modelRatio * v * 2; + return renderDiscountedPrice(cachePrice, record, `${tokenUnitLabel} tokens`); }; const advancedColumns = hasAdvancedPricing @@ -266,20 +276,13 @@ const ChannelPricingCard = ({ dataIndex: 'modelPrice', render: (price, record) => { if (record.quotaType !== 1) return '-'; - return ( - <> -
- {displayPrice(price)} -
-
/ {t('次')}
- - ); + return renderDiscountedPrice(price, record, t('次')); }, }, ] : []; - const columns = [...baseColumns, ...tokenBasedColumns, ...advancedColumns, ...callBasedColumn]; + const columns = [...baseColumns, ...discountColumn, ...tokenBasedColumns, ...advancedColumns, ...callBasedColumn]; return ( diff --git a/web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx b/web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx index 59c48e2..530e0d0 100644 --- a/web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx +++ b/web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx @@ -105,7 +105,7 @@ const FilterModalContent = ({ sidebarProps, t }) => { t={t} /> - { models={groupCountModels} loading={loading} t={t} - /> + /> */} renderQuotaUsage(text, record, t), }, - { - title: t('分组'), - dataIndex: 'group', - key: 'group', - render: (text, record) => renderGroupColumn(text, record, t), - }, + // { + // title: t('分组'), + // dataIndex: 'group', + // key: 'group', + // render: (text, record) => renderGroupColumn(text, record, t), + // }, { title: t('密钥'), key: 'token_key', diff --git a/web/src/components/table/tokens/modals/EditTokenModal.jsx b/web/src/components/table/tokens/modals/EditTokenModal.jsx index 73ad619..e3fa52a 100644 --- a/web/src/components/table/tokens/modals/EditTokenModal.jsx +++ b/web/src/components/table/tokens/modals/EditTokenModal.jsx @@ -378,7 +378,7 @@ const EditTokenModal = (props) => { showClear /> - + {/* {groups.length > 0 ? ( { '开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道', )} /> - + */} { allSubscriptions={allSubscriptions} reloadSubscriptionSelf={getSubscriptionSelf} /> - + /> */} );