Explorar o código

Merge branch 'main' into fix-display-home-content

Calcium-Ion %!s(int64=2) %!d(string=hai) anos
pai
achega
415d296171

+ 26 - 19
README.md

@@ -14,30 +14,37 @@
 > 最新版Docker镜像 calciumion/new-api:latest  
 > 更新指令 docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR
 
-## 此分叉版本的主要变更
+## 主要变更
+此分叉版本的主要变更如下:
+
 1. 全新的UI界面(部分界面还待更新)
-2. 添加[Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy)接口的支持:
-    + [x] /mj/submit/imagine
-    + [x] /mj/submit/change
-    + [x] /mj/submit/blend
-    + [x] /mj/submit/describe
-    + [x] /mj/image/{id} (通过此接口获取图片,**请必须在系统设置中填写服务器地址!!**)
-    + [x] /mj/task/{id}/fetch (此接口返回的图片地址为经过One API转发的地址)
-    + [x] /task/list-by-condition
+2. 添加[Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy)接口的支持
+   + [x] /mj/submit/imagine
+   + [x] /mj/submit/change
+   + [x] /mj/submit/blend
+   + [x] /mj/submit/describe
+   + [x] /mj/image/{id} (通过此接口获取图片,**请必须在系统设置中填写服务器地址!!**)
+   + [x] /mj/task/{id}/fetch (此接口返回的图片地址为经过One API转发的地址)
+   + [x] /task/list-by-condition
 3. 支持在线充值功能,可在系统设置中设置,当前支持的支付接口:
-    + [x] 易支付
+   + [x] 易支付
 4. 支持用key查询使用额度:
-    + 配合项目[neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)可实现用key查询使用情况,方便二次分销
+   + 配合项目[neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)可实现用key查询使用
 5. 渠道显示已使用额度,支持指定组织访问
 6. 分页支持选择每页显示数量
-7. 支持 gpt-4-1106-vision-preview,dall-e-3,tts-1
-8. 支持第三方模型 **gps** (gpt-4-gizmo-*),在渠道中添加自定义模型gpt-4-gizmo-*即可
-9. 兼容原版One API的数据库,可直接使用原版数据库(one-api.db)
-10. 支持模型按次数收费,可在 系统设置-运营设置 中设置
-11. 支持gemini-pro,gemini-pro-vision模型
-12. 支持渠道**加权随机**
-13. 数据看板
-14. 可设置令牌能调用的模型
+7. 兼容原版One API的数据库,可直接使用原版数据库(one-api.db)
+8. 支持模型按次数收费,可在 系统设置-运营设置 中设置
+9. 支持渠道**加权随机**
+10. 数据看板
+11. 可设置令牌能调用的模型
+12. 支持Telegram授权登录
+
+## 模型支持
+此版本额外支持以下模型:
+1. 第三方模型 **gps** (gpt-4-gizmo-*)
+2. 智谱glm-4v,glm-4v识图
+
+您可以在渠道中添加自定义模型gpt-4-gizmo-*,此模型并非OpenAI官方模型,而是第三方模型,使用官方key无法调用。
 
 ## 部署
 ### 基于 Docker 进行部署

+ 13 - 4
common/constants.go

@@ -9,14 +9,19 @@ import (
 	"github.com/google/uuid"
 )
 
-var StartTime = time.Now().Unix() // unit: second
-var Version = "v0.0.0"            // this hard coding will be replaced automatically when building, no need to manually change
-var SystemName = "New API"
-var ServerAddress = "http://localhost:3000"
+// Pay Settings
+
 var PayAddress = ""
+var CustomCallbackAddress = ""
 var EpayId = ""
 var EpayKey = ""
 var Price = 7.3
+var MinTopUp = 1
+
+var StartTime = time.Now().Unix() // unit: second
+var Version = "v0.0.0"            // this hard coding will be replaced automatically when building, no need to manually change
+var SystemName = "New API"
+var ServerAddress = "http://localhost:3000"
 var Footer = ""
 var Logo = ""
 var TopUpLink = ""
@@ -46,6 +51,7 @@ var PasswordRegisterEnabled = true
 var EmailVerificationEnabled = false
 var GitHubOAuthEnabled = false
 var WeChatAuthEnabled = false
+var TelegramOAuthEnabled = false
 var TurnstileCheckEnabled = false
 var RegisterEnabled = true
 
@@ -83,6 +89,9 @@ var WeChatAccountQRCodeImageURL = ""
 var TurnstileSiteKey = ""
 var TurnstileSecretKey = ""
 
+var TelegramBotToken = ""
+var TelegramBotName = ""
+
 var QuotaForNewUser = 0
 var QuotaForInviter = 0
 var QuotaForInvitee = 0

+ 4 - 1
common/model-ratio.go

@@ -80,7 +80,10 @@ var ModelRatio = map[string]float64{
 	"qwen-turbo":                0.8572, // ¥0.012 / 1k tokens
 	"qwen-plus":                 10,     // ¥0.14 / 1k tokens
 	"text-embedding-v1":         0.05,   // ¥0.0007 / 1k tokens
-	"SparkDesk":                 1.2858, // ¥0.018 / 1k tokens
+	"SparkDesk-v1.1":            1.2858, // ¥0.018 / 1k tokens
+	"SparkDesk-v2.1":            1.2858, // ¥0.018 / 1k tokens
+	"SparkDesk-v3.1":            1.2858, // ¥0.018 / 1k tokens
+	"SparkDesk-v3.5":            1.2858, // ¥0.018 / 1k tokens
 	"360GPT_S2_V9":              0.8572, // ¥0.012 / 1k tokens
 	"embedding-bert-512-v1":     0.0715, // ¥0.001 / 1k tokens
 	"embedding_s1_v1":           0.0715, // ¥0.001 / 1k tokens

+ 19 - 0
controller/misc.go

@@ -11,6 +11,22 @@ import (
 	"github.com/gin-gonic/gin"
 )
 
+func TestStatus(c *gin.Context) {
+	err := model.PingDB()
+	if err != nil {
+		c.JSON(http.StatusServiceUnavailable, gin.H{
+			"success": false,
+			"message": "数据库连接失败",
+		})
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "Server is running",
+	})
+	return
+}
+
 func GetStatus(c *gin.Context) {
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
@@ -20,6 +36,8 @@ func GetStatus(c *gin.Context) {
 			"email_verification":       common.EmailVerificationEnabled,
 			"github_oauth":             common.GitHubOAuthEnabled,
 			"github_client_id":         common.GitHubClientId,
+			"telegram_oauth":           common.TelegramOAuthEnabled,
+			"telegram_bot_name":        common.TelegramBotName,
 			"system_name":              common.SystemName,
 			"logo":                     common.Logo,
 			"footer_html":              common.Footer,
@@ -27,6 +45,7 @@ func GetStatus(c *gin.Context) {
 			"wechat_login":             common.WeChatAuthEnabled,
 			"server_address":           common.ServerAddress,
 			"price":                    common.Price,
+			"min_topup":                common.MinTopUp,
 			"turnstile_check":          common.TurnstileCheckEnabled,
 			"turnstile_site_key":       common.TurnstileSiteKey,
 			"top_up_link":              common.TopUpLink,

+ 7 - 0
controller/model.go

@@ -129,6 +129,13 @@ func ListModels(c *gin.Context) {
 	})
 }
 
+func ChannelListModels(c *gin.Context) {
+	c.JSON(200, gin.H{
+		"object": "list",
+		"data":   openAIModels,
+	})
+}
+
 func RetrieveModel(c *gin.Context) {
 	modelId := c.Param("model")
 	if model, ok := openAIModelsMap[modelId]; ok {

+ 116 - 0
controller/telegram.go

@@ -0,0 +1,116 @@
+package controller
+
+import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"io"
+	"one-api/common"
+	"one-api/model"
+	"sort"
+
+	"github.com/gin-contrib/sessions"
+	"github.com/gin-gonic/gin"
+)
+
+func TelegramBind(c *gin.Context) {
+	if !common.TelegramOAuthEnabled {
+		c.JSON(200, gin.H{
+			"message": "管理员未开启通过 Telegram 登录以及注册",
+			"success": false,
+		})
+		return
+	}
+	params := c.Request.URL.Query()
+	if !checkTelegramAuthorization(params, common.TelegramBotToken) {
+		c.JSON(200, gin.H{
+			"message": "无效的请求",
+			"success": false,
+		})
+		return
+	}
+	telegramId := params["id"][0]
+	if model.IsTelegramIdAlreadyTaken(telegramId) {
+		c.JSON(200, gin.H{
+			"message": "该 Telegram 账户已被绑定",
+			"success": false,
+		})
+		return
+	}
+
+	session := sessions.Default(c)
+	id := session.Get("id")
+	user := model.User{Id: id.(int)}
+	if err := user.FillUserById(); err != nil {
+		c.JSON(200, gin.H{
+			"message": err.Error(),
+			"success": false,
+		})
+		return
+	}
+	user.TelegramId = telegramId
+	if err := user.Update(false); err != nil {
+		c.JSON(200, gin.H{
+			"message": err.Error(),
+			"success": false,
+		})
+		return
+	}
+
+	c.Redirect(302, "/setting")
+}
+
+func TelegramLogin(c *gin.Context) {
+	if !common.TelegramOAuthEnabled {
+		c.JSON(200, gin.H{
+			"message": "管理员未开启通过 Telegram 登录以及注册",
+			"success": false,
+		})
+		return
+	}
+	params := c.Request.URL.Query()
+	if !checkTelegramAuthorization(params, common.TelegramBotToken) {
+		c.JSON(200, gin.H{
+			"message": "无效的请求",
+			"success": false,
+		})
+		return
+	}
+
+	telegramId := params["id"][0]
+	user := model.User{TelegramId: telegramId}
+	if err := user.FillUserByTelegramId(); err != nil {
+		c.JSON(200, gin.H{
+			"message": err.Error(),
+			"success": false,
+		})
+		return
+	}
+	setupLogin(&user, c)
+}
+
+func checkTelegramAuthorization(params map[string][]string, token string) bool {
+	strs := []string{}
+	var hash = ""
+	for k, v := range params {
+		if k == "hash" {
+			hash = v[0]
+			continue
+		}
+		strs = append(strs, k+"="+v[0])
+	}
+	sort.Strings(strs)
+	var imploded = ""
+	for _, s := range strs {
+		if imploded != "" {
+			imploded += "\n"
+		}
+		imploded += s
+	}
+	sha256hash := sha256.New()
+	io.WriteString(sha256hash, token)
+	hmachash := hmac.New(sha256.New, sha256hash.Sum(nil))
+	io.WriteString(hmachash, imploded)
+	ss := hex.EncodeToString(hmachash.Sum(nil))
+	return hash == ss
+}

+ 8 - 8
controller/topup.go

@@ -9,6 +9,7 @@ import (
 	"net/url"
 	"one-api/common"
 	"one-api/model"
+	"one-api/service"
 	"strconv"
 	"time"
 )
@@ -55,14 +56,14 @@ func RequestEpay(c *gin.Context) {
 		c.JSON(200, gin.H{"message": err.Error(), "data": 10})
 		return
 	}
-	if req.Amount < 1 {
-		c.JSON(200, gin.H{"message": "充值金额不能小于1", "data": 10})
+	if req.Amount < common.MinTopUp {
+		c.JSON(200, gin.H{"message": fmt.Sprintf("充值数量不能小于 %d", common.MinTopUp), "data": 10})
 		return
 	}
 
 	id := c.GetInt("id")
 	user, _ := model.GetUserById(id, false)
-	amount := GetAmount(float64(req.Amount), *user)
+	payMoney := GetAmount(float64(req.Amount), *user)
 
 	var payType epay.PurchaseType
 	if req.PaymentMethod == "zfb" {
@@ -72,11 +73,10 @@ func RequestEpay(c *gin.Context) {
 		req.PaymentMethod = "wxpay"
 		payType = epay.WechatPay
 	}
-
+	callBackAddress := service.GetCallbackAddress()
 	returnUrl, _ := url.Parse(common.ServerAddress + "/log")
-	notifyUrl, _ := url.Parse(common.ServerAddress + "/api/user/epay/notify")
+	notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify")
 	tradeNo := strconv.FormatInt(time.Now().Unix(), 10)
-	payMoney := amount
 	client := GetEpayClient()
 	if client == nil {
 		c.JSON(200, gin.H{"message": "error", "data": "当前管理员未配置支付信息"})
@@ -169,8 +169,8 @@ func RequestAmount(c *gin.Context) {
 		c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
 		return
 	}
-	if req.Amount < 1 {
-		c.JSON(200, gin.H{"message": "error", "data": "充值金额不能小于1"})
+	if req.Amount < common.MinTopUp {
+		c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", common.MinTopUp)})
 		return
 	}
 	id := c.GetInt("id")

+ 1 - 0
docker-compose.yml

@@ -3,6 +3,7 @@ version: '3.4'
 services:
   new-api:
     image: calciumion/new-api:latest
+    # build: .
     container_name: new-api
     restart: always
     command: --log-dir /app/logs

+ 14 - 0
makefile

@@ -0,0 +1,14 @@
+FRONTEND_DIR = ./web
+BACKEND_DIR = .
+
+.PHONY: all build-frontend start-backend
+
+all: build-frontend start-backend
+
+build-frontend:
+	@echo "Building frontend..."
+	@cd $(FRONTEND_DIR) && npm install && DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat VERSION) npm run build npm run build
+
+start-backend:
+	@echo "Starting backend dev server..."
+	@cd $(BACKEND_DIR) && go run main.go &

+ 10 - 7
model/cache.go

@@ -291,24 +291,27 @@ func CacheGetRandomSatisfiedChannel(group string, model string) (*Channel, error
 			}
 		}
 	}
+
+	// 平滑系数
+	smoothingFactor := 10
 	// Calculate the total weight of all channels up to endIdx
 	totalWeight := 0
 	for _, channel := range channels[:endIdx] {
-		totalWeight += channel.GetWeight()
+		totalWeight += channel.GetWeight() + smoothingFactor
 	}
 
-	if totalWeight == 0 {
-		// If all weights are 0, select a channel randomly
-		return channels[rand.Intn(endIdx)], nil
-	}
+	//if totalWeight == 0 {
+	//	// If all weights are 0, select a channel randomly
+	//	return channels[rand.Intn(endIdx)], nil
+	//}
 
 	// Generate a random value in the range [0, totalWeight)
 	randomWeight := rand.Intn(totalWeight)
 
 	// Find a channel based on its weight
 	for _, channel := range channels[:endIdx] {
-		randomWeight -= channel.GetWeight()
-		if randomWeight <= 0 {
+		randomWeight -= channel.GetWeight() + smoothingFactor
+		if randomWeight < 0 {
 			return channel, nil
 		}
 	}

+ 1 - 1
model/channel.go

@@ -8,7 +8,7 @@ import (
 type Channel struct {
 	Id                 int     `json:"id"`
 	Type               int     `json:"type" gorm:"default:0"`
-	Key                string  `json:"key" gorm:"not null;index"`
+	Key                string  `json:"key" gorm:"not null"`
 	OpenAIOrganization *string `json:"openai_organization"`
 	Status             int     `json:"status" gorm:"default:1"`
 	Name               string  `json:"name" gorm:"index"`

+ 1 - 1
model/log.go

@@ -85,7 +85,7 @@ func RecordConsumeLog(ctx context.Context, userId int, channelId int, promptToke
 	}
 	if common.DataExportEnabled {
 		common.SafeGoroutine(func() {
-			LogQuotaData(userId, username, modelName, quota, common.GetTimestamp())
+			LogQuotaData(userId, username, modelName, quota, common.GetTimestamp(), promptTokens+completionTokens)
 		})
 	}
 }

+ 32 - 0
model/main.go

@@ -5,9 +5,11 @@ import (
 	"gorm.io/driver/postgres"
 	"gorm.io/driver/sqlite"
 	"gorm.io/gorm"
+	"log"
 	"one-api/common"
 	"os"
 	"strings"
+	"sync"
 	"time"
 )
 
@@ -148,3 +150,33 @@ func CloseDB() error {
 	err = sqlDB.Close()
 	return err
 }
+
+var (
+	lastPingTime time.Time
+	pingMutex    sync.Mutex
+)
+
+func PingDB() error {
+	pingMutex.Lock()
+	defer pingMutex.Unlock()
+
+	if time.Since(lastPingTime) < time.Second*10 {
+		return nil
+	}
+
+	sqlDB, err := DB.DB()
+	if err != nil {
+		log.Printf("Error getting sql.DB from GORM: %v", err)
+		return err
+	}
+
+	err = sqlDB.Ping()
+	if err != nil {
+		log.Printf("Error pinging DB: %v", err)
+		return err
+	}
+
+	lastPingTime = time.Now()
+	common.SysLog("Database pinged successfully")
+	return nil
+}

+ 15 - 0
model/option.go

@@ -30,6 +30,7 @@ func InitOptionMap() {
 	common.OptionMap["PasswordRegisterEnabled"] = strconv.FormatBool(common.PasswordRegisterEnabled)
 	common.OptionMap["EmailVerificationEnabled"] = strconv.FormatBool(common.EmailVerificationEnabled)
 	common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled)
+	common.OptionMap["TelegramOAuthEnabled"] = strconv.FormatBool(common.TelegramOAuthEnabled)
 	common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled)
 	common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled)
 	common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled)
@@ -56,12 +57,16 @@ func InitOptionMap() {
 	common.OptionMap["Logo"] = common.Logo
 	common.OptionMap["ServerAddress"] = ""
 	common.OptionMap["PayAddress"] = ""
+	common.OptionMap["CustomCallbackAddress"] = ""
 	common.OptionMap["EpayId"] = ""
 	common.OptionMap["EpayKey"] = ""
 	common.OptionMap["Price"] = strconv.FormatFloat(common.Price, 'f', -1, 64)
+	common.OptionMap["MinTopUp"] = strconv.Itoa(common.MinTopUp)
 	common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
 	common.OptionMap["GitHubClientId"] = ""
 	common.OptionMap["GitHubClientSecret"] = ""
+	common.OptionMap["TelegramBotToken"] = ""
+	common.OptionMap["TelegramBotName"] = ""
 	common.OptionMap["WeChatServerAddress"] = ""
 	common.OptionMap["WeChatServerToken"] = ""
 	common.OptionMap["WeChatAccountQRCodeImageURL"] = ""
@@ -152,6 +157,8 @@ func updateOptionMap(key string, value string) (err error) {
 			common.GitHubOAuthEnabled = boolValue
 		case "WeChatAuthEnabled":
 			common.WeChatAuthEnabled = boolValue
+		case "TelegramOAuthEnabled":
+			common.TelegramOAuthEnabled = boolValue
 		case "TurnstileCheckEnabled":
 			common.TurnstileCheckEnabled = boolValue
 		case "RegisterEnabled":
@@ -194,12 +201,16 @@ func updateOptionMap(key string, value string) (err error) {
 		common.ServerAddress = value
 	case "PayAddress":
 		common.PayAddress = value
+	case "CustomCallbackAddress":
+		common.CustomCallbackAddress = value
 	case "EpayId":
 		common.EpayId = value
 	case "EpayKey":
 		common.EpayKey = value
 	case "Price":
 		common.Price, _ = strconv.ParseFloat(value, 64)
+	case "MinTopUp":
+		common.MinTopUp, _ = strconv.Atoi(value)
 	case "TopupGroupRatio":
 		err = common.UpdateTopupGroupRatioByJSONString(value)
 	case "GitHubClientId":
@@ -218,6 +229,10 @@ func updateOptionMap(key string, value string) (err error) {
 		common.WeChatServerToken = value
 	case "WeChatAccountQRCodeImageURL":
 		common.WeChatAccountQRCodeImageURL = value
+	case "TelegramBotToken":
+		common.TelegramBotToken = value
+	case "TelegramBotName":
+		common.TelegramBotName = value
 	case "TurnstileSiteKey":
 		common.TurnstileSiteKey = value
 	case "TurnstileSecretKey":

+ 5 - 3
model/usedata.go

@@ -15,6 +15,7 @@ type QuotaData struct {
 	Username  string `json:"username" gorm:"index:idx_qdt_model_user_name,priority:2;size:64;default:''"`
 	ModelName string `json:"model_name" gorm:"index:idx_qdt_model_user_name,priority:1;size:64;default:''"`
 	CreatedAt int64  `json:"created_at" gorm:"bigint;index:idx_qdt_created_at,priority:2"`
+	TokenUsed int    `json:"token_used" gorm:"default:0"`
 	Count     int    `json:"count" gorm:"default:0"`
 	Quota     int    `json:"quota" gorm:"default:0"`
 }
@@ -38,7 +39,7 @@ func UpdateQuotaData() {
 var CacheQuotaData = make(map[string]*QuotaData)
 var CacheQuotaDataLock = sync.Mutex{}
 
-func logQuotaDataCache(userId int, username string, modelName string, quota int, createdAt int64) {
+func logQuotaDataCache(userId int, username string, modelName string, quota int, createdAt int64, tokenUsed int) {
 	key := fmt.Sprintf("%d-%s-%s-%d", userId, username, modelName, createdAt)
 	quotaData, ok := CacheQuotaData[key]
 	if ok {
@@ -52,18 +53,19 @@ func logQuotaDataCache(userId int, username string, modelName string, quota int,
 			CreatedAt: createdAt,
 			Count:     1,
 			Quota:     quota,
+			TokenUsed: tokenUsed,
 		}
 	}
 	CacheQuotaData[key] = quotaData
 }
 
-func LogQuotaData(userId int, username string, modelName string, quota int, createdAt int64) {
+func LogQuotaData(userId int, username string, modelName string, quota int, createdAt int64, tokenUsed int) {
 	// 只精确到小时
 	createdAt = createdAt - (createdAt % 3600)
 
 	CacheQuotaDataLock.Lock()
 	defer CacheQuotaDataLock.Unlock()
-	logQuotaDataCache(userId, username, modelName, quota, createdAt)
+	logQuotaDataCache(userId, username, modelName, quota, createdAt, tokenUsed)
 }
 
 func SaveQuotaDataCache() {

+ 18 - 1
model/user.go

@@ -3,10 +3,11 @@ package model
 import (
 	"errors"
 	"fmt"
-	"gorm.io/gorm"
 	"one-api/common"
 	"strings"
 	"time"
+
+	"gorm.io/gorm"
 )
 
 // User if you add sensitive fields, don't forget to clean them in setupLogin function.
@@ -21,6 +22,7 @@ type User struct {
 	Email            string         `json:"email" gorm:"index" validate:"max=50"`
 	GitHubId         string         `json:"github_id" gorm:"column:github_id;index"`
 	WeChatId         string         `json:"wechat_id" gorm:"column:wechat_id;index"`
+	TelegramId       string         `json:"telegram_id" gorm:"column:telegram_id;index"`
 	VerificationCode string         `json:"verification_code" gorm:"-:all"`                                    // this field is only for Email verification, don't save it to database!
 	AccessToken      string         `json:"access_token" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
 	Quota            int            `json:"quota" gorm:"type:int;default:0"`
@@ -286,6 +288,17 @@ func (user *User) FillUserByUsername() error {
 	return nil
 }
 
+func (user *User) FillUserByTelegramId() error {
+	if user.TelegramId == "" {
+		return errors.New("Telegram id 为空!")
+	}
+	err := DB.Where(User{TelegramId: user.TelegramId}).First(user).Error
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		return errors.New("该 Telegram 账户未绑定")
+	}
+	return nil
+}
+
 func IsEmailAlreadyTaken(email string) bool {
 	return DB.Where("email = ?", email).Find(&User{}).RowsAffected == 1
 }
@@ -302,6 +315,10 @@ func IsUsernameAlreadyTaken(username string) bool {
 	return DB.Where("username = ?", username).Find(&User{}).RowsAffected == 1
 }
 
+func IsTelegramIdAlreadyTaken(telegramId string) bool {
+	return DB.Where("telegram_id = ?", telegramId).Find(&User{}).RowsAffected == 1
+}
+
 func ResetUserPasswordByEmail(email string, password string) error {
 	if email == "" || password == "" {
 		return errors.New("邮箱地址或密码为空!")

+ 5 - 2
relay/relay-text.go

@@ -59,6 +59,7 @@ func getAndValidateTextRequest(c *gin.Context, relayInfo *relaycommon.RelayInfo)
 		}
 	}
 	relayInfo.IsStream = textRequest.Stream
+	relayInfo.UpstreamModelName = textRequest.Model
 	return textRequest, nil
 }
 
@@ -114,7 +115,7 @@ func TextHelper(c *gin.Context) *dto.OpenAIErrorWithStatusCode {
 
 	// pre-consume quota 预消耗配额
 	preConsumedQuota, userQuota, openaiErr := preConsumeQuota(c, preConsumedQuota, relayInfo)
-	if err != nil {
+	if openaiErr != nil {
 		return openaiErr
 	}
 
@@ -168,6 +169,8 @@ func getPromptTokens(textRequest *dto.GeneralOpenAIRequest, info *relaycommon.Re
 		promptTokens, err = service.CountTokenInput(textRequest.Prompt, textRequest.Model), nil
 	case relayconstant.RelayModeModerations:
 		promptTokens, err = service.CountTokenInput(textRequest.Input, textRequest.Model), nil
+	case relayconstant.RelayModeEmbeddings:
+		promptTokens, err = service.CountTokenInput(textRequest.Input, textRequest.Model), nil
 	default:
 		err = errors.New("unknown relay mode")
 		promptTokens = 0
@@ -182,7 +185,7 @@ func preConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommo
 	if err != nil {
 		return 0, 0, service.OpenAIErrorWrapper(err, "get_user_quota_failed", http.StatusInternalServerError)
 	}
-	if userQuota < 0 || userQuota-preConsumedQuota < 0 {
+	if userQuota <= 0 || userQuota-preConsumedQuota < 0 {
 		return 0, 0, service.OpenAIErrorWrapper(errors.New("user quota is not enough"), "insufficient_user_quota", http.StatusForbidden)
 	}
 	err = model.CacheDecreaseUserQuota(relayInfo.UserId, preConsumedQuota)

+ 4 - 1
router/api-router.go

@@ -14,6 +14,7 @@ func SetApiRouter(router *gin.Engine) {
 	apiRouter.Use(middleware.GlobalAPIRateLimit())
 	{
 		apiRouter.GET("/status", controller.GetStatus)
+		apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus)
 		apiRouter.GET("/notice", controller.GetNotice)
 		apiRouter.GET("/about", controller.GetAbout)
 		apiRouter.GET("/midjourney", controller.GetMidjourney)
@@ -26,6 +27,8 @@ func SetApiRouter(router *gin.Engine) {
 		apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth)
 		apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.WeChatBind)
 		apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.EmailBind)
+		apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), controller.TelegramLogin)
+		apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.TelegramBind)
 
 		userRoute := apiRouter.Group("/user")
 		{
@@ -73,7 +76,7 @@ func SetApiRouter(router *gin.Engine) {
 		{
 			channelRoute.GET("/", controller.GetAllChannels)
 			channelRoute.GET("/search", controller.SearchChannels)
-			channelRoute.GET("/models", controller.ListModels)
+			channelRoute.GET("/models", controller.ChannelListModels)
 			channelRoute.GET("/:id", controller.GetChannel)
 			channelRoute.GET("/test", controller.TestAllChannels)
 			channelRoute.GET("/test/:id", controller.TestChannel)

+ 10 - 0
service/epay.go

@@ -0,0 +1,10 @@
+package service
+
+import "one-api/common"
+
+func GetCallbackAddress() string {
+	if common.CustomCallbackAddress == "" {
+		return common.ServerAddress
+	}
+	return common.CustomCallbackAddress
+}

+ 3 - 2
web/package.json

@@ -3,10 +3,10 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
-    "@douyinfe/semi-ui": "^2.46.1",
     "@douyinfe/semi-icons": "^2.46.1",
-    "@visactor/vchart": "~1.8.8",
+    "@douyinfe/semi-ui": "^2.46.1",
     "@visactor/react-vchart": "~1.8.8",
+    "@visactor/vchart": "~1.8.8",
     "@visactor/vchart-semi-theme": "~1.8.8",
     "axios": "^0.27.2",
     "history": "^5.3.0",
@@ -17,6 +17,7 @@
     "react-fireworks": "^1.0.4",
     "react-router-dom": "^6.3.0",
     "react-scripts": "5.0.1",
+    "react-telegram-login": "^1.1.2",
     "react-toastify": "^9.0.8",
     "react-turnstile": "^1.0.5",
     "semantic-ui-css": "^2.5.0",

+ 52 - 24
web/src/components/LoginForm.js

@@ -1,14 +1,15 @@
-import React, {useContext, useEffect, useState} from 'react';
-import {Link, useNavigate, useSearchParams} from 'react-router-dom';
-import {UserContext} from '../context/User';
-import {API, getLogo, isMobile, showError, showInfo, showSuccess, showWarning} from '../helpers';
-import {onGitHubOAuthClicked} from './utils';
+import React, { useContext, useEffect, useState } from 'react';
+import { Link, useNavigate, useSearchParams } from 'react-router-dom';
+import { UserContext } from '../context/User';
+import { API, getLogo, isMobile, showError, showInfo, showSuccess, showWarning } from '../helpers';
+import { onGitHubOAuthClicked } from './utils';
 import Turnstile from "react-turnstile";
-import {Layout, Card, Image, Form, Button, Divider, Modal} from "@douyinfe/semi-ui";
+import { Layout, Card, Image, Form, Button, Divider, Modal } from "@douyinfe/semi-ui";
 import Title from "@douyinfe/semi-ui/lib/es/typography/title";
 import Text from "@douyinfe/semi-ui/lib/es/typography/text";
+import TelegramLoginButton from 'react-telegram-login';
 
-import {IconGithubLogo} from '@douyinfe/semi-icons';
+import { IconGithubLogo } from '@douyinfe/semi-icons';
 
 const LoginForm = () => {
     const [inputs, setInputs] = useState({
@@ -18,7 +19,7 @@ const LoginForm = () => {
     });
     const [searchParams, setSearchParams] = useSearchParams();
     const [submitted, setSubmitted] = useState(false);
-    const {username, password} = inputs;
+    const { username, password } = inputs;
     const [userState, userDispatch] = useContext(UserContext);
     const [turnstileEnabled, setTurnstileEnabled] = useState(false);
     const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
@@ -56,9 +57,9 @@ const LoginForm = () => {
         const res = await API.get(
             `/api/oauth/wechat?code=${inputs.wechat_verification_code}`
         );
-        const {success, message, data} = res.data;
+        const { success, message, data } = res.data;
         if (success) {
-            userDispatch({type: 'login', payload: data});
+            userDispatch({ type: 'login', payload: data });
             localStorage.setItem('user', JSON.stringify(data));
             navigate('/');
             showSuccess('登录成功!');
@@ -69,7 +70,7 @@ const LoginForm = () => {
     };
 
     function handleChange(name, value) {
-        setInputs((inputs) => ({...inputs, [name]: value}));
+        setInputs((inputs) => ({ ...inputs, [name]: value }));
     }
 
     async function handleSubmit(e) {
@@ -83,13 +84,13 @@ const LoginForm = () => {
                 username,
                 password
             });
-            const {success, message, data} = res.data;
+            const { success, message, data } = res.data;
             if (success) {
-                userDispatch({type: 'login', payload: data});
+                userDispatch({ type: 'login', payload: data });
                 localStorage.setItem('user', JSON.stringify(data));
                 showSuccess('登录成功!');
                 if (username === 'root' && password === '123456') {
-                    Modal.error({title: '您正在使用默认密码!', content: '请立刻修改默认密码!', centered: true});
+                    Modal.error({ title: '您正在使用默认密码!', content: '请立刻修改默认密码!', centered: true });
                 }
                 navigate('/token');
             } else {
@@ -100,16 +101,37 @@ const LoginForm = () => {
         }
     }
 
+    // 添加Telegram登录处理函数
+    const onTelegramLoginClicked = async (response) => {
+        const fields = ["id", "first_name", "last_name", "username", "photo_url", "auth_date", "hash", "lang"];
+        const params = {};
+        fields.forEach((field) => {
+            if (response[field]) {
+                params[field] = response[field];
+            }
+        });
+        const res = await API.get(`/api/oauth/telegram/login`, { params });
+        const { success, message, data } = res.data;
+        if (success) {
+            userDispatch({ type: 'login', payload: data });
+            localStorage.setItem('user', JSON.stringify(data));
+            showSuccess('登录成功!');
+            navigate('/');
+        } else {
+            showError(message);
+        }
+    };
+
     return (
         <div>
             <Layout>
                 <Layout.Header>
                 </Layout.Header>
                 <Layout.Content>
-                    <div style={{justifyContent: 'center', display: "flex", marginTop: 120}}>
-                        <div style={{width: 500}}>
+                    <div style={{ justifyContent: 'center', display: "flex", marginTop: 120 }}>
+                        <div style={{ width: 500 }}>
                             <Card>
-                                <Title heading={2} style={{textAlign: 'center'}}>
+                                <Title heading={2} style={{ textAlign: 'center' }}>
                                     用户登录
                                 </Title>
                                 <Form>
@@ -129,12 +151,12 @@ const LoginForm = () => {
                                         onChange={(value) => handleChange('password', value)}
                                     />
 
-                                    <Button theme='solid' style={{width: '100%'}} type={'primary'} size='large'
-                                            htmlType={'submit'} onClick={handleSubmit}>
+                                    <Button theme='solid' style={{ width: '100%' }} type={'primary'} size='large'
+                                        htmlType={'submit'} onClick={handleSubmit}>
                                         登录
                                     </Button>
                                 </Form>
-                                <div style={{display: 'flex', justifyContent: 'space-between', marginTop: 20}}>
+                                <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 20 }}>
                                     <Text>
                                         没有账号请先 <Link to='/register'>注册账号</Link>
                                     </Text>
@@ -142,16 +164,16 @@ const LoginForm = () => {
                                         忘记密码 <Link to='/reset'>点击重置</Link>
                                     </Text>
                                 </div>
-                                {status.github_oauth || status.wechat_login ? (
+                                {status.github_oauth || status.wechat_login || status.telegram_oauth ? (
                                     <>
                                         <Divider margin='12px' align='center'>
                                             第三方登录
                                         </Divider>
-                                        <div style={{display: 'flex', justifyContent: 'center', marginTop: 20}}>
+                                        <div style={{ display: 'flex', justifyContent: 'center', marginTop: 20 }}>
                                             {status.github_oauth ? (
                                                 <Button
                                                     type='primary'
-                                                    icon={<IconGithubLogo/>}
+                                                    icon={<IconGithubLogo />}
                                                     onClick={() => onGitHubOAuthClicked(status.github_client_id)}
                                                 />
                                             ) : (
@@ -167,6 +189,12 @@ const LoginForm = () => {
                                             {/*) : (*/}
                                             {/*    <></>*/}
                                             {/*)}*/}
+
+                                            {status.telegram_oauth ? (
+                                                <TelegramLoginButton dataOnauth={onTelegramLoginClicked} botName={status.telegram_bot_name} />
+                                            ) : (
+                                                <></>
+                                            )}
                                         </div>
                                     </>
                                 ) : (
@@ -208,7 +236,7 @@ const LoginForm = () => {
                                 {/*</Modal>*/}
                             </Card>
                             {turnstileEnabled ? (
-                                <div style={{display: 'flex', justifyContent: 'center', marginTop: 20}}>
+                                <div style={{ display: 'flex', justifyContent: 'center', marginTop: 20 }}>
                                     <Turnstile
                                         sitekey={turnstileSiteKey}
                                         onVerify={(token) => {

+ 20 - 0
web/src/components/PersonalSetting.js

@@ -21,6 +21,7 @@ import {getQuotaPerUnit, renderQuota, renderQuotaWithPrompt, stringToColor} from
 import EditToken from "../pages/Token/EditToken";
 import EditUser from "../pages/User/EditUser";
 import passwordResetConfirm from "./PasswordResetConfirm";
+import TelegramLoginButton from 'react-telegram-login';
 
 const PersonalSetting = () => {
     const [userState, userDispatch] = useContext(UserContext);
@@ -443,6 +444,25 @@ const PersonalSetting = () => {
                                 </div>
                             </div>
 
+                            <div style={{marginTop: 10}}>
+                                <Typography.Text strong>Telegram</Typography.Text>
+                                <div style={{display: 'flex', justifyContent: 'space-between'}}>
+                                    <div>
+                                        <Input
+                                            value={userState.user && userState.user.telegram_id !== ''?userState.user.telegram_id:'未绑定'}
+                                            readonly={true}
+                                        ></Input>
+                                    </div>
+                                    <div>
+                                        {status.telegram_oauth ?
+                                            userState.user.telegram_id !== '' ? <Button disabled={true}>已绑定</Button>
+                                            : <TelegramLoginButton dataAuthUrl="/api/oauth/telegram/bind" botName={status.telegram_bot_name} />
+                                        : <Button disabled={true}>未启用</Button>
+                                        }
+                                    </div>
+                                </div>
+                            </div>
+
                             <div style={{marginTop: 10}}>
                                 <Space>
                                     <Button onClick={generateAccessToken}>生成系统访问令牌</Button>

+ 86 - 27
web/src/components/SystemSetting.js

@@ -1,6 +1,6 @@
-import React, {useEffect, useState} from 'react';
-import {Button, Divider, Form, Grid, Header, Modal, Message} from 'semantic-ui-react';
-import {API, removeTrailingSlash, showError, verifyJSON} from '../helpers';
+import React, { useEffect, useState } from 'react';
+import { Button, Divider, Form, Grid, Header, Modal, Message } from 'semantic-ui-react';
+import { API, removeTrailingSlash, showError, verifyJSON } from '../helpers';
 
 const SystemSetting = () => {
     let [inputs, setInputs] = useState({
@@ -20,8 +20,10 @@ const SystemSetting = () => {
         EpayId: '',
         EpayKey: '',
         Price: 7.3,
+        MinTopUp: 1,
         TopupGroupRatio: '',
         PayAddress: '',
+        CustomCallbackAddress: '',
         Footer: '',
         WeChatAuthEnabled: '',
         WeChatServerAddress: '',
@@ -32,7 +34,11 @@ const SystemSetting = () => {
         TurnstileSecretKey: '',
         RegisterEnabled: '',
         EmailDomainRestrictionEnabled: '',
-        EmailDomainWhitelist: ''
+        EmailDomainWhitelist: '',
+        // telegram login
+        TelegramOAuthEnabled: '',
+        TelegramBotToken: '',
+        TelegramBotName: '',
     });
     const [originInputs, setOriginInputs] = useState({});
     let [loading, setLoading] = useState(false);
@@ -42,7 +48,7 @@ const SystemSetting = () => {
 
     const getOptions = async () => {
         const res = await API.get('/api/option/');
-        const {success, message, data} = res.data;
+        const { success, message, data } = res.data;
         if (success) {
             let newInputs = {};
             data.forEach((item) => {
@@ -58,7 +64,7 @@ const SystemSetting = () => {
             setOriginInputs(newInputs);
 
             setEmailDomainWhitelist(newInputs.EmailDomainWhitelist.split(',').map((item) => {
-                return {key: item, text: item, value: item};
+                return { key: item, text: item, value: item };
             }));
         } else {
             showError(message);
@@ -77,6 +83,7 @@ const SystemSetting = () => {
             case 'EmailVerificationEnabled':
             case 'GitHubOAuthEnabled':
             case 'WeChatAuthEnabled':
+            case 'TelegramOAuthEnabled':
             case 'TurnstileCheckEnabled':
             case 'EmailDomainRestrictionEnabled':
             case 'RegisterEnabled':
@@ -89,7 +96,7 @@ const SystemSetting = () => {
             key,
             value
         });
-        const {success, message} = res.data;
+        const { success, message } = res.data;
         if (success) {
             if (key === 'EmailDomainWhitelist') {
                 value = value.split(',');
@@ -106,7 +113,7 @@ const SystemSetting = () => {
         setLoading(false);
     };
 
-    const handleInputChange = async (e, {name, value}) => {
+    const handleInputChange = async (e, { name, value }) => {
         if (name === 'PasswordLoginEnabled' && inputs[name] === 'true') {
             // block disabling password login
             setShowPasswordWarningModal(true);
@@ -128,9 +135,11 @@ const SystemSetting = () => {
             name === 'TurnstileSiteKey' ||
             name === 'TurnstileSecretKey' ||
             name === 'EmailDomainWhitelist' ||
-            name === 'TopupGroupRatio'
+            name === 'TopupGroupRatio' ||
+            name === 'TelegramBotToken' ||
+            name === 'TelegramBotName'
         ) {
-            setInputs((inputs) => ({...inputs, [name]: value}));
+            setInputs((inputs) => ({ ...inputs, [name]: value }));
         } else {
             await updateOption(name, value);
         }
@@ -234,6 +243,12 @@ const SystemSetting = () => {
         }
     };
 
+    const submitTelegramSettings = async () => {
+        // await updateOption('TelegramOAuthEnabled', inputs.TelegramOAuthEnabled);
+        await updateOption('TelegramBotToken', inputs.TelegramBotToken);
+        await updateOption('TelegramBotName', inputs.TelegramBotName);
+    };
+
     const submitTurnstile = async () => {
         if (originInputs['TurnstileSiteKey'] !== inputs.TurnstileSiteKey) {
             await updateOption('TurnstileSiteKey', inputs.TurnstileSiteKey);
@@ -280,7 +295,7 @@ const SystemSetting = () => {
                         更新服务器地址
                     </Form.Button>
                     <Divider/>
-                    <Header as='h3'>支付设置(当前仅支持易支付接口,使用上方服务器地址作为回调地址!)</Header>
+                    <Header as='h3'>支付设置(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)</Header>
                     <Form.Group widths='equal'>
                         <Form.Input
                             label='支付地址,不填写则不启用在线支付'
@@ -303,14 +318,31 @@ const SystemSetting = () => {
                             name='EpayKey'
                             onChange={handleInputChange}
                         />
-                        <Form.Input
-                            label='充值价格(x元/美金)'
-                            placeholder='例如:7,就是7元/美金'
-                            value={inputs.Price}
-                            name='Price'
 
-                            min={0}
-                            onChange={handleInputChange}
+                    </Form.Group>
+                    <Form.Group widths='equal'>
+                        <Form.Input
+                          label='回调地址,不填写则使用上方服务器地址作为回调地址'
+                          placeholder='例如:https://yourdomain.com'
+                          value={inputs.CustomCallbackAddress}
+                          name='CustomCallbackAddress'
+                          onChange={handleInputChange}
+                        />
+                        <Form.Input
+                          label='充值价格(x元/美金)'
+                          placeholder='例如:7,就是7元/美金'
+                          value={inputs.Price}
+                          name='Price'
+                          min={0}
+                          onChange={handleInputChange}
+                        />
+                        <Form.Input
+                          label='最低充值数量'
+                          placeholder='例如:2,就是最低充值2$'
+                          value={inputs.MinTopUp}
+                          name='MinTopUp'
+                          min={1}
+                          onChange={handleInputChange}
                         />
                     </Form.Group>
                     <Form.Group widths='equal'>
@@ -318,7 +350,7 @@ const SystemSetting = () => {
                             label='充值分组倍率'
                             name='TopupGroupRatio'
                             onChange={handleInputChange}
-                            style={{minHeight: 250, fontFamily: 'JetBrains Mono, Consolas'}}
+                            style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
                             autoComplete='new-password'
                             value={inputs.TopupGroupRatio}
                             placeholder='为一个 JSON 文本,键为组名称,值为倍率'
@@ -327,7 +359,7 @@ const SystemSetting = () => {
                     <Form.Button onClick={submitPayAddress}>
                         更新支付设置
                     </Form.Button>
-                    <Divider/>
+                    <Divider />
                     <Header as='h3'>配置登录注册</Header>
                     <Form.Group inline>
                         <Form.Checkbox
@@ -342,7 +374,7 @@ const SystemSetting = () => {
                                 open={showPasswordWarningModal}
                                 onClose={() => setShowPasswordWarningModal(false)}
                                 size={'tiny'}
-                                style={{maxWidth: '450px'}}
+                                style={{ maxWidth: '450px' }}
                             >
                                 <Modal.Header>警告</Modal.Header>
                                 <Modal.Content>
@@ -386,6 +418,12 @@ const SystemSetting = () => {
                             name='WeChatAuthEnabled'
                             onChange={handleInputChange}
                         />
+                        <Form.Checkbox
+                            checked={inputs.TelegramOAuthEnabled === 'true'}
+                            label='允许通过 Telegram 进行登录'
+                            name='TelegramOAuthEnabled'
+                            onChange={handleInputChange}
+                        />
                     </Form.Group>
                     <Form.Group inline>
                         <Form.Checkbox
@@ -401,7 +439,7 @@ const SystemSetting = () => {
                             onChange={handleInputChange}
                         />
                     </Form.Group>
-                    <Divider/>
+                    <Divider />
                     <Header as='h3'>
                         配置邮箱域名白名单
                         <Header.Subheader>用以防止恶意用户利用临时邮箱批量注册</Header.Subheader>
@@ -443,13 +481,13 @@ const SystemSetting = () => {
                             autoComplete='new-password'
                             placeholder='输入新的允许的邮箱域名'
                             value={restrictedDomainInput}
-                            onChange={(e, {value}) => {
+                            onChange={(e, { value }) => {
                                 setRestrictedDomainInput(value);
                             }}
                         />
                     </Form.Group>
                     <Form.Button onClick={submitEmailDomainWhitelist}>保存邮箱域名白名单设置</Form.Button>
-                    <Divider/>
+                    <Divider />
                     <Header as='h3'>
                         配置 SMTP
                         <Header.Subheader>用以支持系统的邮件发送</Header.Subheader>
@@ -500,7 +538,7 @@ const SystemSetting = () => {
                         />
                     </Form.Group>
                     <Form.Button onClick={submitSMTP}>保存 SMTP 设置</Form.Button>
-                    <Divider/>
+                    <Divider />
                     <Header as='h3'>
                         配置 GitHub OAuth App
                         <Header.Subheader>
@@ -538,7 +576,7 @@ const SystemSetting = () => {
                     <Form.Button onClick={submitGitHubOAuth}>
                         保存 GitHub OAuth 设置
                     </Form.Button>
-                    <Divider/>
+                    <Divider />
                     <Header as='h3'>
                         配置 WeChat Server
                         <Header.Subheader>
@@ -582,7 +620,28 @@ const SystemSetting = () => {
                     <Form.Button onClick={submitWeChat}>
                         保存 WeChat Server 设置
                     </Form.Button>
-                    <Divider/>
+                    <Divider />
+                    <Header as='h3'>配置 Telegram 登录</Header>
+                    <Form.Group inline>
+                        <Form.Input
+                            label='Telegram Bot Token'
+                            name='TelegramBotToken'
+                            onChange={handleInputChange}
+                            value={inputs.TelegramBotToken}
+                            placeholder='输入你的 Telegram Bot Token'
+                        />
+                        <Form.Input
+                            label='Telegram Bot 名称'
+                            name='TelegramBotName'
+                            onChange={handleInputChange}
+                            value={inputs.TelegramBotName}
+                            placeholder='输入你的 Telegram Bot 名称'
+                        />
+                    </Form.Group>
+                    <Form.Button onClick={submitTelegramSettings}>
+                        保存 Telegram 登录设置
+                    </Form.Button>
+                    <Divider />
                     <Header as='h3'>
                         配置 Turnstile
                         <Header.Subheader>

+ 2 - 0
web/src/helpers/render.js

@@ -132,6 +132,8 @@ export const modelColorMap = {
     'gpt-4-0314': 'rgb(70,130,180)',  // 钢蓝色
     'gpt-4-0613': 'rgb(100,149,237)',  // 矢车菊蓝
     'gpt-4-1106-preview': 'rgb(30,144,255)',  // 道奇蓝
+    'gpt-4-0125-preview': 'rgb(2,177,236)',  // 深天蓝
+    'gpt-4-turbo-preview': 'rgb(2,177,255)',  // 深天蓝
     'gpt-4-32k': 'rgb(104,111,238)',  // 中紫色
     'gpt-4-32k-0314': 'rgb(90,105,205)',  // 暗灰蓝色
     'gpt-4-32k-0613': 'rgb(61,71,139)',  // 暗蓝灰色

+ 2 - 2
web/src/pages/Channel/EditChannel.js

@@ -241,7 +241,7 @@ const EditChannel = (props) => {
 
     const addCustomModel = () => {
         if (customModel.trim() === '') return;
-        if (inputs.models.includes(customModel)) return;
+        if (inputs.models.includes(customModel)) return showError("该模型已存在!");
         let localModels = [...inputs.models];
         localModels.push(customModel);
         let localModelOptions = [];
@@ -454,7 +454,7 @@ const EditChannel = (props) => {
                             placeholder='输入自定义模型名称'
                             value={customModel}
                             onChange={(value) => {
-                                setCustomModel(value);
+                                setCustomModel(value.trim());
                             }}
                         />
                     </div>

+ 113 - 104
web/src/pages/Home/index.js

@@ -5,117 +5,126 @@ import { StatusContext } from '../../context/Status';
 import { marked } from 'marked';
 
 const Home = () => {
-    const [statusState] = useContext(StatusContext);
-    const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
-    const [homePageContent, setHomePageContent] = useState('');
+  const [statusState] = useContext(StatusContext);
+  const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
+  const [homePageContent, setHomePageContent] = useState('');
 
-    const displayNotice = async () => {
-        const res = await API.get('/api/notice');
-        const { success, message, data } = res.data;
-        if (success) {
-            let oldNotice = localStorage.getItem('notice');
-            if (data !== oldNotice && data !== '') {
-                const htmlNotice = marked(data);
-                showNotice(htmlNotice, true);
-                localStorage.setItem('notice', data);
-            }
-        } else {
-            showError(message);
-        }
-    };
+  const displayNotice = async () => {
+    const res = await API.get('/api/notice');
+    const { success, message, data } = res.data;
+    if (success) {
+      let oldNotice = localStorage.getItem('notice');
+      if (data !== oldNotice && data !== '') {
+        const htmlNotice = marked(data);
+        showNotice(htmlNotice, true);
+        localStorage.setItem('notice', data);
+      }
+    } else {
+      showError(message);
+    }
+  };
 
-    const displayHomePageContent = async () => {
-        setHomePageContent(localStorage.getItem('home_page_content') || '');
-        const res = await API.get('/api/home_page_content');
-        const { success, message, data } = res.data;
-        if (success) {
-            let content = data;
-            if (!data.startsWith('https://')) {
-                content = marked.parse(data);
-            }
-            setHomePageContent(content);
-            localStorage.setItem('home_page_content', content);
-        } else {
-            showError(message);
-            setHomePageContent('加载首页内容失败...');
-        }
-        setHomePageContentLoaded(true);
-    };
+  const displayHomePageContent = async () => {
+    setHomePageContent(localStorage.getItem('home_page_content') || '');
+    const res = await API.get('/api/home_page_content');
+    const { success, message, data } = res.data;
+    if (success) {
+      let content = data;
+      if (!data.startsWith('https://')) {
+        content = marked.parse(data);
+      }
+      setHomePageContent(content);
+      localStorage.setItem('home_page_content', content);
+    } else {
+      showError(message);
+      setHomePageContent('加载首页内容失败...');
+    }
+    setHomePageContentLoaded(true);
+  };
+
+  const getStartTimeString = () => {
+    const timestamp = statusState?.status?.start_time;
+    return statusState.status ? timestamp2string(timestamp) : '';
+  };
 
-    const getStartTimeString = () => {
-        const timestamp = statusState?.status?.start_time;
-        return statusState.status ? timestamp2string(timestamp) : '';
-    };
+  useEffect(() => {
+    displayNotice().then();
+    displayHomePageContent().then();
+  }, []);
+  return (
+    <>
+      {
+        homePageContentLoaded && homePageContent === '' ?
+          <>
+            <Card
+              bordered={false}
+              headerLine={false}
+              title='系统状况'
+              bodyStyle={{ padding: '10px 20px' }}
+            >
+              <Row gutter={16}>
+                <Col span={12}>
+                  <Card
+                    title='系统信息'
+                    headerExtraContent={<span
+                      style={{ fontSize: '12px', color: 'var(--semi-color-text-1)' }}>系统信息总览</span>}>
+                    <p>名称:{statusState?.status?.system_name}</p>
+                    <p>版本:{statusState?.status?.version ? statusState?.status?.version : 'unknown'}</p>
+                    <p>
+                      源码:
+                      <a
+                        href='https://github.com/songquanpeng/one-api'
+                        target='_blank' rel='noreferrer'
+                      >
+                        https://github.com/songquanpeng/one-api
+                      </a>
+                    </p>
+                    <p>启动时间:{getStartTimeString()}</p>
+                  </Card>
+                </Col>
+                <Col span={12}>
+                  <Card
+                    title='系统配置'
+                    headerExtraContent={<span
+                      style={{ fontSize: '12px', color: 'var(--semi-color-text-1)' }}>系统配置总览</span>}>
+                    <p>
+                      邮箱验证:
+                      {statusState?.status?.email_verification === true ? '已启用' : '未启用'}
+                    </p>
+                    <p>
+                      GitHub 身份验证:
+                      {statusState?.status?.github_oauth === true ? '已启用' : '未启用'}
+                    </p>
+                    <p>
+                      微信身份验证:
+                      {statusState?.status?.wechat_login === true ? '已启用' : '未启用'}
+                    </p>
+                    <p>
+                      Turnstile 用户校验:
+                      {statusState?.status?.turnstile_check === true ? '已启用' : '未启用'}
+                    </p>
+                    <p>
+                      Telegram 身份验证:
+                      {statusState?.status?.telegram_oauth === true
+                        ? '已启用' : '未启用'}
+                    </p>
+                  </Card>
+                </Col>
+              </Row>
+            </Card>
 
-    useEffect(() => {
-        displayNotice().then();
-        displayHomePageContent().then();
-    }, []);
-    return (
-        <>
+          </>
+          : <>
             {
-                homePageContentLoaded && homePageContent === '' ? <>
-                   <Card
-                        bordered={false}
-                        headerLine={false}
-                        title='系统状况'
-                        bodyStyle={{padding: '10px 20px'}}
-                        >
-                        <Row gutter={16}>
-                            <Col span={12}>
-                                <Card
-                                    title='系统信息'
-                                    headerExtraContent={<span style={{ fontSize:'12px', color: 'var(--semi-color-text-1)'}}>系统信息总览</span>}>
-                                      <p>名称:{statusState?.status?.system_name}</p>
-                                      <p>版本:{statusState?.status?.version ? statusState?.status?.version : "unknown"}</p>
-                                      <p>
-                                          源码:
-                                          <a
-                                              href='https://github.com/songquanpeng/one-api'
-                                              target='_blank' rel="noreferrer"
-                                          >
-                                              https://github.com/songquanpeng/one-api
-                                          </a>
-                                      </p>
-                                      <p>启动时间:{getStartTimeString()}</p>
-                                </Card>
-                            </Col>
-                            <Col span={12}>
-                                <Card
-                                    title='系统配置'
-                                    headerExtraContent={<span style={{ fontSize:'12px', color: 'var(--semi-color-text-1)'}}>系统配置总览</span>}>
-                                      <p>
-                                          邮箱验证:
-                                          {statusState?.status?.email_verification === true ? '已启用': '未启用'}
-                                      </p>
-                                      <p>
-                                          GitHub 身份验证:
-                                          {statusState?.status?.github_oauth === true ? '已启用' : '未启用'}
-                                      </p>
-                                      <p>
-                                          微信身份验证:
-                                          {statusState?.status?.wechat_login === true ? '已启用' : '未启用'}
-                                      </p>
-                                      <p>
-                                          Turnstile 用户校验:
-                                          {statusState?.status?.turnstile_check === true ? '已启用' : '未启用'}
-                                      </p>
-                                </Card>
-                            </Col>
-                        </Row>
-                    </Card>
-                </> : <>
-                    {
-                        homePageContent.startsWith('https://') ? <iframe
-                            src={homePageContent}
-                            style={{ width: '100%', height: '100vh', border: 'none' }}
-                        /> : <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: homePageContent }}></div>
-                    }
-                </>
+              homePageContent.startsWith('https://') ?
+                <iframe src={homePageContent} style={{ width: '100%', height: '100vh', border: 'none' }} /> :
+                <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: homePageContent }}></div>
             }
+          </>
+      }
 
-        </>
-    );
+    </>
+  );
 };
 
 export default Home;

+ 15 - 2
web/src/pages/TopUp/index.js

@@ -10,6 +10,7 @@ const TopUp = () => {
     const [topUpCount, setTopUpCount] = useState(10);
     const [minTopupCount, setMinTopUpCount] = useState(1);
     const [amount, setAmount] = useState(0.0);
+    const [minTopUp, setMinTopUp] = useState(1);
     const [topUpLink, setTopUpLink] = useState('');
     const [enableOnlineTopUp, setEnableOnlineTopUp] = useState(false);
     const [userQuota, setUserQuota] = useState(0);
@@ -61,6 +62,10 @@ const TopUp = () => {
         if (amount === 0) {
             await getAmount();
         }
+        if (topUpCount < minTopUp) {
+            showInfo('充值数量不能小于' + minTopUp);
+            return;
+        }
         setPayWay(payment)
         setOpen(true);
     }
@@ -69,6 +74,10 @@ const TopUp = () => {
         if (amount === 0) {
             await getAmount();
         }
+        if (topUpCount < minTopUp) {
+            showInfo('充值数量不能小于' + minTopUp);
+            return;
+        }
         setOpen(false);
         try {
             const res = await API.post('/api/user/pay', {
@@ -132,6 +141,9 @@ const TopUp = () => {
             if (status.top_up_link) {
                 setTopUpLink(status.top_up_link);
             }
+            if (status.min_topup) {
+                setMinTopUp(status.min_topup);
+            }
             if (status.enable_online_topup) {
                 setEnableOnlineTopUp(status.enable_online_topup);
             }
@@ -239,12 +251,13 @@ const TopUp = () => {
                                         disabled={!enableOnlineTopUp}
                                         field={'redemptionCount'}
                                         label={'实付金额:' + renderAmount()}
-                                        placeholder='充值数量'
+                                        placeholder={'充值数量,最低' + minTopUp + '$'}
                                         name='redemptionCount'
                                         type={'number'}
                                         value={topUpCount}
                                         suffix={'$'}
-                                        min={1}
+                                        min={minTopUp}
+                                        defaultValue={minTopUp}
                                         max={100000}
                                         onChange={async (value) => {
                                             if (value < 1) {

+ 127 - 121
web/src/pages/User/EditUser.js

@@ -1,9 +1,9 @@
 import React, { useEffect, useState } from 'react';
 import { useParams, useNavigate } from 'react-router-dom';
-import {API, isMobile, showError, showSuccess} from '../../helpers';
+import { API, isMobile, showError, showSuccess } from '../../helpers';
 import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
 import Title from "@douyinfe/semi-ui/lib/es/typography/title";
-import {SideSheet, Space, Button, Spin, Input, Typography, Select, Divider} from "@douyinfe/semi-ui";
+import { SideSheet, Space, Button, Spin, Input, Typography, Select, Divider } from "@douyinfe/semi-ui";
 
 const EditUser = (props) => {
   const userId = props.editingUser.id;
@@ -19,8 +19,8 @@ const EditUser = (props) => {
     group: 'default'
   });
   const [groupOptions, setGroupOptions] = useState([]);
-  const { username, display_name, password, github_id, wechat_id, email, quota, group } =
-      inputs;
+  const { username, display_name, password, github_id, wechat_id, telegram_id, email, quota, group } =
+    inputs;
   const handleInputChange = (name, value) => {
     setInputs((inputs) => ({ ...inputs, [name]: value }));
   };
@@ -88,126 +88,132 @@ const EditUser = (props) => {
   };
 
   return (
-      <>
-        <SideSheet
-            placement={'right'}
-            title={<Title level={3}>{'编辑用户'}</Title>}
-            headerStyle={{borderBottom: '1px solid var(--semi-color-border)'}}
-            bodyStyle={{borderBottom: '1px solid var(--semi-color-border)'}}
-            visible={props.visible}
-            footer={
-              <div style={{display: 'flex', justifyContent: 'flex-end'}}>
-                <Space>
-                  <Button theme='solid' size={'large'} onClick={submit}>提交</Button>
-                  <Button theme='solid' size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>
-                </Space>
+    <>
+      <SideSheet
+        placement={'right'}
+        title={<Title level={3}>{'编辑用户'}</Title>}
+        headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
+        bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
+        visible={props.visible}
+        footer={
+          <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
+            <Space>
+              <Button theme='solid' size={'large'} onClick={submit}>提交</Button>
+              <Button theme='solid' size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>
+            </Space>
+          </div>
+        }
+        closeIcon={null}
+        onCancel={() => handleCancel()}
+        width={isMobile() ? '100%' : 600}
+      >
+        <Spin spinning={loading}>
+          <div style={{ marginTop: 20 }}>
+            <Typography.Text>用户名</Typography.Text>
+          </div>
+          <Input
+            label='用户名'
+            name='username'
+            placeholder={'请输入新的用户名'}
+            onChange={value => handleInputChange('username', value)}
+            value={username}
+            autoComplete='new-password'
+          />
+          <div style={{ marginTop: 20 }}>
+            <Typography.Text>密码</Typography.Text>
+          </div>
+          <Input
+            label='密码'
+            name='password'
+            type={'password'}
+            placeholder={'请输入新的密码,最短 8 位'}
+            onChange={value => handleInputChange('password', value)}
+            value={password}
+            autoComplete='new-password'
+          />
+          <div style={{ marginTop: 20 }}>
+            <Typography.Text>显示名称</Typography.Text>
+          </div>
+          <Input
+            label='显示名称'
+            name='display_name'
+            placeholder={'请输入新的显示名称'}
+            onChange={value => handleInputChange('display_name', value)}
+            value={display_name}
+            autoComplete='new-password'
+          />
+          {
+            userId && <>
+              <div style={{ marginTop: 20 }}>
+                <Typography.Text>分组</Typography.Text>
               </div>
-            }
-            closeIcon={null}
-            onCancel={() => handleCancel()}
-            width={isMobile() ? '100%' : 600}
-        >
-          <Spin spinning={loading}>
-            <div style={{marginTop: 20}}>
-              <Typography.Text>用户名</Typography.Text>
-            </div>
-            <Input
-                label='用户名'
-                name='username'
-                placeholder={'请输入新的用户名'}
-                onChange={value => handleInputChange('username', value)}
-                value={username}
+              <Select
+                placeholder={'请选择分组'}
+                name='group'
+                fluid
+                search
+                selection
+                allowAdditions
+                additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
+                onChange={value => handleInputChange('group', value)}
+                value={inputs.group}
                 autoComplete='new-password'
-            />
-            <div style={{marginTop: 20}}>
-              <Typography.Text>密码</Typography.Text>
-            </div>
-            <Input
-                label='密码'
-                name='password'
-                type={'password'}
-                placeholder={'请输入新的密码,最短 8 位'}
-                onChange={value => handleInputChange('password', value)}
-                value={password}
-                autoComplete='new-password'
-            />
-            <div style={{marginTop: 20}}>
-              <Typography.Text>显示名称</Typography.Text>
-            </div>
-            <Input
-                label='显示名称'
-                name='display_name'
-                placeholder={'请输入新的显示名称'}
-                onChange={value => handleInputChange('display_name', value)}
-                value={display_name}
-                autoComplete='new-password'
-            />
-            {
-                userId && <>
-                  <div style={{marginTop: 20}}>
-                    <Typography.Text>分组</Typography.Text>
-                  </div>
-                  <Select
-                      placeholder={'请选择分组'}
-                      name='group'
-                      fluid
-                      search
-                      selection
-                      allowAdditions
-                      additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
-                      onChange={value => handleInputChange('group', value)}
-                      value={inputs.group}
-                      autoComplete='new-password'
-                      optionList={groupOptions}
-                  />
-                  <div style={{marginTop: 20}}>
-                    <Typography.Text>{`剩余额度${renderQuotaWithPrompt(quota)}`}</Typography.Text>
-                  </div>
-                  <Input
-                      name='quota'
-                      placeholder={'请输入新的剩余额度'}
-                      onChange={value => handleInputChange('quota', value)}
-                      value={quota}
-                      type={'number'}
-                      autoComplete='new-password'
-                  />
-                </>
-            }
-            <Divider style={{marginTop: 20}}>以下信息不可修改</Divider>
-            <div style={{marginTop: 20}}>
-              <Typography.Text>已绑定的 GitHub 账户</Typography.Text>
-            </div>
-            <Input
-                name='github_id'
-                value={github_id}
-                autoComplete='new-password'
-                placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
-                readonly
-            />
-            <div style={{marginTop: 20}}>
-              <Typography.Text>已绑定的微信账户</Typography.Text>
-            </div>
-            <Input
-                name='wechat_id'
-                value={wechat_id}
-                autoComplete='new-password'
-                placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
-                readonly
-            />
-            <div style={{marginTop: 20}}>
-              <Typography.Text>已绑定的邮箱账户</Typography.Text>
-            </div>
-            <Input
-                name='email'
-                value={email}
+                optionList={groupOptions}
+              />
+              <div style={{ marginTop: 20 }}>
+                <Typography.Text>{`剩余额度${renderQuotaWithPrompt(quota)}`}</Typography.Text>
+              </div>
+              <Input
+                name='quota'
+                placeholder={'请输入新的剩余额度'}
+                onChange={value => handleInputChange('quota', value)}
+                value={quota}
+                type={'number'}
                 autoComplete='new-password'
-                placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
-                readonly
-            />
-          </Spin>
-
-        </SideSheet>
-      </>
+              />
+            </>
+          }
+          <Divider style={{ marginTop: 20 }}>以下信息不可修改</Divider>
+          <div style={{ marginTop: 20 }}>
+            <Typography.Text>已绑定的 GitHub 账户</Typography.Text>
+          </div>
+          <Input
+            name='github_id'
+            value={github_id}
+            autoComplete='new-password'
+            placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
+            readonly
+          />
+          <div style={{ marginTop: 20 }}>
+            <Typography.Text>已绑定的微信账户</Typography.Text>
+          </div>
+          <Input
+            name='wechat_id'
+            value={wechat_id}
+            autoComplete='new-password'
+            placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
+            readonly
+          />
+          <Input
+            name='telegram_id'
+            value={telegram_id}
+            autoComplete='new-password'
+            placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
+            readonly
+          />
+          <div style={{ marginTop: 20 }}>
+            <Typography.Text>已绑定的邮箱账户</Typography.Text>
+          </div>
+          <Input
+            name='email'
+            value={email}
+            autoComplete='new-password'
+            placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
+            readonly
+          />
+        </Spin>
+      </SideSheet>
+    </>
   );
 };