mirror of
				https://github.com/songquanpeng/one-api.git
				synced 2025-10-31 13:53:41 +08:00 
			
		
		
		
	Compare commits
	
		
			39 Commits
		
	
	
		
			v0.5.0-alp
			...
			0.0.2
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 7419ca511e | ||
|  | ccd7a99b68 | ||
|  | 759423d69e | ||
|  | 04110d4b01 | ||
|  | d6b2131720 | ||
|  | 438daea433 | ||
|  | c134604cee | ||
|  | 929e43ef81 | ||
|  | dce8bbe1ca | ||
|  | c6c070b8bd | ||
|  | 13b3bfee2a | ||
|  | 2b42b4f364 | ||
|  | f37e41eb1d | ||
|  | c144c64fff | ||
|  | 8956e2fd60 | ||
|  | 30187cebe8 | ||
|  | 00d3a78bef | ||
|  | a588241515 | ||
|  | 546f9e1db5 | ||
|  | 4908a9eddc | ||
|  | 15cdaee762 | ||
|  | 395ee121ed | ||
|  | 4cea6279ab | ||
|  | f50683e75f | ||
|  | bc2f48b1f2 | ||
|  | 889af8b2db | ||
|  | 4eea096654 | ||
|  | 4ab3211c0e | ||
|  | 3da119efba | ||
|  | dccd66b852 | ||
|  | 2fcd6852e0 | ||
|  | 9b4d1964d4 | ||
|  | 806bf8241c | ||
|  | ce93c9b6b2 | ||
|  | 4ec4289565 | ||
|  | 3dc5a0f91d | ||
|  | 80a846673a | ||
|  | 26c6719ea3 | ||
|  | c87e05bfc2 | 
							
								
								
									
										2
									
								
								.github/workflows/docker-image-amd64-en.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/docker-image-amd64-en.yml
									
									
									
									
										vendored
									
									
								
							| @@ -38,7 +38,7 @@ jobs: | ||||
|         uses: docker/metadata-action@v4 | ||||
|         with: | ||||
|           images: | | ||||
|             justsong/one-api-en | ||||
|             ckt1031/one-api-en | ||||
|  | ||||
|       - name: Build and push Docker images | ||||
|         uses: docker/build-push-action@v3 | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/docker-image-amd64.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/docker-image-amd64.yml
									
									
									
									
										vendored
									
									
								
							| @@ -42,7 +42,7 @@ jobs: | ||||
|         uses: docker/metadata-action@v4 | ||||
|         with: | ||||
|           images: | | ||||
|             justsong/one-api | ||||
|             ckt1031/one-api | ||||
|             ghcr.io/${{ github.repository }} | ||||
|  | ||||
|       - name: Build and push Docker images | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/docker-image-arm64.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/docker-image-arm64.yml
									
									
									
									
										vendored
									
									
								
							| @@ -49,7 +49,7 @@ jobs: | ||||
|         uses: docker/metadata-action@v4 | ||||
|         with: | ||||
|           images: | | ||||
|             justsong/one-api | ||||
|             ckt1031/one-api | ||||
|             ghcr.io/${{ github.repository }} | ||||
|  | ||||
|       - name: Build and push Docker images | ||||
|   | ||||
| @@ -10,7 +10,7 @@ | ||||
|  | ||||
| # One API | ||||
|  | ||||
| _✨ An OpenAI key management & redistribution system, easy to deploy & use ✨_ | ||||
| _✨ Access all LLM through the standard OpenAI API format, easy to deploy & use ✨_ | ||||
|  | ||||
| </div> | ||||
|  | ||||
|   | ||||
							
								
								
									
										49
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										49
									
								
								README.md
									
									
									
									
									
								
							| @@ -11,7 +11,7 @@ | ||||
|  | ||||
| # One API | ||||
|  | ||||
| _✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用✨_ | ||||
| _✨ 通过标准的 OpenAI API 格式访问所有的大模型,开箱即用 ✨_ | ||||
|  | ||||
| </div> | ||||
|  | ||||
| @@ -58,12 +58,13 @@ _✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用 | ||||
| > **Warning**:从 `v0.3` 版本升级到 `v0.4` 版本需要手动迁移数据库,请手动执行[数据库迁移脚本](./bin/migration_v0.3-v0.4.sql)。 | ||||
|  | ||||
| ## 功能 | ||||
| 1. 支持多种 API 访问渠道: | ||||
|    + [x] OpenAI 官方通道(支持配置镜像) | ||||
|    + [x] **Azure OpenAI API** | ||||
| 1. 支持多种大模型: | ||||
|    + [x] [OpenAI ChatGPT 系列模型](https://platform.openai.com/docs/guides/gpt/chat-completions-api)(支持 [Azure OpenAI API](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference)) | ||||
|    + [x] [Anthropic Claude 系列模型](https://anthropic.com) | ||||
|    + [x] [Google PaLM2 系列模型](https://developers.generativeai.google) | ||||
|    + [x] [百度文心一言系列模型](https://cloud.baidu.com/doc/WENXINWORKSHOP/index.html) | ||||
|    + [x] [智谱 ChatGLM 系列模型](https://bigmodel.cn) | ||||
| 2. 支持配置镜像以及众多第三方代理服务: | ||||
|    + [x] [API Distribute](https://api.gptjk.top/register?aff=QGxj) | ||||
|    + [x] [OpenAI-SB](https://openai-sb.com) | ||||
|    + [x] [API2D](https://api2d.com/r/197971) | ||||
| @@ -71,32 +72,30 @@ _✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用 | ||||
|    + [x] [AI Proxy](https://aiproxy.io/?i=OneAPI) (邀请码:`OneAPI`) | ||||
|    + [x] [CloseAI](https://console.closeai-asia.com/r/2412) | ||||
|    + [x] 自定义渠道:例如各种未收录的第三方代理服务 | ||||
| 2. 支持通过**负载均衡**的方式访问多个渠道。 | ||||
| 3. 支持 **stream 模式**,可以通过流式传输实现打字机效果。 | ||||
| 4. 支持**多机部署**,[详见此处](#多机部署)。 | ||||
| 5. 支持**令牌管理**,设置令牌的过期时间和额度。 | ||||
| 6. 支持**兑换码管理**,支持批量生成和导出兑换码,可使用兑换码为账户进行充值。 | ||||
| 7. 支持**通道管理**,批量创建通道。 | ||||
| 8. 支持**用户分组**以及**渠道分组**,支持为不同分组设置不同的倍率。 | ||||
| 9. 支持渠道**设置模型列表**。 | ||||
| 10. 支持**查看额度明细**。 | ||||
| 11. 支持**用户邀请奖励**。 | ||||
| 12. 支持以美元为单位显示额度。 | ||||
| 13. 支持发布公告,设置充值链接,设置新用户初始额度。 | ||||
| 14. 支持模型映射,重定向用户的请求模型。 | ||||
| 15. 支持失败自动重试。 | ||||
| 16. 支持绘图接口。 | ||||
| 17. 支持丰富的**自定义**设置, | ||||
| 3. 支持通过**负载均衡**的方式访问多个渠道。 | ||||
| 4. 支持 **stream 模式**,可以通过流式传输实现打字机效果。 | ||||
| 5. 支持**多机部署**,[详见此处](#多机部署)。 | ||||
| 6. 支持**令牌管理**,设置令牌的过期时间和额度。 | ||||
| 7. 支持**兑换码管理**,支持批量生成和导出兑换码,可使用兑换码为账户进行充值。 | ||||
| 8. 支持**通道管理**,批量创建通道。 | ||||
| 9. 支持**用户分组**以及**渠道分组**,支持为不同分组设置不同的倍率。 | ||||
| 10. 支持渠道**设置模型列表**。 | ||||
| 11. 支持**查看额度明细**。 | ||||
| 12. 支持**用户邀请奖励**。 | ||||
| 13. 支持以美元为单位显示额度。 | ||||
| 14. 支持发布公告,设置充值链接,设置新用户初始额度。 | ||||
| 15. 支持模型映射,重定向用户的请求模型。 | ||||
| 16. 支持失败自动重试。 | ||||
| 17. 支持绘图接口。 | ||||
| 18. 支持丰富的**自定义**设置, | ||||
|     1. 支持自定义系统名称,logo 以及页脚。 | ||||
|     2. 支持自定义首页和关于页面,可以选择使用 HTML & Markdown 代码进行自定义,或者使用一个单独的网页通过 iframe 嵌入。 | ||||
| 18. 支持通过系统访问令牌访问管理 API。 | ||||
| 19. 支持 Cloudflare Turnstile 用户校验。 | ||||
| 20. 支持用户管理,支持**多种用户登录注册方式**: | ||||
| 19. 支持通过系统访问令牌访问管理 API。 | ||||
| 20. 支持 Cloudflare Turnstile 用户校验。 | ||||
| 21. 支持用户管理,支持**多种用户登录注册方式**: | ||||
|     + 邮箱登录注册以及通过邮箱进行密码重置。 | ||||
|     + [GitHub 开放授权](https://github.com/settings/applications/new)。 | ||||
|     + 微信公众号授权(需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server))。 | ||||
| 21. 支持 [ChatGLM](https://github.com/THUDM/ChatGLM2-6B)。 | ||||
| 22. 未来其他大模型开放 API 后,将第一时间支持,并将其封装成同样的 API 访问方式。 | ||||
|  | ||||
| ## 部署 | ||||
| ### 基于 Docker 进行部署 | ||||
|   | ||||
| @@ -38,7 +38,9 @@ var PasswordLoginEnabled = true | ||||
| var PasswordRegisterEnabled = true | ||||
| var EmailVerificationEnabled = false | ||||
| var GitHubOAuthEnabled = false | ||||
| var DiscordOAuthEnabled = false | ||||
| var WeChatAuthEnabled = false | ||||
| var GoogleOAuthEnabled = false | ||||
| var TurnstileCheckEnabled = false | ||||
| var RegisterEnabled = true | ||||
|  | ||||
| @@ -53,10 +55,16 @@ var SMTPToken = "" | ||||
| var GitHubClientId = "" | ||||
| var GitHubClientSecret = "" | ||||
|  | ||||
| var DiscordClientId = "" | ||||
| var DiscordClientSecret = "" | ||||
|  | ||||
| var WeChatServerAddress = "" | ||||
| var WeChatServerToken = "" | ||||
| var WeChatAccountQRCodeImageURL = "" | ||||
|  | ||||
| var GoogleClientId = "" | ||||
| var GoogleClientSecret = "" | ||||
|  | ||||
| var TurnstileSiteKey = "" | ||||
| var TurnstileSecretKey = "" | ||||
|  | ||||
| @@ -77,6 +85,8 @@ var IsMasterNode = os.Getenv("NODE_TYPE") != "slave" | ||||
| var requestInterval, _ = strconv.Atoi(os.Getenv("POLLING_INTERVAL")) | ||||
| var RequestInterval = time.Duration(requestInterval) * time.Second | ||||
|  | ||||
| var SyncFrequency = 10 * 60 // unit is second, will be overwritten by SYNC_FREQUENCY | ||||
|  | ||||
| const ( | ||||
| 	RoleGuestUser  = 0 | ||||
| 	RoleCommonUser = 1 | ||||
| @@ -136,6 +146,16 @@ const ( | ||||
| 	ChannelStatusDisabled = 2 // also don't use 0 | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	ChannelAllowNonStreamEnabled  = 1 | ||||
| 	ChannelAllowNonStreamDisabled = 2 | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	ChannelAllowStreamEnabled  = 1 | ||||
| 	ChannelAllowStreamDisabled = 2 | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	ChannelTypeUnknown   = 0 | ||||
| 	ChannelTypeOpenAI    = 1 | ||||
| @@ -153,6 +173,7 @@ const ( | ||||
| 	ChannelTypeAIGC2D    = 13 | ||||
| 	ChannelTypeAnthropic = 14 | ||||
| 	ChannelTypeBaidu     = 15 | ||||
| 	ChannelTypeZhipu     = 16 | ||||
| ) | ||||
|  | ||||
| var ChannelBaseURLs = []string{ | ||||
| @@ -172,4 +193,5 @@ var ChannelBaseURLs = []string{ | ||||
| 	"https://api.aigc2d.com",        // 13 | ||||
| 	"https://api.anthropic.com",     // 14 | ||||
| 	"https://aip.baidubce.com",      // 15 | ||||
| 	"https://open.bigmodel.cn",      // 16 | ||||
| } | ||||
|   | ||||
| @@ -8,6 +8,7 @@ import "encoding/json" | ||||
| // https://openai.com/pricing | ||||
| // TODO: when a new api is enabled, check the pricing here | ||||
| // 1 === $0.002 / 1K tokens | ||||
| // 1 === ¥0.014 / 1k tokens | ||||
| var ModelRatio = map[string]float64{ | ||||
| 	"gpt-4":                   15, | ||||
| 	"gpt-4-0314":              15, | ||||
| @@ -39,9 +40,12 @@ var ModelRatio = map[string]float64{ | ||||
| 	"dall-e":                  8, | ||||
| 	"claude-instant-1":        0.75, | ||||
| 	"claude-2":                30, | ||||
| 	"ERNIE-Bot":               1,    // 0.012元/千tokens | ||||
| 	"ERNIE-Bot-turbo":         0.67, // 0.008元/千tokens | ||||
| 	"ERNIE-Bot":               0.8572, // ¥0.012 / 1k tokens | ||||
| 	"ERNIE-Bot-turbo":         0.5715, // ¥0.008 / 1k tokens | ||||
| 	"PaLM-2":                  1, | ||||
| 	"chatglm_pro":             0.7143, // ¥0.01 / 1k tokens | ||||
| 	"chatglm_std":             0.3572, // ¥0.005 / 1k tokens | ||||
| 	"chatglm_lite":            0.1429, // ¥0.002 / 1k tokens | ||||
| } | ||||
|  | ||||
| func ModelRatio2JSONString() string { | ||||
|   | ||||
| @@ -11,9 +11,11 @@ func GetSubscription(c *gin.Context) { | ||||
| 	var usedQuota int | ||||
| 	var err error | ||||
| 	var token *model.Token | ||||
| 	var expiredTime int64 | ||||
| 	if common.DisplayTokenStatEnabled { | ||||
| 		tokenId := c.GetInt("token_id") | ||||
| 		token, err = model.GetTokenById(tokenId) | ||||
| 		expiredTime = token.ExpiredTime | ||||
| 		remainQuota = token.RemainQuota | ||||
| 		usedQuota = token.UsedQuota | ||||
| 	} else { | ||||
| @@ -21,6 +23,9 @@ func GetSubscription(c *gin.Context) { | ||||
| 		remainQuota, err = model.GetUserQuota(userId) | ||||
| 		usedQuota, err = model.GetUserUsedQuota(userId) | ||||
| 	} | ||||
| 	if expiredTime <= 0 { | ||||
| 		expiredTime = 0 | ||||
| 	} | ||||
| 	if err != nil { | ||||
| 		openAIError := OpenAIError{ | ||||
| 			Message: err.Error(), | ||||
| @@ -45,6 +50,7 @@ func GetSubscription(c *gin.Context) { | ||||
| 		SoftLimitUSD:       amount, | ||||
| 		HardLimitUSD:       amount, | ||||
| 		SystemHardLimitUSD: amount, | ||||
| 		AccessUntil:        expiredTime, | ||||
| 	} | ||||
| 	c.JSON(200, subscription) | ||||
| 	return | ||||
|   | ||||
| @@ -22,6 +22,7 @@ type OpenAISubscriptionResponse struct { | ||||
| 	SoftLimitUSD       float64 `json:"soft_limit_usd"` | ||||
| 	HardLimitUSD       float64 `json:"hard_limit_usd"` | ||||
| 	SystemHardLimitUSD float64 `json:"system_hard_limit_usd"` | ||||
| 	AccessUntil        int64   `json:"access_until"` | ||||
| } | ||||
|  | ||||
| type OpenAIUsageDailyCost struct { | ||||
| @@ -84,7 +85,6 @@ func GetAuthHeader(token string) http.Header { | ||||
| } | ||||
|  | ||||
| func GetResponseBody(method, url string, channel *model.Channel, headers http.Header) ([]byte, error) { | ||||
| 	client := &http.Client{} | ||||
| 	req, err := http.NewRequest(method, url, nil) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| @@ -92,10 +92,13 @@ func GetResponseBody(method, url string, channel *model.Channel, headers http.He | ||||
| 	for k := range headers { | ||||
| 		req.Header.Add(k, headers.Get(k)) | ||||
| 	} | ||||
| 	res, err := client.Do(req) | ||||
| 	res, err := httpClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	if res.StatusCode != http.StatusOK { | ||||
| 		return nil, fmt.Errorf("status code: %d", res.StatusCode) | ||||
| 	} | ||||
| 	body, err := io.ReadAll(res.Body) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
|   | ||||
| @@ -1,21 +1,32 @@ | ||||
| package controller | ||||
|  | ||||
| import ( | ||||
| 	"bufio" | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"one-api/model" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| func testChannel(channel *model.Channel, request ChatRequest) (error, *OpenAIError) { | ||||
| 	switch channel.Type { | ||||
| 	case common.ChannelTypePaLM: | ||||
| 		fallthrough | ||||
| 	case common.ChannelTypeAnthropic: | ||||
| 		fallthrough | ||||
| 	case common.ChannelTypeBaidu: | ||||
| 		fallthrough | ||||
| 	case common.ChannelTypeZhipu: | ||||
| 		return errors.New("该渠道类型当前版本不支持测试,请手动测试"), nil | ||||
| 	case common.ChannelTypeAzure: | ||||
| 		request.Model = "gpt-35-turbo" | ||||
| 	default: | ||||
| @@ -45,27 +56,88 @@ func testChannel(channel *model.Channel, request ChatRequest) (error, *OpenAIErr | ||||
| 		req.Header.Set("Authorization", "Bearer "+channel.Key) | ||||
| 	} | ||||
| 	req.Header.Set("Content-Type", "application/json") | ||||
| 	client := &http.Client{} | ||||
| 	resp, err := client.Do(req) | ||||
| 	resp, err := httpClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return err, nil | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
| 	var response TextResponse | ||||
| 	err = json.NewDecoder(resp.Body).Decode(&response) | ||||
| 	if err != nil { | ||||
| 		return err, nil | ||||
| 	} | ||||
| 	if response.Usage.CompletionTokens == 0 { | ||||
| 		return errors.New(fmt.Sprintf("type %s, code %v, message %s", response.Error.Type, response.Error.Code, response.Error.Message)), &response.Error | ||||
|  | ||||
| 	isStream := strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") | ||||
|  | ||||
| 	if channel.AllowStreaming == common.ChannelAllowStreamEnabled && isStream { | ||||
| 		responseText := "" | ||||
| 		scanner := bufio.NewScanner(resp.Body) | ||||
| 		scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) { | ||||
| 			if atEOF && len(data) == 0 { | ||||
| 				return 0, nil, nil | ||||
| 			} | ||||
| 			if i := strings.Index(string(data), "\n"); i >= 0 { | ||||
| 				return i + 1, data[0:i], nil | ||||
| 			} | ||||
| 			if atEOF { | ||||
| 				return len(data), data, nil | ||||
| 			} | ||||
| 			return 0, nil, nil | ||||
| 		}) | ||||
| 		for scanner.Scan() { | ||||
| 			data := scanner.Text() | ||||
| 			if len(data) < 6 { // ignore blank line or wrong format | ||||
| 				continue | ||||
| 			} | ||||
| 			// ChatGPT Next Web | ||||
| 			if strings.HasPrefix(data, "event:") || strings.Contains(data, "event:") { | ||||
| 				// Remove event: event in the front or back | ||||
| 				data = strings.TrimPrefix(data, "event: event") | ||||
| 				data = strings.TrimSuffix(data, "event: event") | ||||
| 				// Remove everything, only keep `data: {...}` <--- this is the json | ||||
| 				// Find the start and end indices of `data: {...}` substring | ||||
| 				startIndex := strings.Index(data, "data:") | ||||
| 				endIndex := strings.LastIndex(data, "}") | ||||
|  | ||||
| 				// If both indices are found and end index is greater than start index | ||||
| 				if startIndex != -1 && endIndex != -1 && endIndex > startIndex { | ||||
| 					// Extract the `data: {...}` substring | ||||
| 					data = data[startIndex : endIndex+1] | ||||
| 				} | ||||
| 			} | ||||
| 			if !strings.HasPrefix(data, "data:") { | ||||
| 				continue | ||||
| 			} | ||||
| 			data = data[6:] | ||||
| 			if !strings.HasPrefix(data, "[DONE]") { | ||||
| 				var streamResponse ChatCompletionsStreamResponse | ||||
| 				err := json.Unmarshal([]byte(data), &streamResponse) | ||||
| 				if err != nil { | ||||
| 					return err, nil | ||||
| 				} | ||||
| 				for _, choice := range streamResponse.Choices { | ||||
| 					responseText += choice.Delta.Content | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if responseText == "" { | ||||
| 			return errors.New("Empty response"), nil | ||||
| 		} | ||||
| 	} else { | ||||
| 		var response TextResponse | ||||
| 		err = json.NewDecoder(resp.Body).Decode(&response) | ||||
| 		if err != nil { | ||||
| 			return err, nil | ||||
| 		} | ||||
| 		if response.Usage.CompletionTokens == 0 { | ||||
| 			return errors.New(fmt.Sprintf("type %s, code %v, message %s", response.Error.Type, response.Error.Code, response.Error.Message)), &response.Error | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return nil, nil | ||||
| } | ||||
|  | ||||
| func buildTestRequest() *ChatRequest { | ||||
| func buildTestRequest(stream bool) *ChatRequest { | ||||
| 	testRequest := &ChatRequest{ | ||||
| 		Model:     "", // this will be set later | ||||
| 		MaxTokens: 1, | ||||
| 		Stream:    stream, | ||||
| 	} | ||||
| 	testMessage := Message{ | ||||
| 		Role:    "user", | ||||
| @@ -92,7 +164,7 @@ func TestChannel(c *gin.Context) { | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	testRequest := buildTestRequest() | ||||
| 	testRequest := buildTestRequest(channel.AllowStreaming == common.ChannelAllowStreamEnabled) | ||||
| 	tik := time.Now() | ||||
| 	err, _ = testChannel(channel, *testRequest) | ||||
| 	tok := time.Now() | ||||
| @@ -147,7 +219,6 @@ func testAllChannels(notify bool) error { | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	testRequest := buildTestRequest() | ||||
| 	var disableThreshold = int64(common.ChannelDisableThreshold * 1000) | ||||
| 	if disableThreshold == 0 { | ||||
| 		disableThreshold = 10000000 // a impossible value | ||||
| @@ -158,6 +229,7 @@ func testAllChannels(notify bool) error { | ||||
| 				continue | ||||
| 			} | ||||
| 			tik := time.Now() | ||||
| 			testRequest := buildTestRequest(channel.AllowStreaming == common.ChannelAllowStreamEnabled) | ||||
| 			err, openaiErr := testChannel(channel, *testRequest) | ||||
| 			tok := time.Now() | ||||
| 			milliseconds := tok.Sub(tik).Milliseconds() | ||||
|   | ||||
| @@ -1,12 +1,13 @@ | ||||
| package controller | ||||
|  | ||||
| import ( | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"one-api/model" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| func GetAllChannels(c *gin.Context) { | ||||
|   | ||||
							
								
								
									
										223
									
								
								controller/discord.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								controller/discord.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,223 @@ | ||||
| package controller | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"one-api/model" | ||||
| 	"strconv" | ||||
|  | ||||
| 	"github.com/gin-contrib/sessions" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| type DiscordOAuthResponse struct { | ||||
| 	AccessToken string `json:"access_token"` | ||||
| 	Scope       string `json:"scope"` | ||||
| 	TokenType   string `json:"token_type"` | ||||
| } | ||||
|  | ||||
| type DiscordUser struct { | ||||
| 	Id       string `json:"id"` | ||||
| 	Username string `json:"username"` | ||||
| } | ||||
|  | ||||
| func getDiscordUserInfoByCode(codeFromURLParamaters string, host string) (*DiscordUser, error) { | ||||
| 	if codeFromURLParamaters == "" { | ||||
| 		return nil, errors.New("无效参数") | ||||
| 	} | ||||
|  | ||||
| 	RequestClient := &http.Client{} | ||||
|  | ||||
| 	accessTokenBody := bytes.NewBuffer([]byte(fmt.Sprintf( | ||||
| 		"client_id=%s&client_secret=%s&grant_type=authorization_code&redirect_uri=https://%s/oauth/discord&code=%s&scope=identify", | ||||
| 		common.DiscordClientId, common.DiscordClientSecret, host, codeFromURLParamaters, | ||||
| 	))) | ||||
|  | ||||
| 	req, _ := http.NewRequest("POST", | ||||
| 		"https://discordapp.com/api/oauth2/token", | ||||
| 		accessTokenBody, | ||||
| 	) | ||||
|  | ||||
| 	req.Header = http.Header{ | ||||
| 		"Content-Type": []string{"application/x-www-form-urlencoded"}, | ||||
| 		"Accept":       []string{"application/json"}, | ||||
| 	} | ||||
|  | ||||
| 	resp, err := RequestClient.Do(req) | ||||
|  | ||||
| 	if resp.StatusCode != 200 || err != nil { | ||||
| 		return nil, errors.New("访问令牌无效") | ||||
| 	} | ||||
|  | ||||
| 	var discordOAuthResponse DiscordOAuthResponse | ||||
|  | ||||
| 	json.NewDecoder(resp.Body).Decode(&discordOAuthResponse) | ||||
|  | ||||
| 	accessToken := fmt.Sprintf("Bearer %s", discordOAuthResponse.AccessToken) | ||||
|  | ||||
| 	// Get User Info | ||||
| 	req, _ = http.NewRequest("GET", "https://discord.com/api/users/@me", nil) | ||||
|  | ||||
| 	req.Header = http.Header{ | ||||
| 		"Content-Type":  []string{"application/json"}, | ||||
| 		"Authorization": []string{accessToken}, | ||||
| 	} | ||||
|  | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	resp, err = RequestClient.Do(req) | ||||
|  | ||||
| 	if resp.StatusCode != 200 || err != nil { | ||||
| 		return nil, errors.New("Discord 用户信息无效") | ||||
| 	} | ||||
|  | ||||
| 	var discordUser DiscordUser | ||||
|  | ||||
| 	json.NewDecoder(resp.Body).Decode(&discordUser) | ||||
|  | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	if discordUser.Id == "" { | ||||
| 		return nil, errors.New("返回值无效,用户字段为空,请稍后再试!") | ||||
| 	} | ||||
|  | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	return &discordUser, nil | ||||
| } | ||||
|  | ||||
| func DiscordOAuth(c *gin.Context) { | ||||
| 	session := sessions.Default(c) | ||||
| 	username := session.Get("username") | ||||
| 	if username != nil { | ||||
| 		DiscordBind(c) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if !common.DiscordOAuthEnabled { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": "管理员未开启通过 Discord 登录以及注册", | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	code := c.Query("code") | ||||
|  | ||||
| 	discordUser, err := getDiscordUserInfoByCode(code, c.Request.Host) | ||||
| 	if err != nil { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": err.Error(), | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	user := model.User{ | ||||
| 		DiscordId: discordUser.Id, | ||||
| 	} | ||||
| 	if model.IsDiscordIdAlreadyTaken(user.DiscordId) { | ||||
| 		err := user.FillUserByDiscordId() | ||||
| 		if err != nil { | ||||
| 			c.JSON(http.StatusOK, gin.H{ | ||||
| 				"success": false, | ||||
| 				"message": err.Error(), | ||||
| 			}) | ||||
| 			return | ||||
| 		} | ||||
| 	} else { | ||||
| 		if common.RegisterEnabled { | ||||
| 			user.Username = "discord_" + strconv.Itoa(model.GetMaxUserId()+1) | ||||
| 			if discordUser.Username != "" { | ||||
| 				user.DisplayName = discordUser.Username | ||||
| 			} else { | ||||
| 				user.DisplayName = "Discord User" | ||||
| 			} | ||||
| 			user.Role = common.RoleCommonUser | ||||
| 			user.Status = common.UserStatusEnabled | ||||
|  | ||||
| 			if err := user.Insert(0); err != nil { | ||||
| 				c.JSON(http.StatusOK, gin.H{ | ||||
| 					"success": false, | ||||
| 					"message": err.Error(), | ||||
| 				}) | ||||
| 				return | ||||
| 			} | ||||
| 		} else { | ||||
| 			c.JSON(http.StatusOK, gin.H{ | ||||
| 				"success": false, | ||||
| 				"message": "管理员关闭了新用户注册", | ||||
| 			}) | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if user.Status != common.UserStatusEnabled { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"message": "用户已被封禁", | ||||
| 			"success": false, | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	setupLogin(&user, c) | ||||
| } | ||||
|  | ||||
| func DiscordBind(c *gin.Context) { | ||||
| 	if !common.DiscordOAuthEnabled { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": "管理员未开启通过 Discord 登录以及注册", | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	code := c.Query("code") | ||||
|  | ||||
| 	discordUser, err := getDiscordUserInfoByCode(code, c.Request.Host) | ||||
| 	if err != nil { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": err.Error(), | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	user := model.User{ | ||||
| 		DiscordId: discordUser.Id, | ||||
| 	} | ||||
| 	if model.IsDiscordIdAlreadyTaken(user.DiscordId) { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": "该 Discord 账户已被绑定", | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	session := sessions.Default(c) | ||||
| 	id := session.Get("id") | ||||
| 	// id := c.GetInt("id")  // critical bug! | ||||
| 	user.Id = id.(int) | ||||
| 	err = user.FillUserById() | ||||
| 	if err != nil { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": err.Error(), | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	user.DiscordId = discordUser.Id | ||||
| 	err = user.Update(false) | ||||
| 	if err != nil { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": err.Error(), | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	c.JSON(http.StatusOK, gin.H{ | ||||
| 		"success": true, | ||||
| 		"message": "bind", | ||||
| 	}) | ||||
| 	return | ||||
| } | ||||
							
								
								
									
										226
									
								
								controller/google.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										226
									
								
								controller/google.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,226 @@ | ||||
| package controller | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"one-api/model" | ||||
| 	"strconv" | ||||
|  | ||||
| 	"github.com/gin-contrib/sessions" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| type GoogleAccessTokenResponse struct { | ||||
| 	AccessToken  string `json:"access_token"` | ||||
| 	ExpiresIn    int    `json:"expires_in"` | ||||
| 	TokenType    string `json:"token_type"` | ||||
| 	Scope        string `json:"scope"` | ||||
| 	RefreshToken string `json:"refresh_token"` | ||||
| } | ||||
|  | ||||
| type GoogleUser struct { | ||||
| 	Sub  string `json:"sub"` | ||||
| 	Name string `json:"name"` | ||||
| } | ||||
|  | ||||
| func getGoogleUserInfoByCode(codeFromURLParamaters string, host string) (*GoogleUser, error) { | ||||
| 	if codeFromURLParamaters == "" { | ||||
| 		return nil, errors.New("无效参数") | ||||
| 	} | ||||
|  | ||||
| 	RequestClient := &http.Client{} | ||||
|  | ||||
| 	accessTokenBody := bytes.NewBuffer([]byte(fmt.Sprintf( | ||||
| 		"code=%s&client_id=%s&client_secret=%s&redirect_uri=https://%s/oauth/google&grant_type=authorization_code", | ||||
| 		codeFromURLParamaters, common.GoogleClientId, common.GoogleClientSecret, host, | ||||
| 	))) | ||||
|  | ||||
| 	req, _ := http.NewRequest("POST", | ||||
| 		"https://oauth2.googleapis.com/token", | ||||
| 		accessTokenBody, | ||||
| 	) | ||||
|  | ||||
| 	req.Header = http.Header{ | ||||
| 		"Content-Type": []string{"application/x-www-form-urlencoded"}, | ||||
| 		"Accept":       []string{"application/json"}, | ||||
| 	} | ||||
|  | ||||
| 	resp, err := RequestClient.Do(req) | ||||
|  | ||||
| 	if resp.StatusCode != 200 || err != nil { | ||||
| 		return nil, errors.New("访问令牌无效") | ||||
| 	} | ||||
|  | ||||
| 	var googleTokenResponse GoogleAccessTokenResponse | ||||
|  | ||||
| 	json.NewDecoder(resp.Body).Decode(&googleTokenResponse) | ||||
|  | ||||
| 	accessToken := "Bearer " + googleTokenResponse.AccessToken | ||||
|  | ||||
| 	// Get User Info | ||||
| 	req, _ = http.NewRequest("GET", "https://www.googleapis.com/oauth2/v3/userinfo", nil) | ||||
|  | ||||
| 	req.Header = http.Header{ | ||||
| 		"Content-Type":  []string{"application/json"}, | ||||
| 		"Authorization": []string{accessToken}, | ||||
| 	} | ||||
|  | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	resp, err = RequestClient.Do(req) | ||||
|  | ||||
| 	if resp.StatusCode != 200 || err != nil { | ||||
| 		return nil, errors.New("Google 用户信息无效") | ||||
| 	} | ||||
|  | ||||
| 	var googleUser GoogleUser | ||||
|  | ||||
| 	// Parse json to googleUser | ||||
| 	err = json.NewDecoder(resp.Body).Decode(&googleUser) | ||||
|  | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	if googleUser.Sub == "" { | ||||
| 		return nil, errors.New("返回值无效,用户字段为空,请稍后再试!") | ||||
| 	} | ||||
|  | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	return &googleUser, nil | ||||
| } | ||||
|  | ||||
| func GoogleOAuth(c *gin.Context) { | ||||
| 	session := sessions.Default(c) | ||||
| 	username := session.Get("username") | ||||
| 	if username != nil { | ||||
| 		GoogleBind(c) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if !common.GoogleOAuthEnabled { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": "管理员未开启通过 Google 登录以及注册", | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	code := c.Query("code") | ||||
|  | ||||
| 	googleUser, err := getGoogleUserInfoByCode(code, c.Request.Host) | ||||
| 	if err != nil { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": err.Error(), | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	user := model.User{ | ||||
| 		GoogleId: googleUser.Sub, | ||||
| 	} | ||||
| 	if model.IsGoogleIdAlreadyTaken(user.GoogleId) { | ||||
| 		err := user.FillUserByGoogleId() | ||||
| 		if err != nil { | ||||
| 			c.JSON(http.StatusOK, gin.H{ | ||||
| 				"success": false, | ||||
| 				"message": err.Error(), | ||||
| 			}) | ||||
| 			return | ||||
| 		} | ||||
| 	} else { | ||||
| 		if common.RegisterEnabled { | ||||
| 			user.Username = "google_" + strconv.Itoa(model.GetMaxUserId()+1) | ||||
| 			if googleUser.Name != "" { | ||||
| 				user.DisplayName = googleUser.Name | ||||
| 			} else { | ||||
| 				user.DisplayName = "Google User" | ||||
| 			} | ||||
| 			user.Role = common.RoleCommonUser | ||||
| 			user.Status = common.UserStatusEnabled | ||||
|  | ||||
| 			if err := user.Insert(0); err != nil { | ||||
| 				c.JSON(http.StatusOK, gin.H{ | ||||
| 					"success": false, | ||||
| 					"message": err.Error(), | ||||
| 				}) | ||||
| 				return | ||||
| 			} | ||||
| 		} else { | ||||
| 			c.JSON(http.StatusOK, gin.H{ | ||||
| 				"success": false, | ||||
| 				"message": "管理员关闭了新用户注册", | ||||
| 			}) | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if user.Status != common.UserStatusEnabled { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"message": "用户已被封禁", | ||||
| 			"success": false, | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	setupLogin(&user, c) | ||||
| } | ||||
|  | ||||
| func GoogleBind(c *gin.Context) { | ||||
| 	if !common.GoogleOAuthEnabled { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": "管理员未开启通过 Google 登录以及注册", | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	code := c.Query("code") | ||||
|  | ||||
| 	googleUser, err := getGoogleUserInfoByCode(code, c.Request.Host) | ||||
| 	if err != nil { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": err.Error(), | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	user := model.User{ | ||||
| 		GoogleId: googleUser.Sub, | ||||
| 	} | ||||
| 	if model.IsGoogleIdAlreadyTaken(user.GoogleId) { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": "该 Google 账户已被绑定", | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	session := sessions.Default(c) | ||||
| 	id := session.Get("id") | ||||
| 	// id := c.GetInt("id")  // critical bug! | ||||
| 	user.Id = id.(int) | ||||
| 	err = user.FillUserById() | ||||
| 	if err != nil { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": err.Error(), | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	user.GoogleId = googleUser.Sub | ||||
| 	err = user.Update(false) | ||||
| 	if err != nil { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": err.Error(), | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	c.JSON(http.StatusOK, gin.H{ | ||||
| 		"success": true, | ||||
| 		"message": "bind", | ||||
| 	}) | ||||
| 	return | ||||
| } | ||||
| @@ -3,10 +3,11 @@ package controller | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"one-api/model" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| func GetStatus(c *gin.Context) { | ||||
| @@ -19,6 +20,10 @@ func GetStatus(c *gin.Context) { | ||||
| 			"email_verification":  common.EmailVerificationEnabled, | ||||
| 			"github_oauth":        common.GitHubOAuthEnabled, | ||||
| 			"github_client_id":    common.GitHubClientId, | ||||
| 			"discord_oauth":       common.DiscordOAuthEnabled, | ||||
| 			"discord_client_id":   common.DiscordClientId, | ||||
| 			"google_oauth":        common.GoogleOAuthEnabled, | ||||
| 			"google_client_id":    common.GoogleClientId, | ||||
| 			"system_name":         common.SystemName, | ||||
| 			"logo":                common.Logo, | ||||
| 			"footer_html":         common.Footer, | ||||
| @@ -127,8 +132,9 @@ func SendPasswordResetEmail(c *gin.Context) { | ||||
| 	link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", common.ServerAddress, email, code) | ||||
| 	subject := fmt.Sprintf("%s密码重置", common.SystemName) | ||||
| 	content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+ | ||||
| 		"<p>点击<a href='%s'>此处</a>进行密码重置。</p>"+ | ||||
| 		"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, common.VerificationValidMinutes) | ||||
| 		"<p>点击 <a href='%s'>此处</a> 进行密码重置。</p>"+ | ||||
| 		"<p>如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:<br> %s </p>"+ | ||||
| 		"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, link, common.VerificationValidMinutes) | ||||
| 	err := common.SendEmail(subject, email, content) | ||||
| 	if err != nil { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
|   | ||||
| @@ -252,24 +252,6 @@ func init() { | ||||
| 			Root:       "code-davinci-edit-001", | ||||
| 			Parent:     nil, | ||||
| 		}, | ||||
| 		{ | ||||
| 			Id:         "ChatGLM", | ||||
| 			Object:     "model", | ||||
| 			Created:    1677649963, | ||||
| 			OwnedBy:    "thudm", | ||||
| 			Permission: permission, | ||||
| 			Root:       "ChatGLM", | ||||
| 			Parent:     nil, | ||||
| 		}, | ||||
| 		{ | ||||
| 			Id:         "ChatGLM2", | ||||
| 			Object:     "model", | ||||
| 			Created:    1677649963, | ||||
| 			OwnedBy:    "thudm", | ||||
| 			Permission: permission, | ||||
| 			Root:       "ChatGLM2", | ||||
| 			Parent:     nil, | ||||
| 		}, | ||||
| 		{ | ||||
| 			Id:         "claude-instant-1", | ||||
| 			Object:     "model", | ||||
| @@ -315,6 +297,33 @@ func init() { | ||||
| 			Root:       "PaLM-2", | ||||
| 			Parent:     nil, | ||||
| 		}, | ||||
| 		{ | ||||
| 			Id:         "chatglm_pro", | ||||
| 			Object:     "model", | ||||
| 			Created:    1677649963, | ||||
| 			OwnedBy:    "zhipu", | ||||
| 			Permission: permission, | ||||
| 			Root:       "chatglm_pro", | ||||
| 			Parent:     nil, | ||||
| 		}, | ||||
| 		{ | ||||
| 			Id:         "chatglm_std", | ||||
| 			Object:     "model", | ||||
| 			Created:    1677649963, | ||||
| 			OwnedBy:    "zhipu", | ||||
| 			Permission: permission, | ||||
| 			Root:       "chatglm_std", | ||||
| 			Parent:     nil, | ||||
| 		}, | ||||
| 		{ | ||||
| 			Id:         "chatglm_lite", | ||||
| 			Object:     "model", | ||||
| 			Created:    1677649963, | ||||
| 			OwnedBy:    "zhipu", | ||||
| 			Permission: permission, | ||||
| 			Root:       "chatglm_lite", | ||||
| 			Parent:     nil, | ||||
| 		}, | ||||
| 	} | ||||
| 	openAIModelsMap = make(map[string]OpenAIModels) | ||||
| 	for _, model := range openAIModels { | ||||
|   | ||||
| @@ -2,11 +2,12 @@ package controller | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"one-api/model" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| func GetOptions(c *gin.Context) { | ||||
| @@ -49,6 +50,14 @@ func UpdateOption(c *gin.Context) { | ||||
| 			}) | ||||
| 			return | ||||
| 		} | ||||
| 	case "DiscordOAuthEnabled": | ||||
| 		if option.Value == "true" && common.DiscordClientId == "" { | ||||
| 			c.JSON(http.StatusOK, gin.H{ | ||||
| 				"success": false, | ||||
| 				"message": "无法启用 Discord OAuth,请先填入 Discord Client ID 以及 Discord Client Secret!", | ||||
| 			}) | ||||
| 			return | ||||
| 		} | ||||
| 	case "WeChatAuthEnabled": | ||||
| 		if option.Value == "true" && common.WeChatServerAddress == "" { | ||||
| 			c.JSON(http.StatusOK, gin.H{ | ||||
| @@ -57,6 +66,14 @@ func UpdateOption(c *gin.Context) { | ||||
| 			}) | ||||
| 			return | ||||
| 		} | ||||
| 	case "GoogleOAuthEnabled": | ||||
| 		if option.Value == "true" && common.GoogleClientId == "" { | ||||
| 			c.JSON(http.StatusOK, gin.H{ | ||||
| 				"success": false, | ||||
| 				"message": "无法启用 Google OAuth,请先填入 Google Client ID 以及 Google Client Secret!", | ||||
| 			}) | ||||
| 			return | ||||
| 		} | ||||
| 	case "TurnstileCheckEnabled": | ||||
| 		if option.Value == "true" && common.TurnstileSiteKey == "" { | ||||
| 			c.JSON(http.StatusOK, gin.H{ | ||||
|   | ||||
| @@ -109,8 +109,7 @@ func relayImageHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode | ||||
| 	req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) | ||||
| 	req.Header.Set("Accept", c.Request.Header.Get("Accept")) | ||||
|  | ||||
| 	client := &http.Client{} | ||||
| 	resp, err := client.Do(req) | ||||
| 	resp, err := httpClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return errorWrapper(err, "do_request_failed", http.StatusInternalServerError) | ||||
| 	} | ||||
|   | ||||
| @@ -4,11 +4,12 @@ import ( | ||||
| 	"bufio" | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| func openaiStreamHandler(c *gin.Context, resp *http.Response, relayMode int) (*OpenAIErrorWithStatusCode, string) { | ||||
| @@ -34,6 +35,25 @@ func openaiStreamHandler(c *gin.Context, resp *http.Response, relayMode int) (*O | ||||
| 			if len(data) < 6 { // ignore blank line or wrong format | ||||
| 				continue | ||||
| 			} | ||||
| 			// ChatGPT Next Web | ||||
| 			if strings.HasPrefix(data, "event:") || strings.Contains(data, "event:") { | ||||
| 				// Remove event: event in the front or back | ||||
| 				data = strings.TrimPrefix(data, "event: event") | ||||
| 				data = strings.TrimSuffix(data, "event: event") | ||||
| 				// Remove everything, only keep `data: {...}` <--- this is the json | ||||
| 				// Find the start and end indices of `data: {...}` substring | ||||
| 				startIndex := strings.Index(data, "data:") | ||||
| 				endIndex := strings.LastIndex(data, "}") | ||||
|  | ||||
| 				// If both indices are found and end index is greater than start index | ||||
| 				if startIndex != -1 && endIndex != -1 && endIndex > startIndex { | ||||
| 					// Extract the `data: {...}` substring | ||||
| 					data = data[startIndex : endIndex+1] | ||||
| 				} | ||||
| 			} | ||||
| 			if !strings.HasPrefix(data, "data:") { | ||||
| 				continue | ||||
| 			} | ||||
| 			dataChan <- data | ||||
| 			data = data[6:] | ||||
| 			if !strings.HasPrefix(data, "[DONE]") { | ||||
| @@ -115,7 +135,7 @@ func openaiHandler(c *gin.Context, resp *http.Response, consumeQuota bool) (*Ope | ||||
| 	} | ||||
| 	// We shouldn't set the header before we parse the response body, because the parse part may fail. | ||||
| 	// And then we will have to send an error response, but in this case, the header has already been set. | ||||
| 	// So the client will be confused by the response. | ||||
| 	// So the httpClient will be confused by the response. | ||||
| 	// For example, Postman will report error, and we cannot check the response at all. | ||||
| 	for k, v := range resp.Header { | ||||
| 		c.Writer.Header().Set(k, v[0]) | ||||
|   | ||||
| @@ -19,8 +19,15 @@ const ( | ||||
| 	APITypeClaude | ||||
| 	APITypePaLM | ||||
| 	APITypeBaidu | ||||
| 	APITypeZhipu | ||||
| ) | ||||
|  | ||||
| var httpClient *http.Client | ||||
|  | ||||
| func init() { | ||||
| 	httpClient = &http.Client{} | ||||
| } | ||||
|  | ||||
| func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 	channelType := c.GetInt("channel") | ||||
| 	tokenId := c.GetInt("token_id") | ||||
| @@ -78,12 +85,15 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 		} | ||||
| 	} | ||||
| 	apiType := APITypeOpenAI | ||||
| 	if strings.HasPrefix(textRequest.Model, "claude") { | ||||
| 	switch channelType { | ||||
| 	case common.ChannelTypeAnthropic: | ||||
| 		apiType = APITypeClaude | ||||
| 	} else if strings.HasPrefix(textRequest.Model, "ERNIE") { | ||||
| 	case common.ChannelTypeBaidu: | ||||
| 		apiType = APITypeBaidu | ||||
| 	} else if strings.HasPrefix(textRequest.Model, "PaLM") { | ||||
| 	case common.ChannelTypePaLM: | ||||
| 		apiType = APITypePaLM | ||||
| 	case common.ChannelTypeZhipu: | ||||
| 		apiType = APITypeZhipu | ||||
| 	} | ||||
| 	baseURL := common.ChannelBaseURLs[channelType] | ||||
| 	requestURL := c.Request.URL.String() | ||||
| @@ -131,9 +141,18 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 		fullRequestURL += "?access_token=" + apiKey // TODO: access token expire in 30 days | ||||
| 	case APITypePaLM: | ||||
| 		fullRequestURL = "https://generativelanguage.googleapis.com/v1beta2/models/chat-bison-001:generateMessage" | ||||
| 		if baseURL != "" { | ||||
| 			fullRequestURL = fmt.Sprintf("%s/v1beta2/models/chat-bison-001:generateMessage", baseURL) | ||||
| 		} | ||||
| 		apiKey := c.Request.Header.Get("Authorization") | ||||
| 		apiKey = strings.TrimPrefix(apiKey, "Bearer ") | ||||
| 		fullRequestURL += "?key=" + apiKey | ||||
| 	case APITypeZhipu: | ||||
| 		method := "invoke" | ||||
| 		if textRequest.Stream { | ||||
| 			method = "sse-invoke" | ||||
| 		} | ||||
| 		fullRequestURL = fmt.Sprintf("https://open.bigmodel.cn/api/paas/v3/model-api/%s/%s", textRequest.Model, method) | ||||
| 	} | ||||
| 	var promptTokens int | ||||
| 	var completionTokens int | ||||
| @@ -200,6 +219,13 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 			return errorWrapper(err, "marshal_text_request_failed", http.StatusInternalServerError) | ||||
| 		} | ||||
| 		requestBody = bytes.NewBuffer(jsonStr) | ||||
| 	case APITypeZhipu: | ||||
| 		zhipuRequest := requestOpenAI2Zhipu(textRequest) | ||||
| 		jsonStr, err := json.Marshal(zhipuRequest) | ||||
| 		if err != nil { | ||||
| 			return errorWrapper(err, "marshal_text_request_failed", http.StatusInternalServerError) | ||||
| 		} | ||||
| 		requestBody = bytes.NewBuffer(jsonStr) | ||||
| 	} | ||||
| 	req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) | ||||
| 	if err != nil { | ||||
| @@ -221,12 +247,14 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 			anthropicVersion = "2023-06-01" | ||||
| 		} | ||||
| 		req.Header.Set("anthropic-version", anthropicVersion) | ||||
| 	case APITypeZhipu: | ||||
| 		token := getZhipuToken(apiKey) | ||||
| 		req.Header.Set("Authorization", token) | ||||
| 	} | ||||
| 	req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) | ||||
| 	req.Header.Set("Accept", c.Request.Header.Get("Accept")) | ||||
| 	//req.Header.Set("Connection", c.Request.Header.Get("Connection")) | ||||
| 	client := &http.Client{} | ||||
| 	resp, err := client.Do(req) | ||||
| 	resp, err := httpClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return errorWrapper(err, "do_request_failed", http.StatusInternalServerError) | ||||
| 	} | ||||
| @@ -252,11 +280,15 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 			if strings.HasPrefix(textRequest.Model, "gpt-4") { | ||||
| 				completionRatio = 2 | ||||
| 			} | ||||
| 			if isStream && apiType != APITypeBaidu { | ||||
| 			if isStream && apiType != APITypeBaidu && apiType != APITypeZhipu { | ||||
| 				completionTokens = countTokenText(streamResponseText, textRequest.Model) | ||||
| 			} else { | ||||
| 				promptTokens = textResponse.Usage.PromptTokens | ||||
| 				completionTokens = textResponse.Usage.CompletionTokens | ||||
| 				if apiType == APITypeZhipu { | ||||
| 					// zhipu's API does not return prompt tokens & completion tokens | ||||
| 					promptTokens = textResponse.Usage.TotalTokens | ||||
| 				} | ||||
| 			} | ||||
| 			quota = promptTokens + int(float64(completionTokens)*completionRatio) | ||||
| 			quota = int(float64(quota) * ratio) | ||||
| @@ -302,7 +334,9 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 			textResponse.Usage = *usage | ||||
| 			if usage != nil { | ||||
| 				textResponse.Usage = *usage | ||||
| 			} | ||||
| 			return nil | ||||
| 		} | ||||
| 	case APITypeClaude: | ||||
| @@ -318,7 +352,9 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 			textResponse.Usage = *usage | ||||
| 			if usage != nil { | ||||
| 				textResponse.Usage = *usage | ||||
| 			} | ||||
| 			return nil | ||||
| 		} | ||||
| 	case APITypeBaidu: | ||||
| @@ -327,14 +363,18 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 			textResponse.Usage = *usage | ||||
| 			if usage != nil { | ||||
| 				textResponse.Usage = *usage | ||||
| 			} | ||||
| 			return nil | ||||
| 		} else { | ||||
| 			err, usage := baiduHandler(c, resp) | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 			textResponse.Usage = *usage | ||||
| 			if usage != nil { | ||||
| 				textResponse.Usage = *usage | ||||
| 			} | ||||
| 			return nil | ||||
| 		} | ||||
| 	case APITypePaLM: | ||||
| @@ -350,7 +390,29 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 			textResponse.Usage = *usage | ||||
| 			if usage != nil { | ||||
| 				textResponse.Usage = *usage | ||||
| 			} | ||||
| 			return nil | ||||
| 		} | ||||
| 	case APITypeZhipu: | ||||
| 		if isStream { | ||||
| 			err, usage := zhipuStreamHandler(c, resp) | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 			if usage != nil { | ||||
| 				textResponse.Usage = *usage | ||||
| 			} | ||||
| 			return nil | ||||
| 		} else { | ||||
| 			err, usage := zhipuHandler(c, resp) | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 			if usage != nil { | ||||
| 				textResponse.Usage = *usage | ||||
| 			} | ||||
| 			return nil | ||||
| 		} | ||||
| 	default: | ||||
|   | ||||
							
								
								
									
										290
									
								
								controller/relay-zhipu.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										290
									
								
								controller/relay-zhipu.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,290 @@ | ||||
| package controller | ||||
|  | ||||
| import ( | ||||
| 	"bufio" | ||||
| 	"encoding/json" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/golang-jwt/jwt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| // https://open.bigmodel.cn/doc/api#chatglm_std | ||||
| // chatglm_std, chatglm_lite | ||||
| // https://open.bigmodel.cn/api/paas/v3/model-api/chatglm_std/invoke | ||||
| // https://open.bigmodel.cn/api/paas/v3/model-api/chatglm_std/sse-invoke | ||||
|  | ||||
| type ZhipuMessage struct { | ||||
| 	Role    string `json:"role"` | ||||
| 	Content string `json:"content"` | ||||
| } | ||||
|  | ||||
| type ZhipuRequest struct { | ||||
| 	Prompt      []ZhipuMessage `json:"prompt"` | ||||
| 	Temperature float64        `json:"temperature,omitempty"` | ||||
| 	TopP        float64        `json:"top_p,omitempty"` | ||||
| 	RequestId   string         `json:"request_id,omitempty"` | ||||
| 	Incremental bool           `json:"incremental,omitempty"` | ||||
| } | ||||
|  | ||||
| type ZhipuResponseData struct { | ||||
| 	TaskId     string         `json:"task_id"` | ||||
| 	RequestId  string         `json:"request_id"` | ||||
| 	TaskStatus string         `json:"task_status"` | ||||
| 	Choices    []ZhipuMessage `json:"choices"` | ||||
| 	Usage      `json:"usage"` | ||||
| } | ||||
|  | ||||
| type ZhipuResponse struct { | ||||
| 	Code    int               `json:"code"` | ||||
| 	Msg     string            `json:"msg"` | ||||
| 	Success bool              `json:"success"` | ||||
| 	Data    ZhipuResponseData `json:"data"` | ||||
| } | ||||
|  | ||||
| type ZhipuStreamMetaResponse struct { | ||||
| 	RequestId  string `json:"request_id"` | ||||
| 	TaskId     string `json:"task_id"` | ||||
| 	TaskStatus string `json:"task_status"` | ||||
| 	Usage      `json:"usage"` | ||||
| } | ||||
|  | ||||
| type zhipuTokenData struct { | ||||
| 	Token      string | ||||
| 	ExpiryTime time.Time | ||||
| } | ||||
|  | ||||
| var zhipuTokens sync.Map | ||||
| var expSeconds int64 = 24 * 3600 | ||||
|  | ||||
| func getZhipuToken(apikey string) string { | ||||
| 	data, ok := zhipuTokens.Load(apikey) | ||||
| 	if ok { | ||||
| 		tokenData := data.(zhipuTokenData) | ||||
| 		if time.Now().Before(tokenData.ExpiryTime) { | ||||
| 			return tokenData.Token | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	split := strings.Split(apikey, ".") | ||||
| 	if len(split) != 2 { | ||||
| 		common.SysError("invalid zhipu key: " + apikey) | ||||
| 		return "" | ||||
| 	} | ||||
|  | ||||
| 	id := split[0] | ||||
| 	secret := split[1] | ||||
|  | ||||
| 	expMillis := time.Now().Add(time.Duration(expSeconds)*time.Second).UnixNano() / 1e6 | ||||
| 	expiryTime := time.Now().Add(time.Duration(expSeconds) * time.Second) | ||||
|  | ||||
| 	timestamp := time.Now().UnixNano() / 1e6 | ||||
|  | ||||
| 	payload := jwt.MapClaims{ | ||||
| 		"api_key":   id, | ||||
| 		"exp":       expMillis, | ||||
| 		"timestamp": timestamp, | ||||
| 	} | ||||
|  | ||||
| 	token := jwt.NewWithClaims(jwt.SigningMethodHS256, payload) | ||||
|  | ||||
| 	token.Header["alg"] = "HS256" | ||||
| 	token.Header["sign_type"] = "SIGN" | ||||
|  | ||||
| 	tokenString, err := token.SignedString([]byte(secret)) | ||||
| 	if err != nil { | ||||
| 		return "" | ||||
| 	} | ||||
|  | ||||
| 	zhipuTokens.Store(apikey, zhipuTokenData{ | ||||
| 		Token:      tokenString, | ||||
| 		ExpiryTime: expiryTime, | ||||
| 	}) | ||||
|  | ||||
| 	return tokenString | ||||
| } | ||||
|  | ||||
| func requestOpenAI2Zhipu(request GeneralOpenAIRequest) *ZhipuRequest { | ||||
| 	messages := make([]ZhipuMessage, 0, len(request.Messages)) | ||||
| 	for _, message := range request.Messages { | ||||
| 		messages = append(messages, ZhipuMessage{ | ||||
| 			Role:    message.Role, | ||||
| 			Content: message.Content, | ||||
| 		}) | ||||
| 	} | ||||
| 	return &ZhipuRequest{ | ||||
| 		Prompt:      messages, | ||||
| 		Temperature: request.Temperature, | ||||
| 		TopP:        request.TopP, | ||||
| 		Incremental: false, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func responseZhipu2OpenAI(response *ZhipuResponse) *OpenAITextResponse { | ||||
| 	fullTextResponse := OpenAITextResponse{ | ||||
| 		Id:      response.Data.TaskId, | ||||
| 		Object:  "chat.completion", | ||||
| 		Created: common.GetTimestamp(), | ||||
| 		Choices: make([]OpenAITextResponseChoice, 0, len(response.Data.Choices)), | ||||
| 		Usage:   response.Data.Usage, | ||||
| 	} | ||||
| 	for i, choice := range response.Data.Choices { | ||||
| 		openaiChoice := OpenAITextResponseChoice{ | ||||
| 			Index: i, | ||||
| 			Message: Message{ | ||||
| 				Role:    choice.Role, | ||||
| 				Content: strings.Trim(choice.Content, "\""), | ||||
| 			}, | ||||
| 			FinishReason: "", | ||||
| 		} | ||||
| 		if i == len(response.Data.Choices)-1 { | ||||
| 			openaiChoice.FinishReason = "stop" | ||||
| 		} | ||||
| 		fullTextResponse.Choices = append(fullTextResponse.Choices, openaiChoice) | ||||
| 	} | ||||
| 	return &fullTextResponse | ||||
| } | ||||
|  | ||||
| func streamResponseZhipu2OpenAI(zhipuResponse string) *ChatCompletionsStreamResponse { | ||||
| 	var choice ChatCompletionsStreamResponseChoice | ||||
| 	choice.Delta.Content = zhipuResponse | ||||
| 	choice.FinishReason = "" | ||||
| 	response := ChatCompletionsStreamResponse{ | ||||
| 		Object:  "chat.completion.chunk", | ||||
| 		Created: common.GetTimestamp(), | ||||
| 		Model:   "chatglm", | ||||
| 		Choices: []ChatCompletionsStreamResponseChoice{choice}, | ||||
| 	} | ||||
| 	return &response | ||||
| } | ||||
|  | ||||
| func streamMetaResponseZhipu2OpenAI(zhipuResponse *ZhipuStreamMetaResponse) (*ChatCompletionsStreamResponse, *Usage) { | ||||
| 	var choice ChatCompletionsStreamResponseChoice | ||||
| 	choice.Delta.Content = "" | ||||
| 	choice.FinishReason = "stop" | ||||
| 	response := ChatCompletionsStreamResponse{ | ||||
| 		Id:      zhipuResponse.RequestId, | ||||
| 		Object:  "chat.completion.chunk", | ||||
| 		Created: common.GetTimestamp(), | ||||
| 		Model:   "chatglm", | ||||
| 		Choices: []ChatCompletionsStreamResponseChoice{choice}, | ||||
| 	} | ||||
| 	return &response, &zhipuResponse.Usage | ||||
| } | ||||
|  | ||||
| func zhipuStreamHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStatusCode, *Usage) { | ||||
| 	var usage *Usage | ||||
| 	scanner := bufio.NewScanner(resp.Body) | ||||
| 	scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) { | ||||
| 		if atEOF && len(data) == 0 { | ||||
| 			return 0, nil, nil | ||||
| 		} | ||||
| 		if i := strings.Index(string(data), "\n"); i >= 0 { | ||||
| 			return i + 1, data[0:i], nil | ||||
| 		} | ||||
| 		if atEOF { | ||||
| 			return len(data), data, nil | ||||
| 		} | ||||
| 		return 0, nil, nil | ||||
| 	}) | ||||
| 	dataChan := make(chan string) | ||||
| 	metaChan := make(chan string) | ||||
| 	stopChan := make(chan bool) | ||||
| 	go func() { | ||||
| 		for scanner.Scan() { | ||||
| 			data := scanner.Text() | ||||
| 			data = strings.Trim(data, "\"") | ||||
| 			if len(data) < 5 { // ignore blank line or wrong format | ||||
| 				continue | ||||
| 			} | ||||
| 			if data[:5] == "data:" { | ||||
| 				dataChan <- data[5:] | ||||
| 			} else if data[:5] == "meta:" { | ||||
| 				metaChan <- data[5:] | ||||
| 			} | ||||
| 		} | ||||
| 		stopChan <- true | ||||
| 	}() | ||||
| 	c.Writer.Header().Set("Content-Type", "text/event-stream") | ||||
| 	c.Writer.Header().Set("Cache-Control", "no-cache") | ||||
| 	c.Writer.Header().Set("Connection", "keep-alive") | ||||
| 	c.Writer.Header().Set("Transfer-Encoding", "chunked") | ||||
| 	c.Writer.Header().Set("X-Accel-Buffering", "no") | ||||
| 	c.Stream(func(w io.Writer) bool { | ||||
| 		select { | ||||
| 		case data := <-dataChan: | ||||
| 			response := streamResponseZhipu2OpenAI(data) | ||||
| 			jsonResponse, err := json.Marshal(response) | ||||
| 			if err != nil { | ||||
| 				common.SysError("error marshalling stream response: " + err.Error()) | ||||
| 				return true | ||||
| 			} | ||||
| 			c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)}) | ||||
| 			return true | ||||
| 		case data := <-metaChan: | ||||
| 			var zhipuResponse ZhipuStreamMetaResponse | ||||
| 			err := json.Unmarshal([]byte(data), &zhipuResponse) | ||||
| 			if err != nil { | ||||
| 				common.SysError("error unmarshalling stream response: " + err.Error()) | ||||
| 				return true | ||||
| 			} | ||||
| 			response, zhipuUsage := streamMetaResponseZhipu2OpenAI(&zhipuResponse) | ||||
| 			jsonResponse, err := json.Marshal(response) | ||||
| 			if err != nil { | ||||
| 				common.SysError("error marshalling stream response: " + err.Error()) | ||||
| 				return true | ||||
| 			} | ||||
| 			usage = zhipuUsage | ||||
| 			c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)}) | ||||
| 			return true | ||||
| 		case <-stopChan: | ||||
| 			c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) | ||||
| 			return false | ||||
| 		} | ||||
| 	}) | ||||
| 	err := resp.Body.Close() | ||||
| 	if err != nil { | ||||
| 		return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil | ||||
| 	} | ||||
| 	return nil, usage | ||||
| } | ||||
|  | ||||
| func zhipuHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStatusCode, *Usage) { | ||||
| 	var zhipuResponse ZhipuResponse | ||||
| 	responseBody, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return errorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil | ||||
| 	} | ||||
| 	err = resp.Body.Close() | ||||
| 	if err != nil { | ||||
| 		return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil | ||||
| 	} | ||||
| 	err = json.Unmarshal(responseBody, &zhipuResponse) | ||||
| 	if err != nil { | ||||
| 		return errorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil | ||||
| 	} | ||||
| 	if !zhipuResponse.Success { | ||||
| 		return &OpenAIErrorWithStatusCode{ | ||||
| 			OpenAIError: OpenAIError{ | ||||
| 				Message: zhipuResponse.Msg, | ||||
| 				Type:    "zhipu_error", | ||||
| 				Param:   "", | ||||
| 				Code:    zhipuResponse.Code, | ||||
| 			}, | ||||
| 			StatusCode: resp.StatusCode, | ||||
| 		}, nil | ||||
| 	} | ||||
| 	fullTextResponse := responseZhipu2OpenAI(&zhipuResponse) | ||||
| 	jsonResponse, err := json.Marshal(fullTextResponse) | ||||
| 	if err != nil { | ||||
| 		return errorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil | ||||
| 	} | ||||
| 	c.Writer.Header().Set("Content-Type", "application/json") | ||||
| 	c.Writer.WriteHeader(resp.StatusCode) | ||||
| 	_, err = c.Writer.Write(jsonResponse) | ||||
| 	return nil, &fullTextResponse.Usage | ||||
| } | ||||
| @@ -46,6 +46,7 @@ type ChatRequest struct { | ||||
| 	Model     string    `json:"model"` | ||||
| 	Messages  []Message `json:"messages"` | ||||
| 	MaxTokens int       `json:"max_tokens"` | ||||
| 	Stream    bool      `json:"stream"` | ||||
| } | ||||
|  | ||||
| type TextRequest struct { | ||||
|   | ||||
| @@ -3,12 +3,13 @@ package controller | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"github.com/gin-contrib/sessions" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"one-api/model" | ||||
| 	"strconv" | ||||
|  | ||||
| 	"github.com/gin-contrib/sessions" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| type LoginRequest struct { | ||||
| @@ -477,6 +478,16 @@ func DeleteUser(c *gin.Context) { | ||||
|  | ||||
| func DeleteSelf(c *gin.Context) { | ||||
| 	id := c.GetInt("id") | ||||
| 	user, _ := model.GetUserById(id, false) | ||||
|  | ||||
| 	if user.Role == common.RoleRootUser { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": "不能删除超级管理员账户", | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	err := model.DeleteUserById(id) | ||||
| 	if err != nil { | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
|   | ||||
| @@ -2,7 +2,7 @@ version: '3.4' | ||||
|  | ||||
| services: | ||||
|   one-api: | ||||
|     image: justsong/one-api:latest | ||||
|     image: ckt1031/one-api:latest | ||||
|     container_name: one-api | ||||
|     restart: always | ||||
|     command: --log-dir /app/logs | ||||
|   | ||||
							
								
								
									
										39
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										39
									
								
								go.mod
									
									
									
									
									
								
							| @@ -9,49 +9,50 @@ require ( | ||||
| 	github.com/gin-contrib/sessions v0.0.5 | ||||
| 	github.com/gin-contrib/static v0.0.1 | ||||
| 	github.com/gin-gonic/gin v1.9.1 | ||||
| 	github.com/go-playground/validator/v10 v10.14.0 | ||||
| 	github.com/go-playground/validator/v10 v10.14.1 | ||||
| 	github.com/go-redis/redis/v8 v8.11.5 | ||||
| 	github.com/golang-jwt/jwt v3.2.2+incompatible | ||||
| 	github.com/google/uuid v1.3.0 | ||||
| 	github.com/pkoukk/tiktoken-go v0.1.1 | ||||
| 	golang.org/x/crypto v0.9.0 | ||||
| 	gorm.io/driver/mysql v1.4.3 | ||||
| 	gorm.io/driver/sqlite v1.4.3 | ||||
| 	gorm.io/gorm v1.24.0 | ||||
| 	github.com/pkoukk/tiktoken-go v0.1.5 | ||||
| 	golang.org/x/crypto v0.11.0 | ||||
| 	gorm.io/driver/mysql v1.5.1 | ||||
| 	gorm.io/driver/sqlite v1.5.2 | ||||
| 	gorm.io/gorm v1.25.2 | ||||
| ) | ||||
|  | ||||
| require ( | ||||
| 	github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect | ||||
| 	github.com/bytedance/sonic v1.9.1 // indirect | ||||
| 	github.com/cespare/xxhash/v2 v2.1.2 // indirect | ||||
| 	github.com/bytedance/sonic v1.9.2 // indirect | ||||
| 	github.com/cespare/xxhash/v2 v2.2.0 // indirect | ||||
| 	github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect | ||||
| 	github.com/chenzhuoyu/iasm v0.9.0 // indirect | ||||
| 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect | ||||
| 	github.com/dlclark/regexp2 v1.8.1 // indirect | ||||
| 	github.com/dlclark/regexp2 v1.10.0 // indirect | ||||
| 	github.com/gabriel-vasile/mimetype v1.4.2 // indirect | ||||
| 	github.com/gin-contrib/sse v0.1.0 // indirect | ||||
| 	github.com/go-playground/locales v0.14.1 // indirect | ||||
| 	github.com/go-playground/universal-translator v0.18.1 // indirect | ||||
| 	github.com/go-sql-driver/mysql v1.6.0 // indirect | ||||
| 	github.com/go-sql-driver/mysql v1.7.1 // indirect | ||||
| 	github.com/goccy/go-json v0.10.2 // indirect | ||||
| 	github.com/gomodule/redigo v2.0.0+incompatible // indirect | ||||
| 	github.com/gorilla/context v1.1.1 // indirect | ||||
| 	github.com/gorilla/securecookie v1.1.1 // indirect | ||||
| 	github.com/gorilla/sessions v1.2.1 // indirect | ||||
| 	github.com/jinzhu/inflection v1.0.0 // indirect | ||||
| 	github.com/jinzhu/now v1.1.5 // indirect | ||||
| 	github.com/json-iterator/go v1.1.12 // indirect | ||||
| 	github.com/klauspost/cpuid/v2 v2.2.4 // indirect | ||||
| 	github.com/klauspost/cpuid/v2 v2.2.5 // indirect | ||||
| 	github.com/knz/go-libedit v1.10.1 // indirect | ||||
| 	github.com/leodido/go-urn v1.2.4 // indirect | ||||
| 	github.com/mattn/go-isatty v0.0.19 // indirect | ||||
| 	github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect | ||||
| 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect | ||||
| 	github.com/modern-go/reflect2 v1.0.2 // indirect | ||||
| 	github.com/pelletier/go-toml/v2 v2.0.8 // indirect | ||||
| 	github.com/pelletier/go-toml/v2 v2.0.9 // indirect | ||||
| 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect | ||||
| 	github.com/ugorji/go/codec v1.2.11 // indirect | ||||
| 	golang.org/x/arch v0.3.0 // indirect | ||||
| 	golang.org/x/net v0.10.0 // indirect | ||||
| 	golang.org/x/sys v0.8.0 // indirect | ||||
| 	golang.org/x/text v0.9.0 // indirect | ||||
| 	google.golang.org/protobuf v1.30.0 // indirect | ||||
| 	golang.org/x/arch v0.4.0 // indirect | ||||
| 	golang.org/x/net v0.12.0 // indirect | ||||
| 	golang.org/x/sys v0.10.0 // indirect | ||||
| 	golang.org/x/text v0.11.0 // indirect | ||||
| 	google.golang.org/protobuf v1.31.0 // indirect | ||||
| 	gopkg.in/yaml.v3 v3.0.1 // indirect | ||||
| ) | ||||
|   | ||||
							
								
								
									
										56
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										56
									
								
								go.sum
									
									
									
									
									
								
							| @@ -1,13 +1,23 @@ | ||||
| github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff h1:RmdPFa+slIr4SCBg4st/l/vZWVe9QJKMXGO60Bxbe04= | ||||
| github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw= | ||||
| github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= | ||||
| github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= | ||||
| github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= | ||||
| github.com/bytedance/sonic v1.9.2 h1:GDaNjuWSGu09guE9Oql0MSTNhNCLlWwO8y/xM5BzcbM= | ||||
| github.com/bytedance/sonic v1.9.2/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= | ||||
| github.com/bytedance/sonic v1.10.0-rc h1:3S5HeWxjX08CUqNrXtEittExpJsEKBNzrV5UnrzHxVQ= | ||||
| github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= | ||||
| github.com/bytedance/sonic v1.10.0-rc2 h1:oDfRZ+4m6AYCOC0GFeOCeYqvBmucy1isvouS2K0cPzo= | ||||
| github.com/bytedance/sonic v1.10.0-rc2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= | ||||
| github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= | ||||
| github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= | ||||
| github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= | ||||
| github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= | ||||
| github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= | ||||
| github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= | ||||
| github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= | ||||
| github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= | ||||
| github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= | ||||
| github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo= | ||||
| github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= | ||||
| github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= | ||||
| github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||
| github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||||
| @@ -16,6 +26,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r | ||||
| github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= | ||||
| github.com/dlclark/regexp2 v1.8.1 h1:6Lcdwya6GjPUNsBct8Lg/yRPwMhABj269AAzdGSiR+0= | ||||
| github.com/dlclark/regexp2 v1.8.1/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= | ||||
| github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= | ||||
| github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= | ||||
| github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= | ||||
| github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= | ||||
| github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= | ||||
| @@ -47,17 +59,22 @@ github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GO | ||||
| github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= | ||||
| github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= | ||||
| github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= | ||||
| github.com/go-playground/validator/v10 v10.14.1 h1:9c50NUPC30zyuKprjL3vNZ0m5oG+jU0zvx4AqHGnv4k= | ||||
| github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= | ||||
| github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= | ||||
| github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= | ||||
| github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= | ||||
| github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= | ||||
| github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= | ||||
| github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= | ||||
| github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= | ||||
| github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= | ||||
| github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= | ||||
| github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= | ||||
| github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= | ||||
| github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= | ||||
| github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= | ||||
| github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= | ||||
| github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0= | ||||
| github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= | ||||
| github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= | ||||
| github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||||
| github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= | ||||
| @@ -67,7 +84,6 @@ github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8 | ||||
| github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= | ||||
| github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= | ||||
| github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= | ||||
| github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= | ||||
| github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= | ||||
| github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= | ||||
| github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= | ||||
| @@ -81,6 +97,10 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm | ||||
| github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= | ||||
| github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= | ||||
| github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= | ||||
| github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= | ||||
| github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= | ||||
| github.com/knz/go-libedit v1.10.1 h1:0pHpWtx9vcvC0xGZqEQlQdfSQs7WRlAjuPvk3fOZDCo= | ||||
| github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= | ||||
| github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= | ||||
| github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= | ||||
| github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= | ||||
| @@ -112,9 +132,13 @@ github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= | ||||
| github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo= | ||||
| github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= | ||||
| github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= | ||||
| github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0= | ||||
| github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= | ||||
| github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= | ||||
| github.com/pkoukk/tiktoken-go v0.1.1 h1:jtkYlIECjyM9OW1w4rjPmTohK4arORP9V25y6TM6nXo= | ||||
| github.com/pkoukk/tiktoken-go v0.1.1/go.mod h1:boMWvk9pQCOTx11pgu0DrIdrAKgQzzJKUP6vLXaz7Rw= | ||||
| github.com/pkoukk/tiktoken-go v0.1.5 h1:hAlT4dCf6Uk50x8E7HQrddhH3EWMKUN+LArExQQsQx4= | ||||
| github.com/pkoukk/tiktoken-go v0.1.5/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= | ||||
| github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||||
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||||
| github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= | ||||
| @@ -133,6 +157,7 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o | ||||
| github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= | ||||
| github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= | ||||
| github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= | ||||
| github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= | ||||
| github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= | ||||
| github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= | ||||
| github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= | ||||
| @@ -144,27 +169,38 @@ github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ | ||||
| golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= | ||||
| golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= | ||||
| golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= | ||||
| golang.org/x/arch v0.4.0 h1:A8WCeEWhLwPBKNbFi5Wv5UTCBx5zzubnXDlMOFAzFMc= | ||||
| golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= | ||||
| golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= | ||||
| golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= | ||||
| golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= | ||||
| golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= | ||||
| golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= | ||||
| golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= | ||||
| golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= | ||||
| golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= | ||||
| golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= | ||||
| golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= | ||||
| golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||
| golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||
| golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= | ||||
| golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= | ||||
| golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= | ||||
| golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= | ||||
| golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||
| golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||
| golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= | ||||
| golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= | ||||
| golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= | ||||
| golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= | ||||
| golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | ||||
| golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= | ||||
| golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||
| @@ -172,6 +208,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 | ||||
| google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= | ||||
| google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= | ||||
| google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= | ||||
| google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= | ||||
| google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= | ||||
| gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||
| gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||
| gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= | ||||
| @@ -188,9 +226,17 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||||
| gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||
| gorm.io/driver/mysql v1.4.3 h1:/JhWJhO2v17d8hjApTltKNADm7K7YI2ogkR7avJUL3k= | ||||
| gorm.io/driver/mysql v1.4.3/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c= | ||||
| gorm.io/driver/mysql v1.5.1 h1:WUEH5VF9obL/lTtzjmML/5e6VfFR/788coz2uaVCAZw= | ||||
| gorm.io/driver/mysql v1.5.1/go.mod h1:Jo3Xu7mMhCyj8dlrb3WoCaRd1FhsVh+yMXb1jUInf5o= | ||||
| gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU= | ||||
| gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI= | ||||
| gorm.io/driver/sqlite v1.5.2 h1:TpQ+/dqCY4uCigCFyrfnrJnrW9zjpelWVoEVNy5qJkc= | ||||
| gorm.io/driver/sqlite v1.5.2/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4= | ||||
| gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= | ||||
| gorm.io/gorm v1.24.0 h1:j/CoiSm6xpRpmzbFJsQHYj+I8bGYWLXVHeYEyyKlF74= | ||||
| gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= | ||||
| gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= | ||||
| gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho= | ||||
| gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= | ||||
| nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= | ||||
| rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= | ||||
|   | ||||
							
								
								
									
										1
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								main.go
									
									
									
									
									
								
							| @@ -54,6 +54,7 @@ func main() { | ||||
| 		if err != nil { | ||||
| 			common.FatalLog("failed to parse SYNC_FREQUENCY: " + err.Error()) | ||||
| 		} | ||||
| 		common.SyncFrequency = frequency | ||||
| 		go model.SyncOptions(frequency) | ||||
| 		if common.RedisEnabled { | ||||
| 			go model.SyncChannelCache(frequency) | ||||
|   | ||||
| @@ -2,6 +2,7 @@ package middleware | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"log" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"one-api/model" | ||||
| @@ -12,7 +13,8 @@ import ( | ||||
| ) | ||||
|  | ||||
| type ModelRequest struct { | ||||
| 	Model string `json:"model"` | ||||
| 	Model  string `json:"model"` | ||||
| 	Stream bool   `json:"stream" default:"true"` | ||||
| } | ||||
|  | ||||
| func Distribute() func(c *gin.Context) { | ||||
| @@ -84,9 +86,10 @@ func Distribute() func(c *gin.Context) { | ||||
| 					modelRequest.Model = "dall-e" | ||||
| 				} | ||||
| 			} | ||||
| 			channel, err = model.CacheGetRandomSatisfiedChannel(userGroup, modelRequest.Model) | ||||
| 			log.Print(modelRequest.Stream) | ||||
| 			channel, err = model.CacheGetRandomSatisfiedChannel(userGroup, modelRequest.Model, modelRequest.Stream) | ||||
| 			if err != nil { | ||||
| 				message := "无可用渠道" | ||||
| 				message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", userGroup, modelRequest.Model) | ||||
| 				if channel != nil { | ||||
| 					common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id)) | ||||
| 					message = "数据库一致性已被破坏,请联系管理员" | ||||
|   | ||||
| @@ -1,24 +1,36 @@ | ||||
| package model | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"one-api/common" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| type Ability struct { | ||||
| 	Group     string `json:"group" gorm:"type:varchar(32);primaryKey;autoIncrement:false"` | ||||
| 	Model     string `json:"model" gorm:"primaryKey;autoIncrement:false"` | ||||
| 	ChannelId int    `json:"channel_id" gorm:"primaryKey;autoIncrement:false;index"` | ||||
| 	Enabled   bool   `json:"enabled"` | ||||
| 	Group             string `json:"group" gorm:"type:varchar(32);primaryKey;autoIncrement:false"` | ||||
| 	Model             string `json:"model" gorm:"primaryKey;autoIncrement:false"` | ||||
| 	ChannelId         int    `json:"channel_id" gorm:"primaryKey;autoIncrement:false;index"` | ||||
| 	Enabled           bool   `json:"enabled"` | ||||
| 	AllowStreaming    int    `json:"allow_streaming" gorm:"default:1"` | ||||
| 	AllowNonStreaming int    `json:"allow_non_streaming" gorm:"default:1"` | ||||
| } | ||||
|  | ||||
| func GetRandomSatisfiedChannel(group string, model string) (*Channel, error) { | ||||
| func GetRandomSatisfiedChannel(group string, model string, stream bool) (*Channel, error) { | ||||
| 	ability := Ability{} | ||||
| 	var err error = nil | ||||
| 	if common.UsingSQLite { | ||||
| 		err = DB.Where("`group` = ? and model = ? and enabled = 1", group, model).Order("RANDOM()").Limit(1).First(&ability).Error | ||||
|  | ||||
| 	cmd := "`group` = ? and model = ? and enabled = 1" | ||||
|  | ||||
| 	if stream { | ||||
| 		cmd += fmt.Sprintf(" and allow_streaming = %d", common.ChannelAllowStreamEnabled) | ||||
| 	} else { | ||||
| 		err = DB.Where("`group` = ? and model = ? and enabled = 1", group, model).Order("RAND()").Limit(1).First(&ability).Error | ||||
| 		cmd += fmt.Sprintf(" and allow_non_streaming = %d", common.ChannelAllowNonStreamEnabled) | ||||
| 	} | ||||
|  | ||||
| 	if common.UsingSQLite { | ||||
| 		err = DB.Where(cmd, group, model).Order("RANDOM()").Limit(1).First(&ability).Error | ||||
| 	} else { | ||||
| 		err = DB.Where(cmd, group, model).Order("RAND()").Limit(1).First(&ability).Error | ||||
| 	} | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| @@ -36,10 +48,12 @@ func (channel *Channel) AddAbilities() error { | ||||
| 	for _, model := range models_ { | ||||
| 		for _, group := range groups_ { | ||||
| 			ability := Ability{ | ||||
| 				Group:     group, | ||||
| 				Model:     model, | ||||
| 				ChannelId: channel.Id, | ||||
| 				Enabled:   channel.Status == common.ChannelStatusEnabled, | ||||
| 				Group:             group, | ||||
| 				Model:             model, | ||||
| 				ChannelId:         channel.Id, | ||||
| 				Enabled:           channel.Status == common.ChannelStatusEnabled, | ||||
| 				AllowStreaming:    channel.AllowStreaming, | ||||
| 				AllowNonStreaming: channel.AllowNonStreaming, | ||||
| 			} | ||||
| 			abilities = append(abilities, ability) | ||||
| 		} | ||||
|   | ||||
| @@ -12,11 +12,11 @@ import ( | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	TokenCacheSeconds         = 60 * 60 | ||||
| 	UserId2GroupCacheSeconds  = 60 * 60 | ||||
| 	UserId2QuotaCacheSeconds  = 10 * 60 | ||||
| 	UserId2StatusCacheSeconds = 60 * 60 | ||||
| var ( | ||||
| 	TokenCacheSeconds         = common.SyncFrequency | ||||
| 	UserId2GroupCacheSeconds  = common.SyncFrequency | ||||
| 	UserId2QuotaCacheSeconds  = common.SyncFrequency | ||||
| 	UserId2StatusCacheSeconds = common.SyncFrequency | ||||
| ) | ||||
|  | ||||
| func CacheGetTokenByKey(key string) (*Token, error) { | ||||
| @@ -35,7 +35,7 @@ func CacheGetTokenByKey(key string) (*Token, error) { | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 		err = common.RedisSet(fmt.Sprintf("token:%s", key), string(jsonBytes), TokenCacheSeconds*time.Second) | ||||
| 		err = common.RedisSet(fmt.Sprintf("token:%s", key), string(jsonBytes), time.Duration(TokenCacheSeconds)*time.Second) | ||||
| 		if err != nil { | ||||
| 			common.SysError("Redis set token error: " + err.Error()) | ||||
| 		} | ||||
| @@ -55,7 +55,7 @@ func CacheGetUserGroup(id int) (group string, err error) { | ||||
| 		if err != nil { | ||||
| 			return "", err | ||||
| 		} | ||||
| 		err = common.RedisSet(fmt.Sprintf("user_group:%d", id), group, UserId2GroupCacheSeconds*time.Second) | ||||
| 		err = common.RedisSet(fmt.Sprintf("user_group:%d", id), group, time.Duration(UserId2GroupCacheSeconds)*time.Second) | ||||
| 		if err != nil { | ||||
| 			common.SysError("Redis set user group error: " + err.Error()) | ||||
| 		} | ||||
| @@ -73,7 +73,7 @@ func CacheGetUserQuota(id int) (quota int, err error) { | ||||
| 		if err != nil { | ||||
| 			return 0, err | ||||
| 		} | ||||
| 		err = common.RedisSet(fmt.Sprintf("user_quota:%d", id), fmt.Sprintf("%d", quota), UserId2QuotaCacheSeconds*time.Second) | ||||
| 		err = common.RedisSet(fmt.Sprintf("user_quota:%d", id), fmt.Sprintf("%d", quota), time.Duration(UserId2QuotaCacheSeconds)*time.Second) | ||||
| 		if err != nil { | ||||
| 			common.SysError("Redis set user quota error: " + err.Error()) | ||||
| 		} | ||||
| @@ -91,7 +91,7 @@ func CacheUpdateUserQuota(id int) error { | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	err = common.RedisSet(fmt.Sprintf("user_quota:%d", id), fmt.Sprintf("%d", quota), UserId2QuotaCacheSeconds*time.Second) | ||||
| 	err = common.RedisSet(fmt.Sprintf("user_quota:%d", id), fmt.Sprintf("%d", quota), time.Duration(UserId2QuotaCacheSeconds)*time.Second) | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| @@ -106,7 +106,7 @@ func CacheIsUserEnabled(userId int) bool { | ||||
| 			status = common.UserStatusEnabled | ||||
| 		} | ||||
| 		enabled = fmt.Sprintf("%d", status) | ||||
| 		err = common.RedisSet(fmt.Sprintf("user_enabled:%d", userId), enabled, UserId2StatusCacheSeconds*time.Second) | ||||
| 		err = common.RedisSet(fmt.Sprintf("user_enabled:%d", userId), enabled, time.Duration(UserId2StatusCacheSeconds)*time.Second) | ||||
| 		if err != nil { | ||||
| 			common.SysError("Redis set user enabled error: " + err.Error()) | ||||
| 		} | ||||
| @@ -160,9 +160,9 @@ func SyncChannelCache(frequency int) { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func CacheGetRandomSatisfiedChannel(group string, model string) (*Channel, error) { | ||||
| func CacheGetRandomSatisfiedChannel(group string, model string, stream bool) (*Channel, error) { | ||||
| 	if !common.RedisEnabled { | ||||
| 		return GetRandomSatisfiedChannel(group, model) | ||||
| 		return GetRandomSatisfiedChannel(group, model, stream) | ||||
| 	} | ||||
| 	channelSyncLock.RLock() | ||||
| 	defer channelSyncLock.RUnlock() | ||||
| @@ -170,6 +170,14 @@ func CacheGetRandomSatisfiedChannel(group string, model string) (*Channel, error | ||||
| 	if len(channels) == 0 { | ||||
| 		return nil, errors.New("channel not found") | ||||
| 	} | ||||
| 	idx := rand.Intn(len(channels)) | ||||
| 	return channels[idx], nil | ||||
|  | ||||
| 	var filteredChannels []*Channel | ||||
| 	for _, channel := range channels { | ||||
| 		if (stream && channel.AllowStreaming == common.ChannelAllowStreamEnabled) || (!stream && channel.AllowNonStreaming == common.ChannelAllowNonStreamEnabled) { | ||||
| 			filteredChannels = append(filteredChannels, channel) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	idx := rand.Intn(len(filteredChannels)) | ||||
| 	return filteredChannels[idx], nil | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,9 @@ | ||||
| package model | ||||
|  | ||||
| import ( | ||||
| 	"gorm.io/gorm" | ||||
| 	"one-api/common" | ||||
|  | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| type Channel struct { | ||||
| @@ -23,6 +24,8 @@ type Channel struct { | ||||
| 	Group              string  `json:"group" gorm:"type:varchar(32);default:'default'"` | ||||
| 	UsedQuota          int64   `json:"used_quota" gorm:"bigint;default:0"` | ||||
| 	ModelMapping       string  `json:"model_mapping" gorm:"type:varchar(1024);default:''"` | ||||
| 	AllowStreaming     int     `json:"allow_streaming" gorm:"default:1"` | ||||
| 	AllowNonStreaming  int     `json:"allow_non_streaming" gorm:"default:1"` | ||||
| } | ||||
|  | ||||
| func GetAllChannels(startIdx int, num int, selectAll bool) ([]*Channel, error) { | ||||
|   | ||||
| @@ -30,7 +30,9 @@ 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["DiscordOAuthEnabled"] = strconv.FormatBool(common.DiscordOAuthEnabled) | ||||
| 	common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled) | ||||
| 	common.OptionMap["GoogleOAuthEnabled"] = strconv.FormatBool(common.GoogleOAuthEnabled) | ||||
| 	common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled) | ||||
| 	common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled) | ||||
| 	common.OptionMap["AutomaticDisableChannelEnabled"] = strconv.FormatBool(common.AutomaticDisableChannelEnabled) | ||||
| @@ -53,9 +55,13 @@ func InitOptionMap() { | ||||
| 	common.OptionMap["ServerAddress"] = "" | ||||
| 	common.OptionMap["GitHubClientId"] = "" | ||||
| 	common.OptionMap["GitHubClientSecret"] = "" | ||||
| 	common.OptionMap["DiscordClientId"] = "" | ||||
| 	common.OptionMap["DiscordClientSecret"] = "" | ||||
| 	common.OptionMap["WeChatServerAddress"] = "" | ||||
| 	common.OptionMap["WeChatServerToken"] = "" | ||||
| 	common.OptionMap["WeChatAccountQRCodeImageURL"] = "" | ||||
| 	common.OptionMap["GoogleClientId"] = "" | ||||
| 	common.OptionMap["GoogleClientSecret"] = "" | ||||
| 	common.OptionMap["TurnstileSiteKey"] = "" | ||||
| 	common.OptionMap["TurnstileSecretKey"] = "" | ||||
| 	common.OptionMap["QuotaForNewUser"] = strconv.Itoa(common.QuotaForNewUser) | ||||
| @@ -135,8 +141,12 @@ func updateOptionMap(key string, value string) (err error) { | ||||
| 			common.EmailVerificationEnabled = boolValue | ||||
| 		case "GitHubOAuthEnabled": | ||||
| 			common.GitHubOAuthEnabled = boolValue | ||||
| 		case "DiscordOAuthEnabled": | ||||
| 			common.DiscordOAuthEnabled = boolValue | ||||
| 		case "WeChatAuthEnabled": | ||||
| 			common.WeChatAuthEnabled = boolValue | ||||
| 		case "GoogleOAuthEnabled": | ||||
| 			common.GoogleOAuthEnabled = boolValue | ||||
| 		case "TurnstileCheckEnabled": | ||||
| 			common.TurnstileCheckEnabled = boolValue | ||||
| 		case "RegisterEnabled": | ||||
| @@ -171,6 +181,10 @@ func updateOptionMap(key string, value string) (err error) { | ||||
| 		common.GitHubClientId = value | ||||
| 	case "GitHubClientSecret": | ||||
| 		common.GitHubClientSecret = value | ||||
| 	case "DiscordClientId": | ||||
| 		common.DiscordClientId = value | ||||
| 	case "DiscordClientSecret": | ||||
| 		common.DiscordClientSecret = value | ||||
| 	case "Footer": | ||||
| 		common.Footer = value | ||||
| 	case "SystemName": | ||||
| @@ -183,6 +197,10 @@ func updateOptionMap(key string, value string) (err error) { | ||||
| 		common.WeChatServerToken = value | ||||
| 	case "WeChatAccountQRCodeImageURL": | ||||
| 		common.WeChatAccountQRCodeImageURL = value | ||||
| 	case "GoogleClientId": | ||||
| 		common.GoogleClientId = value | ||||
| 	case "GoogleClientSecret": | ||||
| 		common.GoogleClientSecret = value | ||||
| 	case "TurnstileSiteKey": | ||||
| 		common.TurnstileSiteKey = value | ||||
| 	case "TurnstileSecretKey": | ||||
|   | ||||
| @@ -51,20 +51,21 @@ func Redeem(key string, userId int) (quota int, err error) { | ||||
| 	redemption := &Redemption{} | ||||
|  | ||||
| 	err = DB.Transaction(func(tx *gorm.DB) error { | ||||
| 		err := DB.Where("`key` = ?", key).First(redemption).Error | ||||
| 		err := tx.Set("gorm:query_option", "FOR UPDATE").Where("`key` = ?", key).First(redemption).Error | ||||
| 		if err != nil { | ||||
| 			return errors.New("无效的兑换码") | ||||
| 		} | ||||
| 		if redemption.Status != common.RedemptionCodeStatusEnabled { | ||||
| 			return errors.New("该兑换码已被使用") | ||||
| 		} | ||||
| 		err = DB.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error | ||||
| 		err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		redemption.RedeemedTime = common.GetTimestamp() | ||||
| 		redemption.Status = common.RedemptionCodeStatusUsed | ||||
| 		return redemption.SelectUpdate() | ||||
| 		err = tx.Save(redemption).Error | ||||
| 		return err | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		return 0, errors.New("兑换失败," + err.Error()) | ||||
|   | ||||
| @@ -3,9 +3,10 @@ package model | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"gorm.io/gorm" | ||||
| 	"one-api/common" | ||||
| 	"strings" | ||||
|  | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| // User if you add sensitive fields, don't forget to clean them in setupLogin function. | ||||
| @@ -19,7 +20,9 @@ type User struct { | ||||
| 	Status           int    `json:"status" gorm:"type:int;default:1"` // enabled, disabled | ||||
| 	Email            string `json:"email" gorm:"index" validate:"max=50"` | ||||
| 	GitHubId         string `json:"github_id" gorm:"column:github_id;index"` | ||||
| 	DiscordId        string `json:"discord_id" gorm:"column:discord_id;index"` | ||||
| 	WeChatId         string `json:"wechat_id" gorm:"column:wechat_id;index"` | ||||
| 	GoogleId         string `json:"google_id" gorm:"column:google_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"` | ||||
| @@ -169,6 +172,14 @@ func (user *User) FillUserByGitHubId() error { | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (user *User) FillUserByDiscordId() error { | ||||
| 	if user.DiscordId == "" { | ||||
| 		return errors.New("Discord id 为空!") | ||||
| 	} | ||||
| 	DB.Where(User{DiscordId: user.DiscordId}).First(user) | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (user *User) FillUserByWeChatId() error { | ||||
| 	if user.WeChatId == "" { | ||||
| 		return errors.New("WeChat id 为空!") | ||||
| @@ -177,6 +188,14 @@ func (user *User) FillUserByWeChatId() error { | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (user *User) FillUserByGoogleId() error { | ||||
| 	if user.GoogleId == "" { | ||||
| 		return errors.New("Google id 为空!") | ||||
| 	} | ||||
| 	DB.Where(User{GoogleId: user.GoogleId}).First(user) | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (user *User) FillUserByUsername() error { | ||||
| 	if user.Username == "" { | ||||
| 		return errors.New("username 为空!") | ||||
| @@ -193,6 +212,14 @@ func IsWeChatIdAlreadyTaken(wechatId string) bool { | ||||
| 	return DB.Where("wechat_id = ?", wechatId).Find(&User{}).RowsAffected == 1 | ||||
| } | ||||
|  | ||||
| func IsDiscordIdAlreadyTaken(discordId string) bool { | ||||
| 	return DB.Where("discord_id = ?", discordId).Find(&User{}).RowsAffected == 1 | ||||
| } | ||||
|  | ||||
| func IsGoogleIdAlreadyTaken(googleId string) bool { | ||||
| 	return DB.Where("google_id = ?", googleId).Find(&User{}).RowsAffected == 1 | ||||
| } | ||||
|  | ||||
| func IsGitHubIdAlreadyTaken(githubId string) bool { | ||||
| 	return DB.Where("github_id = ?", githubId).Find(&User{}).RowsAffected == 1 | ||||
| } | ||||
|   | ||||
| @@ -21,14 +21,16 @@ func SetApiRouter(router *gin.Engine) { | ||||
| 		apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail) | ||||
| 		apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword) | ||||
| 		apiRouter.GET("/oauth/github", middleware.CriticalRateLimit(), controller.GitHubOAuth) | ||||
| 		apiRouter.GET("/oauth/discord", middleware.CriticalRateLimit(), controller.DiscordOAuth) | ||||
| 		apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth) | ||||
| 		apiRouter.GET("/oauth/google", middleware.CriticalRateLimit(), controller.GoogleOAuth) | ||||
| 		apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.WeChatBind) | ||||
| 		apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.EmailBind) | ||||
|  | ||||
| 		userRoute := apiRouter.Group("/user") | ||||
| 		{ | ||||
| 			userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register) | ||||
| 			userRoute.POST("/login", middleware.CriticalRateLimit(), controller.Login) | ||||
| 			userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Login) | ||||
| 			userRoute.GET("/logout", controller.Logout) | ||||
|  | ||||
| 			selfRoute := userRoute.Group("/") | ||||
| @@ -36,7 +38,7 @@ func SetApiRouter(router *gin.Engine) { | ||||
| 			{ | ||||
| 				selfRoute.GET("/self", controller.GetSelf) | ||||
| 				selfRoute.PUT("/self", controller.UpdateSelf) | ||||
| 				selfRoute.DELETE("/self", controller.DeleteSelf) | ||||
| 				selfRoute.DELETE("/self", middleware.TurnstileCheck(), controller.DeleteSelf) | ||||
| 				selfRoute.GET("/token", controller.GenerateAccessToken) | ||||
| 				selfRoute.GET("/aff", controller.GetAffCode) | ||||
| 				selfRoute.POST("/topup", controller.TopUp) | ||||
|   | ||||
| @@ -12,7 +12,7 @@ func SetRelayRouter(router *gin.Engine) { | ||||
| 	modelsRouter := router.Group("/v1/models") | ||||
| 	modelsRouter.Use(middleware.TokenAuth()) | ||||
| 	{ | ||||
| 		modelsRouter.GET("/", controller.ListModels) | ||||
| 		modelsRouter.GET("", controller.ListModels) | ||||
| 		modelsRouter.GET("/:model", controller.RetrieveModel) | ||||
| 	} | ||||
| 	relayV1Router := router.Group("/v1") | ||||
|   | ||||
| @@ -3,18 +3,19 @@ | ||||
|   "version": "0.1.0", | ||||
|   "private": true, | ||||
|   "dependencies": { | ||||
|     "axios": "^0.27.2", | ||||
|     "@babel/plugin-proposal-private-property-in-object": "^7.21.11", | ||||
|     "axios": "^1.4.0", | ||||
|     "history": "^5.3.0", | ||||
|     "marked": "^4.1.1", | ||||
|     "marked": "^5.1.1", | ||||
|     "react": "^18.2.0", | ||||
|     "react-dom": "^18.2.0", | ||||
|     "react-dropzone": "^14.2.3", | ||||
|     "react-router-dom": "^6.3.0", | ||||
|     "react-router-dom": "^6.14.2", | ||||
|     "react-scripts": "5.0.1", | ||||
|     "react-toastify": "^9.0.8", | ||||
|     "react-turnstile": "^1.0.5", | ||||
|     "react-toastify": "^9.1.3", | ||||
|     "react-turnstile": "^1.1.1", | ||||
|     "semantic-ui-css": "^2.5.0", | ||||
|     "semantic-ui-react": "^2.1.3" | ||||
|     "semantic-ui-react": "^2.1.4" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "start": "react-scripts start", | ||||
| @@ -41,7 +42,7 @@ | ||||
|     ] | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "prettier": "^2.7.1" | ||||
|     "prettier": "^3.0.0" | ||||
|   }, | ||||
|   "prettier": { | ||||
|     "singleQuote": true, | ||||
|   | ||||
| @@ -12,6 +12,8 @@ import AddUser from './pages/User/AddUser'; | ||||
| import { API, getLogo, getSystemName, showError, showNotice } from './helpers'; | ||||
| import PasswordResetForm from './components/PasswordResetForm'; | ||||
| import GitHubOAuth from './components/GitHubOAuth'; | ||||
| import DiscordOAuth from './components/DiscordOAuth'; | ||||
| import GoogleOAuth from './components/GoogleOAuth'; | ||||
| import PasswordResetConfirm from './components/PasswordResetConfirm'; | ||||
| import { UserContext } from './context/User'; | ||||
| import { StatusContext } from './context/Status'; | ||||
| @@ -239,6 +241,24 @@ function App() { | ||||
|           </Suspense> | ||||
|         } | ||||
|       /> | ||||
|       <Route | ||||
|         HEAD | ||||
|         path='/oauth/discord' | ||||
|         element={ | ||||
|           <Suspense fallback={<Loading></Loading>}> | ||||
|             <DiscordOAuth /> | ||||
|           </Suspense> | ||||
|         } | ||||
|       /> | ||||
|       <Route | ||||
|         path='/oauth/google' | ||||
|         element={ | ||||
|           <Suspense fallback={<Loading></Loading>}> | ||||
|             <GoogleOAuth /> | ||||
|             support-google-oauth | ||||
|           </Suspense> | ||||
|         } | ||||
|       /> | ||||
|       <Route | ||||
|         path='/setting' | ||||
|         element={ | ||||
| @@ -252,11 +272,11 @@ function App() { | ||||
|       <Route | ||||
|         path='/topup' | ||||
|         element={ | ||||
|         <PrivateRoute> | ||||
|           <Suspense fallback={<Loading></Loading>}> | ||||
|             <TopUp /> | ||||
|           </Suspense> | ||||
|         </PrivateRoute> | ||||
|           <PrivateRoute> | ||||
|             <Suspense fallback={<Loading></Loading>}> | ||||
|               <TopUp /> | ||||
|             </Suspense> | ||||
|           </PrivateRoute> | ||||
|         } | ||||
|       /> | ||||
|       <Route | ||||
|   | ||||
							
								
								
									
										57
									
								
								web/src/components/DiscordOAuth.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								web/src/components/DiscordOAuth.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| import React, { useContext, useEffect, useState } from 'react'; | ||||
| import { Dimmer, Loader, Segment } from 'semantic-ui-react'; | ||||
| import { useNavigate, useSearchParams } from 'react-router-dom'; | ||||
| import { API, showError, showSuccess } from '../helpers'; | ||||
| import { UserContext } from '../context/User'; | ||||
|  | ||||
| const DiscordOAuth = () => { | ||||
|   const [searchParams, setSearchParams] = useSearchParams(); | ||||
|  | ||||
|   const [userState, userDispatch] = useContext(UserContext); | ||||
|   const [prompt, setPrompt] = useState('处理中...'); | ||||
|   const [processing, setProcessing] = useState(true); | ||||
|  | ||||
|   let navigate = useNavigate(); | ||||
|  | ||||
|   const sendCode = async (code, count) => { | ||||
|     const res = await API.get(`/api/oauth/discord?code=${code}`); | ||||
|     const { success, message, data } = res.data; | ||||
|     if (success) { | ||||
|       if (message === 'bind') { | ||||
|         showSuccess('绑定成功!'); | ||||
|         navigate('/setting'); | ||||
|       } else { | ||||
|         userDispatch({ type: 'login', payload: data }); | ||||
|         localStorage.setItem('user', JSON.stringify(data)); | ||||
|         showSuccess('登录成功!'); | ||||
|         navigate('/'); | ||||
|       } | ||||
|     } else { | ||||
|       showError(message); | ||||
|       if (count === 0) { | ||||
|         setPrompt(`操作失败,重定向至登录界面中...`); | ||||
|         navigate('/setting'); // in case this is failed to bind GitHub | ||||
|         return; | ||||
|       } | ||||
|       count++; | ||||
|       setPrompt(`出现错误,第 ${count} 次重试中...`); | ||||
|       await new Promise((resolve) => setTimeout(resolve, count * 2000)); | ||||
|       await sendCode(code, count); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     let code = searchParams.get('code'); | ||||
|     sendCode(code, 0).then(); | ||||
|   }, []); | ||||
|  | ||||
|   return ( | ||||
|     <Segment style={{ minHeight: '300px' }}> | ||||
|       <Dimmer active inverted> | ||||
|         <Loader size='large'>{prompt}</Loader> | ||||
|       </Dimmer> | ||||
|     </Segment> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default DiscordOAuth; | ||||
							
								
								
									
										57
									
								
								web/src/components/GoogleOAuth.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								web/src/components/GoogleOAuth.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| import React, { useContext, useEffect, useState } from 'react'; | ||||
| import { Dimmer, Loader, Segment } from 'semantic-ui-react'; | ||||
| import { useNavigate, useSearchParams } from 'react-router-dom'; | ||||
| import { API, showError, showSuccess } from '../helpers'; | ||||
| import { UserContext } from '../context/User'; | ||||
|  | ||||
| const GoogleOAuth = () => { | ||||
|   const [searchParams, setSearchParams] = useSearchParams(); | ||||
|  | ||||
|   const [userState, userDispatch] = useContext(UserContext); | ||||
|   const [prompt, setPrompt] = useState('处理中...'); | ||||
|   const [processing, setProcessing] = useState(true); | ||||
|  | ||||
|   let navigate = useNavigate(); | ||||
|  | ||||
|   const sendCode = async (code, count) => { | ||||
|     const res = await API.get(`/api/oauth/google?code=${code}`); | ||||
|     const { success, message, data } = res.data; | ||||
|     if (success) { | ||||
|       if (message === 'bind') { | ||||
|         showSuccess('绑定成功!'); | ||||
|         navigate('/setting'); | ||||
|       } else { | ||||
|         userDispatch({ type: 'login', payload: data }); | ||||
|         localStorage.setItem('user', JSON.stringify(data)); | ||||
|         showSuccess('登录成功!'); | ||||
|         navigate('/'); | ||||
|       } | ||||
|     } else { | ||||
|       showError(message); | ||||
|       if (count === 0) { | ||||
|         setPrompt(`操作失败,重定向至登录界面中...`); | ||||
|         navigate('/setting'); // in case this is failed to bind GitHub | ||||
|         return; | ||||
|       } | ||||
|       count++; | ||||
|       setPrompt(`出现错误,第 ${count} 次重试中...`); | ||||
|       await new Promise((resolve) => setTimeout(resolve, count * 2000)); | ||||
|       await sendCode(code, count); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     let code = searchParams.get('code'); | ||||
|     sendCode(code, 0).then(); | ||||
|   }, []); | ||||
|  | ||||
|   return ( | ||||
|     <Segment style={{ minHeight: '300px' }}> | ||||
|       <Dimmer active inverted> | ||||
|         <Loader size='large'>{prompt}</Loader> | ||||
|       </Dimmer> | ||||
|     </Segment> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default GoogleOAuth; | ||||
| @@ -1,53 +1,63 @@ | ||||
| import React, { useContext, useEffect, useState } from 'react'; | ||||
| import { | ||||
|   Button, | ||||
|   Divider, | ||||
|   Form, | ||||
|   Grid, | ||||
|   Header, | ||||
|   Image, | ||||
|   Message, | ||||
|   Modal, | ||||
|   Segment, | ||||
| } from 'semantic-ui-react'; | ||||
| import { Button, Divider, Form, Grid, Header, Image, Message, Modal, Segment } from 'semantic-ui-react'; | ||||
| import { Link, useNavigate, useSearchParams } from 'react-router-dom'; | ||||
| import { UserContext } from '../context/User'; | ||||
| import { API, getLogo, showError, showSuccess, showInfo } from '../helpers'; | ||||
| import { API, getLogo, showError, showInfo, showSuccess } from '../helpers'; | ||||
| import Turnstile from 'react-turnstile'; | ||||
|  | ||||
| const LoginForm = () => { | ||||
|   const [inputs, setInputs] = useState({ | ||||
|     username: '', | ||||
|     password: '', | ||||
|     wechat_verification_code: '', | ||||
|     wechat_verification_code: '' | ||||
|   }); | ||||
|   const [searchParams, setSearchParams] = useSearchParams(); | ||||
|   const [submitted, setSubmitted] = useState(false); | ||||
|   const { username, password } = inputs; | ||||
|   const [userState, userDispatch] = useContext(UserContext); | ||||
|   const [turnstileEnabled, setTurnstileEnabled] = useState(false); | ||||
|   const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); | ||||
|   const [turnstileToken, setTurnstileToken] = useState(''); | ||||
|   let navigate = useNavigate(); | ||||
|  | ||||
|   const [status, setStatus] = useState({}); | ||||
|   const logo = getLogo(); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (searchParams.get("expired")) { | ||||
|     if (searchParams.get('expired')) { | ||||
|       showError('未登录或登录已过期,请重新登录!'); | ||||
|     } | ||||
|     let status = localStorage.getItem('status'); | ||||
|     if (status) { | ||||
|       status = JSON.parse(status); | ||||
|       setStatus(status); | ||||
|  | ||||
|       if (status.turnstile_check) { | ||||
|         setTurnstileEnabled(true); | ||||
|         setTurnstileSiteKey(status.turnstile_site_key); | ||||
|       } | ||||
|     } | ||||
|   }, []); | ||||
|  | ||||
|   const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false); | ||||
|  | ||||
|   const openGoogleOAuth = () => { | ||||
|     window.open( | ||||
|       `https://accounts.google.com/o/oauth2/v2/auth?client_id=${status.google_client_id}&redirect_uri=${window.location.origin}/oauth/google&response_type=code&scope=profile` | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const onGitHubOAuthClicked = () => { | ||||
|     window.open( | ||||
|       `https://github.com/login/oauth/authorize?client_id=${status.github_client_id}&scope=user:email` | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const onDiscordOAuthClicked = () => { | ||||
|     window.open( | ||||
|       `https://discord.com/oauth2/authorize?response_type=code&client_id=${status.discord_client_id}&redirect_uri=${window.location.origin}/oauth/discord&scope=identify`, | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const onWeChatLoginClicked = () => { | ||||
|     setShowWeChatLoginModal(true); | ||||
|   }; | ||||
| @@ -76,9 +86,14 @@ const LoginForm = () => { | ||||
|   async function handleSubmit(e) { | ||||
|     setSubmitted(true); | ||||
|     if (username && password) { | ||||
|       const res = await API.post(`/api/user/login`, { | ||||
|       if (turnstileEnabled && turnstileToken === '') { | ||||
|         showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       const res = await API.post(`/api/user/login?turnstile=${turnstileToken}`, { | ||||
|         username, | ||||
|         password, | ||||
|         password | ||||
|       }); | ||||
|       const { success, message, data } = res.data; | ||||
|       if (success) { | ||||
| @@ -93,69 +108,91 @@ const LoginForm = () => { | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Grid textAlign="center" style={{ marginTop: '48px' }}> | ||||
|     <Grid textAlign='center' style={{ marginTop: '48px' }}> | ||||
|       <Grid.Column style={{ maxWidth: 450 }}> | ||||
|         <Header as="h2" color="" textAlign="center"> | ||||
|         <Header as='h2' color='' textAlign='center'> | ||||
|           <Image src={logo} /> 用户登录 | ||||
|         </Header> | ||||
|         <Form size="large"> | ||||
|         <Form size='large'> | ||||
|           <Segment> | ||||
|             <Form.Input | ||||
|               fluid | ||||
|               icon="user" | ||||
|               iconPosition="left" | ||||
|               placeholder="用户名" | ||||
|               name="username" | ||||
|               icon='user' | ||||
|               iconPosition='left' | ||||
|               placeholder='用户名' | ||||
|               name='username' | ||||
|               value={username} | ||||
|               onChange={handleChange} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               fluid | ||||
|               icon="lock" | ||||
|               iconPosition="left" | ||||
|               placeholder="密码" | ||||
|               name="password" | ||||
|               type="password" | ||||
|               icon='lock' | ||||
|               iconPosition='left' | ||||
|               placeholder='密码' | ||||
|               name='password' | ||||
|               type='password' | ||||
|               value={password} | ||||
|               onChange={handleChange} | ||||
|             /> | ||||
|             <Button color="" fluid size="large" onClick={handleSubmit}> | ||||
|             {turnstileEnabled ? ( | ||||
|               <Turnstile | ||||
|                 sitekey={turnstileSiteKey} | ||||
|                 onVerify={(token) => { | ||||
|                   setTurnstileToken(token); | ||||
|                 }} | ||||
|               /> | ||||
|             ) : ( | ||||
|               <></> | ||||
|             )} | ||||
|             <Button color='green' fluid size='large' onClick={handleSubmit}> | ||||
|               登录 | ||||
|             </Button> | ||||
|           </Segment> | ||||
|         </Form> | ||||
|         <Message> | ||||
|           忘记密码? | ||||
|           <Link to="/reset" className="btn btn-link"> | ||||
|           <Link to='/reset' className='btn btn-link'> | ||||
|             点击重置 | ||||
|           </Link> | ||||
|           ; 没有账户? | ||||
|           <Link to="/register" className="btn btn-link"> | ||||
|           <Link to='/register' className='btn btn-link'> | ||||
|             点击注册 | ||||
|           </Link> | ||||
|         </Message> | ||||
|         {status.github_oauth || status.wechat_login ? ( | ||||
|         {status.github_oauth || status.wechat_login || status.discord_oauth || status.google_oauth ? ( | ||||
|           <> | ||||
|             <Divider horizontal>Or</Divider> | ||||
|             {status.github_oauth ? ( | ||||
|             {status.discord_oauth && ( | ||||
|               <Button | ||||
|                 circular | ||||
|                 color="black" | ||||
|                 icon="github" | ||||
|                 color='blue' | ||||
|                 icon='discord' | ||||
|                 onClick={onDiscordOAuthClicked} | ||||
|               /> | ||||
|             )} | ||||
|             {status.github_oauth && ( | ||||
|               <Button | ||||
|                 circular | ||||
|                 color='black' | ||||
|                 icon='github' | ||||
|                 onClick={onGitHubOAuthClicked} | ||||
|               /> | ||||
|             ) : ( | ||||
|               <></> | ||||
|             )} | ||||
|             {status.wechat_login ? ( | ||||
|             {status.wechat_login && ( | ||||
|               <Button | ||||
|                 circular | ||||
|                 color="green" | ||||
|                 icon="wechat" | ||||
|                 color='green' | ||||
|                 icon='wechat' | ||||
|                 onClick={onWeChatLoginClicked} | ||||
|               /> | ||||
|             ) : ( | ||||
|               <></> | ||||
|             )} | ||||
|             {status.google_oauth && ( | ||||
|               <Button | ||||
|                 circular | ||||
|                 color='red' | ||||
|                 icon='google' | ||||
|                 onClick={openGoogleOAuth} | ||||
|               /> | ||||
|             )} | ||||
|           </> | ||||
|         ) : ( | ||||
| @@ -175,18 +212,18 @@ const LoginForm = () => { | ||||
|                   微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效) | ||||
|                 </p> | ||||
|               </div> | ||||
|               <Form size="large"> | ||||
|               <Form size='large'> | ||||
|                 <Form.Input | ||||
|                   fluid | ||||
|                   placeholder="验证码" | ||||
|                   name="wechat_verification_code" | ||||
|                   placeholder='验证码' | ||||
|                   name='wechat_verification_code' | ||||
|                   value={inputs.wechat_verification_code} | ||||
|                   onChange={handleChange} | ||||
|                 /> | ||||
|                 <Button | ||||
|                   color="" | ||||
|                   color='' | ||||
|                   fluid | ||||
|                   size="large" | ||||
|                   size='large' | ||||
|                   onClick={onSubmitWeChatVerificationCode} | ||||
|                 > | ||||
|                   登录 | ||||
|   | ||||
| @@ -12,6 +12,11 @@ const PasswordResetConfirm = () => { | ||||
|  | ||||
|   const [loading, setLoading] = useState(false); | ||||
|  | ||||
|   const [disableButton, setDisableButton] = useState(false); | ||||
|   const [countdown, setCountdown] = useState(30); | ||||
|  | ||||
|   const [newPassword, setNewPassword] = useState(''); | ||||
|  | ||||
|   const [searchParams, setSearchParams] = useSearchParams(); | ||||
|   useEffect(() => { | ||||
|     let token = searchParams.get('token'); | ||||
| @@ -22,7 +27,21 @@ const PasswordResetConfirm = () => { | ||||
|     }); | ||||
|   }, []); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     let countdownInterval = null; | ||||
|     if (disableButton && countdown > 0) { | ||||
|       countdownInterval = setInterval(() => { | ||||
|         setCountdown(countdown - 1); | ||||
|       }, 1000); | ||||
|     } else if (countdown === 0) { | ||||
|       setDisableButton(false); | ||||
|       setCountdown(30); | ||||
|     } | ||||
|     return () => clearInterval(countdownInterval);  | ||||
|   }, [disableButton, countdown]); | ||||
|  | ||||
|   async function handleSubmit(e) { | ||||
|     setDisableButton(true); | ||||
|     if (!email) return; | ||||
|     setLoading(true); | ||||
|     const res = await API.post(`/api/user/reset`, { | ||||
| @@ -32,14 +51,15 @@ const PasswordResetConfirm = () => { | ||||
|     const { success, message } = res.data; | ||||
|     if (success) { | ||||
|       let password = res.data.data; | ||||
|       setNewPassword(password); | ||||
|       await copy(password); | ||||
|       showNotice(`密码已重置并已复制到剪贴板:${password}`); | ||||
|       showNotice(`新密码已复制到剪贴板:${password}`); | ||||
|     } else { | ||||
|       showError(message); | ||||
|     } | ||||
|     setLoading(false); | ||||
|   } | ||||
|  | ||||
|    | ||||
|   return ( | ||||
|     <Grid textAlign='center' style={{ marginTop: '48px' }}> | ||||
|       <Grid.Column style={{ maxWidth: 450 }}> | ||||
| @@ -57,20 +77,37 @@ const PasswordResetConfirm = () => { | ||||
|               value={email} | ||||
|               readOnly | ||||
|             /> | ||||
|             {newPassword && ( | ||||
|               <Form.Input | ||||
|               fluid | ||||
|               icon='lock' | ||||
|               iconPosition='left' | ||||
|               placeholder='新密码' | ||||
|               name='newPassword' | ||||
|               value={newPassword} | ||||
|               readOnly | ||||
|               onClick={(e) => { | ||||
|                 e.target.select(); | ||||
|                 navigator.clipboard.writeText(newPassword); | ||||
|                 showNotice(`密码已复制到剪贴板:${newPassword}`); | ||||
|               }} | ||||
|             />             | ||||
|             )} | ||||
|             <Button | ||||
|               color='' | ||||
|               color='green' | ||||
|               fluid | ||||
|               size='large' | ||||
|               onClick={handleSubmit} | ||||
|               loading={loading} | ||||
|               disabled={disableButton} | ||||
|             > | ||||
|               提交 | ||||
|               {disableButton ? `密码重置完成` : '提交'} | ||||
|             </Button> | ||||
|           </Segment> | ||||
|         </Form> | ||||
|       </Grid.Column> | ||||
|     </Grid> | ||||
|   ); | ||||
|   );   | ||||
| }; | ||||
|  | ||||
| export default PasswordResetConfirm; | ||||
|   | ||||
| @@ -5,7 +5,7 @@ import Turnstile from 'react-turnstile'; | ||||
|  | ||||
| const PasswordResetForm = () => { | ||||
|   const [inputs, setInputs] = useState({ | ||||
|     email: '', | ||||
|     email: '' | ||||
|   }); | ||||
|   const { email } = inputs; | ||||
|  | ||||
| @@ -13,24 +13,29 @@ const PasswordResetForm = () => { | ||||
|   const [turnstileEnabled, setTurnstileEnabled] = useState(false); | ||||
|   const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); | ||||
|   const [turnstileToken, setTurnstileToken] = useState(''); | ||||
|   const [disableButton, setDisableButton] = useState(false); | ||||
|   const [countdown, setCountdown] = useState(30); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     let status = localStorage.getItem('status'); | ||||
|     if (status) { | ||||
|       status = JSON.parse(status); | ||||
|       if (status.turnstile_check) { | ||||
|         setTurnstileEnabled(true); | ||||
|         setTurnstileSiteKey(status.turnstile_site_key); | ||||
|       } | ||||
|     let countdownInterval = null; | ||||
|     if (disableButton && countdown > 0) { | ||||
|       countdownInterval = setInterval(() => { | ||||
|         setCountdown(countdown - 1); | ||||
|       }, 1000); | ||||
|     } else if (countdown === 0) { | ||||
|       setDisableButton(false); | ||||
|       setCountdown(30); | ||||
|     } | ||||
|   }, []); | ||||
|     return () => clearInterval(countdownInterval); | ||||
|   }, [disableButton, countdown]); | ||||
|  | ||||
|   function handleChange(e) { | ||||
|     const { name, value } = e.target; | ||||
|     setInputs((inputs) => ({ ...inputs, [name]: value })); | ||||
|     setInputs(inputs => ({ ...inputs, [name]: value })); | ||||
|   } | ||||
|  | ||||
|   async function handleSubmit(e) { | ||||
|     setDisableButton(true); | ||||
|     if (!email) return; | ||||
|     if (turnstileEnabled && turnstileToken === '') { | ||||
|       showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); | ||||
| @@ -78,13 +83,14 @@ const PasswordResetForm = () => { | ||||
|               <></> | ||||
|             )} | ||||
|             <Button | ||||
|               color='' | ||||
|               color='green' | ||||
|               fluid | ||||
|               size='large' | ||||
|               onClick={handleSubmit} | ||||
|               loading={loading} | ||||
|               disabled={disableButton} | ||||
|             > | ||||
|               提交 | ||||
|               {disableButton ? `重试 (${countdown})` : '提交'} | ||||
|             </Button> | ||||
|           </Segment> | ||||
|         </Form> | ||||
|   | ||||
| @@ -1,22 +1,30 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import React, { useContext, useEffect, useState } from 'react'; | ||||
| import { Button, Divider, Form, Header, Image, Message, Modal } from 'semantic-ui-react'; | ||||
| import { Link } from 'react-router-dom'; | ||||
| import { Link, useNavigate } from 'react-router-dom'; | ||||
| import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers'; | ||||
| import Turnstile from 'react-turnstile'; | ||||
| import { UserContext } from '../context/User'; | ||||
|  | ||||
| const PersonalSetting = () => { | ||||
|   const [userState, userDispatch] = useContext(UserContext); | ||||
|   let navigate = useNavigate(); | ||||
|  | ||||
|   const [inputs, setInputs] = useState({ | ||||
|     wechat_verification_code: '', | ||||
|     email_verification_code: '', | ||||
|     email: '', | ||||
|     self_account_deletion_confirmation: '' | ||||
|   }); | ||||
|   const [status, setStatus] = useState({}); | ||||
|   const [showWeChatBindModal, setShowWeChatBindModal] = useState(false); | ||||
|   const [showEmailBindModal, setShowEmailBindModal] = useState(false); | ||||
|   const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false); | ||||
|   const [turnstileEnabled, setTurnstileEnabled] = useState(false); | ||||
|   const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); | ||||
|   const [turnstileToken, setTurnstileToken] = useState(''); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [disableButton, setDisableButton] = useState(false); | ||||
|   const [countdown, setCountdown] = useState(30); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     let status = localStorage.getItem('status'); | ||||
| @@ -30,6 +38,19 @@ const PersonalSetting = () => { | ||||
|     } | ||||
|   }, []); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     let countdownInterval = null; | ||||
|     if (disableButton && countdown > 0) { | ||||
|       countdownInterval = setInterval(() => { | ||||
|         setCountdown(countdown - 1); | ||||
|       }, 1000); | ||||
|     } else if (countdown === 0) { | ||||
|       setDisableButton(false); | ||||
|       setCountdown(30); | ||||
|     } | ||||
|     return () => clearInterval(countdownInterval); // Clean up on unmount | ||||
|   }, [disableButton, countdown]); | ||||
|  | ||||
|   const handleInputChange = (e, { name, value }) => { | ||||
|     setInputs((inputs) => ({ ...inputs, [name]: value })); | ||||
|   }; | ||||
| @@ -57,6 +78,26 @@ const PersonalSetting = () => { | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const deleteAccount = async () => { | ||||
|     if (inputs.self_account_deletion_confirmation !== userState.user.username) { | ||||
|       showError('请输入你的账户名以确认删除!'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const res = await API.delete('/api/user/self'); | ||||
|     const { success, message } = res.data; | ||||
|  | ||||
|     if (success) { | ||||
|       showSuccess('账户已删除!'); | ||||
|       await API.get('/api/user/logout'); | ||||
|       userDispatch({ type: 'logout' }); | ||||
|       localStorage.removeItem('user'); | ||||
|       navigate('/login'); | ||||
|     } else { | ||||
|       showError(message); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const bindWeChat = async () => { | ||||
|     if (inputs.wechat_verification_code === '') return; | ||||
|     const res = await API.get( | ||||
| @@ -71,13 +112,26 @@ const PersonalSetting = () => { | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const openGoogleOAuth = () => { | ||||
|     window.open( | ||||
|       `https://accounts.google.com/o/oauth2/v2/auth?client_id=${status.google_client_id}&redirect_uri=${window.location.origin}/oauth/google&response_type=code&scope=https://www.googleapis.com/auth/userinfo.profile` | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const openGitHubOAuth = () => { | ||||
|     window.open( | ||||
|       `https://github.com/login/oauth/authorize?client_id=${status.github_client_id}&scope=user:email` | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const openDiscordOAuth = () => { | ||||
|     window.open( | ||||
|       `https://discord.com/api/oauth2/authorize?client_id=${status.discord_client_id}&response_type=code&redirect_uri=${window.location.origin}/oauth/discord&scope=identify`, | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const sendVerificationCode = async () => { | ||||
|     setDisableButton(true); | ||||
|     if (inputs.email === '') return; | ||||
|     if (turnstileEnabled && turnstileToken === '') { | ||||
|       showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); | ||||
| @@ -123,6 +177,9 @@ const PersonalSetting = () => { | ||||
|       </Button> | ||||
|       <Button onClick={generateAccessToken}>生成系统访问令牌</Button> | ||||
|       <Button onClick={getAffLink}>复制邀请链接</Button> | ||||
|       <Button onClick={() => { | ||||
|         setShowAccountDeleteModal(true); | ||||
|       }}>删除个人账户</Button> | ||||
|       <Divider /> | ||||
|       <Header as='h3'>账号绑定</Header> | ||||
|       { | ||||
| @@ -170,6 +227,17 @@ const PersonalSetting = () => { | ||||
|           <Button onClick={openGitHubOAuth}>绑定 GitHub 账号</Button> | ||||
|         ) | ||||
|       } | ||||
|       { | ||||
|         status.discord_oauth && ( | ||||
|           <Button onClick={openDiscordOAuth}>绑定 Discord 账号</Button> | ||||
|         ) | ||||
|       } | ||||
|       { | ||||
|         status.google_oauth && ( | ||||
|           <Button onClick={openGoogleOAuth}>绑定 Google 账号</Button> | ||||
|  | ||||
|         ) | ||||
|       } | ||||
|       <Button | ||||
|         onClick={() => { | ||||
|           setShowEmailBindModal(true); | ||||
| @@ -195,8 +263,8 @@ const PersonalSetting = () => { | ||||
|                 name='email' | ||||
|                 type='email' | ||||
|                 action={ | ||||
|                   <Button onClick={sendVerificationCode} disabled={loading}> | ||||
|                     获取验证码 | ||||
|                   <Button onClick={sendVerificationCode} disabled={disableButton || loading}> | ||||
|                     {disableButton ? `重新发送(${countdown})` : '获取验证码'} | ||||
|                   </Button> | ||||
|                 } | ||||
|               /> | ||||
| @@ -230,6 +298,47 @@ const PersonalSetting = () => { | ||||
|           </Modal.Description> | ||||
|         </Modal.Content> | ||||
|       </Modal> | ||||
|       <Modal | ||||
|         onClose={() => setShowAccountDeleteModal(false)} | ||||
|         onOpen={() => setShowAccountDeleteModal(true)} | ||||
|         open={showAccountDeleteModal} | ||||
|         size={'tiny'} | ||||
|         style={{ maxWidth: '450px' }} | ||||
|       > | ||||
|         <Modal.Header>确认删除自己的帐户</Modal.Header> | ||||
|         <Modal.Content> | ||||
|           <Modal.Description> | ||||
|             <Form size='large'> | ||||
|               <Form.Input | ||||
|                 fluid | ||||
|                 placeholder={`输入你的账户名 ${userState?.user?.username} 以确认删除`} | ||||
|                 name='self_account_deletion_confirmation' | ||||
|                 value={inputs.self_account_deletion_confirmation} | ||||
|                 onChange={handleInputChange} | ||||
|               /> | ||||
|               {turnstileEnabled ? ( | ||||
|                 <Turnstile | ||||
|                   sitekey={turnstileSiteKey} | ||||
|                   onVerify={(token) => { | ||||
|                     setTurnstileToken(token); | ||||
|                   }} | ||||
|                 /> | ||||
|               ) : ( | ||||
|                 <></> | ||||
|               )} | ||||
|               <Button | ||||
|                 color='red' | ||||
|                 fluid | ||||
|                 size='large' | ||||
|                 onClick={deleteAccount} | ||||
|                 loading={loading} | ||||
|               > | ||||
|                 删除 | ||||
|               </Button> | ||||
|             </Form> | ||||
|           </Modal.Description> | ||||
|         </Modal.Content> | ||||
|       </Modal> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -1,13 +1,5 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { | ||||
|   Button, | ||||
|   Form, | ||||
|   Grid, | ||||
|   Header, | ||||
|   Image, | ||||
|   Message, | ||||
|   Segment, | ||||
| } from 'semantic-ui-react'; | ||||
| import { Button, Form, Grid, Header, Image, Message, Segment } from 'semantic-ui-react'; | ||||
| import { Link, useNavigate } from 'react-router-dom'; | ||||
| import { API, getLogo, showError, showInfo, showSuccess } from '../helpers'; | ||||
| import Turnstile from 'react-turnstile'; | ||||
| @@ -18,7 +10,7 @@ const RegisterForm = () => { | ||||
|     password: '', | ||||
|     password2: '', | ||||
|     email: '', | ||||
|     verification_code: '', | ||||
|     verification_code: '' | ||||
|   }); | ||||
|   const { username, password, password2 } = inputs; | ||||
|   const [showEmailVerification, setShowEmailVerification] = useState(false); | ||||
| @@ -178,7 +170,7 @@ const RegisterForm = () => { | ||||
|               <></> | ||||
|             )} | ||||
|             <Button | ||||
|               color='' | ||||
|               color='green' | ||||
|               fluid | ||||
|               size='large' | ||||
|               onClick={handleSubmit} | ||||
|   | ||||
| @@ -8,8 +8,11 @@ const SystemSetting = () => { | ||||
|     PasswordRegisterEnabled: '', | ||||
|     EmailVerificationEnabled: '', | ||||
|     GitHubOAuthEnabled: '', | ||||
|     DiscordOAuthEnabled: '', | ||||
|     GitHubClientId: '', | ||||
|     GitHubClientSecret: '', | ||||
|     DiscordClientId: '', | ||||
|     DiscordClientSecret: '', | ||||
|     Notice: '', | ||||
|     SMTPServer: '', | ||||
|     SMTPPort: '', | ||||
| @@ -22,6 +25,9 @@ const SystemSetting = () => { | ||||
|     WeChatServerAddress: '', | ||||
|     WeChatServerToken: '', | ||||
|     WeChatAccountQRCodeImageURL: '', | ||||
|     GoogleOAuthEnabled: '', | ||||
|     GoogleClientId: '', | ||||
|     GoogleClientSecret: '', | ||||
|     TurnstileCheckEnabled: '', | ||||
|     TurnstileSiteKey: '', | ||||
|     TurnstileSecretKey: '', | ||||
| @@ -56,7 +62,9 @@ const SystemSetting = () => { | ||||
|       case 'PasswordRegisterEnabled': | ||||
|       case 'EmailVerificationEnabled': | ||||
|       case 'GitHubOAuthEnabled': | ||||
|       case 'DiscordOAuthEnabled': | ||||
|       case 'WeChatAuthEnabled': | ||||
|       case 'GoogleOAuthEnabled': | ||||
|       case 'TurnstileCheckEnabled': | ||||
|       case 'RegisterEnabled': | ||||
|         value = inputs[key] === 'true' ? 'false' : 'true'; | ||||
| @@ -84,9 +92,13 @@ const SystemSetting = () => { | ||||
|       name === 'ServerAddress' || | ||||
|       name === 'GitHubClientId' || | ||||
|       name === 'GitHubClientSecret' || | ||||
|       name === 'DiscordClientId' || | ||||
|       name === 'DiscordClientSecret' || | ||||
|       name === 'WeChatServerAddress' || | ||||
|       name === 'WeChatServerToken' || | ||||
|       name === 'WeChatAccountQRCodeImageURL' || | ||||
|       name === 'GoogleClientId' || | ||||
|       name === 'GoogleClientSecret' || | ||||
|       name === 'TurnstileSiteKey' || | ||||
|       name === 'TurnstileSecretKey' | ||||
|     ) { | ||||
| @@ -149,6 +161,18 @@ const SystemSetting = () => { | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const submitGoogleOAuth = async () => { | ||||
|     if (originInputs['GoogleClientId'] !== inputs.GoogleClientId) { | ||||
|       await updateOption('GoogleClientId', inputs.GoogleClientId); | ||||
|     } | ||||
|     if ( | ||||
|       originInputs['GoogleClientSecret'] !== inputs.GoogleClientSecret && | ||||
|       inputs.GoogleClientSecret !== '' | ||||
|     ) { | ||||
|       await updateOption('GoogleClientSecret', inputs.GoogleClientSecret); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const submitGitHubOAuth = async () => { | ||||
|     if (originInputs['GitHubClientId'] !== inputs.GitHubClientId) { | ||||
|       await updateOption('GitHubClientId', inputs.GitHubClientId); | ||||
| @@ -161,6 +185,18 @@ const SystemSetting = () => { | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const submitDiscordOAuth = async () => { | ||||
|     if (originInputs['DiscordClientId'] !== inputs.DiscordClientId) { | ||||
|       await updateOption('DiscordClientId', inputs.DiscordClientId); | ||||
|     } | ||||
|     if ( | ||||
|       originInputs['DiscordClientSecret'] !== inputs.DiscordClientSecret && | ||||
|       inputs.DiscordClientSecret !== '' | ||||
|     ) { | ||||
|       await updateOption('DiscordClientSecret', inputs.DiscordClientSecret); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const submitTurnstile = async () => { | ||||
|     if (originInputs['TurnstileSiteKey'] !== inputs.TurnstileSiteKey) { | ||||
|       await updateOption('TurnstileSiteKey', inputs.TurnstileSiteKey); | ||||
| @@ -211,12 +247,24 @@ const SystemSetting = () => { | ||||
|               name='EmailVerificationEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.DiscordOAuthEnabled === 'true'} | ||||
|               label='允许通过 Discord 账户登录和注册' | ||||
|               name='DiscordOAuthEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.GitHubOAuthEnabled === 'true'} | ||||
|               label='允许通过 GitHub 账户登录 & 注册' | ||||
|               name='GitHubOAuthEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.GoogleOAuthEnabled === 'true'} | ||||
|               label='允许通过 Google 账户登录和注册' | ||||
|               name='GoogleOAuthEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.WeChatAuthEnabled === 'true'} | ||||
|               label='允许通过微信登录 & 注册' | ||||
| @@ -372,6 +420,82 @@ const SystemSetting = () => { | ||||
|             保存 WeChat Server 设置 | ||||
|           </Form.Button> | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             配置 Discord OAuth 应用程序 | ||||
|             <Header.Subheader> | ||||
|               用以支持通过 Discord 进行登录注册, | ||||
|               <a href='https://discord.com/developers/applications' target='_blank'> | ||||
|                 点击此处 | ||||
|               </a> | ||||
|               管理你的 Discord OAuth App | ||||
|             </Header.Subheader> | ||||
|           </Header> | ||||
|           <Message> | ||||
|             Homepage URL 填 <code>{inputs.ServerAddress}</code> | ||||
|             ,Authorization callback URL 填{' '} | ||||
|             <code>{`${inputs.ServerAddress}/oauth/discord`}</code> | ||||
|           </Message> | ||||
|           <Form.Group widths={3}> | ||||
|             <Form.Input | ||||
|               label='Discord 客户 ID' | ||||
|               name='DiscordClientId' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.DiscordClientId} | ||||
|               placeholder='输入您注册的 Discord OAuth APP 的 ID' | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='Discord 客户秘密' | ||||
|               name='DiscordClientSecret' | ||||
|               onChange={handleInputChange} | ||||
|               type='password' | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.DiscordClientSecret} | ||||
|               placeholder='敏感信息不会发送到前端显示' | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitDiscordOAuth}> | ||||
|             保存 Discord OAuth 设置 | ||||
|           </Form.Button> | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             配置 Google OAuth 应用程序 | ||||
|             <Header.Subheader> | ||||
|               用以支持通过 Google 进行登录注册, | ||||
|               <a href='https://console.cloud.google.com/' target='_blank'> | ||||
|                 点击此处 | ||||
|               </a> | ||||
|               管理你的 Google OAuth App | ||||
|             </Header.Subheader> | ||||
|           </Header> | ||||
|           <Message> | ||||
|             Homepage URL 填 <code>{inputs.ServerAddress}</code> | ||||
|             ,Authorization callback URL 填{' '} | ||||
|             <code>{`${inputs.ServerAddress}/oauth/google`}</code> | ||||
|           </Message> | ||||
|           <Form.Group widths={3}> | ||||
|             <Form.Input | ||||
|               label='Google 客户 ID' | ||||
|               name='GoogleClientId' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.GoogleClientId} | ||||
|               placeholder='输入您注册的 Google OAuth APP 的 ID' | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='Google 客户秘密' | ||||
|               name='GoogleClientSecret' | ||||
|               onChange={handleInputChange} | ||||
|               type='password' | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.GoogleClientSecret} | ||||
|               placeholder='敏感信息不会发送到前端显示' | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitGoogleOAuth}> | ||||
|             保存 Google OAuth 设置 | ||||
|           </Form.Button> | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             配置 Turnstile | ||||
|             <Header.Subheader> | ||||
|   | ||||
| @@ -1,17 +1,18 @@ | ||||
| export const CHANNEL_OPTIONS = [ | ||||
|   { key: 1, text: 'OpenAI', value: 1, color: 'green' }, | ||||
|   { key: 14, text: 'Anthropic', value: 14, color: 'black' }, | ||||
|   { key: 8, text: '自定义', value: 8, color: 'pink' }, | ||||
|   { key: 3, text: 'Azure', value: 3, color: 'olive' }, | ||||
|   { key: 11, text: 'PaLM', value: 11, color: 'orange' }, | ||||
|   { key: 15, text: 'Baidu', value: 15, color: 'blue' }, | ||||
|   { key: 2, text: 'API2D', value: 2, color: 'blue' }, | ||||
|   { key: 4, text: 'CloseAI', value: 4, color: 'teal' }, | ||||
|   { key: 5, text: 'OpenAI-SB', value: 5, color: 'brown' }, | ||||
|   { key: 6, text: 'OpenAI Max', value: 6, color: 'violet' }, | ||||
|   { key: 7, text: 'OhMyGPT', value: 7, color: 'purple' }, | ||||
|   { key: 9, text: 'AI.LS', value: 9, color: 'yellow' }, | ||||
|   { key: 10, text: 'AI Proxy', value: 10, color: 'purple' }, | ||||
|   { key: 12, text: 'API2GPT', value: 12, color: 'blue' }, | ||||
|   { key: 13, text: 'AIGC2D', value: 13, color: 'purple' } | ||||
|   { key: 14, text: 'Anthropic Claude', value: 14, color: 'black' }, | ||||
|   { key: 3, text: 'Azure OpenAI', value: 3, color: 'olive' }, | ||||
|   { key: 11, text: 'Google PaLM2', value: 11, color: 'orange' }, | ||||
|   { key: 15, text: '百度文心千帆', value: 15, color: 'blue' }, | ||||
|   { key: 16, text: '智谱 ChatGLM', value: 16, color: 'violet' }, | ||||
|   { key: 8, text: '自定义渠道', value: 8, color: 'pink' }, | ||||
|   { key: 2, text: '代理:API2D', value: 2, color: 'blue' }, | ||||
|   { key: 5, text: '代理:OpenAI-SB', value: 5, color: 'brown' }, | ||||
|   { key: 7, text: '代理:OhMyGPT', value: 7, color: 'purple' }, | ||||
|   { key: 10, text: '代理:AI Proxy', value: 10, color: 'purple' }, | ||||
|   { key: 4, text: '代理:CloseAI', value: 4, color: 'teal' }, | ||||
|   { key: 6, text: '代理:OpenAI Max', value: 6, color: 'violet' }, | ||||
|   { key: 9, text: '代理:AI.LS', value: 9, color: 'yellow' }, | ||||
|   { key: 12, text: '代理:API2GPT', value: 12, color: 'blue' }, | ||||
|   { key: 13, text: '代理:AIGC2D', value: 13, color: 'purple' } | ||||
| ]; | ||||
| @@ -46,9 +46,7 @@ const About = () => { | ||||
|             about.startsWith('https://') ? <iframe | ||||
|               src={about} | ||||
|               style={{ width: '100%', height: '100vh', border: 'none' }} | ||||
|             /> : <Segment> | ||||
|               <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: about }}></div> | ||||
|             </Segment> | ||||
|             /> : <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: about }}></div> | ||||
|           } | ||||
|         </> | ||||
|       } | ||||
|   | ||||
| @@ -22,6 +22,8 @@ const EditChannel = () => { | ||||
|     base_url: '', | ||||
|     other: '', | ||||
|     model_mapping: '', | ||||
|     allow_streaming: 1, | ||||
|     allow_non_streaming: 1, | ||||
|     models: [], | ||||
|     groups: ['default'] | ||||
|   }; | ||||
| @@ -94,6 +96,9 @@ const EditChannel = () => { | ||||
|  | ||||
|   useEffect(() => { | ||||
|     let localModelOptions = [...originModelOptions]; | ||||
|     if (!Array.isArray(inputs.models)) { | ||||
|       inputs.models = inputs.models.split(','); | ||||
|     } | ||||
|     inputs.models.forEach((model) => { | ||||
|       if (!localModelOptions.find((option) => option.key === model)) { | ||||
|         localModelOptions.push({ | ||||
| @@ -127,6 +132,11 @@ const EditChannel = () => { | ||||
|       showInfo('模型映射必须是合法的 JSON 格式!'); | ||||
|       return; | ||||
|     } | ||||
|     // allow streaming and allow non streaming cannot be both false | ||||
|     if (inputs.allow_streaming === 2 && inputs.allow_non_streaming === 2) { | ||||
|       showInfo('流式请求和非流式请求不能同时禁用!'); | ||||
|       return; | ||||
|     } | ||||
|     let localInputs = inputs; | ||||
|     if (localInputs.base_url.endsWith('/')) { | ||||
|       localInputs.base_url = localInputs.base_url.slice(0, localInputs.base_url.length - 1); | ||||
| @@ -176,7 +186,7 @@ const EditChannel = () => { | ||||
|                 <Message> | ||||
|                   注意,<strong>模型部署名称必须和模型名称保持一致</strong>,因为 One API 会把请求体中的 model | ||||
|                   参数替换为你的部署名称(模型名称中的点会被剔除),<a target='_blank' | ||||
|                                                                     href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'>图片演示</a>。 | ||||
|                     href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'>图片演示</a>。 | ||||
|                 </Message> | ||||
|                 <Form.Field> | ||||
|                   <Form.Input | ||||
| @@ -215,26 +225,12 @@ const EditChannel = () => { | ||||
|               </Form.Field> | ||||
|             ) | ||||
|           } | ||||
|           { | ||||
|             inputs.type !== 3 && inputs.type !== 8 && ( | ||||
|               <Form.Field> | ||||
|                 <Form.Input | ||||
|                   label='镜像' | ||||
|                   name='base_url' | ||||
|                   placeholder={'此项可选,输入镜像站地址,格式为:https://domain.com'} | ||||
|                   onChange={handleInputChange} | ||||
|                   value={inputs.base_url} | ||||
|                   autoComplete='new-password' | ||||
|                 /> | ||||
|               </Form.Field> | ||||
|             ) | ||||
|           } | ||||
|           <Form.Field> | ||||
|             <Form.Input | ||||
|               label='名称' | ||||
|               required | ||||
|               name='name' | ||||
|               placeholder={'请输入名称'} | ||||
|               placeholder={'请为渠道命名'} | ||||
|               onChange={handleInputChange} | ||||
|               value={inputs.name} | ||||
|               autoComplete='new-password' | ||||
| @@ -243,7 +239,7 @@ const EditChannel = () => { | ||||
|           <Form.Field> | ||||
|             <Form.Dropdown | ||||
|               label='分组' | ||||
|               placeholder={'请选择分组'} | ||||
|               placeholder={'请选择可以使用该渠道的分组'} | ||||
|               name='groups' | ||||
|               required | ||||
|               fluid | ||||
| @@ -260,7 +256,7 @@ const EditChannel = () => { | ||||
|           <Form.Field> | ||||
|             <Form.Dropdown | ||||
|               label='模型' | ||||
|               placeholder={'请选择该通道所支持的模型'} | ||||
|               placeholder={'请选择该渠道所支持的模型'} | ||||
|               name='models' | ||||
|               required | ||||
|               fluid | ||||
| @@ -284,7 +280,7 @@ const EditChannel = () => { | ||||
|             }}>清除所有模型</Button> | ||||
|             <Input | ||||
|               action={ | ||||
|                 <Button type={'button'} onClick={()=>{ | ||||
|                 <Button type={'button'} onClick={() => { | ||||
|                   if (customModel.trim() === "") return; | ||||
|                   if (inputs.models.includes(customModel)) return; | ||||
|                   let localModels = [...inputs.models]; | ||||
| @@ -295,7 +291,7 @@ const EditChannel = () => { | ||||
|                     text: customModel, | ||||
|                     value: customModel, | ||||
|                   }); | ||||
|                   setModelOptions(modelOptions=>{ | ||||
|                   setModelOptions(modelOptions => { | ||||
|                     return [...modelOptions, ...localModelOptions]; | ||||
|                   }); | ||||
|                   setCustomModel(''); | ||||
| @@ -312,7 +308,7 @@ const EditChannel = () => { | ||||
|           <Form.Field> | ||||
|             <Form.TextArea | ||||
|               label='模型映射' | ||||
|               placeholder={`此项可选,为一个 JSON 文本,键为用户请求的模型名称,值为要替换的模型名称,例如:\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`} | ||||
|               placeholder={`此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`} | ||||
|               name='model_mapping' | ||||
|               onChange={handleInputChange} | ||||
|               value={inputs.model_mapping} | ||||
| @@ -320,6 +316,26 @@ const EditChannel = () => { | ||||
|               autoComplete='new-password' | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           <Form.Field> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.allow_streaming === 1} | ||||
|               label='允许流式请求' | ||||
|               name='allow_streaming' | ||||
|               onChange={() => { | ||||
|                 setInputs((inputs) => ({ ...inputs, allow_streaming: inputs.allow_streaming === 1 ? 2 : 1 })); | ||||
|               }} | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           <Form.Field> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.allow_non_streaming === 1} | ||||
|               label='允许非流式请求' | ||||
|               name='allow_non_streaming' | ||||
|               onChange={() => { | ||||
|                 setInputs((inputs) => ({ ...inputs, allow_non_streaming: inputs.allow_non_streaming === 1 ? 2 : 1 })); | ||||
|               }} | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           { | ||||
|             batch ? <Form.Field> | ||||
|               <Form.TextArea | ||||
| @@ -337,7 +353,7 @@ const EditChannel = () => { | ||||
|                 label='密钥' | ||||
|                 name='key' | ||||
|                 required | ||||
|                 placeholder={inputs.type === 15 ? "请输入 access token,当前版本暂不支持自动刷新,请每 30 天更新一次" : '请输入密钥'} | ||||
|                 placeholder={inputs.type === 15 ? "请输入 access token,当前版本暂不支持自动刷新,请每 30 天更新一次" : '请输入渠道对应的鉴权密钥'} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={inputs.key} | ||||
|                 autoComplete='new-password' | ||||
| @@ -354,6 +370,20 @@ const EditChannel = () => { | ||||
|               /> | ||||
|             ) | ||||
|           } | ||||
|           { | ||||
|             inputs.type !== 3 && inputs.type !== 8 && ( | ||||
|               <Form.Field> | ||||
|                 <Form.Input | ||||
|                   label='镜像' | ||||
|                   name='base_url' | ||||
|                   placeholder={'此项可选,用于通过镜像站来进行 API 调用,请输入镜像站地址,格式为:https://domain.com'} | ||||
|                   onChange={handleInputChange} | ||||
|                   value={inputs.base_url} | ||||
|                   autoComplete='new-password' | ||||
|                 /> | ||||
|               </Form.Field> | ||||
|             ) | ||||
|           } | ||||
|           <Button type={isEdit ? "button" : "submit"} positive onClick={submit}>提交</Button> | ||||
|         </Form> | ||||
|       </Segment> | ||||
|   | ||||
| @@ -97,12 +97,24 @@ const Home = () => { | ||||
|                           ? '已启用' | ||||
|                           : '未启用'} | ||||
|                       </p> | ||||
|                       <p> | ||||
|                         Discord 身份验证: | ||||
|                         {statusState?.status?.discord_oauth === true | ||||
|                           ? '已启用' | ||||
|                           : '未启用'} | ||||
|                       </p> | ||||
|                       <p> | ||||
|                         微信身份验证: | ||||
|                         {statusState?.status?.wechat_login === true | ||||
|                           ? '已启用' | ||||
|                           : '未启用'} | ||||
|                       </p> | ||||
|                       <p> | ||||
|                         Google 身份验证: | ||||
|                         {statusState?.status?.google_oauth === true | ||||
|                           ? '已启用' | ||||
|                           : '未启用'} | ||||
|                       </p> | ||||
|                       <p> | ||||
|                         Turnstile 用户校验: | ||||
|                         {statusState?.status?.turnstile_check === true | ||||
|   | ||||
| @@ -7,24 +7,32 @@ const TopUp = () => { | ||||
|   const [redemptionCode, setRedemptionCode] = useState(''); | ||||
|   const [topUpLink, setTopUpLink] = useState(''); | ||||
|   const [userQuota, setUserQuota] = useState(0); | ||||
|   const [isSubmitting, setIsSubmitting] = useState(false); | ||||
|  | ||||
|   const topUp = async () => { | ||||
|     if (redemptionCode === '') { | ||||
|       showInfo('请输入充值码!') | ||||
|       return; | ||||
|     } | ||||
|     const res = await API.post('/api/user/topup', { | ||||
|       key: redemptionCode | ||||
|     }); | ||||
|     const { success, message, data } = res.data; | ||||
|     if (success) { | ||||
|       showSuccess('充值成功!'); | ||||
|       setUserQuota((quota) => { | ||||
|         return quota + data; | ||||
|     setIsSubmitting(true); | ||||
|     try { | ||||
|       const res = await API.post('/api/user/topup', { | ||||
|         key: redemptionCode | ||||
|       }); | ||||
|       setRedemptionCode(''); | ||||
|     } else { | ||||
|       showError(message); | ||||
|       const { success, message, data } = res.data; | ||||
|       if (success) { | ||||
|         showSuccess('充值成功!'); | ||||
|         setUserQuota((quota) => { | ||||
|           return quota + data; | ||||
|         }); | ||||
|         setRedemptionCode(''); | ||||
|       } else { | ||||
|         showError(message); | ||||
|       } | ||||
|     } catch (err) { | ||||
|       showError('请求失败'); | ||||
|     } finally { | ||||
|       setIsSubmitting(false);  | ||||
|     } | ||||
|   }; | ||||
|  | ||||
| @@ -74,8 +82,8 @@ const TopUp = () => { | ||||
|             <Button color='green' onClick={openTopUpLink}> | ||||
|               获取兑换码 | ||||
|             </Button> | ||||
|             <Button color='yellow' onClick={topUp}> | ||||
|               充值 | ||||
|             <Button color='yellow' onClick={topUp} disabled={isSubmitting}> | ||||
|                 {isSubmitting ? '兑换中...' : '兑换'} | ||||
|             </Button> | ||||
|           </Form> | ||||
|         </Grid.Column> | ||||
| @@ -92,5 +100,4 @@ const TopUp = () => { | ||||
|   ); | ||||
| }; | ||||
|  | ||||
|  | ||||
| export default TopUp; | ||||
| export default TopUp; | ||||
| @@ -13,13 +13,15 @@ const EditUser = () => { | ||||
|     display_name: '', | ||||
|     password: '', | ||||
|     github_id: '', | ||||
|     discord_id: '', | ||||
|     wechat_id: '', | ||||
|     google_id: '', | ||||
|     email: '', | ||||
|     quota: 0, | ||||
|     group: 'default' | ||||
|   }); | ||||
|   const [groupOptions, setGroupOptions] = useState([]); | ||||
|   const { username, display_name, password, github_id, wechat_id, email, quota, group } = | ||||
|   const { username, display_name, password, github_id, wechat_id, email, quota, google_id, discord_id } = | ||||
|     inputs; | ||||
|   const handleInputChange = (e, { name, value }) => { | ||||
|     setInputs((inputs) => ({ ...inputs, [name]: value })); | ||||
| @@ -166,6 +168,26 @@ const EditUser = () => { | ||||
|               readOnly | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           <Form.Field> | ||||
|             <Form.Input | ||||
|               label='已绑定的 Discord 账户' | ||||
|               name='discord_id' | ||||
|               value={discord_id} | ||||
|               autoComplete='new-password' | ||||
|               placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改' | ||||
|               readOnly | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           <Form.Field> | ||||
|             <Form.Input | ||||
|               label='已绑定的 Google 账户' | ||||
|               name='google_id' | ||||
|               value={google_id} | ||||
|               autoComplete='new-password' | ||||
|               placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改' | ||||
|               readOnly | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           <Form.Field> | ||||
|             <Form.Input | ||||
|               label='已绑定的邮箱账户' | ||||
|   | ||||
		Reference in New Issue
	
	Block a user