From d8410d2f11fdce79376531b1d752552efd17283f Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 12 Sep 2025 19:11:17 +0800 Subject: [PATCH] feat(payment): add payment settings configuration and update payment methods handling --- controller/channel-billing.go | 4 +- controller/channel.go | 7 +- controller/misc.go | 12 +- controller/topup.go | 63 +++++- controller/topup_stripe.go | 11 +- dto/channel_settings.go | 10 +- model/channel.go | 3 +- model/option.go | 24 +-- relay/channel/vertex/adaptor.go | 116 ++++++----- router/api-router.go | 1 + service/epay.go | 5 +- setting/operation_setting/payment_setting.go | 23 +++ .../payment_setting_old.go} | 21 +- .../components/settings/PaymentSetting.jsx | 26 +++ .../channels/modals/EditChannelModal.jsx | 133 ++++++++----- web/src/components/topup/RechargeCard.jsx | 182 +++++++++++------- web/src/components/topup/index.jsx | 173 +++++++++++------ .../topup/modals/PaymentConfirmModal.jsx | 39 +++- web/src/helpers/data.js | 1 - .../Payment/SettingsPaymentGateway.jsx | 76 ++++++++ 20 files changed, 655 insertions(+), 275 deletions(-) create mode 100644 setting/operation_setting/payment_setting.go rename setting/{payment.go => operation_setting/payment_setting_old.go} (57%) diff --git a/controller/channel-billing.go b/controller/channel-billing.go index 18acf2319..1082b9e73 100644 --- a/controller/channel-billing.go +++ b/controller/channel-billing.go @@ -10,7 +10,7 @@ import ( "one-api/constant" "one-api/model" "one-api/service" - "one-api/setting" + "one-api/setting/operation_setting" "one-api/types" "strconv" "time" @@ -342,7 +342,7 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) { return 0, fmt.Errorf("failed to update moonshot balance, status: %v, code: %d, scode: %s", response.Status, response.Code, response.Scode) } availableBalanceCny := response.Data.AvailableBalance - availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(setting.Price)).InexactFloat64() + availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(operation_setting.Price)).InexactFloat64() channel.UpdateBalance(availableBalanceUsd) return availableBalanceUsd, nil } diff --git a/controller/channel.go b/controller/channel.go index 70be91d42..403eb04cc 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -6,6 +6,7 @@ import ( "net/http" "one-api/common" "one-api/constant" + "one-api/dto" "one-api/model" "strconv" "strings" @@ -560,7 +561,7 @@ func AddChannel(c *gin.Context) { case "multi_to_single": addChannelRequest.Channel.ChannelInfo.IsMultiKey = true addChannelRequest.Channel.ChannelInfo.MultiKeyMode = addChannelRequest.MultiKeyMode - if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi { + if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey { array, err := getVertexArrayKeys(addChannelRequest.Channel.Key) if err != nil { c.JSON(http.StatusOK, gin.H{ @@ -585,7 +586,7 @@ func AddChannel(c *gin.Context) { } keys = []string{addChannelRequest.Channel.Key} case "batch": - if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi { + if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey { // multi json keys, err = getVertexArrayKeys(addChannelRequest.Channel.Key) if err != nil { @@ -840,7 +841,7 @@ func UpdateChannel(c *gin.Context) { } // 处理 Vertex AI 的特殊情况 - if channel.Type == constant.ChannelTypeVertexAi { + if channel.Type == constant.ChannelTypeVertexAi && channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey { // 尝试解析新密钥为JSON数组 if strings.HasPrefix(strings.TrimSpace(channel.Key), "[") { array, err := getVertexArrayKeys(channel.Key) diff --git a/controller/misc.go b/controller/misc.go index 897dad254..085829302 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -59,10 +59,6 @@ func GetStatus(c *gin.Context) { "wechat_qrcode": common.WeChatAccountQRCodeImageURL, "wechat_login": common.WeChatAuthEnabled, "server_address": setting.ServerAddress, - "price": setting.Price, - "stripe_unit_price": setting.StripeUnitPrice, - "min_topup": setting.MinTopUp, - "stripe_min_topup": setting.StripeMinTopUp, "turnstile_check": common.TurnstileCheckEnabled, "turnstile_site_key": common.TurnstileSiteKey, "top_up_link": common.TopUpLink, @@ -75,15 +71,15 @@ func GetStatus(c *gin.Context) { "enable_data_export": common.DataExportEnabled, "data_export_default_time": common.DataExportDefaultTime, "default_collapse_sidebar": common.DefaultCollapseSidebar, - "enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "", - "enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "", "mj_notify_enabled": setting.MjNotifyEnabled, "chats": setting.Chats, "demo_site_enabled": operation_setting.DemoSiteEnabled, "self_use_mode_enabled": operation_setting.SelfUseModeEnabled, "default_use_auto_group": setting.DefaultUseAutoGroup, - "pay_methods": setting.PayMethods, - "usd_exchange_rate": setting.USDExchangeRate, + + "usd_exchange_rate": operation_setting.USDExchangeRate, + "price": operation_setting.Price, + "stripe_unit_price": setting.StripeUnitPrice, // 面板启用开关 "api_info_enabled": cs.ApiInfoEnabled, diff --git a/controller/topup.go b/controller/topup.go index 3f3c86231..93f3e58e0 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -9,6 +9,7 @@ import ( "one-api/model" "one-api/service" "one-api/setting" + "one-api/setting/operation_setting" "strconv" "sync" "time" @@ -19,6 +20,44 @@ import ( "github.com/shopspring/decimal" ) +func GetTopUpInfo(c *gin.Context) { + // 获取支付方式 + payMethods := operation_setting.PayMethods + + // 如果启用了 Stripe 支付,添加到支付方法列表 + if setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "" { + // 检查是否已经包含 Stripe + hasStripe := false + for _, method := range payMethods { + if method["type"] == "stripe" { + hasStripe = true + break + } + } + + if !hasStripe { + stripeMethod := map[string]string{ + "name": "Stripe", + "type": "stripe", + "color": "rgba(var(--semi-purple-5), 1)", + "min_topup": strconv.Itoa(setting.StripeMinTopUp), + } + payMethods = append(payMethods, stripeMethod) + } + } + + data := gin.H{ + "enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "", + "enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "", + "pay_methods": payMethods, + "min_topup": operation_setting.MinTopUp, + "stripe_min_topup": setting.StripeMinTopUp, + "amount_options": operation_setting.GetPaymentSetting().AmountOptions, + "discount": operation_setting.GetPaymentSetting().AmountDiscount, + } + common.ApiSuccess(c, data) +} + type EpayRequest struct { Amount int64 `json:"amount"` PaymentMethod string `json:"payment_method"` @@ -31,13 +70,13 @@ type AmountRequest struct { } func GetEpayClient() *epay.Client { - if setting.PayAddress == "" || setting.EpayId == "" || setting.EpayKey == "" { + if operation_setting.PayAddress == "" || operation_setting.EpayId == "" || operation_setting.EpayKey == "" { return nil } withUrl, err := epay.NewClient(&epay.Config{ - PartnerID: setting.EpayId, - Key: setting.EpayKey, - }, setting.PayAddress) + PartnerID: operation_setting.EpayId, + Key: operation_setting.EpayKey, + }, operation_setting.PayAddress) if err != nil { return nil } @@ -58,15 +97,23 @@ func getPayMoney(amount int64, group string) float64 { } dTopupGroupRatio := decimal.NewFromFloat(topupGroupRatio) - dPrice := decimal.NewFromFloat(setting.Price) + dPrice := decimal.NewFromFloat(operation_setting.Price) + // apply optional preset discount by the original request amount (if configured), default 1.0 + discount := 1.0 + if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(amount)]; ok { + if ds > 0 { + discount = ds + } + } + dDiscount := decimal.NewFromFloat(discount) - payMoney := dAmount.Mul(dPrice).Mul(dTopupGroupRatio) + payMoney := dAmount.Mul(dPrice).Mul(dTopupGroupRatio).Mul(dDiscount) return payMoney.InexactFloat64() } func getMinTopup() int64 { - minTopup := setting.MinTopUp + minTopup := operation_setting.MinTopUp if !common.DisplayInCurrencyEnabled { dMinTopup := decimal.NewFromInt(int64(minTopup)) dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) @@ -99,7 +146,7 @@ func RequestEpay(c *gin.Context) { return } - if !setting.ContainsPayMethod(req.PaymentMethod) { + if !operation_setting.ContainsPayMethod(req.PaymentMethod) { c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"}) return } diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index eb3208092..bf0d7bf36 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -8,6 +8,7 @@ import ( "one-api/common" "one-api/model" "one-api/setting" + "one-api/setting/operation_setting" "strconv" "strings" "time" @@ -254,6 +255,7 @@ func GetChargedAmount(count float64, user model.User) float64 { } func getStripePayMoney(amount float64, group string) float64 { + originalAmount := amount if !common.DisplayInCurrencyEnabled { amount = amount / common.QuotaPerUnit } @@ -262,7 +264,14 @@ func getStripePayMoney(amount float64, group string) float64 { if topupGroupRatio == 0 { topupGroupRatio = 1 } - payMoney := amount * setting.StripeUnitPrice * topupGroupRatio + // apply optional preset discount by the original request amount (if configured), default 1.0 + discount := 1.0 + if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok { + if ds > 0 { + discount = ds + } + } + payMoney := amount * setting.StripeUnitPrice * topupGroupRatio * discount return payMoney } diff --git a/dto/channel_settings.go b/dto/channel_settings.go index 2c58795cb..8791f516e 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -9,6 +9,14 @@ type ChannelSettings struct { SystemPromptOverride bool `json:"system_prompt_override,omitempty"` } +type VertexKeyType string + +const ( + VertexKeyTypeJSON VertexKeyType = "json" + VertexKeyTypeAPIKey VertexKeyType = "api_key" +) + type ChannelOtherSettings struct { - AzureResponsesVersion string `json:"azure_responses_version,omitempty"` + AzureResponsesVersion string `json:"azure_responses_version,omitempty"` + VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key" } diff --git a/model/channel.go b/model/channel.go index a61b3eccf..534e2f3f2 100644 --- a/model/channel.go +++ b/model/channel.go @@ -42,7 +42,6 @@ type Channel struct { Priority *int64 `json:"priority" gorm:"bigint;default:0"` AutoBan *int `json:"auto_ban" gorm:"default:1"` OtherInfo string `json:"other_info"` - OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置 Tag *string `json:"tag" gorm:"index"` Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置 ParamOverride *string `json:"param_override" gorm:"type:text"` @@ -51,6 +50,8 @@ type Channel struct { // add after v0.8.5 ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"` + OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置,存储azure版本等不需要检索的信息,详见dto.ChannelOtherSettings + // cache info Keys []string `json:"-" gorm:"-"` } diff --git a/model/option.go b/model/option.go index 2121710ce..73fe92ad1 100644 --- a/model/option.go +++ b/model/option.go @@ -73,9 +73,9 @@ func InitOptionMap() { common.OptionMap["CustomCallbackAddress"] = "" common.OptionMap["EpayId"] = "" common.OptionMap["EpayKey"] = "" - common.OptionMap["Price"] = strconv.FormatFloat(setting.Price, 'f', -1, 64) - common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(setting.USDExchangeRate, 'f', -1, 64) - common.OptionMap["MinTopUp"] = strconv.Itoa(setting.MinTopUp) + common.OptionMap["Price"] = strconv.FormatFloat(operation_setting.Price, 'f', -1, 64) + common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(operation_setting.USDExchangeRate, 'f', -1, 64) + common.OptionMap["MinTopUp"] = strconv.Itoa(operation_setting.MinTopUp) common.OptionMap["StripeMinTopUp"] = strconv.Itoa(setting.StripeMinTopUp) common.OptionMap["StripeApiSecret"] = setting.StripeApiSecret common.OptionMap["StripeWebhookSecret"] = setting.StripeWebhookSecret @@ -85,7 +85,7 @@ func InitOptionMap() { common.OptionMap["Chats"] = setting.Chats2JsonString() common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() common.OptionMap["DefaultUseAutoGroup"] = strconv.FormatBool(setting.DefaultUseAutoGroup) - common.OptionMap["PayMethods"] = setting.PayMethods2JsonString() + common.OptionMap["PayMethods"] = operation_setting.PayMethods2JsonString() common.OptionMap["GitHubClientId"] = "" common.OptionMap["GitHubClientSecret"] = "" common.OptionMap["TelegramBotToken"] = "" @@ -299,23 +299,23 @@ func updateOptionMap(key string, value string) (err error) { case "WorkerValidKey": setting.WorkerValidKey = value case "PayAddress": - setting.PayAddress = value + operation_setting.PayAddress = value case "Chats": err = setting.UpdateChatsByJsonString(value) case "AutoGroups": err = setting.UpdateAutoGroupsByJsonString(value) case "CustomCallbackAddress": - setting.CustomCallbackAddress = value + operation_setting.CustomCallbackAddress = value case "EpayId": - setting.EpayId = value + operation_setting.EpayId = value case "EpayKey": - setting.EpayKey = value + operation_setting.EpayKey = value case "Price": - setting.Price, _ = strconv.ParseFloat(value, 64) + operation_setting.Price, _ = strconv.ParseFloat(value, 64) case "USDExchangeRate": - setting.USDExchangeRate, _ = strconv.ParseFloat(value, 64) + operation_setting.USDExchangeRate, _ = strconv.ParseFloat(value, 64) case "MinTopUp": - setting.MinTopUp, _ = strconv.Atoi(value) + operation_setting.MinTopUp, _ = strconv.Atoi(value) case "StripeApiSecret": setting.StripeApiSecret = value case "StripeWebhookSecret": @@ -413,7 +413,7 @@ func updateOptionMap(key string, value string) (err error) { case "StreamCacheQueueLength": setting.StreamCacheQueueLength, _ = strconv.Atoi(value) case "PayMethods": - err = setting.UpdatePayMethodsByJsonString(value) + err = operation_setting.UpdatePayMethodsByJsonString(value) } return err } diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index 0b6b26743..b6a78b7aa 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "one-api/common" "one-api/dto" "one-api/relay/channel" "one-api/relay/channel/claude" @@ -80,16 +81,64 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) { } } -func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { - adc := &Credentials{} - if err := json.Unmarshal([]byte(info.ApiKey), adc); err != nil { - return "", fmt.Errorf("failed to decode credentials file: %w", err) - } +func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix string) (string, error) { region := GetModelRegion(info.ApiVersion, info.OriginModelName) - a.AccountCredentials = *adc + if info.ChannelOtherSettings.VertexKeyType != dto.VertexKeyTypeAPIKey { + adc := &Credentials{} + if err := common.Unmarshal([]byte(info.ApiKey), adc); err != nil { + return "", fmt.Errorf("failed to decode credentials file: %w", err) + } + a.AccountCredentials = *adc + + if a.RequestMode == RequestModeLlama { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions", + region, + adc.ProjectID, + region, + ), nil + } + + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s", + adc.ProjectID, + modelName, + suffix, + ), nil + } else { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s", + region, + adc.ProjectID, + region, + modelName, + suffix, + ), nil + } + } else { + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s", + modelName, + suffix, + info.ApiKey, + ), nil + } else { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s", + region, + modelName, + suffix, + info.ApiKey, + ), nil + } + } +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { suffix := "" if a.RequestMode == RequestModeGemini { - if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { // 新增逻辑:处理 -thinking- 格式 if strings.Contains(info.UpstreamModelName, "-thinking-") { @@ -112,23 +161,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { suffix = "predict" } - if region == "global" { - return fmt.Sprintf( - "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s", - adc.ProjectID, - info.UpstreamModelName, - suffix, - ), nil - } else { - return fmt.Sprintf( - "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s", - region, - adc.ProjectID, - region, - info.UpstreamModelName, - suffix, - ), nil - } + return a.getRequestUrl(info, info.UpstreamModelName, suffix) } else if a.RequestMode == RequestModeClaude { if info.IsStream { suffix = "streamRawPredict?alt=sse" @@ -139,41 +172,22 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { if v, ok := claudeModelMap[info.UpstreamModelName]; ok { model = v } - if region == "global" { - return fmt.Sprintf( - "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:%s", - adc.ProjectID, - model, - suffix, - ), nil - } else { - return fmt.Sprintf( - "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s", - region, - adc.ProjectID, - region, - model, - suffix, - ), nil - } + return a.getRequestUrl(info, model, suffix) } else if a.RequestMode == RequestModeLlama { - return fmt.Sprintf( - "https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions", - region, - adc.ProjectID, - region, - ), nil + return a.getRequestUrl(info, "", "") } return "", errors.New("unsupported request mode") } func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { channel.SetupApiRequestHeader(info, c, req) - accessToken, err := getAccessToken(a, info) - if err != nil { - return err + if info.ChannelOtherSettings.VertexKeyType == "json" { + accessToken, err := getAccessToken(a, info) + if err != nil { + return err + } + req.Set("Authorization", "Bearer "+accessToken) } - req.Set("Authorization", "Bearer "+accessToken) return nil } diff --git a/router/api-router.go b/router/api-router.go index 773857385..e16d06628 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -60,6 +60,7 @@ func SetApiRouter(router *gin.Engine) { selfRoute.DELETE("/self", controller.DeleteSelf) selfRoute.GET("/token", controller.GenerateAccessToken) selfRoute.GET("/aff", controller.GetAffCode) + selfRoute.GET("/topup/info", controller.GetTopUpInfo) selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp) selfRoute.POST("/pay", middleware.CriticalRateLimit(), controller.RequestEpay) selfRoute.POST("/amount", controller.RequestAmount) diff --git a/service/epay.go b/service/epay.go index a8259d21d..a1ff484e6 100644 --- a/service/epay.go +++ b/service/epay.go @@ -2,11 +2,12 @@ package service import ( "one-api/setting" + "one-api/setting/operation_setting" ) func GetCallbackAddress() string { - if setting.CustomCallbackAddress == "" { + if operation_setting.CustomCallbackAddress == "" { return setting.ServerAddress } - return setting.CustomCallbackAddress + return operation_setting.CustomCallbackAddress } diff --git a/setting/operation_setting/payment_setting.go b/setting/operation_setting/payment_setting.go new file mode 100644 index 000000000..c8df039cf --- /dev/null +++ b/setting/operation_setting/payment_setting.go @@ -0,0 +1,23 @@ +package operation_setting + +import "one-api/setting/config" + +type PaymentSetting struct { + AmountOptions []int `json:"amount_options"` + AmountDiscount map[int]float64 `json:"amount_discount"` // 充值金额对应的折扣,例如 100 元 0.9 表示 100 元充值享受 9 折优惠 +} + +// 默认配置 +var paymentSetting = PaymentSetting{ + AmountOptions: []int{10, 20, 50, 100, 200, 500}, + AmountDiscount: map[int]float64{}, +} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("payment_setting", &paymentSetting) +} + +func GetPaymentSetting() *PaymentSetting { + return &paymentSetting +} diff --git a/setting/payment.go b/setting/operation_setting/payment_setting_old.go similarity index 57% rename from setting/payment.go rename to setting/operation_setting/payment_setting_old.go index 7fc5ad3fd..a6313179e 100644 --- a/setting/payment.go +++ b/setting/operation_setting/payment_setting_old.go @@ -1,6 +1,13 @@ -package setting +/** +此文件为旧版支付设置文件,如需增加新的参数、变量等,请在 payment_setting.go 中添加 +This file is the old version of the payment settings file. If you need to add new parameters, variables, etc., please add them in payment_setting.go +*/ -import "encoding/json" +package operation_setting + +import ( + "one-api/common" +) var PayAddress = "" var CustomCallbackAddress = "" @@ -21,15 +28,21 @@ var PayMethods = []map[string]string{ "color": "rgba(var(--semi-green-5), 1)", "type": "wxpay", }, + { + "name": "自定义1", + "color": "black", + "type": "custom1", + "min_topup": "50", + }, } func UpdatePayMethodsByJsonString(jsonString string) error { PayMethods = make([]map[string]string, 0) - return json.Unmarshal([]byte(jsonString), &PayMethods) + return common.Unmarshal([]byte(jsonString), &PayMethods) } func PayMethods2JsonString() string { - jsonBytes, err := json.Marshal(PayMethods) + jsonBytes, err := common.Marshal(PayMethods) if err != nil { return "[]" } diff --git a/web/src/components/settings/PaymentSetting.jsx b/web/src/components/settings/PaymentSetting.jsx index a632760aa..faaa9561b 100644 --- a/web/src/components/settings/PaymentSetting.jsx +++ b/web/src/components/settings/PaymentSetting.jsx @@ -37,6 +37,8 @@ const PaymentSetting = () => { TopupGroupRatio: '', CustomCallbackAddress: '', PayMethods: '', + AmountOptions: '', + AmountDiscount: '', StripeApiSecret: '', StripeWebhookSecret: '', @@ -66,6 +68,30 @@ const PaymentSetting = () => { newInputs[item.key] = item.value; } break; + case 'payment_setting.amount_options': + try { + newInputs['AmountOptions'] = JSON.stringify( + JSON.parse(item.value), + null, + 2, + ); + } catch (error) { + console.error('解析AmountOptions出错:', error); + newInputs['AmountOptions'] = item.value; + } + break; + case 'payment_setting.amount_discount': + try { + newInputs['AmountDiscount'] = JSON.stringify( + JSON.parse(item.value), + null, + 2, + ); + } catch (error) { + console.error('解析AmountDiscount出错:', error); + newInputs['AmountDiscount'] = item.value; + } + break; case 'Price': case 'MinTopUp': case 'StripeUnitPrice': diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 7a86fa114..c0a216246 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -142,6 +142,8 @@ const EditChannelModal = (props) => { system_prompt: '', system_prompt_override: false, settings: '', + // 仅 Vertex: 密钥格式(存入 settings.vertex_key_type) + vertex_key_type: 'json', }; const [batch, setBatch] = useState(false); const [multiToSingle, setMultiToSingle] = useState(false); @@ -409,11 +411,17 @@ const EditChannelModal = (props) => { const parsedSettings = JSON.parse(data.settings); data.azure_responses_version = parsedSettings.azure_responses_version || ''; + // 读取 Vertex 密钥格式 + data.vertex_key_type = parsedSettings.vertex_key_type || 'json'; } catch (error) { console.error('解析其他设置失败:', error); data.azure_responses_version = ''; data.region = ''; + data.vertex_key_type = 'json'; } + } else { + // 兼容历史数据:老渠道没有 settings 时,默认按 json 展示 + data.vertex_key_type = 'json'; } setInputs(data); @@ -745,59 +753,56 @@ const EditChannelModal = (props) => { let localInputs = { ...formValues }; if (localInputs.type === 41) { - if (useManualInput) { - // 手动输入模式 - if (localInputs.key && localInputs.key.trim() !== '') { - try { - // 验证 JSON 格式 - const parsedKey = JSON.parse(localInputs.key); - // 确保是有效的密钥格式 - localInputs.key = JSON.stringify(parsedKey); - } catch (err) { - showError(t('密钥格式无效,请输入有效的 JSON 格式密钥')); - return; - } - } else if (!isEdit) { + const keyType = localInputs.vertex_key_type || 'json'; + if (keyType === 'api_key') { + // 直接作为普通字符串密钥处理 + if (!isEdit && (!localInputs.key || localInputs.key.trim() === '')) { showInfo(t('请输入密钥!')); return; } } else { - // 文件上传模式 - let keys = vertexKeys; - - // 若当前未选择文件,尝试从已上传文件列表解析(异步读取) - if (keys.length === 0 && vertexFileList.length > 0) { - try { - const parsed = await Promise.all( - vertexFileList.map(async (item) => { - const fileObj = item.fileInstance; - if (!fileObj) return null; - const txt = await fileObj.text(); - return JSON.parse(txt); - }), - ); - keys = parsed.filter(Boolean); - } catch (err) { - showError(t('解析密钥文件失败: {{msg}}', { msg: err.message })); + // JSON 服务账号密钥 + if (useManualInput) { + if (localInputs.key && localInputs.key.trim() !== '') { + try { + const parsedKey = JSON.parse(localInputs.key); + localInputs.key = JSON.stringify(parsedKey); + } catch (err) { + showError(t('密钥格式无效,请输入有效的 JSON 格式密钥')); + return; + } + } else if (!isEdit) { + showInfo(t('请输入密钥!')); return; } - } - - // 创建模式必须上传密钥;编辑模式可选 - if (keys.length === 0) { - if (!isEdit) { - showInfo(t('请上传密钥文件!')); - return; - } else { - // 编辑模式且未上传新密钥,不修改 key - delete localInputs.key; - } } else { - // 有新密钥,则覆盖 - if (batch) { - localInputs.key = JSON.stringify(keys); + // 文件上传模式 + let keys = vertexKeys; + if (keys.length === 0 && vertexFileList.length > 0) { + try { + const parsed = await Promise.all( + vertexFileList.map(async (item) => { + const fileObj = item.fileInstance; + if (!fileObj) return null; + const txt = await fileObj.text(); + return JSON.parse(txt); + }), + ); + keys = parsed.filter(Boolean); + } catch (err) { + showError(t('解析密钥文件失败: {{msg}}', { msg: err.message })); + return; + } + } + if (keys.length === 0) { + if (!isEdit) { + showInfo(t('请上传密钥文件!')); + return; + } else { + delete localInputs.key; + } } else { - localInputs.key = JSON.stringify(keys[0]); + localInputs.key = batch ? JSON.stringify(keys) : JSON.stringify(keys[0]); } } } @@ -853,6 +858,8 @@ const EditChannelModal = (props) => { delete localInputs.pass_through_body_enabled; delete localInputs.system_prompt; delete localInputs.system_prompt_override; + // 顶层的 vertex_key_type 不应发送给后端 + delete localInputs.vertex_key_type; let res; localInputs.auto_ban = localInputs.auto_ban ? 1 : 0; @@ -1178,8 +1185,40 @@ const EditChannelModal = (props) => { autoComplete='new-password' /> + {inputs.type === 41 && ( + { + // 更新设置中的 vertex_key_type + handleChannelOtherSettingsChange('vertex_key_type', value); + // 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件 + if (value === 'api_key') { + setBatch(false); + setUseManualInput(false); + setVertexKeys([]); + setVertexFileList([]); + if (formApiRef.current) { + formApiRef.current.setValue('vertex_files', []); + } + } + }} + extraText={ + inputs.vertex_key_type === 'api_key' + ? t('API Key 模式下不支持批量创建') + : t('JSON 模式支持手动输入或上传服务账号 JSON') + } + /> + )} {batch ? ( - inputs.type === 41 ? ( + inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? ( { ) ) : ( <> - {inputs.type === 41 ? ( + {inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? ( <> {!batch && (
diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx index 7fb06b0ca..f23381f40 100644 --- a/web/src/components/topup/RechargeCard.jsx +++ b/web/src/components/topup/RechargeCard.jsx @@ -21,6 +21,7 @@ import React, { useRef } from 'react'; import { Avatar, Typography, + Tag, Card, Button, Banner, @@ -29,7 +30,7 @@ import { Space, Row, Col, - Spin, + Spin, Tooltip } from '@douyinfe/semi-ui'; import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si'; import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react'; @@ -68,6 +69,7 @@ const RechargeCard = ({ userState, renderQuota, statusLoading, + topupInfo, }) => { const onlineFormApiRef = useRef(null); const redeemFormApiRef = useRef(null); @@ -261,44 +263,58 @@ const RechargeCard = ({ - - {payMethods.map((payMethod) => ( - - ))} - + {payMethods && payMethods.length > 0 ? ( + + {payMethods.map((payMethod) => { + const minTopupVal = Number(payMethod.min_topup) || 0; + const isStripe = payMethod.type === 'stripe'; + const disabled = + (!enableOnlineTopUp && !isStripe) || + (!enableStripeTopUp && isStripe) || + minTopupVal > Number(topUpCount || 0); + + const buttonEl = ( + + ); + + return disabled && minTopupVal > Number(topUpCount || 0) ? ( + + {buttonEl} + + ) : ( + {buttonEl} + ); + })} + + ) : ( +
+ {t('暂无可用的支付方式,请联系管理员配置')} +
+ )}
@@ -306,41 +322,59 @@ const RechargeCard = ({ {(enableOnlineTopUp || enableStripeTopUp) && ( - - {presetAmounts.map((preset, index) => ( - - ))} - +
+ {presetAmounts.map((preset, index) => { + const discount = preset.discount || topupInfo?.discount?.[preset.value] || 1.0; + const originalPrice = preset.value * priceRatio; + const discountedPrice = originalPrice * discount; + const hasDiscount = discount < 1.0; + const actualPay = discountedPrice; + const save = originalPrice - discountedPrice; + + return ( + { + selectPresetAmount(preset); + onlineFormApiRef.current?.setValue( + 'topUpCount', + preset.value, + ); + }} + > +
+ + {formatLargeNumber(preset.value)} {t('美元额度')} + {hasDiscount && ( + + {t('折').includes('off') ? + ((1 - discount) * 100).toFixed(1) : + (discount * 10).toFixed(1)}{t('折')} + + )} + +
+ {t('实付')} {actualPay.toFixed(2)}, + {hasDiscount ? `${t('节省')} ${save.toFixed(2)}` : `${t('节省')} 0.00`} +
+
+
+ ); + })} +
)}
diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index a09244488..929a47e39 100644 --- a/web/src/components/topup/index.jsx +++ b/web/src/components/topup/index.jsx @@ -80,6 +80,12 @@ const TopUp = () => { // 预设充值额度选项 const [presetAmounts, setPresetAmounts] = useState([]); const [selectedPreset, setSelectedPreset] = useState(null); + + // 充值配置信息 + const [topupInfo, setTopupInfo] = useState({ + amount_options: [], + discount: {} + }); const topUp = async () => { if (redemptionCode === '') { @@ -248,6 +254,99 @@ const TopUp = () => { } }; + // 获取充值配置信息 + const getTopupInfo = async () => { + try { + const res = await API.get('/api/user/topup/info'); + const { message, data, success } = res.data; + if (success) { + setTopupInfo({ + amount_options: data.amount_options || [], + discount: data.discount || {} + }); + + // 处理支付方式 + let payMethods = data.pay_methods || []; + try { + if (typeof payMethods === 'string') { + payMethods = JSON.parse(payMethods); + } + if (payMethods && payMethods.length > 0) { + // 检查name和type是否为空 + payMethods = payMethods.filter((method) => { + return method.name && method.type; + }); + // 如果没有color,则设置默认颜色 + payMethods = payMethods.map((method) => { + // 规范化最小充值数 + const normalizedMinTopup = Number(method.min_topup); + method.min_topup = Number.isFinite(normalizedMinTopup) ? normalizedMinTopup : 0; + + // Stripe 的最小充值从后端字段回填 + if (method.type === 'stripe' && (!method.min_topup || method.min_topup <= 0)) { + const stripeMin = Number(data.stripe_min_topup); + if (Number.isFinite(stripeMin)) { + method.min_topup = stripeMin; + } + } + + if (!method.color) { + if (method.type === 'alipay') { + method.color = 'rgba(var(--semi-blue-5), 1)'; + } else if (method.type === 'wxpay') { + method.color = 'rgba(var(--semi-green-5), 1)'; + } else if (method.type === 'stripe') { + method.color = 'rgba(var(--semi-purple-5), 1)'; + } else { + method.color = 'rgba(var(--semi-primary-5), 1)'; + } + } + return method; + }); + } else { + payMethods = []; + } + + // 如果启用了 Stripe 支付,添加到支付方法列表 + // 这个逻辑现在由后端处理,如果 Stripe 启用,后端会在 pay_methods 中包含它 + + setPayMethods(payMethods); + const enableStripeTopUp = data.enable_stripe_topup || false; + const enableOnlineTopUp = data.enable_online_topup || false; + const minTopUpValue = enableOnlineTopUp? data.min_topup : enableStripeTopUp? data.stripe_min_topup : 1; + setEnableOnlineTopUp(enableOnlineTopUp); + setEnableStripeTopUp(enableStripeTopUp); + setMinTopUp(minTopUpValue); + setTopUpCount(minTopUpValue); + + // 如果没有自定义充值数量选项,根据最小充值金额生成预设充值额度选项 + if (topupInfo.amount_options.length === 0) { + setPresetAmounts(generatePresetAmounts(minTopUpValue)); + } + + // 初始化显示实付金额 + getAmount(minTopUpValue); + } catch (e) { + console.log('解析支付方式失败:', e); + setPayMethods([]); + } + + // 如果有自定义充值数量选项,使用它们替换默认的预设选项 + if (data.amount_options && data.amount_options.length > 0) { + const customPresets = data.amount_options.map(amount => ({ + value: amount, + discount: data.discount[amount] || 1.0 + })); + setPresetAmounts(customPresets); + } + } else { + console.error('获取充值配置失败:', data); + } + } catch (error) { + console.error('获取充值配置异常:', error); + } + }; + // 获取邀请链接 const getAffLink = async () => { const res = await API.get('/api/user/aff'); @@ -290,52 +389,7 @@ const TopUp = () => { getUserQuota().then(); } setTransferAmount(getQuotaPerUnit()); - - let payMethods = localStorage.getItem('pay_methods'); - try { - payMethods = JSON.parse(payMethods); - if (payMethods && payMethods.length > 0) { - // 检查name和type是否为空 - payMethods = payMethods.filter((method) => { - return method.name && method.type; - }); - // 如果没有color,则设置默认颜色 - payMethods = payMethods.map((method) => { - if (!method.color) { - if (method.type === 'alipay') { - method.color = 'rgba(var(--semi-blue-5), 1)'; - } else if (method.type === 'wxpay') { - method.color = 'rgba(var(--semi-green-5), 1)'; - } else if (method.type === 'stripe') { - method.color = 'rgba(var(--semi-purple-5), 1)'; - } else { - method.color = 'rgba(var(--semi-primary-5), 1)'; - } - } - return method; - }); - } else { - payMethods = []; - } - - // 如果启用了 Stripe 支付,添加到支付方法列表 - if (statusState?.status?.enable_stripe_topup) { - const hasStripe = payMethods.some((method) => method.type === 'stripe'); - if (!hasStripe) { - payMethods.push({ - name: 'Stripe', - type: 'stripe', - color: 'rgba(var(--semi-purple-5), 1)', - }); - } - } - - setPayMethods(payMethods); - } catch (e) { - console.log(e); - showError(t('支付方式配置错误, 请联系管理员')); - } - }, [statusState?.status?.enable_stripe_topup]); + }, []); useEffect(() => { if (affFetchedRef.current) return; @@ -343,20 +397,18 @@ const TopUp = () => { getAffLink().then(); }, []); + // 在 statusState 可用时获取充值信息 + useEffect(() => { + getTopupInfo().then(); + }, []); + useEffect(() => { if (statusState?.status) { - const minTopUpValue = statusState.status.min_topup || 1; - setMinTopUp(minTopUpValue); - setTopUpCount(minTopUpValue); + // const minTopUpValue = statusState.status.min_topup || 1; + // setMinTopUp(minTopUpValue); + // setTopUpCount(minTopUpValue); setTopUpLink(statusState.status.top_up_link || ''); - setEnableOnlineTopUp(statusState.status.enable_online_topup || false); setPriceRatio(statusState.status.price || 1); - setEnableStripeTopUp(statusState.status.enable_stripe_topup || false); - - // 根据最小充值金额生成预设充值额度选项 - setPresetAmounts(generatePresetAmounts(minTopUpValue)); - // 初始化显示实付金额 - getAmount(minTopUpValue); setStatusLoading(false); } @@ -431,7 +483,11 @@ const TopUp = () => { const selectPresetAmount = (preset) => { setTopUpCount(preset.value); setSelectedPreset(preset.value); - setAmount(preset.value * priceRatio); + + // 计算实际支付金额,考虑折扣 + const discount = preset.discount || topupInfo.discount[preset.value] || 1.0; + const discountedAmount = preset.value * priceRatio * discount; + setAmount(discountedAmount); }; // 格式化大数字显示 @@ -475,6 +531,8 @@ const TopUp = () => { renderAmount={renderAmount} payWay={payWay} payMethods={payMethods} + amountNumber={amount} + discountRate={topupInfo?.discount?.[topUpCount] || 1.0} /> {/* 用户信息头部 */} @@ -512,6 +570,7 @@ const TopUp = () => { userState={userState} renderQuota={renderQuota} statusLoading={statusLoading} + topupInfo={topupInfo} /> diff --git a/web/src/components/topup/modals/PaymentConfirmModal.jsx b/web/src/components/topup/modals/PaymentConfirmModal.jsx index 76ea5eb22..1bffbfed1 100644 --- a/web/src/components/topup/modals/PaymentConfirmModal.jsx +++ b/web/src/components/topup/modals/PaymentConfirmModal.jsx @@ -36,7 +36,13 @@ const PaymentConfirmModal = ({ renderAmount, payWay, payMethods, + // 新增:用于显示折扣明细 + amountNumber, + discountRate, }) => { + const hasDiscount = discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0; + const originalAmount = hasDiscount ? (amountNumber / discountRate) : 0; + const discountAmount = hasDiscount ? (originalAmount - amountNumber) : 0; return ( ) : ( - - {renderAmount()} - +
+ + {renderAmount()} + + {hasDiscount && ( + + {Math.round(discountRate * 100)}% + + )} +
)} + {hasDiscount && !amountLoading && ( + <> +
+ + {t('原价')}: + + + {`${originalAmount.toFixed(2)} ${t('元')}`} + +
+
+ + {t('优惠')}: + + + {`- ${discountAmount.toFixed(2)} ${t('元')}`} + +
+ + )}
{t('支付方式')}: diff --git a/web/src/helpers/data.js b/web/src/helpers/data.js index 62353327c..b894a953c 100644 --- a/web/src/helpers/data.js +++ b/web/src/helpers/data.js @@ -28,7 +28,6 @@ export function setStatusData(data) { localStorage.setItem('enable_task', data.enable_task); localStorage.setItem('enable_data_export', data.enable_data_export); localStorage.setItem('chats', JSON.stringify(data.chats)); - localStorage.setItem('pay_methods', JSON.stringify(data.pay_methods)); localStorage.setItem( 'data_export_default_time', data.data_export_default_time, diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx index ce8958dca..d681b6a27 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx @@ -41,6 +41,8 @@ export default function SettingsPaymentGateway(props) { TopupGroupRatio: '', CustomCallbackAddress: '', PayMethods: '', + AmountOptions: '', + AmountDiscount: '', }); const [originInputs, setOriginInputs] = useState({}); const formApiRef = useRef(null); @@ -62,7 +64,30 @@ export default function SettingsPaymentGateway(props) { TopupGroupRatio: props.options.TopupGroupRatio || '', CustomCallbackAddress: props.options.CustomCallbackAddress || '', PayMethods: props.options.PayMethods || '', + AmountOptions: props.options.AmountOptions || '', + AmountDiscount: props.options.AmountDiscount || '', }; + + // 美化 JSON 展示 + try { + if (currentInputs.AmountOptions) { + currentInputs.AmountOptions = JSON.stringify( + JSON.parse(currentInputs.AmountOptions), + null, + 2, + ); + } + } catch {} + try { + if (currentInputs.AmountDiscount) { + currentInputs.AmountDiscount = JSON.stringify( + JSON.parse(currentInputs.AmountDiscount), + null, + 2, + ); + } + } catch {} + setInputs(currentInputs); setOriginInputs({ ...currentInputs }); formApiRef.current.setValues(currentInputs); @@ -93,6 +118,20 @@ export default function SettingsPaymentGateway(props) { } } + if (originInputs['AmountOptions'] !== inputs.AmountOptions && inputs.AmountOptions.trim() !== '') { + if (!verifyJSON(inputs.AmountOptions)) { + showError(t('自定义充值数量选项不是合法的 JSON 数组')); + return; + } + } + + if (originInputs['AmountDiscount'] !== inputs.AmountDiscount && inputs.AmountDiscount.trim() !== '') { + if (!verifyJSON(inputs.AmountDiscount)) { + showError(t('充值金额折扣配置不是合法的 JSON 对象')); + return; + } + } + setLoading(true); try { const options = [ @@ -123,6 +162,12 @@ export default function SettingsPaymentGateway(props) { if (originInputs['PayMethods'] !== inputs.PayMethods) { options.push({ key: 'PayMethods', value: inputs.PayMethods }); } + if (originInputs['AmountOptions'] !== inputs.AmountOptions) { + options.push({ key: 'payment_setting.amount_options', value: inputs.AmountOptions }); + } + if (originInputs['AmountDiscount'] !== inputs.AmountDiscount) { + options.push({ key: 'payment_setting.amount_discount', value: inputs.AmountDiscount }); + } // 发送请求 const requestQueue = options.map((opt) => @@ -228,6 +273,37 @@ export default function SettingsPaymentGateway(props) { placeholder={t('为一个 JSON 文本')} autosize /> + + + + + + + + + + + + +