Browse Source

feat(payment): add payment settings configuration and update payment methods handling

CaIon 3 months ago
parent
commit
d8410d2f11

+ 2 - 2
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
 }

+ 4 - 3
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)

+ 4 - 8
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,

+ 55 - 8
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
 	}

+ 10 - 1
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
 }
 

+ 9 - 1
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"
 }

+ 2 - 1
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:"-"`
 }

+ 12 - 12
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
 }

+ 65 - 51
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-<budget> 格式
 			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
 }
 

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

+ 3 - 2
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
 }

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

+ 17 - 4
setting/payment.go → 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 "[]"
 	}

+ 26 - 0
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':

+ 86 - 47
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 }));
-            return;
-          }
-        }
-
-        // 创建模式必须上传密钥;编辑模式可选
-        if (keys.length === 0) {
-          if (!isEdit) {
-            showInfo(t('请上传密钥文件!'));
+        // 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;
-          } 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 && (
+                    <Form.Select
+                      field='vertex_key_type'
+                      label={t('密钥格式')}
+                      placeholder={t('请选择密钥格式')}
+                      optionList={[
+                        { label: 'JSON', value: 'json' },
+                        { label: 'API Key', value: 'api_key' },
+                      ]}
+                      style={{ width: '100%' }}
+                      value={inputs.vertex_key_type || 'json'}
+                      onChange={(value) => {
+                        // 更新设置中的 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' ? (
                       <Form.Upload
                         field='vertex_files'
                         label={t('密钥文件 (.json)')}
@@ -1243,7 +1282,7 @@ const EditChannelModal = (props) => {
                     )
                   ) : (
                     <>
-                      {inputs.type === 41 ? (
+                      {inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
                         <>
                           {!batch && (
                             <div className='flex items-center justify-between mb-3'>

+ 108 - 74
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 = ({
                     </Col>
                     <Col xs={24} sm={24} md={24} lg={14} xl={14}>
                       <Form.Slot label={t('选择支付方式')}>
-                        <Space wrap>
-                          {payMethods.map((payMethod) => (
-                            <Button
-                              key={payMethod.type}
-                              theme='outline'
-                              type='tertiary'
-                              onClick={() => preTopUp(payMethod.type)}
-                              disabled={
-                                (!enableOnlineTopUp &&
-                                  payMethod.type !== 'stripe') ||
-                                (!enableStripeTopUp &&
-                                  payMethod.type === 'stripe')
-                              }
-                              loading={
-                                paymentLoading && payWay === payMethod.type
-                              }
-                              icon={
-                                payMethod.type === 'alipay' ? (
-                                  <SiAlipay size={18} color='#1677FF' />
-                                ) : payMethod.type === 'wxpay' ? (
-                                  <SiWechat size={18} color='#07C160' />
-                                ) : payMethod.type === 'stripe' ? (
-                                  <SiStripe size={18} color='#635BFF' />
-                                ) : (
-                                  <CreditCard
-                                    size={18}
-                                    color={
-                                      payMethod.color ||
-                                      'var(--semi-color-text-2)'
-                                    }
-                                  />
-                                )
-                              }
-                            >
-                              {payMethod.name}
-                            </Button>
-                          ))}
-                        </Space>
+                        {payMethods && payMethods.length > 0 ? (
+                          <Space wrap>
+                            {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 = (
+                                <Button
+                                  key={payMethod.type}
+                                  theme='outline'
+                                  type='tertiary'
+                                  onClick={() => preTopUp(payMethod.type)}
+                                  disabled={disabled}
+                                  loading={paymentLoading && payWay === payMethod.type}
+                                  icon={
+                                    payMethod.type === 'alipay' ? (
+                                      <SiAlipay size={18} color='#1677FF' />
+                                    ) : payMethod.type === 'wxpay' ? (
+                                      <SiWechat size={18} color='#07C160' />
+                                    ) : payMethod.type === 'stripe' ? (
+                                      <SiStripe size={18} color='#635BFF' />
+                                    ) : (
+                                      <CreditCard
+                                        size={18}
+                                        color={payMethod.color || 'var(--semi-color-text-2)'}
+                                      />
+                                    )
+                                  }
+                                  className='!rounded-lg !px-4 !py-2'
+                                >
+                                  {payMethod.name}
+                                </Button>
+                              );
+
+                              return disabled && minTopupVal > Number(topUpCount || 0) ? (
+                                <Tooltip content={t('此支付方式最低充值金额为') + ' ' + minTopupVal} key={payMethod.type}>
+                                  {buttonEl}
+                                </Tooltip>
+                              ) : (
+                                <React.Fragment key={payMethod.type}>{buttonEl}</React.Fragment>
+                              );
+                            })}
+                          </Space>
+                        ) : (
+                          <div className='text-gray-500 text-sm p-3 bg-gray-50 rounded-lg border border-dashed border-gray-300'>
+                            {t('暂无可用的支付方式,请联系管理员配置')}
+                          </div>
+                        )}
                       </Form.Slot>
                     </Col>
                   </Row>
@@ -306,41 +322,59 @@ const RechargeCard = ({
 
                 {(enableOnlineTopUp || enableStripeTopUp) && (
                   <Form.Slot label={t('选择充值额度')}>
-                    <Space wrap>
-                      {presetAmounts.map((preset, index) => (
-                        <Button
-                          key={index}
-                          theme={
-                            selectedPreset === preset.value
-                              ? 'solid'
-                              : 'outline'
-                          }
-                          type={
-                            selectedPreset === preset.value
-                              ? 'primary'
-                              : 'tertiary'
-                          }
-                          onClick={() => {
-                            selectPresetAmount(preset);
-                            onlineFormApiRef.current?.setValue(
-                              'topUpCount',
-                              preset.value,
-                            );
-                          }}
-                          className='!rounded-lg !py-2 !px-3'
-                        >
-                          <div className='flex items-center gap-2'>
-                            <Coins size={14} className='opacity-80' />
-                            <span className='font-medium'>
-                              {formatLargeNumber(preset.value)}
-                            </span>
-                            <span className='text-xs text-gray-500'>
-                              ¥{(preset.value * priceRatio).toFixed(2)}
-                            </span>
-                          </div>
-                        </Button>
-                      ))}
-                    </Space>
+                    <div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
+                      {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 (
+                          <Card
+                            key={index}
+                            style={{
+                              cursor: 'pointer',
+                              border: selectedPreset === preset.value 
+                                ? '2px solid var(--semi-color-primary)' 
+                                : '1px solid var(--semi-color-border)',
+                              height: '100%',
+                              width: '100%'
+                            }}
+                            bodyStyle={{ padding: '12px' }}
+                            onClick={() => {
+                              selectPresetAmount(preset);
+                              onlineFormApiRef.current?.setValue(
+                                'topUpCount',
+                                preset.value,
+                              );
+                            }}
+                          >
+                            <div style={{ textAlign: 'center' }}>
+                              <Typography.Title heading={6} style={{ margin: '0 0 8px 0' }}>
+                                {formatLargeNumber(preset.value)} {t('美元额度')}
+                                {hasDiscount && (
+                                   <Tag style={{ marginLeft: 4 }} color="green">
+                                   {t('折').includes('off') ?
+                                     ((1 - discount) * 100).toFixed(1) :
+                                     (discount * 10).toFixed(1)}{t('折')}
+                                 </Tag>
+                                )}
+                              </Typography.Title>
+                              <div style={{ 
+                                color: 'var(--semi-color-text-2)', 
+                                fontSize: '12px', 
+                                margin: '4px 0' 
+                              }}>
+                                {t('实付')} {actualPay.toFixed(2)},
+                                {hasDiscount ? `${t('节省')} ${save.toFixed(2)}` : `${t('节省')} 0.00`}
+                              </div>
+                            </div>
+                          </Card>
+                        );
+                      })}
+                    </div>
                   </Form.Slot>
                 )}
               </div>

+ 116 - 57
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}
             />
           </div>
 

+ 36 - 3
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 (
     <Modal
       title={
@@ -71,11 +77,38 @@ const PaymentConfirmModal = ({
               {amountLoading ? (
                 <Skeleton.Title style={{ width: '60px', height: '16px' }} />
               ) : (
-                <Text strong className='font-bold' style={{ color: 'red' }}>
-                  {renderAmount()}
-                </Text>
+                <div className='flex items-baseline space-x-2'>
+                  <Text strong className='font-bold' style={{ color: 'red' }}>
+                    {renderAmount()}
+                  </Text>
+                  {hasDiscount && (
+                    <Text size='small' className='text-rose-500'>
+                      {Math.round(discountRate * 100)}%
+                    </Text>
+                  )}
+                </div>
               )}
             </div>
+            {hasDiscount && !amountLoading && (
+              <>
+                <div className='flex justify-between items-center'>
+                  <Text className='text-slate-500 dark:text-slate-400'>
+                    {t('原价')}:
+                  </Text>
+                  <Text delete className='text-slate-500 dark:text-slate-400'>
+                    {`${originalAmount.toFixed(2)} ${t('元')}`}
+                  </Text>
+                </div>
+                <div className='flex justify-between items-center'>
+                  <Text className='text-slate-500 dark:text-slate-400'>
+                    {t('优惠')}:
+                  </Text>
+                  <Text className='text-emerald-600 dark:text-emerald-400'>
+                    {`- ${discountAmount.toFixed(2)} ${t('元')}`}
+                  </Text>
+                </div>
+              </>
+            )}
             <div className='flex justify-between items-center'>
               <Text strong className='text-slate-700 dark:text-slate-200'>
                 {t('支付方式')}:

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

+ 76 - 0
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
           />
+          
+          <Row
+            gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
+            style={{ marginTop: 16 }}
+          >
+            <Col span={24}>
+              <Form.TextArea
+                field='AmountOptions'
+                label={t('自定义充值数量选项')}
+                placeholder={t('为一个 JSON 数组,例如:[10, 20, 50, 100, 200, 500]')}
+                autosize
+                extraText={t('设置用户可选择的充值数量选项,例如:[10, 20, 50, 100, 200, 500]')}
+              />
+            </Col>
+          </Row>
+          
+          <Row
+            gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
+            style={{ marginTop: 16 }}
+          >
+            <Col span={24}>
+              <Form.TextArea
+                field='AmountDiscount'
+                label={t('充值金额折扣配置')}
+                placeholder={t('为一个 JSON 对象,例如:{"100": 0.95, "200": 0.9, "500": 0.85}')}
+                autosize
+                extraText={t('设置不同充值金额对应的折扣,键为充值金额,值为折扣率,例如:{"100": 0.95, "200": 0.9, "500": 0.85}')}
+              />
+            </Col>
+          </Row>
+          
           <Button onClick={submitPayAddress}>{t('更新支付设置')}</Button>
         </Form.Section>
       </Form>