mirror of
https://github.com/songquanpeng/one-api.git
synced 2025-10-23 01:43:42 +08:00
Compare commits
22 Commits
v0.2.6
...
v0.3.1-alp
Author | SHA1 | Date | |
---|---|---|---|
|
ceb289cb4d | ||
|
6f8cc712b0 | ||
|
ad01e1f3b3 | ||
|
cc1ef2ffd5 | ||
|
7201bd1c97 | ||
|
73d5e0f283 | ||
|
efc744ca35 | ||
|
e8da98139f | ||
|
519cb030f7 | ||
|
58fe923c85 | ||
|
c9ac5e391f | ||
|
69cf1de7bd | ||
|
4d6172a242 | ||
|
8afdc56b11 | ||
|
a9ea1d9d10 | ||
|
ea8e7c517b | ||
|
d1e9b86f05 | ||
|
6d1e5cb5dc | ||
|
01abed0a30 | ||
|
7c56a36a1c | ||
|
c48327ff91 | ||
|
a5406c6963 |
@@ -40,15 +40,18 @@ _✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用
|
||||
<a href="https://openai.justsong.cn/">在线演示</a>
|
||||
</p>
|
||||
|
||||
> **Warning**:从 `v0.2` 版本升级到 `v0.3` 版本需要手动迁移数据库,请手动执行[数据库迁移脚本](./bin/migration_v0.2-v0.3.sql)。
|
||||
|
||||
|
||||
## 功能
|
||||
1. 支持多种 API 访问渠道,欢迎 PR 或提 issue 添加更多渠道:
|
||||
+ [x] OpenAI 官方通道
|
||||
+ [x] **Azure OpenAI API**
|
||||
+ [x] [API2D](https://api2d.com/r/197971)
|
||||
+ [x] [OhMyGPT](https://aigptx.top?aff=uFpUl2Kf)
|
||||
+ [x] [CloseAI](https://console.openai-asia.com)
|
||||
+ [x] [OpenAI-SB](https://openai-sb.com)
|
||||
+ [x] [OpenAI Max](https://openaimax.com)
|
||||
+ [x] [OhMyGPT](https://www.ohmygpt.com)
|
||||
+ [x] 自定义渠道:例如使用自行搭建的 OpenAI 代理
|
||||
2. 支持通过**负载均衡**的方式访问多个渠道。
|
||||
3. 支持 **stream 模式**,可以通过流式传输实现打字机效果。
|
||||
|
6
bin/migration_v0.2-v0.3.sql
Normal file
6
bin/migration_v0.2-v0.3.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
UPDATE users
|
||||
SET quota = quota + (
|
||||
SELECT SUM(remain_quota)
|
||||
FROM tokens
|
||||
WHERE tokens.user_id = users.id
|
||||
)
|
@@ -50,10 +50,11 @@ var WeChatAccountQRCodeImageURL = ""
|
||||
var TurnstileSiteKey = ""
|
||||
var TurnstileSecretKey = ""
|
||||
|
||||
var QuotaForNewUser = 100
|
||||
|
||||
var QuotaForNewUser = 0
|
||||
var ChannelDisableThreshold = 5.0
|
||||
var AutomaticDisableChannelEnabled = false
|
||||
var QuotaRemindThreshold = 1000
|
||||
var PreConsumedQuota = 500
|
||||
|
||||
var RootUserEmail = ""
|
||||
|
||||
@@ -131,7 +132,7 @@ const (
|
||||
var ChannelBaseURLs = []string{
|
||||
"", // 0
|
||||
"https://api.openai.com", // 1
|
||||
"https://openai.api2d.net", // 2
|
||||
"https://oa.api2d.net", // 2
|
||||
"", // 3
|
||||
"https://api.openai-asia.com", // 4
|
||||
"https://api.openai-sb.com", // 5
|
||||
|
@@ -210,11 +210,12 @@ func testChannel(channel *model.Channel, request *ChatRequest) error {
|
||||
func buildTestRequest(c *gin.Context) *ChatRequest {
|
||||
model_ := c.Query("model")
|
||||
testRequest := &ChatRequest{
|
||||
Model: model_,
|
||||
Model: model_,
|
||||
MaxTokens: 1,
|
||||
}
|
||||
testMessage := Message{
|
||||
Role: "user",
|
||||
Content: "echo hi",
|
||||
Content: "hi",
|
||||
}
|
||||
testRequest.Messages = append(testRequest.Messages, testMessage)
|
||||
return testRequest
|
||||
@@ -264,14 +265,14 @@ var testAllChannelsLock sync.Mutex
|
||||
var testAllChannelsRunning bool = false
|
||||
|
||||
// disable & notify
|
||||
func disableChannel(channelId int, channelName string, err error) {
|
||||
func disableChannel(channelId int, channelName string, reason string) {
|
||||
if common.RootUserEmail == "" {
|
||||
common.RootUserEmail = model.GetRootUserEmail()
|
||||
}
|
||||
model.UpdateChannelStatusById(channelId, common.ChannelStatusDisabled)
|
||||
subject := fmt.Sprintf("通道「%s」(#%d)已被禁用", channelName, channelId)
|
||||
content := fmt.Sprintf("通道「%s」(#%d)已被禁用,原因:%s", channelName, channelId, err.Error())
|
||||
err = common.SendEmail(subject, common.RootUserEmail, content)
|
||||
content := fmt.Sprintf("通道「%s」(#%d)已被禁用,原因:%s", channelName, channelId, reason)
|
||||
err := common.SendEmail(subject, common.RootUserEmail, content)
|
||||
if err != nil {
|
||||
common.SysError(fmt.Sprintf("发送邮件失败:%s", err.Error()))
|
||||
}
|
||||
@@ -311,7 +312,7 @@ func testAllChannels(c *gin.Context) error {
|
||||
if milliseconds > disableThreshold {
|
||||
err = errors.New(fmt.Sprintf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0))
|
||||
}
|
||||
disableChannel(channel.Id, channel.Name, err)
|
||||
disableChannel(channel.Id, channel.Name, err.Error())
|
||||
}
|
||||
channel.UpdateResponseTime(milliseconds)
|
||||
}
|
||||
|
153
controller/model.go
Normal file
153
controller/model.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// https://platform.openai.com/docs/api-reference/models/list
|
||||
|
||||
type OpenAIModelPermission struct {
|
||||
Id string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int `json:"created"`
|
||||
AllowCreateEngine bool `json:"allow_create_engine"`
|
||||
AllowSampling bool `json:"allow_sampling"`
|
||||
AllowLogprobs bool `json:"allow_logprobs"`
|
||||
AllowSearchIndices bool `json:"allow_search_indices"`
|
||||
AllowView bool `json:"allow_view"`
|
||||
AllowFineTuning bool `json:"allow_fine_tuning"`
|
||||
Organization string `json:"organization"`
|
||||
Group *string `json:"group"`
|
||||
IsBlocking bool `json:"is_blocking"`
|
||||
}
|
||||
|
||||
type OpenAIModels struct {
|
||||
Id string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int `json:"created"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
Permission OpenAIModelPermission `json:"permission"`
|
||||
Root string `json:"root"`
|
||||
Parent *string `json:"parent"`
|
||||
}
|
||||
|
||||
var openAIModels []OpenAIModels
|
||||
var openAIModelsMap map[string]OpenAIModels
|
||||
|
||||
func init() {
|
||||
permission := OpenAIModelPermission{
|
||||
Id: "modelperm-LwHkVFn8AcMItP432fKKDIKJ",
|
||||
Object: "model_permission",
|
||||
Created: 1626777600,
|
||||
AllowCreateEngine: true,
|
||||
AllowSampling: true,
|
||||
AllowLogprobs: true,
|
||||
AllowSearchIndices: false,
|
||||
AllowView: true,
|
||||
AllowFineTuning: false,
|
||||
Organization: "*",
|
||||
Group: nil,
|
||||
IsBlocking: false,
|
||||
}
|
||||
// https://platform.openai.com/docs/models/model-endpoint-compatibility
|
||||
openAIModels = []OpenAIModels{
|
||||
{
|
||||
Id: "gpt-3.5-turbo",
|
||||
Object: "model",
|
||||
Created: 1677649963,
|
||||
OwnedBy: "openai",
|
||||
Permission: permission,
|
||||
Root: "gpt-3.5-turbo",
|
||||
Parent: nil,
|
||||
},
|
||||
{
|
||||
Id: "gpt-3.5-turbo-0301",
|
||||
Object: "model",
|
||||
Created: 1677649963,
|
||||
OwnedBy: "openai",
|
||||
Permission: permission,
|
||||
Root: "gpt-3.5-turbo-0301",
|
||||
Parent: nil,
|
||||
},
|
||||
{
|
||||
Id: "gpt-4",
|
||||
Object: "model",
|
||||
Created: 1677649963,
|
||||
OwnedBy: "openai",
|
||||
Permission: permission,
|
||||
Root: "gpt-4",
|
||||
Parent: nil,
|
||||
},
|
||||
{
|
||||
Id: "gpt-4-0314",
|
||||
Object: "model",
|
||||
Created: 1677649963,
|
||||
OwnedBy: "openai",
|
||||
Permission: permission,
|
||||
Root: "gpt-4-0314",
|
||||
Parent: nil,
|
||||
},
|
||||
{
|
||||
Id: "gpt-4-32k",
|
||||
Object: "model",
|
||||
Created: 1677649963,
|
||||
OwnedBy: "openai",
|
||||
Permission: permission,
|
||||
Root: "gpt-4-32k",
|
||||
Parent: nil,
|
||||
},
|
||||
{
|
||||
Id: "gpt-4-32k-0314",
|
||||
Object: "model",
|
||||
Created: 1677649963,
|
||||
OwnedBy: "openai",
|
||||
Permission: permission,
|
||||
Root: "gpt-4-32k-0314",
|
||||
Parent: nil,
|
||||
},
|
||||
{
|
||||
Id: "gpt-3.5-turbo",
|
||||
Object: "model",
|
||||
Created: 1677649963,
|
||||
OwnedBy: "openai",
|
||||
Permission: permission,
|
||||
Root: "gpt-3.5-turbo",
|
||||
Parent: nil,
|
||||
},
|
||||
{
|
||||
Id: "text-embedding-ada-002",
|
||||
Object: "model",
|
||||
Created: 1677649963,
|
||||
OwnedBy: "openai",
|
||||
Permission: permission,
|
||||
Root: "text-embedding-ada-002",
|
||||
Parent: nil,
|
||||
},
|
||||
}
|
||||
openAIModelsMap = make(map[string]OpenAIModels)
|
||||
for _, model := range openAIModels {
|
||||
openAIModelsMap[model.Id] = model
|
||||
}
|
||||
}
|
||||
|
||||
func ListModels(c *gin.Context) {
|
||||
c.JSON(200, openAIModels)
|
||||
}
|
||||
|
||||
func RetrieveModel(c *gin.Context) {
|
||||
modelId := c.Param("model")
|
||||
if model, ok := openAIModelsMap[modelId]; ok {
|
||||
c.JSON(200, model)
|
||||
} else {
|
||||
openAIError := OpenAIError{
|
||||
Message: fmt.Sprintf("The model '%s' does not exist", modelId),
|
||||
Type: "invalid_request_error",
|
||||
Param: "model",
|
||||
Code: "model_not_found",
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"error": openAIError,
|
||||
})
|
||||
}
|
||||
}
|
@@ -4,7 +4,6 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pkoukk/tiktoken-go"
|
||||
@@ -21,14 +20,16 @@ type Message struct {
|
||||
}
|
||||
|
||||
type ChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
type TextRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Prompt string `json:"prompt"`
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Prompt string `json:"prompt"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
//Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
@@ -45,6 +46,11 @@ type OpenAIError struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type OpenAIErrorWithStatusCode struct {
|
||||
OpenAIError
|
||||
StatusCode int `json:"status_code"`
|
||||
}
|
||||
|
||||
type TextResponse struct {
|
||||
Usage `json:"usage"`
|
||||
Error OpenAIError `json:"error"`
|
||||
@@ -69,21 +75,33 @@ func countToken(text string) int {
|
||||
func Relay(c *gin.Context) {
|
||||
err := relayHelper(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"error": gin.H{
|
||||
"message": err.Error(),
|
||||
"type": "one_api_error",
|
||||
},
|
||||
c.JSON(err.StatusCode, gin.H{
|
||||
"error": err.OpenAIError,
|
||||
})
|
||||
if common.AutomaticDisableChannelEnabled {
|
||||
channelId := c.GetInt("channel_id")
|
||||
common.SysError(fmt.Sprintf("Relay error (channel #%d): %s", channelId, err.Message))
|
||||
if err.Type != "invalid_request_error" && err.StatusCode != http.StatusTooManyRequests &&
|
||||
common.AutomaticDisableChannelEnabled {
|
||||
channelId := c.GetInt("channel_id")
|
||||
channelName := c.GetString("channel_name")
|
||||
disableChannel(channelId, channelName, err)
|
||||
disableChannel(channelId, channelName, err.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func relayHelper(c *gin.Context) error {
|
||||
func errorWrapper(err error, code string, statusCode int) *OpenAIErrorWithStatusCode {
|
||||
openAIError := OpenAIError{
|
||||
Message: err.Error(),
|
||||
Type: "one_api_error",
|
||||
Code: code,
|
||||
}
|
||||
return &OpenAIErrorWithStatusCode{
|
||||
OpenAIError: openAIError,
|
||||
StatusCode: statusCode,
|
||||
}
|
||||
}
|
||||
|
||||
func relayHelper(c *gin.Context) *OpenAIErrorWithStatusCode {
|
||||
channelType := c.GetInt("channel")
|
||||
tokenId := c.GetInt("token_id")
|
||||
consumeQuota := c.GetBool("consume_quota")
|
||||
@@ -91,15 +109,15 @@ func relayHelper(c *gin.Context) error {
|
||||
if consumeQuota || channelType == common.ChannelTypeAzure {
|
||||
requestBody, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
return errorWrapper(err, "read_request_body_failed", http.StatusBadRequest)
|
||||
}
|
||||
err = c.Request.Body.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
return errorWrapper(err, "close_request_body_failed", http.StatusBadRequest)
|
||||
}
|
||||
err = json.Unmarshal(requestBody, &textRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
return errorWrapper(err, "unmarshal_request_body_failed", http.StatusBadRequest)
|
||||
}
|
||||
// Reset request body
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
|
||||
@@ -128,9 +146,26 @@ func relayHelper(c *gin.Context) error {
|
||||
model_ = strings.TrimSuffix(model_, "-0314")
|
||||
fullRequestURL = fmt.Sprintf("%s/openai/deployments/%s/%s", baseURL, model_, task)
|
||||
}
|
||||
var promptText string
|
||||
for _, message := range textRequest.Messages {
|
||||
promptText += fmt.Sprintf("%s: %s\n", message.Role, message.Content)
|
||||
}
|
||||
promptTokens := countToken(promptText) + 3
|
||||
preConsumedTokens := common.PreConsumedQuota
|
||||
if textRequest.MaxTokens != 0 {
|
||||
preConsumedTokens = promptTokens + textRequest.MaxTokens
|
||||
}
|
||||
ratio := common.GetModelRatio(textRequest.Model)
|
||||
preConsumedQuota := int(float64(preConsumedTokens) * ratio)
|
||||
if consumeQuota {
|
||||
err := model.PreConsumeTokenQuota(tokenId, preConsumedQuota)
|
||||
if err != nil {
|
||||
return errorWrapper(err, "pre_consume_token_quota_failed", http.StatusOK)
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequest(c.Request.Method, fullRequestURL, c.Request.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
return errorWrapper(err, "new_request_failed", http.StatusOK)
|
||||
}
|
||||
if channelType == common.ChannelTypeAzure {
|
||||
key := c.Request.Header.Get("Authorization")
|
||||
@@ -145,18 +180,18 @@ func relayHelper(c *gin.Context) error {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
return errorWrapper(err, "do_request_failed", http.StatusOK)
|
||||
}
|
||||
err = req.Body.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
return errorWrapper(err, "close_request_body_failed", http.StatusOK)
|
||||
}
|
||||
err = c.Request.Body.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
return errorWrapper(err, "close_request_body_failed", http.StatusOK)
|
||||
}
|
||||
var textResponse TextResponse
|
||||
isStream := resp.Header.Get("Content-Type") == "text/event-stream"
|
||||
isStream := strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream")
|
||||
var streamResponseText string
|
||||
|
||||
defer func() {
|
||||
@@ -168,18 +203,14 @@ func relayHelper(c *gin.Context) error {
|
||||
completionRatio = 2
|
||||
}
|
||||
if isStream {
|
||||
var promptText string
|
||||
for _, message := range textRequest.Messages {
|
||||
promptText += fmt.Sprintf("%s: %s\n", message.Role, message.Content)
|
||||
}
|
||||
completionText := fmt.Sprintf("%s: %s\n", "assistant", streamResponseText)
|
||||
quota = countToken(promptText) + countToken(completionText)*completionRatio + 3
|
||||
quota = promptTokens + countToken(completionText)*completionRatio
|
||||
} else {
|
||||
quota = textResponse.Usage.PromptTokens + textResponse.Usage.CompletionTokens*completionRatio
|
||||
}
|
||||
ratio := common.GetModelRatio(textRequest.Model)
|
||||
quota = int(float64(quota) * ratio)
|
||||
err := model.DecreaseTokenQuota(tokenId, quota)
|
||||
quotaDelta := quota - preConsumedQuota
|
||||
err := model.PostConsumeTokenQuota(tokenId, quotaDelta)
|
||||
if err != nil {
|
||||
common.SysError("Error consuming token remain quota: " + err.Error())
|
||||
}
|
||||
@@ -242,50 +273,60 @@ func relayHelper(c *gin.Context) error {
|
||||
})
|
||||
err = resp.Body.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
return errorWrapper(err, "close_response_body_failed", http.StatusOK)
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
for k, v := range resp.Header {
|
||||
c.Writer.Header().Set(k, v[0])
|
||||
}
|
||||
if consumeQuota {
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
return errorWrapper(err, "read_response_body_failed", http.StatusOK)
|
||||
}
|
||||
err = resp.Body.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
return errorWrapper(err, "close_response_body_failed", http.StatusOK)
|
||||
}
|
||||
err = json.Unmarshal(responseBody, &textResponse)
|
||||
if err != nil {
|
||||
return err
|
||||
return errorWrapper(err, "unmarshal_response_body_failed", http.StatusOK)
|
||||
}
|
||||
if textResponse.Error.Type != "" {
|
||||
return errors.New(fmt.Sprintf("type %s, code %s, message %s",
|
||||
textResponse.Error.Type, textResponse.Error.Code, textResponse.Error.Message))
|
||||
return &OpenAIErrorWithStatusCode{
|
||||
OpenAIError: textResponse.Error,
|
||||
StatusCode: resp.StatusCode,
|
||||
}
|
||||
}
|
||||
// Reset response body
|
||||
resp.Body = io.NopCloser(bytes.NewBuffer(responseBody))
|
||||
}
|
||||
// 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.
|
||||
// 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])
|
||||
}
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
_, err = io.Copy(c.Writer, resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
return errorWrapper(err, "copy_response_body_failed", http.StatusOK)
|
||||
}
|
||||
err = resp.Body.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
return errorWrapper(err, "close_response_body_failed", http.StatusOK)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func RelayNotImplemented(c *gin.Context) {
|
||||
err := OpenAIError{
|
||||
Message: "API not implemented",
|
||||
Type: "one_api_error",
|
||||
Param: "",
|
||||
Code: "api_not_implemented",
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"error": gin.H{
|
||||
"message": "Not Implemented",
|
||||
"type": "one_api_error",
|
||||
},
|
||||
"error": err,
|
||||
})
|
||||
}
|
||||
|
@@ -100,7 +100,6 @@ func GetTokenStatus(c *gin.Context) {
|
||||
}
|
||||
|
||||
func AddToken(c *gin.Context) {
|
||||
isAdmin := c.GetInt("role") >= common.RoleAdminUser
|
||||
token := model.Token{}
|
||||
err := c.ShouldBindJSON(&token)
|
||||
if err != nil {
|
||||
@@ -118,27 +117,14 @@ func AddToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
cleanToken := model.Token{
|
||||
UserId: c.GetInt("id"),
|
||||
Name: token.Name,
|
||||
Key: common.GetUUID(),
|
||||
CreatedTime: common.GetTimestamp(),
|
||||
AccessedTime: common.GetTimestamp(),
|
||||
ExpiredTime: token.ExpiredTime,
|
||||
}
|
||||
if isAdmin {
|
||||
cleanToken.RemainQuota = token.RemainQuota
|
||||
cleanToken.UnlimitedQuota = token.UnlimitedQuota
|
||||
} else {
|
||||
userId := c.GetInt("id")
|
||||
quota, err := model.GetUserQuota(userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
cleanToken.RemainQuota = quota
|
||||
UserId: c.GetInt("id"),
|
||||
Name: token.Name,
|
||||
Key: common.GetUUID(),
|
||||
CreatedTime: common.GetTimestamp(),
|
||||
AccessedTime: common.GetTimestamp(),
|
||||
ExpiredTime: token.ExpiredTime,
|
||||
RemainQuota: token.RemainQuota,
|
||||
UnlimitedQuota: token.UnlimitedQuota,
|
||||
}
|
||||
err = cleanToken.Insert()
|
||||
if err != nil {
|
||||
@@ -148,10 +134,6 @@ func AddToken(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if !isAdmin {
|
||||
// update user quota
|
||||
err = model.DecreaseUserQuota(c.GetInt("id"), cleanToken.RemainQuota)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
@@ -178,7 +160,6 @@ func DeleteToken(c *gin.Context) {
|
||||
}
|
||||
|
||||
func UpdateToken(c *gin.Context) {
|
||||
isAdmin := c.GetInt("role") >= common.RoleAdminUser
|
||||
userId := c.GetInt("id")
|
||||
statusOnly := c.Query("status_only")
|
||||
token := model.Token{}
|
||||
@@ -209,7 +190,7 @@ func UpdateToken(c *gin.Context) {
|
||||
if cleanToken.Status == common.TokenStatusExhausted && cleanToken.RemainQuota <= 0 && !cleanToken.UnlimitedQuota {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "令牌可用次数已用尽,无法启用,请先修改令牌剩余次数,或者设置为无限次数",
|
||||
"message": "令牌可用额度已用尽,无法启用,请先修改令牌剩余额度,或者设置为无限额度",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -220,10 +201,8 @@ func UpdateToken(c *gin.Context) {
|
||||
// If you add more fields, please also update token.Update()
|
||||
cleanToken.Name = token.Name
|
||||
cleanToken.ExpiredTime = token.ExpiredTime
|
||||
if isAdmin {
|
||||
cleanToken.RemainQuota = token.RemainQuota
|
||||
cleanToken.UnlimitedQuota = token.UnlimitedQuota
|
||||
}
|
||||
cleanToken.RemainQuota = token.RemainQuota
|
||||
cleanToken.UnlimitedQuota = token.UnlimitedQuota
|
||||
}
|
||||
err = cleanToken.Update()
|
||||
if err != nil {
|
||||
@@ -240,34 +219,3 @@ func UpdateToken(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
type topUpRequest struct {
|
||||
Id int `json:"id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func TopUp(c *gin.Context) {
|
||||
req := topUpRequest{}
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
quota, err := model.Redeem(req.Key, req.Id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": quota,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
@@ -467,6 +467,13 @@ func CreateUser(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := common.Validate.Struct(&user); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "输入不合法 " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if user.DisplayName == "" {
|
||||
user.DisplayName = user.Username
|
||||
}
|
||||
@@ -654,3 +661,34 @@ func EmailBind(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
type topUpRequest struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func TopUp(c *gin.Context) {
|
||||
req := topUpRequest{}
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
id := c.GetInt("id")
|
||||
quota, err := model.Redeem(req.Key, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": quota,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
@@ -85,6 +85,8 @@ func RootAuth() func(c *gin.Context) {
|
||||
func TokenAuth() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
key := c.Request.Header.Get("Authorization")
|
||||
key = strings.TrimPrefix(key, "Bearer ")
|
||||
key = strings.TrimPrefix(key, "sk-")
|
||||
parts := strings.Split(key, "-")
|
||||
key = parts[0]
|
||||
token, err := model.ValidateUserToken(key)
|
||||
@@ -111,7 +113,7 @@ func TokenAuth() func(c *gin.Context) {
|
||||
c.Set("id", token.UserId)
|
||||
c.Set("token_id", token.Id)
|
||||
requestURL := c.Request.URL.String()
|
||||
consumeQuota := !token.UnlimitedQuota
|
||||
consumeQuota := true
|
||||
if strings.HasPrefix(requestURL, "/v1/models") {
|
||||
consumeQuota = false
|
||||
}
|
||||
|
@@ -54,6 +54,8 @@ func InitOptionMap() {
|
||||
common.OptionMap["TurnstileSiteKey"] = ""
|
||||
common.OptionMap["TurnstileSecretKey"] = ""
|
||||
common.OptionMap["QuotaForNewUser"] = strconv.Itoa(common.QuotaForNewUser)
|
||||
common.OptionMap["QuotaRemindThreshold"] = strconv.Itoa(common.QuotaRemindThreshold)
|
||||
common.OptionMap["PreConsumedQuota"] = strconv.Itoa(common.PreConsumedQuota)
|
||||
common.OptionMap["ModelRatio"] = common.ModelRatio2JSONString()
|
||||
common.OptionMap["TopUpLink"] = common.TopUpLink
|
||||
common.OptionMapRWMutex.Unlock()
|
||||
@@ -156,6 +158,10 @@ func updateOptionMap(key string, value string) (err error) {
|
||||
common.TurnstileSecretKey = value
|
||||
case "QuotaForNewUser":
|
||||
common.QuotaForNewUser, _ = strconv.Atoi(value)
|
||||
case "QuotaRemindThreshold":
|
||||
common.QuotaRemindThreshold, _ = strconv.Atoi(value)
|
||||
case "PreConsumedQuota":
|
||||
common.PreConsumedQuota, _ = strconv.Atoi(value)
|
||||
case "ModelRatio":
|
||||
err = common.UpdateModelRatioByJSONString(value)
|
||||
case "TopUpLink":
|
||||
|
@@ -40,12 +40,12 @@ func GetRedemptionById(id int) (*Redemption, error) {
|
||||
return &redemption, err
|
||||
}
|
||||
|
||||
func Redeem(key string, tokenId int) (quota int, err error) {
|
||||
func Redeem(key string, userId int) (quota int, err error) {
|
||||
if key == "" {
|
||||
return 0, errors.New("未提供兑换码")
|
||||
}
|
||||
if tokenId == 0 {
|
||||
return 0, errors.New("未提供 token id")
|
||||
if userId == 0 {
|
||||
return 0, errors.New("无效的 user id")
|
||||
}
|
||||
redemption := &Redemption{}
|
||||
err = DB.Where("`key` = ?", key).First(redemption).Error
|
||||
@@ -55,7 +55,7 @@ func Redeem(key string, tokenId int) (quota int, err error) {
|
||||
if redemption.Status != common.RedemptionCodeStatusEnabled {
|
||||
return 0, errors.New("该兑换码已被使用")
|
||||
}
|
||||
err = IncreaseTokenQuota(tokenId, redemption.Quota)
|
||||
err = IncreaseUserQuota(userId, redemption.Quota)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
103
model/token.go
103
model/token.go
@@ -2,10 +2,10 @@ package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
_ "gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"one-api/common"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
@@ -37,7 +37,6 @@ func ValidateUserToken(key string) (token *Token, err error) {
|
||||
if key == "" {
|
||||
return nil, errors.New("未提供 token")
|
||||
}
|
||||
key = strings.Replace(key, "Bearer ", "", 1)
|
||||
token = &Token{}
|
||||
err = DB.Where("`key` = ?", key).First(token).Error
|
||||
if err == nil {
|
||||
@@ -82,6 +81,16 @@ func GetTokenByIds(id int, userId int) (*Token, error) {
|
||||
return &token, err
|
||||
}
|
||||
|
||||
func GetTokenById(id int) (*Token, error) {
|
||||
if id == 0 {
|
||||
return nil, errors.New("id 为空!")
|
||||
}
|
||||
token := Token{Id: id}
|
||||
var err error = nil
|
||||
err = DB.First(&token, "id = ?", id).Error
|
||||
return &token, err
|
||||
}
|
||||
|
||||
func (token *Token) Insert() error {
|
||||
var err error
|
||||
err = DB.Create(token).Error
|
||||
@@ -116,26 +125,94 @@ func DeleteTokenById(id int, userId int) (err error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
quota := token.RemainQuota
|
||||
if quota != 0 {
|
||||
if quota > 0 {
|
||||
err = IncreaseUserQuota(userId, quota)
|
||||
} else {
|
||||
err = DecreaseUserQuota(userId, -quota)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return token.Delete()
|
||||
}
|
||||
|
||||
func IncreaseTokenQuota(id int, quota int) (err error) {
|
||||
if quota < 0 {
|
||||
return errors.New("quota 不能为负数!")
|
||||
}
|
||||
err = DB.Model(&Token{}).Where("id = ?", id).Update("remain_quota", gorm.Expr("remain_quota + ?", quota)).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func DecreaseTokenQuota(id int, quota int) (err error) {
|
||||
if quota < 0 {
|
||||
return errors.New("quota 不能为负数!")
|
||||
}
|
||||
err = DB.Model(&Token{}).Where("id = ?", id).Update("remain_quota", gorm.Expr("remain_quota - ?", quota)).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func PreConsumeTokenQuota(tokenId int, quota int) (err error) {
|
||||
if quota < 0 {
|
||||
return errors.New("quota 不能为负数!")
|
||||
}
|
||||
token, err := GetTokenById(tokenId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !token.UnlimitedQuota && token.RemainQuota < quota {
|
||||
return errors.New("令牌额度不足")
|
||||
}
|
||||
userQuota, err := GetUserQuota(token.UserId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if userQuota < quota {
|
||||
return errors.New("用户额度不足")
|
||||
}
|
||||
quotaTooLow := userQuota >= common.QuotaRemindThreshold && userQuota-quota < common.QuotaRemindThreshold
|
||||
noMoreQuota := userQuota-quota <= 0
|
||||
if quotaTooLow || noMoreQuota {
|
||||
go func() {
|
||||
email, err := GetUserEmail(token.UserId)
|
||||
if err != nil {
|
||||
common.SysError("获取用户邮箱失败:" + err.Error())
|
||||
}
|
||||
prompt := "您的额度即将用尽"
|
||||
if noMoreQuota {
|
||||
prompt = "您的额度已用尽"
|
||||
}
|
||||
if email != "" {
|
||||
topUpLink := fmt.Sprintf("%s/topup", common.ServerAddress)
|
||||
err = common.SendEmail(prompt, email,
|
||||
fmt.Sprintf("%s,当前剩余额度为 %d,为了不影响您的使用,请及时充值。<br/>充值链接:<a href='%s'>%s</a>", prompt, userQuota, topUpLink, topUpLink))
|
||||
if err != nil {
|
||||
common.SysError("发送邮件失败:" + err.Error())
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
if !token.UnlimitedQuota {
|
||||
err = DecreaseTokenQuota(tokenId, quota)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err = DecreaseUserQuota(token.UserId, quota)
|
||||
return err
|
||||
}
|
||||
|
||||
func PostConsumeTokenQuota(tokenId int, quota int) (err error) {
|
||||
token, err := GetTokenById(tokenId)
|
||||
if quota > 0 {
|
||||
err = DecreaseUserQuota(token.UserId, quota)
|
||||
} else {
|
||||
err = IncreaseUserQuota(token.UserId, -quota)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !token.UnlimitedQuota {
|
||||
if quota > 0 {
|
||||
err = DecreaseTokenQuota(tokenId, quota)
|
||||
} else {
|
||||
err = IncreaseTokenQuota(tokenId, -quota)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@@ -225,12 +225,23 @@ func GetUserQuota(id int) (quota int, err error) {
|
||||
return quota, err
|
||||
}
|
||||
|
||||
func GetUserEmail(id int) (email string, err error) {
|
||||
err = DB.Model(&User{}).Where("id = ?", id).Select("email").Find(&email).Error
|
||||
return email, err
|
||||
}
|
||||
|
||||
func IncreaseUserQuota(id int, quota int) (err error) {
|
||||
if quota < 0 {
|
||||
return errors.New("quota 不能为负数!")
|
||||
}
|
||||
err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota + ?", quota)).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func DecreaseUserQuota(id int, quota int) (err error) {
|
||||
if quota < 0 {
|
||||
return errors.New("quota 不能为负数!")
|
||||
}
|
||||
err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota - ?", quota)).Error
|
||||
return err
|
||||
}
|
||||
|
@@ -37,6 +37,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
selfRoute.PUT("/self", controller.UpdateSelf)
|
||||
selfRoute.DELETE("/self", controller.DeleteSelf)
|
||||
selfRoute.GET("/token", controller.GenerateAccessToken)
|
||||
selfRoute.POST("/topup", controller.TopUp)
|
||||
}
|
||||
|
||||
adminRoute := userRoute.Group("/")
|
||||
@@ -74,7 +75,6 @@ func SetApiRouter(router *gin.Engine) {
|
||||
{
|
||||
tokenRoute.GET("/", controller.GetAllTokens)
|
||||
tokenRoute.GET("/search", controller.SearchTokens)
|
||||
tokenRoute.POST("/topup", controller.TopUp)
|
||||
tokenRoute.GET("/:id", controller.GetToken)
|
||||
tokenRoute.POST("/", controller.AddToken)
|
||||
tokenRoute.PUT("/", controller.UpdateToken)
|
||||
|
@@ -11,8 +11,8 @@ func SetRelayRouter(router *gin.Engine) {
|
||||
relayV1Router := router.Group("/v1")
|
||||
relayV1Router.Use(middleware.TokenAuth(), middleware.Distribute())
|
||||
{
|
||||
relayV1Router.GET("/models", controller.Relay)
|
||||
relayV1Router.GET("/models/:model", controller.Relay)
|
||||
relayV1Router.GET("/models", controller.ListModels)
|
||||
relayV1Router.GET("/models/:model", controller.RetrieveModel)
|
||||
relayV1Router.POST("/completions", controller.RelayNotImplemented)
|
||||
relayV1Router.POST("/chat/completions", controller.Relay)
|
||||
relayV1Router.POST("/edits", controller.RelayNotImplemented)
|
||||
|
@@ -9,7 +9,7 @@ import NotFound from './pages/NotFound';
|
||||
import Setting from './pages/Setting';
|
||||
import EditUser from './pages/User/EditUser';
|
||||
import AddUser from './pages/User/AddUser';
|
||||
import { API, showError, showNotice } from './helpers';
|
||||
import { API, getLogo, getSystemName, showError, showNotice } from './helpers';
|
||||
import PasswordResetForm from './components/PasswordResetForm';
|
||||
import GitHubOAuth from './components/GitHubOAuth';
|
||||
import PasswordResetConfirm from './components/PasswordResetConfirm';
|
||||
@@ -21,6 +21,7 @@ import EditToken from './pages/Token/EditToken';
|
||||
import EditChannel from './pages/Channel/EditChannel';
|
||||
import Redemption from './pages/Redemption';
|
||||
import EditRedemption from './pages/Redemption/EditRedemption';
|
||||
import TopUp from './pages/TopUp';
|
||||
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const About = lazy(() => import('./pages/About'));
|
||||
@@ -62,6 +63,17 @@ function App() {
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
loadStatus().then();
|
||||
let systemName = getSystemName();
|
||||
if (systemName) {
|
||||
document.title = systemName;
|
||||
}
|
||||
let logo = getLogo();
|
||||
if (logo) {
|
||||
let linkElement = document.querySelector("link[rel~='icon']");
|
||||
if (linkElement) {
|
||||
linkElement.href = logo;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -228,6 +240,16 @@ function App() {
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/topup'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<TopUp />
|
||||
</Suspense>
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/about'
|
||||
element={
|
||||
|
@@ -30,6 +30,11 @@ const headerButtons = [
|
||||
icon: 'dollar sign',
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
name: '充值',
|
||||
to: '/topup',
|
||||
icon: 'cart',
|
||||
},
|
||||
{
|
||||
name: '用户',
|
||||
to: '/user',
|
||||
|
@@ -12,7 +12,6 @@ const OtherSetting = () => {
|
||||
Logo: '',
|
||||
HomePageContent: '',
|
||||
});
|
||||
let originInputs = {};
|
||||
let [loading, setLoading] = useState(false);
|
||||
const [showUpdateModal, setShowUpdateModal] = useState(false);
|
||||
const [updateData, setUpdateData] = useState({
|
||||
@@ -21,7 +20,7 @@ const OtherSetting = () => {
|
||||
});
|
||||
|
||||
const getOptions = async () => {
|
||||
const res = await API.get('/api/option');
|
||||
const res = await API.get('/api/option/');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let newInputs = {};
|
||||
@@ -31,7 +30,6 @@ const OtherSetting = () => {
|
||||
}
|
||||
});
|
||||
setInputs(newInputs);
|
||||
originInputs = newInputs;
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -43,7 +41,7 @@ const OtherSetting = () => {
|
||||
|
||||
const updateOption = async (key, value) => {
|
||||
setLoading(true);
|
||||
const res = await API.put('/api/option', {
|
||||
const res = await API.put('/api/option/', {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
|
@@ -27,16 +27,18 @@ const SystemSetting = () => {
|
||||
TurnstileSecretKey: '',
|
||||
RegisterEnabled: '',
|
||||
QuotaForNewUser: 0,
|
||||
QuotaRemindThreshold: 0,
|
||||
PreConsumedQuota: 0,
|
||||
ModelRatio: '',
|
||||
TopUpLink: '',
|
||||
AutomaticDisableChannelEnabled: '',
|
||||
ChannelDisableThreshold: 0,
|
||||
});
|
||||
let originInputs = {};
|
||||
const [originInputs, setOriginInputs] = useState({});
|
||||
let [loading, setLoading] = useState(false);
|
||||
|
||||
const getOptions = async () => {
|
||||
const res = await API.get('/api/option');
|
||||
const res = await API.get('/api/option/');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let newInputs = {};
|
||||
@@ -44,7 +46,7 @@ const SystemSetting = () => {
|
||||
newInputs[item.key] = item.value;
|
||||
});
|
||||
setInputs(newInputs);
|
||||
originInputs = newInputs;
|
||||
setOriginInputs(newInputs);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -70,7 +72,7 @@ const SystemSetting = () => {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
const res = await API.put('/api/option', {
|
||||
const res = await API.put('/api/option/', {
|
||||
key,
|
||||
value
|
||||
});
|
||||
@@ -96,6 +98,8 @@ const SystemSetting = () => {
|
||||
name === 'TurnstileSiteKey' ||
|
||||
name === 'TurnstileSecretKey' ||
|
||||
name === 'QuotaForNewUser' ||
|
||||
name === 'QuotaRemindThreshold' ||
|
||||
name === 'PreConsumedQuota' ||
|
||||
name === 'ModelRatio' ||
|
||||
name === 'TopUpLink'
|
||||
) {
|
||||
@@ -114,6 +118,12 @@ const SystemSetting = () => {
|
||||
if (originInputs['QuotaForNewUser'] !== inputs.QuotaForNewUser) {
|
||||
await updateOption('QuotaForNewUser', inputs.QuotaForNewUser);
|
||||
}
|
||||
if (originInputs['QuotaRemindThreshold'] !== inputs.QuotaRemindThreshold) {
|
||||
await updateOption('QuotaRemindThreshold', inputs.QuotaRemindThreshold);
|
||||
}
|
||||
if (originInputs['PreConsumedQuota'] !== inputs.PreConsumedQuota) {
|
||||
await updateOption('PreConsumedQuota', inputs.PreConsumedQuota);
|
||||
}
|
||||
if (originInputs['ModelRatio'] !== inputs.ModelRatio) {
|
||||
if (!verifyJSON(inputs.ModelRatio)) {
|
||||
showError('模型倍率不是合法的 JSON 字符串');
|
||||
@@ -267,7 +277,7 @@ const SystemSetting = () => {
|
||||
<Header as='h3'>
|
||||
运营设置
|
||||
</Header>
|
||||
<Form.Group widths={3}>
|
||||
<Form.Group widths={4}>
|
||||
<Form.Input
|
||||
label='新用户初始配额'
|
||||
name='QuotaForNewUser'
|
||||
@@ -287,6 +297,26 @@ const SystemSetting = () => {
|
||||
type='link'
|
||||
placeholder='例如发卡网站的购买链接'
|
||||
/>
|
||||
<Form.Input
|
||||
label='额度提醒阈值'
|
||||
name='QuotaRemindThreshold'
|
||||
onChange={handleInputChange}
|
||||
autoComplete='new-password'
|
||||
value={inputs.QuotaRemindThreshold}
|
||||
type='number'
|
||||
min='0'
|
||||
placeholder='低于此额度时将发送邮件提醒用户'
|
||||
/>
|
||||
<Form.Input
|
||||
label='请求预扣费额度'
|
||||
name='PreConsumedQuota'
|
||||
onChange={handleInputChange}
|
||||
autoComplete='new-password'
|
||||
value={inputs.PreConsumedQuota}
|
||||
type='number'
|
||||
min='0'
|
||||
placeholder='请求结束后多退少补'
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group widths='equal'>
|
||||
<Form.TextArea
|
||||
@@ -306,7 +336,7 @@ const SystemSetting = () => {
|
||||
</Header>
|
||||
<Form.Group widths={3}>
|
||||
<Form.Input
|
||||
label='最长回应时间'
|
||||
label='最长响应时间'
|
||||
name='ChannelDisableThreshold'
|
||||
onChange={handleInputChange}
|
||||
autoComplete='new-password'
|
||||
|
@@ -36,8 +36,6 @@ const TokensTable = () => {
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [showTopUpModal, setShowTopUpModal] = useState(false);
|
||||
const [targetTokenIdx, setTargetTokenIdx] = useState(0);
|
||||
const [redemptionCode, setRedemptionCode] = useState('');
|
||||
const [topUpLink, setTopUpLink] = useState('');
|
||||
|
||||
const loadTokens = async (startIdx) => {
|
||||
const res = await API.get(`/api/token/?p=${startIdx}`);
|
||||
@@ -77,13 +75,6 @@ const TokensTable = () => {
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
let status = localStorage.getItem('status');
|
||||
if (status) {
|
||||
status = JSON.parse(status);
|
||||
if (status.top_up_link) {
|
||||
setTopUpLink(status.top_up_link);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const manageToken = async (id, action, idx) => {
|
||||
@@ -156,28 +147,6 @@ const TokensTable = () => {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const topUp = async () => {
|
||||
if (redemptionCode === '') {
|
||||
return;
|
||||
}
|
||||
const res = await API.post('/api/token/topup/', {
|
||||
id: tokens[targetTokenIdx].id,
|
||||
key: redemptionCode
|
||||
});
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
showSuccess('充值成功!');
|
||||
let newTokens = [...tokens];
|
||||
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + targetTokenIdx;
|
||||
newTokens[realIdx].remain_quota += data;
|
||||
setTokens(newTokens);
|
||||
setRedemptionCode('');
|
||||
setShowTopUpModal(false);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form onSubmit={searchTokens}>
|
||||
@@ -279,15 +248,6 @@ const TokensTable = () => {
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
<Button
|
||||
size={'small'}
|
||||
color={'yellow'}
|
||||
onClick={() => {
|
||||
setTargetTokenIdx(idx);
|
||||
setShowTopUpModal(true);
|
||||
}}>
|
||||
充值
|
||||
</Button>
|
||||
<Popup
|
||||
trigger={
|
||||
<Button size='small' negative>
|
||||
@@ -355,39 +315,6 @@ const TokensTable = () => {
|
||||
</Table.Row>
|
||||
</Table.Footer>
|
||||
</Table>
|
||||
|
||||
<Modal
|
||||
onClose={() => setShowTopUpModal(false)}
|
||||
onOpen={() => setShowTopUpModal(true)}
|
||||
open={showTopUpModal}
|
||||
size={'mini'}
|
||||
>
|
||||
<Modal.Header>通过兑换码为令牌「{tokens[targetTokenIdx]?.name}」充值</Modal.Header>
|
||||
<Modal.Content>
|
||||
<Modal.Description>
|
||||
{/*<Image src={status.wechat_qrcode} fluid />*/}
|
||||
{
|
||||
topUpLink && <p>
|
||||
<a target='_blank' href={topUpLink}>点击此处获取兑换码</a>
|
||||
</p>
|
||||
}
|
||||
<Form size='large'>
|
||||
<Form.Input
|
||||
fluid
|
||||
placeholder='兑换码'
|
||||
name='redemptionCode'
|
||||
value={redemptionCode}
|
||||
onChange={(e) => {
|
||||
setRedemptionCode(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<Button color='' fluid size='large' onClick={topUp}>
|
||||
充值
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal.Description>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
|
||||
import { API, showError, showSuccess } from '../helpers';
|
||||
|
||||
import { ITEMS_PER_PAGE } from '../constants';
|
||||
import { renderText } from '../helpers/render';
|
||||
|
||||
function renderRole(role) {
|
||||
switch (role) {
|
||||
@@ -64,7 +65,7 @@ const UsersTable = () => {
|
||||
(async () => {
|
||||
const res = await API.post('/api/user/manage', {
|
||||
username,
|
||||
action,
|
||||
action
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
@@ -158,6 +159,14 @@ const UsersTable = () => {
|
||||
<Table basic>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortUser('id');
|
||||
}}
|
||||
>
|
||||
ID
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
@@ -166,14 +175,6 @@ const UsersTable = () => {
|
||||
>
|
||||
用户名
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortUser('display_name');
|
||||
}}
|
||||
>
|
||||
显示名称
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
@@ -182,6 +183,14 @@ const UsersTable = () => {
|
||||
>
|
||||
邮箱地址
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortUser('quota');
|
||||
}}
|
||||
>
|
||||
剩余额度
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
@@ -212,9 +221,18 @@ const UsersTable = () => {
|
||||
if (user.deleted) return <></>;
|
||||
return (
|
||||
<Table.Row key={user.id}>
|
||||
<Table.Cell>{user.username}</Table.Cell>
|
||||
<Table.Cell>{user.display_name}</Table.Cell>
|
||||
<Table.Cell>{user.email ? user.email : '无'}</Table.Cell>
|
||||
<Table.Cell>{user.id}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Popup
|
||||
content={user.email ? user.email : '未绑定邮箱地址'}
|
||||
key={user.display_name}
|
||||
header={user.display_name ? user.display_name : user.username}
|
||||
trigger={<span>{renderText(user.username, 10)}</span>}
|
||||
hoverable
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{user.email ? renderText(user.email, 30) : '无'}</Table.Cell>
|
||||
<Table.Cell>{user.quota}</Table.Cell>
|
||||
<Table.Cell>{renderRole(user.role)}</Table.Cell>
|
||||
<Table.Cell>{renderStatus(user.status)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
@@ -225,6 +243,7 @@ const UsersTable = () => {
|
||||
onClick={() => {
|
||||
manageUser(user.username, 'promote', idx);
|
||||
}}
|
||||
disabled={user.role === 100}
|
||||
>
|
||||
提升
|
||||
</Button>
|
||||
@@ -234,12 +253,13 @@ const UsersTable = () => {
|
||||
onClick={() => {
|
||||
manageUser(user.username, 'demote', idx);
|
||||
}}
|
||||
disabled={user.role === 100}
|
||||
>
|
||||
降级
|
||||
</Button>
|
||||
<Popup
|
||||
trigger={
|
||||
<Button size='small' negative>
|
||||
<Button size='small' negative disabled={user.role === 100}>
|
||||
删除
|
||||
</Button>
|
||||
}
|
||||
@@ -265,6 +285,7 @@ const UsersTable = () => {
|
||||
idx
|
||||
);
|
||||
}}
|
||||
disabled={user.role === 100}
|
||||
>
|
||||
{user.status === 1 ? '禁用' : '启用'}
|
||||
</Button>
|
||||
@@ -272,6 +293,7 @@ const UsersTable = () => {
|
||||
size={'small'}
|
||||
as={Link}
|
||||
to={'/user/edit/' + user.id}
|
||||
disabled={user.role === 100}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
@@ -284,7 +306,7 @@ const UsersTable = () => {
|
||||
|
||||
<Table.Footer>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan='6'>
|
||||
<Table.HeaderCell colSpan='7'>
|
||||
<Button size='small' as={Link} to='/user/add' loading={loading}>
|
||||
添加新的用户
|
||||
</Button>
|
||||
|
6
web/src/helpers/render.js
Normal file
6
web/src/helpers/render.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export function renderText(text, limit) {
|
||||
if (text.length > limit) {
|
||||
return text.slice(0, limit - 3) + '...';
|
||||
}
|
||||
return text;
|
||||
}
|
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Form, Header, Segment } from 'semantic-ui-react';
|
||||
import { Button, Form, Header, Message, Segment } from 'semantic-ui-react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { API, isAdmin, showError, showSuccess, timestamp2string } from '../../helpers';
|
||||
import { API, showError, showSuccess, timestamp2string } from '../../helpers';
|
||||
|
||||
const EditToken = () => {
|
||||
const params = useParams();
|
||||
@@ -14,7 +14,6 @@ const EditToken = () => {
|
||||
expired_time: -1,
|
||||
unlimited_quota: false
|
||||
};
|
||||
const isAdminUser = isAdmin();
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
const { name, remain_quota, expired_time, unlimited_quota } = inputs;
|
||||
|
||||
@@ -107,25 +106,22 @@ const EditToken = () => {
|
||||
required={!isEdit}
|
||||
/>
|
||||
</Form.Field>
|
||||
{
|
||||
isAdminUser && <>
|
||||
<Form.Field>
|
||||
<Form.Input
|
||||
label='额度'
|
||||
name='remain_quota'
|
||||
placeholder={'请输入额度'}
|
||||
onChange={handleInputChange}
|
||||
value={remain_quota}
|
||||
autoComplete='new-password'
|
||||
type='number'
|
||||
disabled={unlimited_quota}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Button type={'button'} style={{marginBottom: '14px'}} onClick={() => {
|
||||
setUnlimitedQuota();
|
||||
}}>{unlimited_quota ? '取消无限额度' : '设置为无限额度'}</Button>
|
||||
</>
|
||||
}
|
||||
<Message>注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。</Message>
|
||||
<Form.Field>
|
||||
<Form.Input
|
||||
label='额度'
|
||||
name='remain_quota'
|
||||
placeholder={'请输入额度'}
|
||||
onChange={handleInputChange}
|
||||
value={remain_quota}
|
||||
autoComplete='new-password'
|
||||
type='number'
|
||||
disabled={unlimited_quota}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Button type={'button'} style={{ marginBottom: '14px' }} onClick={() => {
|
||||
setUnlimitedQuota();
|
||||
}}>{unlimited_quota ? '取消无限额度' : '设置为无限额度'}</Button>
|
||||
<Form.Field>
|
||||
<Form.Input
|
||||
label='过期时间'
|
||||
|
94
web/src/pages/TopUp/index.js
Normal file
94
web/src/pages/TopUp/index.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Form, Grid, Header, Segment, Statistic } from 'semantic-ui-react';
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
|
||||
const TopUp = () => {
|
||||
const [redemptionCode, setRedemptionCode] = useState('');
|
||||
const [topUpLink, setTopUpLink] = useState('');
|
||||
const [userQuota, setUserQuota] = useState(0);
|
||||
|
||||
const topUp = async () => {
|
||||
if (redemptionCode === '') {
|
||||
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;
|
||||
});
|
||||
setRedemptionCode('');
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const openTopUpLink = () => {
|
||||
if (!topUpLink) {
|
||||
showError('超级管理员未设置充值链接!');
|
||||
return;
|
||||
}
|
||||
window.open(topUpLink, '_blank');
|
||||
};
|
||||
|
||||
const getUserQuota = async ()=>{
|
||||
let res = await API.get(`/api/user/self`);
|
||||
const {success, message, data} = res.data;
|
||||
if (success) {
|
||||
setUserQuota(data.quota);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let status = localStorage.getItem('status');
|
||||
if (status) {
|
||||
status = JSON.parse(status);
|
||||
if (status.top_up_link) {
|
||||
setTopUpLink(status.top_up_link);
|
||||
}
|
||||
}
|
||||
getUserQuota().then();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
<Header as='h3'>充值额度</Header>
|
||||
<Grid columns={2} stackable>
|
||||
<Grid.Column>
|
||||
<Form>
|
||||
<Form.Input
|
||||
placeholder='兑换码'
|
||||
name='redemptionCode'
|
||||
value={redemptionCode}
|
||||
onChange={(e) => {
|
||||
setRedemptionCode(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<Button color='green' onClick={openTopUpLink}>
|
||||
获取兑换码
|
||||
</Button>
|
||||
<Button color='yellow' onClick={topUp}>
|
||||
充值
|
||||
</Button>
|
||||
</Form>
|
||||
</Grid.Column>
|
||||
<Grid.Column>
|
||||
<Statistic.Group widths='one'>
|
||||
<Statistic>
|
||||
<Statistic.Value>{userQuota}</Statistic.Value>
|
||||
<Statistic.Label>剩余额度</Statistic.Label>
|
||||
</Statistic>
|
||||
</Statistic.Group>
|
||||
</Grid.Column>
|
||||
</Grid>
|
||||
</Segment>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default TopUp;
|
Reference in New Issue
Block a user