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}
/>
-