Преглед изворни кода

feat(pricing): 渠道定价展示用户折扣价格

登录用户查看渠道定价时,按用户分组倍率和渠道个人倍率计算实际折扣价
并在价格列展示划线原价与折扣价,新增 /pricing/user/*model 接口。

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
master
fengsilin пре 3 дана
родитељ
комит
f7ff684d0d
15 измењених фајлова са 261 додато и 85 уклоњено
  1. +17
    -1
      controller/channel_pricing.go
  2. +112
    -0
      controller/pricing.go
  3. +1
    -0
      model/channel_pricing.go
  4. +28
    -1
      model/pricing.go
  5. +15
    -0
      model/user_channel_ratio.go
  6. +1
    -0
      router/api-router.go
  7. +1
    -0
      web/src/components/table/model-pricing/layout/PricingPage.jsx
  8. +2
    -2
      web/src/components/table/model-pricing/layout/PricingSidebar.jsx
  9. +1
    -1
      web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx
  10. +69
    -66
      web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx
  11. +2
    -2
      web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx
  12. +2
    -2
      web/src/components/table/model-pricing/view/table/PricingTableColumns.jsx
  13. +6
    -6
      web/src/components/table/tokens/TokensColumnDefs.jsx
  14. +2
    -2
      web/src/components/table/tokens/modals/EditTokenModal.jsx
  15. +2
    -2
      web/src/components/topup/index.jsx

+ 17
- 1
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 获取渠道定价(带标签详情)


+ 112
- 0
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)


+ 1
- 0
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 获取指定模型的渠道定价(带渠道信息)


+ 28
- 1
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()


+ 15
- 0
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
}

+ 1
- 0
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)


+ 1
- 0
web/src/components/table/model-pricing/layout/PricingPage.jsx Прегледај датотеку

@@ -37,6 +37,7 @@ const PricingPage = () => {
setShowRatio,
viewMode,
setViewMode,
showTryButton: true,
};

return (


+ 2
- 2
web/src/components/table/model-pricing/layout/PricingSidebar.jsx Прегледај датотеку

@@ -122,7 +122,7 @@ const PricingSidebar = ({
t={t}
/>

<PricingGroups
{/* <PricingGroups
filterGroup={filterGroup}
setFilterGroup={handleGroupClick}
usableGroup={categoryProps.usableGroup}
@@ -130,7 +130,7 @@ const PricingSidebar = ({
models={groupCountModels}
loading={loading}
t={t}
/>
/> */}

<PricingQuotaTypes
filterQuotaType={filterQuotaType}


+ 1
- 1
web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx Прегледај датотеку

@@ -61,7 +61,7 @@ const ModelDetailSideSheet = ({
borderBottom: '1px solid var(--semi-color-border)',
}}
visible={visible}
width={isMobile ? '100%' : 600}
width={isMobile ? '100%' : 750}
closeIcon={
<Button
className='semi-button-tertiary semi-button-size-small semi-button-borderless'


+ 69
- 66
web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx Прегледај датотеку

@@ -17,10 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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 (
<div style={{ whiteSpace: 'nowrap' }}>
<div style={{ textDecoration: 'line-through', color: '#999', fontSize: '13px' }}>
{formatPrice(originalPrice)} / {unit}
</div>
<div style={{ fontSize: '15px', fontWeight: 700 }}>
{formatPrice(discountedPrice)} / {unit}
</div>
</div>
);
}

return (
<div style={{ whiteSpace: 'nowrap' }}>
<span className='font-semibold'>
{formatPrice(originalPrice)}
</span>
<span className='text-xs text-gray-500'> / {unit}</span>
</div>
);
};

// 折扣列(仅登录且有折扣时显示)
const discountColumn = priceMeta.loggedIn
? [
{
title: t('折扣'),
dataIndex: 'userRatio',
render: (userRatio) => {
if (userRatio >= 1) return '-';
return (
<Tag color='red' size='small' shape='circle' style={{ fontSize: '12px', lineHeight: '18px' }}>
<IconArrowDown size='tiny' style={{ marginRight: 2, verticalAlign: 'middle' }} />{Math.round((1 - userRatio) * 100)}%
</Tag>
);
},
},
]
: [];

// 只有存在按量计费的渠道时才显示输入/输出价格列
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 (
<>
<div className='font-semibold text-orange-600'>
{formatPrice(inputPrice)}
</div>
<div className='text-xs text-gray-500'>
/ {tokenUnit === 'K' ? '1K' : '1M'} tokens
</div>
</>
);
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 (
<>
<div className='font-semibold text-orange-600'>
{formatPrice(outputPrice)}
</div>
<div className='text-xs text-gray-500'>
/ {tokenUnit === 'K' ? '1K' : '1M'} tokens
</div>
</>
);
return renderDiscountedPrice(outputPrice, record, `${tokenUnitLabel} tokens`);
},
},
]
@@ -231,16 +249,8 @@ const ChannelPricingCard = ({

const renderCachePrice = (v, record) => {
if (record.quotaType !== 0) return '-';
return (
<>
<div className='font-semibold text-orange-600'>
{formatPrice(record.modelRatio * v * 2)}
</div>
<div className='text-xs text-gray-500'>
/ {tokenUnit === 'K' ? '1K' : '1M'} tokens
</div>
</>
);
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 (
<>
<div className='font-semibold text-orange-600'>
{displayPrice(price)}
</div>
<div className='text-xs text-gray-500'>/ {t('次')}</div>
</>
);
return renderDiscountedPrice(price, record, t('次'));
},
},
]
: [];

const columns = [...baseColumns, ...tokenBasedColumns, ...advancedColumns, ...callBasedColumn];
const columns = [...baseColumns, ...discountColumn, ...tokenBasedColumns, ...advancedColumns, ...callBasedColumn];

return (
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>


+ 2
- 2
web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx Прегледај датотеку

@@ -105,7 +105,7 @@ const FilterModalContent = ({ sidebarProps, t }) => {
t={t}
/>

<PricingGroups
{/* <PricingGroups
filterGroup={filterGroup}
setFilterGroup={setFilterGroup}
usableGroup={categoryProps.usableGroup}
@@ -113,7 +113,7 @@ const FilterModalContent = ({ sidebarProps, t }) => {
models={groupCountModels}
loading={loading}
t={t}
/>
/> */}

<PricingQuotaTypes
filterQuotaType={filterQuotaType}


+ 2
- 2
web/src/components/table/model-pricing/view/table/PricingTableColumns.jsx Прегледај датотеку

@@ -184,7 +184,7 @@ export const getPricingTableColumns = ({
modelNameColumn,
vendorColumn,
descriptionColumn,
tagsColumn,
// tagsColumn,
quotaColumn,
];

@@ -255,7 +255,7 @@ export const getPricingTableColumns = ({
};

const columns = [...baseColumns];
columns.push(endpointColumn);
// columns.push(endpointColumn);
if (showRatio) {
columns.push(ratioColumn);
}


+ 6
- 6
web/src/components/table/tokens/TokensColumnDefs.jsx Прегледај датотеку

@@ -451,12 +451,12 @@ export const getTokensColumns = ({
key: 'quota_usage',
render: (text, record) => 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',


+ 2
- 2
web/src/components/table/tokens/modals/EditTokenModal.jsx Прегледај датотеку

@@ -378,7 +378,7 @@ const EditTokenModal = (props) => {
showClear
/>
</Col>
<Col span={24}>
{/* <Col span={24}>
{groups.length > 0 ? (
<Form.Select
field='group'
@@ -412,7 +412,7 @@ const EditTokenModal = (props) => {
'开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道',
)}
/>
</Col>
</Col> */}
<Col xs={24} sm={24} md={24} lg={10} xl={10}>
<Form.DatePicker
field='expired_time'


+ 2
- 2
web/src/components/topup/index.jsx Прегледај датотеку

@@ -890,14 +890,14 @@ const TopUp = () => {
allSubscriptions={allSubscriptions}
reloadSubscriptionSelf={getSubscriptionSelf}
/>
<InvitationCard
{/* <InvitationCard
t={t}
userState={userState}
renderQuota={renderQuota}
setOpenTransfer={setOpenTransfer}
affLink={affLink}
handleAffLinkClick={handleAffLinkClick}
/>
/> */}
</div>
</div>
);


Loading…
Откажи
Сачувај