Compare commits

...

37 Commits

Author SHA1 Message Date
JustSong
44729da277 fix: provide a default value for api-version if not given (#57) 2023-05-13 11:41:57 +08:00
JustSong
7a3378b4b7 feat: Azure API supported without verification (#48, #57) 2023-05-13 11:36:36 +08:00
JustSong
fd19d7d246 fix: handle errors when update option map 2023-05-13 10:30:55 +08:00
JustSong
5c694a1503 feat: now supports custom smtp port 2023-05-12 11:44:38 +08:00
JustSong
9edc54ca69 fix: fix the default ratio for text-embedding-ada-002 2023-05-11 22:43:39 +08:00
JustSong
e6af636fa0 fix: the initial quota for new token now calculated correctly (#51) 2023-05-11 21:29:05 +08:00
JustSong
6e1ef75009 feat: support /v1/embeddings now (close #50) 2023-05-11 21:00:09 +08:00
JustSong
d9db16e999 feat: able to configure ratio for more models now (close #53) 2023-05-11 20:59:35 +08:00
JustSong
241ade2fae fix: delete a token with a negative quota will now update the account's quota correctly (close #51) 2023-05-11 20:05:49 +08:00
chunzhi
80065de8a3 feat: add Docker Compose support (#55)
* docker-compose.yml

* Create one-api.service

配置systemd守护进程

* Update docker-compose.yml

* Update one-api.service

* Update docker-compose.yml

* Update docker-compose.yml
2023-05-11 16:56:14 +08:00
JustSong
16f53b5afb feat: double check before deleting a user 2023-05-10 10:13:39 +08:00
JustSong
3071300c0c feat: support API /dashboard/billing/credit_grants now (#45) 2023-05-10 09:28:41 +08:00
JustSong
8b056bf408 docs: update README (#47) 2023-05-05 21:56:48 +08:00
JustSong
e5640857b1 docs: add funding link 2023-05-05 15:26:54 +08:00
JustSong
331177d97e fix: return quota to user when delete token (close #37) 2023-05-04 10:20:39 +08:00
JustSong
4fed003f1a chore: update placeholder text (#36) 2023-04-29 18:42:05 +08:00
JustSong
a1ea1bf696 chore: update placeholder text (#36) 2023-04-29 18:41:19 +08:00
JustSong
7c66fc6c21 fix: shouldn't close c.Request.Body too soon (close #35) 2023-04-29 14:49:10 +08:00
JustSong
d93cb8f645 feat: able to configure ratio for different models (close #26) 2023-04-28 19:16:37 +08:00
JustSong
b08cd7e104 refactor: use tiktoken-go to calculate token number 2023-04-28 18:36:17 +08:00
JustSong
aea6c859e7 fix: relay bug fix 2023-04-28 18:16:59 +08:00
JustSong
480e789cd8 feat: support configuring ratio when estimating token number in stream mode 2023-04-28 17:25:05 +08:00
JustSong
23ec541ba6 refactor: improve relay's implementation 2023-04-28 17:11:57 +08:00
JustSong
053bb85a1c feat: now use token as the unit of quota (close #33) 2023-04-28 16:58:55 +08:00
JustSong
601fa5cea8 refactor: use quota instead of times 2023-04-28 14:57:20 +08:00
JustSong
7a5057f02d fix: check user's role when manage user (#30) 2023-04-28 09:47:03 +08:00
JustSong
c76027a210 style: add bottom margin for unlimited times button 2023-04-27 17:18:07 +08:00
JustSong
f97c2b4c22 feat: able to set top up link now 2023-04-27 16:32:21 +08:00
JustSong
54b1e4adef fix: check user status when validating token (#23) 2023-04-27 15:05:33 +08:00
JustSong
9272884381 fix: root user cannot demote itself now (close #30) 2023-04-27 14:45:12 +08:00
JustSong
195e94a75d fix: fix MySQL syntax error (#54) 2023-04-27 11:10:10 +08:00
JustSong
5bfc224669 fix: specify type for token (close #23) 2023-04-27 09:32:20 +08:00
JustSong
fd149c242f fix: remove rate limit for relay api 2023-04-26 21:50:09 +08:00
JustSong
b9cc5dfa3f feat: able to set initial quota for new user (close #22) 2023-04-26 21:40:56 +08:00
JustSong
8c305dc1bc feat: able to manage system vai access token (close #12) 2023-04-26 20:54:39 +08:00
JustSong
f62a671fbe feat: download redemption codes as file (#12) 2023-04-26 17:13:08 +08:00
JustSong
9e2f2383b9 feat: now user can top up via redemption code (close #9) 2023-04-26 17:02:26 +08:00
39 changed files with 1713 additions and 265 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
custom: ['https://iamazing.cn/page/reward']

View File

@@ -49,17 +49,21 @@ _✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用
+ [x] [OpenAI-SB](https://openai-sb.com) + [x] [OpenAI-SB](https://openai-sb.com)
+ [x] [OpenAI Max](https://openaimax.com) + [x] [OpenAI Max](https://openaimax.com)
+ [x] [OhMyGPT](https://www.ohmygpt.com) + [x] [OhMyGPT](https://www.ohmygpt.com)
+ [x] 自定义渠道 + [x] 自定义渠道:例如使用自行搭建的 OpenAI 代理
2. 支持通过负载均衡的方式访问多个渠道。 2. 支持通过负载均衡的方式访问多个渠道。
3. 支持单个访问渠道设置多个 API Key利用起来你的多个 API Key。 3. 支持单个访问渠道设置多个 API Key利用起来你的多个 API Key。
4. 支持设置令牌的过期时间和使用次数 4. 支持 HTTP SSE可以通过流式传输实现打字机效果
5. 支持 HTTP SSE 5. 支持设置令牌的过期时间和使用次数
6. 多种用户登录注册方式: 6. 支持批量生成和导出兑换码,可使用兑换码为令牌进行充值。
+ 邮箱登录注册以及通过邮箱进行密码重置 7. 支持为新用户设置初始配额
+ [GitHub 开放授权](https://github.com/settings/applications/new) 8. 支持发布公告,在线修改关于页面,设置充值链接,自定义页脚
+ 微信公众号授权(需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server) 9. 支持通过系统访问令牌访问管理 API
7. 支持用户管理。 10. 多种用户登录注册方式:
8. 未来其他大模型开放 API 后,将第一时间支持,并将其封装成同样的 API 访问方式 + 邮箱登录注册以及通过邮箱进行密码重置
+ [GitHub 开放授权](https://github.com/settings/applications/new)。
+ 微信公众号授权(需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server))。
11. 支持用户管理。
12. 未来其他大模型开放 API 后,将第一时间支持,并将其封装成同样的 API 访问方式。
## 部署 ## 部署
### 基于 Docker 进行部署 ### 基于 Docker 进行部署

View File

@@ -11,6 +11,7 @@ var Version = "v0.0.0" // this hard coding will be replaced automatic
var SystemName = "One API" var SystemName = "One API"
var ServerAddress = "http://localhost:3000" var ServerAddress = "http://localhost:3000"
var Footer = "" var Footer = ""
var TopUpLink = ""
var UsingSQLite = false var UsingSQLite = false
@@ -33,6 +34,7 @@ var TurnstileCheckEnabled = false
var RegisterEnabled = true var RegisterEnabled = true
var SMTPServer = "" var SMTPServer = ""
var SMTPPort = 587
var SMTPAccount = "" var SMTPAccount = ""
var SMTPToken = "" var SMTPToken = ""
@@ -46,6 +48,8 @@ var WeChatAccountQRCodeImageURL = ""
var TurnstileSiteKey = "" var TurnstileSiteKey = ""
var TurnstileSecretKey = "" var TurnstileSecretKey = ""
var QuotaForNewUser = 100
const ( const (
RoleGuestUser = 0 RoleGuestUser = 0
RoleCommonUser = 1 RoleCommonUser = 1
@@ -63,7 +67,7 @@ var (
// All duration's unit is seconds // All duration's unit is seconds
// Shouldn't larger then RateLimitKeyExpirationDuration // Shouldn't larger then RateLimitKeyExpirationDuration
var ( var (
GlobalApiRateLimitNum = 60000 // TODO: temporary set to 60000 GlobalApiRateLimitNum = 180
GlobalApiRateLimitDuration int64 = 3 * 60 GlobalApiRateLimitDuration int64 = 3 * 60
GlobalWebRateLimitNum = 60 GlobalWebRateLimitNum = 60
@@ -93,6 +97,12 @@ const (
TokenStatusExhausted = 4 TokenStatusExhausted = 4
) )
const (
RedemptionCodeStatusEnabled = 1 // don't use 0, 0 is the default value!
RedemptionCodeStatusDisabled = 2 // also don't use 0
RedemptionCodeStatusUsed = 3 // also don't use 0
)
const ( const (
ChannelStatusUnknown = 0 ChannelStatusUnknown = 0
ChannelStatusEnabled = 1 // don't use 0, 0 is the default value! ChannelStatusEnabled = 1 // don't use 0, 0 is the default value!

View File

@@ -8,7 +8,7 @@ func SendEmail(subject string, receiver string, content string) error {
m.SetHeader("To", receiver) m.SetHeader("To", receiver)
m.SetHeader("Subject", subject) m.SetHeader("Subject", subject)
m.SetBody("text/html", content) m.SetBody("text/html", content)
d := gomail.NewDialer(SMTPServer, 587, SMTPAccount, SMTPToken) d := gomail.NewDialer(SMTPServer, SMTPPort, SMTPAccount, SMTPToken)
err := d.DialAndSend(m) err := d.DialAndSend(m)
return err return err
} }

52
common/model-ratio.go Normal file
View File

@@ -0,0 +1,52 @@
package common
import "encoding/json"
// https://platform.openai.com/docs/models/model-endpoint-compatibility
// https://openai.com/pricing
// TODO: when a new api is enabled, check the pricing here
var ModelRatio = map[string]float64{
"gpt-4": 15,
"gpt-4-0314": 15,
"gpt-4-32k": 30,
"gpt-4-32k-0314": 30,
"gpt-3.5-turbo": 1,
"gpt-3.5-turbo-0301": 1,
"text-ada-001": 0.2,
"text-babbage-001": 0.25,
"text-curie-001": 1,
"text-davinci-002": 10,
"text-davinci-003": 10,
"text-davinci-edit-001": 10,
"code-davinci-edit-001": 10,
"whisper-1": 10,
"davinci": 10,
"curie": 10,
"babbage": 10,
"ada": 10,
"text-embedding-ada-002": 0.2,
"text-search-ada-doc-001": 10,
"text-moderation-stable": 10,
"text-moderation-latest": 10,
}
func ModelRatio2JSONString() string {
jsonBytes, err := json.Marshal(ModelRatio)
if err != nil {
SysError("Error marshalling model ratio: " + err.Error())
}
return string(jsonBytes)
}
func UpdateModelRatioByJSONString(jsonStr string) error {
return json.Unmarshal([]byte(jsonStr), &ModelRatio)
}
func GetModelRatio(name string) float64 {
ratio, ok := ModelRatio[name]
if !ok {
SysError("Model ratio not found: " + name)
return 1
}
return ratio
}

View File

@@ -26,6 +26,7 @@ func GetStatus(c *gin.Context) {
"server_address": common.ServerAddress, "server_address": common.ServerAddress,
"turnstile_check": common.TurnstileCheckEnabled, "turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey, "turnstile_site_key": common.TurnstileSiteKey,
"top_up_link": common.TopUpLink,
}, },
}) })
return return

192
controller/redemption.go Normal file
View File

@@ -0,0 +1,192 @@
package controller
import (
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
)
func GetAllRedemptions(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 0 {
p = 0
}
redemptions, err := model.GetAllRedemptions(p*common.ItemsPerPage, common.ItemsPerPage)
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": redemptions,
})
return
}
func SearchRedemptions(c *gin.Context) {
keyword := c.Query("keyword")
redemptions, err := model.SearchRedemptions(keyword)
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": redemptions,
})
return
}
func GetRedemption(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
redemption, err := model.GetRedemptionById(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": redemption,
})
return
}
func AddRedemption(c *gin.Context) {
redemption := model.Redemption{}
err := c.ShouldBindJSON(&redemption)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
if len(redemption.Name) == 0 || len(redemption.Name) > 20 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "兑换码名称长度必须在1-20之间",
})
return
}
if redemption.Count <= 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "兑换码个数必须大于0",
})
return
}
if redemption.Count > 100 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "一次兑换码批量生成的个数不能大于 100",
})
return
}
var keys []string
for i := 0; i < redemption.Count; i++ {
key := common.GetUUID()
cleanRedemption := model.Redemption{
UserId: c.GetInt("id"),
Name: redemption.Name,
Key: key,
CreatedTime: common.GetTimestamp(),
Quota: redemption.Quota,
}
err = cleanRedemption.Insert()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
"data": keys,
})
return
}
keys = append(keys, key)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": keys,
})
return
}
func DeleteRedemption(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
err := model.DeleteRedemptionById(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": "",
})
return
}
func UpdateRedemption(c *gin.Context) {
statusOnly := c.Query("status_only")
redemption := model.Redemption{}
err := c.ShouldBindJSON(&redemption)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
cleanRedemption, err := model.GetRedemptionById(redemption.Id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
if statusOnly != "" {
cleanRedemption.Status = redemption.Status
} else {
// If you add more fields, please also update redemption.Update()
cleanRedemption.Name = redemption.Name
cleanRedemption.Quota = redemption.Quota
}
err = cleanRedemption.Update()
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": cleanRedemption,
})
return
}

View File

@@ -2,8 +2,11 @@ package controller
import ( import (
"bufio" "bufio"
"bytes"
"encoding/json"
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pkoukk/tiktoken-go"
"io" "io"
"net/http" "net/http"
"one-api/common" "one-api/common"
@@ -11,16 +14,46 @@ import (
"strings" "strings"
) )
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type TextRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Prompt string `json:"prompt"`
//Stream bool `json:"stream"`
}
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type TextResponse struct {
Usage `json:"usage"`
}
type StreamResponse struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
}
var tokenEncoder, _ = tiktoken.GetEncoding("cl100k_base")
func countToken(text string) int {
token := tokenEncoder.Encode(text, nil, nil)
return len(token)
}
func Relay(c *gin.Context) { func Relay(c *gin.Context) {
channelType := c.GetInt("channel") err := relayHelper(c)
tokenId := c.GetInt("token_id")
isUnlimitedTimes := c.GetBool("unlimited_times")
baseURL := common.ChannelBaseURLs[channelType]
if channelType == common.ChannelTypeCustom {
baseURL = c.GetString("base_url")
}
requestURL := c.Request.URL.String()
req, err := http.NewRequest(c.Request.Method, fmt.Sprintf("%s%s", baseURL, requestURL), c.Request.Body)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"error": gin.H{ "error": gin.H{
@@ -28,42 +61,105 @@ func Relay(c *gin.Context) {
"type": "one_api_error", "type": "one_api_error",
}, },
}) })
return
} }
//req.Header = c.Request.Header.Clone() }
// Fix HTTP Decompression failed
// https://github.com/stoplightio/prism/issues/1064#issuecomment-824682360 func relayHelper(c *gin.Context) error {
//req.Header.Del("Accept-Encoding") channelType := c.GetInt("channel")
req.Header.Set("Authorization", c.Request.Header.Get("Authorization")) tokenId := c.GetInt("token_id")
consumeQuota := c.GetBool("consume_quota")
var textRequest TextRequest
if consumeQuota || channelType == common.ChannelTypeAzure {
requestBody, err := io.ReadAll(c.Request.Body)
if err != nil {
return err
}
err = c.Request.Body.Close()
if err != nil {
return err
}
err = json.Unmarshal(requestBody, &textRequest)
if err != nil {
return err
}
// Reset request body
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
}
baseURL := common.ChannelBaseURLs[channelType]
requestURL := c.Request.URL.String()
if channelType == common.ChannelTypeCustom {
baseURL = c.GetString("base_url")
}
fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL)
if channelType == common.ChannelTypeAzure {
// https://learn.microsoft.com/en-us/azure/cognitive-services/openai/chatgpt-quickstart?pivots=rest-api&tabs=command-line#rest-api
query := c.Request.URL.Query()
if query.Get("api-version") == "" {
requestURL = fmt.Sprintf("%s?api-version=2023-03-15-preview", requestURL)
}
baseURL = c.GetString("base_url")
task := strings.TrimPrefix(requestURL, "/v1/")
model_ := textRequest.Model
fullRequestURL = fmt.Sprintf("%s/openai/deployments/%s/%s", baseURL, model_, task)
}
req, err := http.NewRequest(c.Request.Method, fullRequestURL, c.Request.Body)
if err != nil {
return err
}
if channelType == common.ChannelTypeAzure {
key := c.Request.Header.Get("Authorization")
key = strings.TrimPrefix(key, "Bearer ")
req.Header.Set("api-key", key)
} else {
req.Header.Set("Authorization", c.Request.Header.Get("Authorization"))
}
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
req.Header.Set("Accept", c.Request.Header.Get("Accept")) req.Header.Set("Accept", c.Request.Header.Get("Accept"))
req.Header.Set("Connection", c.Request.Header.Get("Connection")) req.Header.Set("Connection", c.Request.Header.Get("Connection"))
client := &http.Client{} client := &http.Client{}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ return err
"error": gin.H{
"message": err.Error(),
"type": "one_api_error",
},
})
return
} }
err = req.Body.Close()
if err != nil {
return err
}
err = c.Request.Body.Close()
if err != nil {
return err
}
var textResponse TextResponse
isStream := resp.Header.Get("Content-Type") == "text/event-stream"
var streamResponseText string
defer func() { defer func() {
err := req.Body.Close() if consumeQuota {
if err != nil { quota := 0
common.SysError("Error closing request body: " + err.Error()) usingGPT4 := strings.HasPrefix(textRequest.Model, "gpt-4")
} completionRatio := 1
if !isUnlimitedTimes && requestURL == "/v1/chat/completions" { if usingGPT4 {
err := model.DecreaseTokenRemainTimesById(tokenId) 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
} else {
quota = textResponse.Usage.PromptTokens + textResponse.Usage.CompletionTokens*completionRatio
}
ratio := common.GetModelRatio(textRequest.Model)
quota = int(float64(quota) * ratio)
err := model.DecreaseTokenQuota(tokenId, quota)
if err != nil { if err != nil {
common.SysError("Error decreasing token remain times: " + err.Error()) common.SysError("Error consuming token remain quota: " + err.Error())
} }
} }
}() }()
isStream := resp.Header.Get("Content-Type") == "text/event-stream"
if isStream { if isStream {
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(resp.Body)
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) { scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
@@ -87,6 +183,18 @@ func Relay(c *gin.Context) {
for scanner.Scan() { for scanner.Scan() {
data := scanner.Text() data := scanner.Text()
dataChan <- data dataChan <- data
data = data[6:]
if data != "[DONE]" {
var streamResponse StreamResponse
err = json.Unmarshal([]byte(data), &streamResponse)
if err != nil {
common.SysError("Error unmarshalling stream response: " + err.Error())
return
}
for _, choice := range streamResponse.Choices {
streamResponseText += choice.Delta.Content
}
}
} }
stopChan <- true stopChan <- true
}() }()
@@ -103,20 +211,48 @@ func Relay(c *gin.Context) {
return false return false
} }
}) })
return err = resp.Body.Close()
if err != nil {
return err
}
return nil
} else { } else {
for k, v := range resp.Header { for k, v := range resp.Header {
c.Writer.Header().Set(k, v[0]) c.Writer.Header().Set(k, v[0])
} }
if consumeQuota {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
err = resp.Body.Close()
if err != nil {
return err
}
err = json.Unmarshal(responseBody, &textResponse)
if err != nil {
return err
}
// Reset response body
resp.Body = io.NopCloser(bytes.NewBuffer(responseBody))
}
_, err = io.Copy(c.Writer, resp.Body) _, err = io.Copy(c.Writer, resp.Body)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ return err
"error": gin.H{
"message": err.Error(),
"type": "one_api_error",
},
})
return
} }
err = resp.Body.Close()
if err != nil {
return err
}
return nil
} }
} }
func RelayNotImplemented(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"error": gin.H{
"message": "Not Implemented",
"type": "one_api_error",
},
})
}

View File

@@ -75,6 +75,30 @@ func GetToken(c *gin.Context) {
return return
} }
func GetTokenStatus(c *gin.Context) {
tokenId := c.GetInt("token_id")
userId := c.GetInt("id")
token, err := model.GetTokenByIds(tokenId, userId)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
expiredAt := token.ExpiredTime
if expiredAt == -1 {
expiredAt = 0
}
c.JSON(http.StatusOK, gin.H{
"object": "credit_summary",
"total_granted": token.RemainQuota,
"total_used": 0, // not supported currently
"total_available": token.RemainQuota,
"expires_at": expiredAt * 1000,
})
}
func AddToken(c *gin.Context) { func AddToken(c *gin.Context) {
isAdmin := c.GetInt("role") >= common.RoleAdminUser isAdmin := c.GetInt("role") >= common.RoleAdminUser
token := model.Token{} token := model.Token{}
@@ -102,8 +126,19 @@ func AddToken(c *gin.Context) {
ExpiredTime: token.ExpiredTime, ExpiredTime: token.ExpiredTime,
} }
if isAdmin { if isAdmin {
cleanToken.RemainTimes = token.RemainTimes cleanToken.RemainQuota = token.RemainQuota
cleanToken.UnlimitedTimes = token.UnlimitedTimes 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
} }
err = cleanToken.Insert() err = cleanToken.Insert()
if err != nil { if err != nil {
@@ -113,6 +148,10 @@ func AddToken(c *gin.Context) {
}) })
return return
} }
if !isAdmin {
// update user quota
err = model.DecreaseUserQuota(c.GetInt("id"), cleanToken.RemainQuota)
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
@@ -167,7 +206,7 @@ func UpdateToken(c *gin.Context) {
}) })
return return
} }
if cleanToken.Status == common.TokenStatusExhausted && cleanToken.RemainTimes <= 0 && !cleanToken.UnlimitedTimes { if cleanToken.Status == common.TokenStatusExhausted && cleanToken.RemainQuota <= 0 && !cleanToken.UnlimitedQuota {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
"message": "令牌可用次数已用尽,无法启用,请先修改令牌剩余次数,或者设置为无限次数", "message": "令牌可用次数已用尽,无法启用,请先修改令牌剩余次数,或者设置为无限次数",
@@ -182,8 +221,8 @@ func UpdateToken(c *gin.Context) {
cleanToken.Name = token.Name cleanToken.Name = token.Name
cleanToken.ExpiredTime = token.ExpiredTime cleanToken.ExpiredTime = token.ExpiredTime
if isAdmin { if isAdmin {
cleanToken.RemainTimes = token.RemainTimes cleanToken.RemainQuota = token.RemainQuota
cleanToken.UnlimitedTimes = token.UnlimitedTimes cleanToken.UnlimitedQuota = token.UnlimitedQuota
} }
} }
err = cleanToken.Update() err = cleanToken.Update()
@@ -201,3 +240,34 @@ func UpdateToken(c *gin.Context) {
}) })
return 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
}

View File

@@ -243,6 +243,42 @@ func GetUser(c *gin.Context) {
return return
} }
func GenerateAccessToken(c *gin.Context) {
id := c.GetInt("id")
user, err := model.GetUserById(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
user.AccessToken = common.GetUUID()
if model.DB.Where("token = ?", user.AccessToken).First(user).RowsAffected != 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "请重试,系统生成的 UUID 竟然重复了!",
})
return
}
if err := user.Update(false); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": user.AccessToken,
})
return
}
func GetSelf(c *gin.Context) { func GetSelf(c *gin.Context) {
id := c.GetInt("id") id := c.GetInt("id")
user, err := model.GetUserById(id, false) user, err := model.GetUserById(id, false)
@@ -503,9 +539,23 @@ func ManageUser(c *gin.Context) {
switch req.Action { switch req.Action {
case "disable": case "disable":
user.Status = common.UserStatusDisabled user.Status = common.UserStatusDisabled
if user.Role == common.RoleRootUser {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法禁用超级管理员用户",
})
return
}
case "enable": case "enable":
user.Status = common.UserStatusEnabled user.Status = common.UserStatusEnabled
case "delete": case "delete":
if user.Role == common.RoleRootUser {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法删除超级管理员用户",
})
return
}
if err := user.Delete(); err != nil { if err := user.Delete(); err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
@@ -521,8 +571,29 @@ func ManageUser(c *gin.Context) {
}) })
return return
} }
if user.Role >= common.RoleAdminUser {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该用户已经是管理员",
})
return
}
user.Role = common.RoleAdminUser user.Role = common.RoleAdminUser
case "demote": case "demote":
if user.Role == common.RoleRootUser {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法降级超级管理员用户",
})
return
}
if user.Role == common.RoleCommonUser {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该用户已经是普通用户",
})
return
}
user.Role = common.RoleCommonUser user.Role = common.RoleCommonUser
} }

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
version: '3.4'
services:
one-api:
image: ghcr.io/songquanpeng/one-api:latest
container_name: one-api
restart: always
command: --log-dir /app/logs
ports:
- "3000:3000"
volumes:
- /home/ubuntu/data/one-api:/data
- /home/ubuntu/data/one-api/logs:/app/logs
# environment:
# REDIS_CONN_STRING: redis://default:redispw@localhost:49153
# SESSION_SECRET: random_string
# SQL_DSN: root:123456@tcp(localhost:3306)/one-api
healthcheck:
test: ["CMD-SHELL", "curl -s http://localhost:3000/api/status | grep -o '\"success\":\\s*true' | awk '{print $2}' | grep 'true'"]
interval: 30s
timeout: 10s
retries: 3

2
go.mod
View File

@@ -25,6 +25,7 @@ require (
github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.8.1 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
@@ -44,6 +45,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.7 // indirect github.com/pelletier/go-toml/v2 v2.0.7 // indirect
github.com/pkoukk/tiktoken-go v0.1.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect

4
go.sum
View File

@@ -14,6 +14,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 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/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g= github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g=
github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs= github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs=
@@ -119,6 +121,8 @@ github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZO
github.com/pelletier/go-toml/v2 v2.0.7 h1:muncTPStnKRos5dpVKULv2FVd4bMOhNePj9CjgDb8Us= github.com/pelletier/go-toml/v2 v2.0.7 h1:muncTPStnKRos5dpVKULv2FVd4bMOhNePj9CjgDb8Us=
github.com/pelletier/go-toml/v2 v2.0.7/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= github.com/pelletier/go-toml/v2 v2.0.7/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=

View File

@@ -16,12 +16,31 @@ func authHelper(c *gin.Context, minRole int) {
id := session.Get("id") id := session.Get("id")
status := session.Get("status") status := session.Get("status")
if username == nil { if username == nil {
c.JSON(http.StatusUnauthorized, gin.H{ // Check access token
"success": false, accessToken := c.Request.Header.Get("Authorization")
"message": "无权进行此操作,未登录", if accessToken == "" {
}) c.JSON(http.StatusUnauthorized, gin.H{
c.Abort() "success": false,
return "message": "无权进行此操作,未登录且未提供 access token",
})
c.Abort()
return
}
user := model.ValidateAccessToken(accessToken)
if user != nil && user.Username != "" {
// Token is valid
username = user.Username
role = user.Role
id = user.Id
status = user.Status
} else {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权进行此操作access token 无效",
})
c.Abort()
return
}
} }
if status.(int) == common.UserStatusDisabled { if status.(int) == common.UserStatusDisabled {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -79,9 +98,29 @@ func TokenAuth() func(c *gin.Context) {
c.Abort() c.Abort()
return return
} }
if !model.IsUserEnabled(token.UserId) {
c.JSON(http.StatusOK, gin.H{
"error": gin.H{
"message": "用户已被封禁",
"type": "one_api_error",
},
})
c.Abort()
return
}
c.Set("id", token.UserId) c.Set("id", token.UserId)
c.Set("token_id", token.Id) c.Set("token_id", token.Id)
c.Set("unlimited_times", token.UnlimitedTimes) requestURL := c.Request.URL.String()
consumeQuota := false
switch requestURL {
case "/v1/chat/completions":
consumeQuota = !token.UnlimitedQuota
case "/v1/completions":
consumeQuota = !token.UnlimitedQuota
case "/v1/edits":
consumeQuota = !token.UnlimitedQuota
}
c.Set("consume_quota", consumeQuota)
if len(parts) > 1 { if len(parts) > 1 {
if model.IsAdmin(token.UserId) { if model.IsAdmin(token.UserId) {
c.Set("channelId", parts[1]) c.Set("channelId", parts[1])

View File

@@ -63,7 +63,7 @@ func Distribute() func(c *gin.Context) {
} }
c.Set("channel", channel.Type) c.Set("channel", channel.Type)
c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key)) c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key))
if channel.Type == common.ChannelTypeCustom { if channel.Type == common.ChannelTypeCustom || channel.Type == common.ChannelTypeAzure {
c.Set("base_url", channel.BaseURL) c.Set("base_url", channel.BaseURL)
} }
c.Next() c.Next()

View File

@@ -25,6 +25,7 @@ func createRootAccountIfNeed() error {
Role: common.RoleRootUser, Role: common.RoleRootUser,
Status: common.UserStatusEnabled, Status: common.UserStatusEnabled,
DisplayName: "Root User", DisplayName: "Root User",
AccessToken: common.GetUUID(),
} }
DB.Create(&rootUser) DB.Create(&rootUser)
} }
@@ -69,6 +70,10 @@ func InitDB() (err error) {
if err != nil { if err != nil {
return err return err
} }
err = db.AutoMigrate(&Redemption{})
if err != nil {
return err
}
err = createRootAccountIfNeed() err = createRootAccountIfNeed()
return err return err
} else { } else {

View File

@@ -33,6 +33,7 @@ func InitOptionMap() {
common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled) common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled)
common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled) common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled)
common.OptionMap["SMTPServer"] = "" common.OptionMap["SMTPServer"] = ""
common.OptionMap["SMTPPort"] = strconv.Itoa(common.SMTPPort)
common.OptionMap["SMTPAccount"] = "" common.OptionMap["SMTPAccount"] = ""
common.OptionMap["SMTPToken"] = "" common.OptionMap["SMTPToken"] = ""
common.OptionMap["Notice"] = "" common.OptionMap["Notice"] = ""
@@ -46,10 +47,16 @@ func InitOptionMap() {
common.OptionMap["WeChatAccountQRCodeImageURL"] = "" common.OptionMap["WeChatAccountQRCodeImageURL"] = ""
common.OptionMap["TurnstileSiteKey"] = "" common.OptionMap["TurnstileSiteKey"] = ""
common.OptionMap["TurnstileSecretKey"] = "" common.OptionMap["TurnstileSecretKey"] = ""
common.OptionMap["QuotaForNewUser"] = strconv.Itoa(common.QuotaForNewUser)
common.OptionMap["ModelRatio"] = common.ModelRatio2JSONString()
common.OptionMap["TopUpLink"] = common.TopUpLink
common.OptionMapRWMutex.Unlock() common.OptionMapRWMutex.Unlock()
options, _ := AllOption() options, _ := AllOption()
for _, option := range options { for _, option := range options {
updateOptionMap(option.Key, option.Value) err := updateOptionMap(option.Key, option.Value)
if err != nil {
common.SysError("Failed to update option map: " + err.Error())
}
} }
} }
@@ -66,11 +73,10 @@ func UpdateOption(key string, value string) error {
// otherwise it will execute Update (with all fields). // otherwise it will execute Update (with all fields).
DB.Save(&option) DB.Save(&option)
// Update OptionMap // Update OptionMap
updateOptionMap(key, value) return updateOptionMap(key, value)
return nil
} }
func updateOptionMap(key string, value string) { func updateOptionMap(key string, value string) (err error) {
common.OptionMapRWMutex.Lock() common.OptionMapRWMutex.Lock()
defer common.OptionMapRWMutex.Unlock() defer common.OptionMapRWMutex.Unlock()
common.OptionMap[key] = value common.OptionMap[key] = value
@@ -109,6 +115,9 @@ func updateOptionMap(key string, value string) {
switch key { switch key {
case "SMTPServer": case "SMTPServer":
common.SMTPServer = value common.SMTPServer = value
case "SMTPPort":
intValue, _ := strconv.Atoi(value)
common.SMTPPort = intValue
case "SMTPAccount": case "SMTPAccount":
common.SMTPAccount = value common.SMTPAccount = value
case "SMTPToken": case "SMTPToken":
@@ -131,5 +140,12 @@ func updateOptionMap(key string, value string) {
common.TurnstileSiteKey = value common.TurnstileSiteKey = value
case "TurnstileSecretKey": case "TurnstileSecretKey":
common.TurnstileSecretKey = value common.TurnstileSecretKey = value
case "QuotaForNewUser":
common.QuotaForNewUser, _ = strconv.Atoi(value)
case "ModelRatio":
err = common.UpdateModelRatioByJSONString(value)
case "TopUpLink":
common.TopUpLink = value
} }
return err
} }

107
model/redemption.go Normal file
View File

@@ -0,0 +1,107 @@
package model
import (
"errors"
_ "gorm.io/driver/sqlite"
"one-api/common"
)
type Redemption struct {
Id int `json:"id"`
UserId int `json:"user_id"`
Key string `json:"key" gorm:"type:char(32);uniqueIndex"`
Status int `json:"status" gorm:"default:1"`
Name string `json:"name" gorm:"index"`
Quota int `json:"quota" gorm:"default:100"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
RedeemedTime int64 `json:"redeemed_time" gorm:"bigint"`
Count int `json:"count" gorm:"-:all"` // only for api request
}
func GetAllRedemptions(startIdx int, num int) ([]*Redemption, error) {
var redemptions []*Redemption
var err error
err = DB.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error
return redemptions, err
}
func SearchRedemptions(keyword string) (redemptions []*Redemption, err error) {
err = DB.Where("id = ? or name LIKE ?", keyword, keyword+"%").Find(&redemptions).Error
return redemptions, err
}
func GetRedemptionById(id int) (*Redemption, error) {
if id == 0 {
return nil, errors.New("id 为空!")
}
redemption := Redemption{Id: id}
var err error = nil
err = DB.First(&redemption, "id = ?", id).Error
return &redemption, err
}
func Redeem(key string, tokenId int) (quota int, err error) {
if key == "" {
return 0, errors.New("未提供兑换码")
}
if tokenId == 0 {
return 0, errors.New("未提供 token id")
}
redemption := &Redemption{}
err = DB.Where("`key` = ?", key).First(redemption).Error
if err != nil {
return 0, errors.New("无效的兑换码")
}
if redemption.Status != common.RedemptionCodeStatusEnabled {
return 0, errors.New("该兑换码已被使用")
}
err = IncreaseTokenQuota(tokenId, redemption.Quota)
if err != nil {
return 0, err
}
go func() {
redemption.RedeemedTime = common.GetTimestamp()
redemption.Status = common.RedemptionCodeStatusUsed
err := redemption.SelectUpdate()
if err != nil {
common.SysError("更新兑换码状态失败:" + err.Error())
}
}()
return redemption.Quota, nil
}
func (redemption *Redemption) Insert() error {
var err error
err = DB.Create(redemption).Error
return err
}
func (redemption *Redemption) SelectUpdate() error {
// This can update zero values
return DB.Model(redemption).Select("redeemed_time", "status").Updates(redemption).Error
}
// Update Make sure your token's fields is completed, because this will update non-zero values
func (redemption *Redemption) Update() error {
var err error
err = DB.Model(redemption).Select("name", "status", "redeemed_time").Updates(redemption).Error
return err
}
func (redemption *Redemption) Delete() error {
var err error
err = DB.Delete(redemption).Error
return err
}
func DeleteRedemptionById(id int) (err error) {
if id == 0 {
return errors.New("id 为空!")
}
redemption := Redemption{Id: id}
err = DB.Where(redemption).First(&redemption).Error
if err != nil {
return err
}
return redemption.Delete()
}

View File

@@ -11,14 +11,14 @@ import (
type Token struct { type Token struct {
Id int `json:"id"` Id int `json:"id"`
UserId int `json:"user_id"` UserId int `json:"user_id"`
Key string `json:"key" gorm:"uniqueIndex"` Key string `json:"key" gorm:"type:char(32);uniqueIndex"`
Status int `json:"status" gorm:"default:1"` Status int `json:"status" gorm:"default:1"`
Name string `json:"name" gorm:"index" ` Name string `json:"name" gorm:"index" `
CreatedTime int64 `json:"created_time" gorm:"bigint"` CreatedTime int64 `json:"created_time" gorm:"bigint"`
AccessedTime int64 `json:"accessed_time" gorm:"bigint"` AccessedTime int64 `json:"accessed_time" gorm:"bigint"`
ExpiredTime int64 `json:"expired_time" gorm:"bigint;default:-1"` // -1 means never expired ExpiredTime int64 `json:"expired_time" gorm:"bigint;default:-1"` // -1 means never expired
RemainTimes int `json:"remain_times" gorm:"default:0"` RemainQuota int `json:"remain_quota" gorm:"default:0"`
UnlimitedTimes bool `json:"unlimited_times" gorm:"default:false"` UnlimitedQuota bool `json:"unlimited_quota" gorm:"default:false"`
} }
func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) { func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) {
@@ -39,7 +39,7 @@ func ValidateUserToken(key string) (token *Token, err error) {
} }
key = strings.Replace(key, "Bearer ", "", 1) key = strings.Replace(key, "Bearer ", "", 1)
token = &Token{} token = &Token{}
err = DB.Where("key = ?", key).First(token).Error err = DB.Where("`key` = ?", key).First(token).Error
if err == nil { if err == nil {
if token.Status != common.TokenStatusEnabled { if token.Status != common.TokenStatusEnabled {
return nil, errors.New("该 token 状态不可用") return nil, errors.New("该 token 状态不可用")
@@ -52,13 +52,13 @@ func ValidateUserToken(key string) (token *Token, err error) {
} }
return nil, errors.New("该 token 已过期") return nil, errors.New("该 token 已过期")
} }
if !token.UnlimitedTimes && token.RemainTimes <= 0 { if !token.UnlimitedQuota && token.RemainQuota <= 0 {
token.Status = common.TokenStatusExhausted token.Status = common.TokenStatusExhausted
err := token.SelectUpdate() err := token.SelectUpdate()
if err != nil { if err != nil {
common.SysError("更新 token 状态失败:" + err.Error()) common.SysError("更新 token 状态失败:" + err.Error())
} }
return nil, errors.New("该 token 可用次数已用尽") return nil, errors.New("该 token 额度已用尽")
} }
go func() { go func() {
token.AccessedTime = common.GetTimestamp() token.AccessedTime = common.GetTimestamp()
@@ -91,7 +91,7 @@ func (token *Token) Insert() error {
// Update Make sure your token's fields is completed, because this will update non-zero values // Update Make sure your token's fields is completed, because this will update non-zero values
func (token *Token) Update() error { func (token *Token) Update() error {
var err error var err error
err = DB.Model(token).Select("name", "status", "expired_time", "remain_times", "unlimited_times").Updates(token).Error err = DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota").Updates(token).Error
return err return err
} }
@@ -116,10 +116,26 @@ func DeleteTokenById(id int, userId int) (err error) {
if err != nil { if err != nil {
return err 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() return token.Delete()
} }
func DecreaseTokenRemainTimesById(id int) (err error) { func IncreaseTokenQuota(id int, quota int) (err error) {
err = DB.Model(&Token{}).Where("id = ?", id).Update("remain_times", gorm.Expr("remain_times - ?", 1)).Error 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) {
err = DB.Model(&Token{}).Where("id = ?", id).Update("remain_quota", gorm.Expr("remain_quota - ?", quota)).Error
return err return err
} }

View File

@@ -2,7 +2,9 @@ package model
import ( import (
"errors" "errors"
"gorm.io/gorm"
"one-api/common" "one-api/common"
"strings"
) )
// User if you add sensitive fields, don't forget to clean them in setupLogin function. // User if you add sensitive fields, don't forget to clean them in setupLogin function.
@@ -19,6 +21,8 @@ type User struct {
WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"` WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"`
VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database! VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
Balance int `json:"balance" gorm:"type:int;default:0"` Balance int `json:"balance" gorm:"type:int;default:0"`
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"`
} }
func GetMaxUserId() int { func GetMaxUserId() int {
@@ -67,6 +71,8 @@ func (user *User) Insert() error {
return err return err
} }
} }
user.Quota = common.QuotaForNewUser
user.AccessToken = common.GetUUID()
err = DB.Create(user).Error err = DB.Create(user).Error
return err return err
} }
@@ -188,3 +194,43 @@ func IsAdmin(userId int) bool {
} }
return user.Role >= common.RoleAdminUser return user.Role >= common.RoleAdminUser
} }
func IsUserEnabled(userId int) bool {
if userId == 0 {
return false
}
var user User
err := DB.Where("id = ?", userId).Select("status").Find(&user).Error
if err != nil {
common.SysError("No such user " + err.Error())
return false
}
return user.Status == common.UserStatusEnabled
}
func ValidateAccessToken(token string) (user *User) {
if token == "" {
return nil
}
token = strings.Replace(token, "Bearer ", "", 1)
user = &User{}
if DB.Where("access_token = ?", token).First(user).RowsAffected == 1 {
return user
}
return nil
}
func GetUserQuota(id int) (quota int, err error) {
err = DB.Model(&User{}).Where("id = ?", id).Select("quota").Find(&quota).Error
return quota, err
}
func IncreaseUserQuota(id int, quota int) (err error) {
err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota + ?", quota)).Error
return err
}
func DecreaseUserQuota(id int, quota int) (err error) {
err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota - ?", quota)).Error
return err
}

13
one-api.service Normal file
View File

@@ -0,0 +1,13 @@
[Unit]
Description=One API Service
After=network.target
[Service]
User=yourusername # 守护进程用户名
WorkingDirectory=/path/to/One-API # One API运行路径
ExecStart=/path/to/One-API/one-api --port 3000 --log-dir /path/to/One-API/logs # 端口
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@@ -35,6 +35,7 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.GET("/self", controller.GetSelf) selfRoute.GET("/self", controller.GetSelf)
selfRoute.PUT("/self", controller.UpdateSelf) selfRoute.PUT("/self", controller.UpdateSelf)
selfRoute.DELETE("/self", controller.DeleteSelf) selfRoute.DELETE("/self", controller.DeleteSelf)
selfRoute.GET("/token", controller.GenerateAccessToken)
} }
adminRoute := userRoute.Group("/") adminRoute := userRoute.Group("/")
@@ -70,10 +71,21 @@ func SetApiRouter(router *gin.Engine) {
{ {
tokenRoute.GET("/", controller.GetAllTokens) tokenRoute.GET("/", controller.GetAllTokens)
tokenRoute.GET("/search", controller.SearchTokens) tokenRoute.GET("/search", controller.SearchTokens)
tokenRoute.POST("/topup", controller.TopUp)
tokenRoute.GET("/:id", controller.GetToken) tokenRoute.GET("/:id", controller.GetToken)
tokenRoute.POST("/", controller.AddToken) tokenRoute.POST("/", controller.AddToken)
tokenRoute.PUT("/", controller.UpdateToken) tokenRoute.PUT("/", controller.UpdateToken)
tokenRoute.DELETE("/:id", controller.DeleteToken) tokenRoute.DELETE("/:id", controller.DeleteToken)
} }
redemptionRoute := apiRouter.Group("/redemption")
redemptionRoute.Use(middleware.AdminAuth())
{
redemptionRoute.GET("/", controller.GetAllRedemptions)
redemptionRoute.GET("/search", controller.SearchRedemptions)
redemptionRoute.GET("/:id", controller.GetRedemption)
redemptionRoute.POST("/", controller.AddRedemption)
redemptionRoute.PUT("/", controller.UpdateRedemption)
redemptionRoute.DELETE("/:id", controller.DeleteRedemption)
}
} }
} }

18
router/dashboard.go Normal file
View File

@@ -0,0 +1,18 @@
package router
import (
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
"one-api/controller"
"one-api/middleware"
)
func SetDashboardRouter(router *gin.Engine) {
apiRouter := router.Group("/dashboard")
apiRouter.Use(gzip.Gzip(gzip.DefaultCompression))
apiRouter.Use(middleware.GlobalAPIRateLimit())
apiRouter.Use(middleware.TokenAuth())
{
apiRouter.GET("/billing/credit_grants", controller.GetTokenStatus)
}
}

View File

@@ -7,6 +7,7 @@ import (
func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) { func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
SetApiRouter(router) SetApiRouter(router)
SetDashboardRouter(router)
SetRelayRouter(router) SetRelayRouter(router)
setWebRouter(router, buildFS, indexPage) setWebRouter(router, buildFS, indexPage)
} }

View File

@@ -7,14 +7,32 @@ import (
) )
func SetRelayRouter(router *gin.Engine) { func SetRelayRouter(router *gin.Engine) {
// https://platform.openai.com/docs/api-reference/introduction
relayV1Router := router.Group("/v1") relayV1Router := router.Group("/v1")
relayV1Router.Use(middleware.GlobalAPIRateLimit(), middleware.TokenAuth(), middleware.Distribute()) relayV1Router.Use(middleware.TokenAuth(), middleware.Distribute())
{ {
relayV1Router.Any("/*path", controller.Relay) relayV1Router.GET("/models", controller.Relay)
} relayV1Router.GET("/models/:model", controller.Relay)
relayDashboardRouter := router.Group("/dashboard") relayV1Router.POST("/completions", controller.RelayNotImplemented)
relayDashboardRouter.Use(middleware.GlobalAPIRateLimit(), middleware.TokenAuth(), middleware.Distribute()) relayV1Router.POST("/chat/completions", controller.Relay)
{ relayV1Router.POST("/edits", controller.RelayNotImplemented)
relayDashboardRouter.Any("/*path", controller.Relay) relayV1Router.POST("/images/generations", controller.RelayNotImplemented)
relayV1Router.POST("/images/edits", controller.RelayNotImplemented)
relayV1Router.POST("/images/variations", controller.RelayNotImplemented)
relayV1Router.POST("/embeddings", controller.Relay)
relayV1Router.POST("/audio/transcriptions", controller.RelayNotImplemented)
relayV1Router.POST("/audio/translations", controller.RelayNotImplemented)
relayV1Router.GET("/files", controller.RelayNotImplemented)
relayV1Router.POST("/files", controller.RelayNotImplemented)
relayV1Router.DELETE("/files/:id", controller.RelayNotImplemented)
relayV1Router.GET("/files/:id", controller.RelayNotImplemented)
relayV1Router.GET("/files/:id/content", controller.RelayNotImplemented)
relayV1Router.POST("/fine-tunes", controller.RelayNotImplemented)
relayV1Router.GET("/fine-tunes", controller.RelayNotImplemented)
relayV1Router.GET("/fine-tunes/:id", controller.RelayNotImplemented)
relayV1Router.POST("/fine-tunes/:id/cancel", controller.RelayNotImplemented)
relayV1Router.GET("/fine-tunes/:id/events", controller.RelayNotImplemented)
relayV1Router.DELETE("/models/:model", controller.RelayNotImplemented)
relayV1Router.POST("/moderations", controller.RelayNotImplemented)
} }
} }

View File

@@ -19,7 +19,8 @@ import Channel from './pages/Channel';
import Token from './pages/Token'; import Token from './pages/Token';
import EditToken from './pages/Token/EditToken'; import EditToken from './pages/Token/EditToken';
import EditChannel from './pages/Channel/EditChannel'; import EditChannel from './pages/Channel/EditChannel';
import AddChannel from './pages/Channel/AddChannel'; import Redemption from './pages/Redemption';
import EditRedemption from './pages/Redemption/EditRedemption';
const Home = lazy(() => import('./pages/Home')); const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About')); const About = lazy(() => import('./pages/About'));
@@ -91,7 +92,7 @@ function App() {
path='/channel/add' path='/channel/add'
element={ element={
<Suspense fallback={<Loading></Loading>}> <Suspense fallback={<Loading></Loading>}>
<AddChannel /> <EditChannel />
</Suspense> </Suspense>
} }
/> />
@@ -119,6 +120,30 @@ function App() {
</Suspense> </Suspense>
} }
/> />
<Route
path='/redemption'
element={
<PrivateRoute>
<Redemption />
</PrivateRoute>
}
/>
<Route
path='/redemption/edit/:id'
element={
<Suspense fallback={<Loading></Loading>}>
<EditRedemption />
</Suspense>
}
/>
<Route
path='/redemption/add'
element={
<Suspense fallback={<Loading></Loading>}>
<EditRedemption />
</Suspense>
}
/>
<Route <Route
path='/user' path='/user'
element={ element={

View File

@@ -24,6 +24,12 @@ const headerButtons = [
to: '/token', to: '/token',
icon: 'key', icon: 'key',
}, },
{
name: '兑换',
to: '/redemption',
icon: 'dollar sign',
admin: true,
},
{ {
name: '用户', name: '用户',
to: '/user', to: '/user',

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Divider, Form, Header, Image, Modal } from 'semantic-ui-react'; import { Button, Divider, Form, Header, Image, Message, Modal } from 'semantic-ui-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { API, copy, showError, showInfo, showSuccess } from '../helpers'; import { API, copy, showError, showInfo, showSuccess } from '../helpers';
import Turnstile from 'react-turnstile'; import Turnstile from 'react-turnstile';
@@ -34,6 +34,17 @@ const PersonalSetting = () => {
setInputs((inputs) => ({ ...inputs, [name]: value })); setInputs((inputs) => ({ ...inputs, [name]: value }));
}; };
const generateAccessToken = async () => {
const res = await API.get('/api/user/token');
const { success, message, data } = res.data;
if (success) {
await copy(data);
showSuccess(`令牌已重置并已复制到剪贴板:${data}`);
} else {
showError(message);
}
};
const bindWeChat = async () => { const bindWeChat = async () => {
if (inputs.wechat_verification_code === '') return; if (inputs.wechat_verification_code === '') return;
const res = await API.get( const res = await API.get(
@@ -92,9 +103,13 @@ const PersonalSetting = () => {
return ( return (
<div style={{ lineHeight: '40px' }}> <div style={{ lineHeight: '40px' }}>
<Header as='h3'>通用设置</Header> <Header as='h3'>通用设置</Header>
<Message>
注意此处生成的令牌用于系统管理而非用于请求 OpenAI 相关的服务请知悉
</Message>
<Button as={Link} to={`/user/edit/`}> <Button as={Link} to={`/user/edit/`}>
更新个人信息 更新个人信息
</Button> </Button>
<Button onClick={generateAccessToken}>生成系统访问令牌</Button>
<Divider /> <Divider />
<Header as='h3'>账号绑定</Header> <Header as='h3'>账号绑定</Header>
<Button <Button

View File

@@ -0,0 +1,303 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Label, Message, Pagination, Table } from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import { API, copy, showError, showInfo, showSuccess, showWarning, timestamp2string } from '../helpers';
import { ITEMS_PER_PAGE } from '../constants';
function renderTimestamp(timestamp) {
return (
<>
{timestamp2string(timestamp)}
</>
);
}
function renderStatus(status) {
switch (status) {
case 1:
return <Label basic color='green'>未使用</Label>;
case 2:
return <Label basic color='red'> 已禁用 </Label>;
case 3:
return <Label basic color='grey'> 已使用 </Label>;
default:
return <Label basic color='black'> 未知状态 </Label>;
}
}
const RedemptionsTable = () => {
const [redemptions, setRedemptions] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false);
const loadRedemptions = async (startIdx) => {
const res = await API.get(`/api/redemption/?p=${startIdx}`);
const { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setRedemptions(data);
} else {
let newRedemptions = redemptions;
newRedemptions.push(...data);
setRedemptions(newRedemptions);
}
} else {
showError(message);
}
setLoading(false);
};
const onPaginationChange = (e, { activePage }) => {
(async () => {
if (activePage === Math.ceil(redemptions.length / ITEMS_PER_PAGE) + 1) {
// In this case we have to load more data and then append them.
await loadRedemptions(activePage - 1);
}
setActivePage(activePage);
})();
};
useEffect(() => {
loadRedemptions(0)
.then()
.catch((reason) => {
showError(reason);
});
}, []);
const manageRedemption = async (id, action, idx) => {
let data = { id };
let res;
switch (action) {
case 'delete':
res = await API.delete(`/api/redemption/${id}/`);
break;
case 'enable':
data.status = 1;
res = await API.put('/api/redemption/?status_only=true', data);
break;
case 'disable':
data.status = 2;
res = await API.put('/api/redemption/?status_only=true', data);
break;
}
const { success, message } = res.data;
if (success) {
showSuccess('操作成功完成!');
let redemption = res.data.data;
let newRedemptions = [...redemptions];
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
if (action === 'delete') {
newRedemptions[realIdx].deleted = true;
} else {
newRedemptions[realIdx].status = redemption.status;
}
setRedemptions(newRedemptions);
} else {
showError(message);
}
};
const searchRedemptions = async () => {
if (searchKeyword === '') {
// if keyword is blank, load files instead.
await loadRedemptions(0);
setActivePage(1);
return;
}
setSearching(true);
const res = await API.get(`/api/redemption/search?keyword=${searchKeyword}`);
const { success, message, data } = res.data;
if (success) {
setRedemptions(data);
setActivePage(1);
} else {
showError(message);
}
setSearching(false);
};
const handleKeywordChange = async (e, { value }) => {
setSearchKeyword(value.trim());
};
const sortRedemption = (key) => {
if (redemptions.length === 0) return;
setLoading(true);
let sortedRedemptions = [...redemptions];
sortedRedemptions.sort((a, b) => {
return ('' + a[key]).localeCompare(b[key]);
});
if (sortedRedemptions[0].id === redemptions[0].id) {
sortedRedemptions.reverse();
}
setRedemptions(sortedRedemptions);
setLoading(false);
};
return (
<>
<Form onSubmit={searchRedemptions}>
<Form.Input
icon='search'
fluid
iconPosition='left'
placeholder='搜索兑换码的 ID 和名称 ...'
value={searchKeyword}
loading={searching}
onChange={handleKeywordChange}
/>
</Form>
<Table basic>
<Table.Header>
<Table.Row>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortRedemption('id');
}}
>
ID
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortRedemption('name');
}}
>
名称
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortRedemption('status');
}}
>
状态
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortRedemption('quota');
}}
>
额度
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortRedemption('created_time');
}}
>
创建时间
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortRedemption('redeemed_time');
}}
>
兑换时间
</Table.HeaderCell>
<Table.HeaderCell>操作</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{redemptions
.slice(
(activePage - 1) * ITEMS_PER_PAGE,
activePage * ITEMS_PER_PAGE
)
.map((redemption, idx) => {
if (redemption.deleted) return <></>;
return (
<Table.Row key={redemption.id}>
<Table.Cell>{redemption.id}</Table.Cell>
<Table.Cell>{redemption.name ? redemption.name : '无'}</Table.Cell>
<Table.Cell>{renderStatus(redemption.status)}</Table.Cell>
<Table.Cell>{redemption.quota}</Table.Cell>
<Table.Cell>{renderTimestamp(redemption.created_time)}</Table.Cell>
<Table.Cell>{redemption.redeemed_time ? renderTimestamp(redemption.redeemed_time) : "尚未兑换"} </Table.Cell>
<Table.Cell>
<div>
<Button
size={'small'}
positive
onClick={async () => {
if (await copy(redemption.key)) {
showSuccess('已复制到剪贴板!');
} else {
showWarning('无法复制到剪贴板,请手动复制,已将兑换码填入搜索框。')
setSearchKeyword(redemption.key);
}
}}
>
复制
</Button>
<Button
size={'small'}
negative
onClick={() => {
manageRedemption(redemption.id, 'delete', idx);
}}
>
删除
</Button>
<Button
size={'small'}
disabled={redemption.status === 3} // used
onClick={() => {
manageRedemption(
redemption.id,
redemption.status === 1 ? 'disable' : 'enable',
idx
);
}}
>
{redemption.status === 1 ? '禁用' : '启用'}
</Button>
<Button
size={'small'}
as={Link}
to={'/redemption/edit/' + redemption.id}
>
编辑
</Button>
</div>
</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan='8'>
<Button size='small' as={Link} to='/redemption/add' loading={loading}>
添加新的兑换码
</Button>
<Pagination
floated='right'
activePage={activePage}
onPageChange={onPaginationChange}
size='small'
siblingRange={1}
totalPages={
Math.ceil(redemptions.length / ITEMS_PER_PAGE) +
(redemptions.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
}
/>
</Table.HeaderCell>
</Table.Row>
</Table.Footer>
</Table>
</>
);
};
export default RedemptionsTable;

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Divider, Form, Grid, Header, Message } from 'semantic-ui-react'; import { Divider, Form, Grid, Header, Message } from 'semantic-ui-react';
import { API, removeTrailingSlash, showError } from '../helpers'; import { API, removeTrailingSlash, showError, verifyJSON } from '../helpers';
const SystemSetting = () => { const SystemSetting = () => {
let [inputs, setInputs] = useState({ let [inputs, setInputs] = useState({
@@ -12,6 +12,7 @@ const SystemSetting = () => {
GitHubClientSecret: '', GitHubClientSecret: '',
Notice: '', Notice: '',
SMTPServer: '', SMTPServer: '',
SMTPPort: '',
SMTPAccount: '', SMTPAccount: '',
SMTPToken: '', SMTPToken: '',
ServerAddress: '', ServerAddress: '',
@@ -24,6 +25,9 @@ const SystemSetting = () => {
TurnstileSiteKey: '', TurnstileSiteKey: '',
TurnstileSecretKey: '', TurnstileSecretKey: '',
RegisterEnabled: '', RegisterEnabled: '',
QuotaForNewUser: 0,
ModelRatio: '',
TopUpLink: ''
}); });
let originInputs = {}; let originInputs = {};
let [loading, setLoading] = useState(false); let [loading, setLoading] = useState(false);
@@ -64,7 +68,7 @@ const SystemSetting = () => {
} }
const res = await API.put('/api/option', { const res = await API.put('/api/option', {
key, key,
value, value
}); });
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
@@ -86,7 +90,10 @@ const SystemSetting = () => {
name === 'WeChatServerToken' || name === 'WeChatServerToken' ||
name === 'WeChatAccountQRCodeImageURL' || name === 'WeChatAccountQRCodeImageURL' ||
name === 'TurnstileSiteKey' || name === 'TurnstileSiteKey' ||
name === 'TurnstileSecretKey' name === 'TurnstileSecretKey' ||
name === 'QuotaForNewUser' ||
name === 'ModelRatio' ||
name === 'TopUpLink'
) { ) {
setInputs((inputs) => ({ ...inputs, [name]: value })); setInputs((inputs) => ({ ...inputs, [name]: value }));
} else { } else {
@@ -99,6 +106,22 @@ const SystemSetting = () => {
await updateOption('ServerAddress', ServerAddress); await updateOption('ServerAddress', ServerAddress);
}; };
const submitOperationConfig = async () => {
if (originInputs['QuotaForNewUser'] !== inputs.QuotaForNewUser) {
await updateOption('QuotaForNewUser', inputs.QuotaForNewUser);
}
if (originInputs['ModelRatio'] !== inputs.ModelRatio) {
if (!verifyJSON(inputs.ModelRatio)) {
showError('模型倍率不是合法的 JSON 字符串');
return;
}
await updateOption('ModelRatio', inputs.ModelRatio);
}
if (originInputs['TopUpLink'] !== inputs.TopUpLink) {
await updateOption('TopUpLink', inputs.TopUpLink);
}
};
const submitSMTP = async () => { const submitSMTP = async () => {
if (originInputs['SMTPServer'] !== inputs.SMTPServer) { if (originInputs['SMTPServer'] !== inputs.SMTPServer) {
await updateOption('SMTPServer', inputs.SMTPServer); await updateOption('SMTPServer', inputs.SMTPServer);
@@ -106,6 +129,12 @@ const SystemSetting = () => {
if (originInputs['SMTPAccount'] !== inputs.SMTPAccount) { if (originInputs['SMTPAccount'] !== inputs.SMTPAccount) {
await updateOption('SMTPAccount', inputs.SMTPAccount); await updateOption('SMTPAccount', inputs.SMTPAccount);
} }
if (
originInputs['SMTPPort'] !== inputs.SMTPPort &&
inputs.SMTPPort !== ''
) {
await updateOption('SMTPPort', inputs.SMTPPort);
}
if ( if (
originInputs['SMTPToken'] !== inputs.SMTPToken && originInputs['SMTPToken'] !== inputs.SMTPToken &&
inputs.SMTPToken !== '' inputs.SMTPToken !== ''
@@ -228,24 +257,69 @@ const SystemSetting = () => {
/> />
</Form.Group> </Form.Group>
<Divider /> <Divider />
<Header as='h3'>
运营设置
</Header>
<Form.Group widths={3}>
<Form.Input
label='新用户初始配额'
name='QuotaForNewUser'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaForNewUser}
type='number'
min='0'
placeholder='例如100'
/>
<Form.Input
label='充值链接'
name='TopUpLink'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.TopUpLink}
type='link'
placeholder='例如发卡网站的购买链接'
/>
</Form.Group>
<Form.Group widths='equal'>
<Form.TextArea
label='模型倍率'
name='ModelRatio'
onChange={handleInputChange}
style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
value={inputs.ModelRatio}
placeholder='为一个 JSON 文本,键为模型名称,值为倍率'
/>
</Form.Group>
<Form.Button onClick={submitOperationConfig}>保存运营设置</Form.Button>
<Divider />
<Header as='h3'> <Header as='h3'>
配置 SMTP 配置 SMTP
<Header.Subheader>用以支持系统的邮件发送</Header.Subheader> <Header.Subheader>用以支持系统的邮件发送</Header.Subheader>
</Header> </Header>
<Form.Group widths={3}> <Form.Group widths={4}>
<Form.Input <Form.Input
label='SMTP 服务器地址' label='SMTP 服务器地址'
name='SMTPServer' name='SMTPServer'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='off' autoComplete='new-password'
value={inputs.SMTPServer} value={inputs.SMTPServer}
placeholder='例如smtp.qq.com' placeholder='例如smtp.qq.com'
/> />
<Form.Input
label='SMTP 端口'
name='SMTPPort'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.SMTPPort}
placeholder='默认: 587'
/>
<Form.Input <Form.Input
label='SMTP 账户' label='SMTP 账户'
name='SMTPAccount' name='SMTPAccount'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='off' autoComplete='new-password'
value={inputs.SMTPAccount} value={inputs.SMTPAccount}
placeholder='通常是邮箱地址' placeholder='通常是邮箱地址'
/> />
@@ -254,7 +328,7 @@ const SystemSetting = () => {
name='SMTPToken' name='SMTPToken'
onChange={handleInputChange} onChange={handleInputChange}
type='password' type='password'
autoComplete='off' autoComplete='new-password'
value={inputs.SMTPToken} value={inputs.SMTPToken}
placeholder='敏感信息不会发送到前端显示' placeholder='敏感信息不会发送到前端显示'
/> />
@@ -281,7 +355,7 @@ const SystemSetting = () => {
label='GitHub Client ID' label='GitHub Client ID'
name='GitHubClientId' name='GitHubClientId'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='off' autoComplete='new-password'
value={inputs.GitHubClientId} value={inputs.GitHubClientId}
placeholder='输入你注册的 GitHub OAuth APP 的 ID' placeholder='输入你注册的 GitHub OAuth APP 的 ID'
/> />
@@ -290,7 +364,7 @@ const SystemSetting = () => {
name='GitHubClientSecret' name='GitHubClientSecret'
onChange={handleInputChange} onChange={handleInputChange}
type='password' type='password'
autoComplete='off' autoComplete='new-password'
value={inputs.GitHubClientSecret} value={inputs.GitHubClientSecret}
placeholder='敏感信息不会发送到前端显示' placeholder='敏感信息不会发送到前端显示'
/> />
@@ -318,7 +392,7 @@ const SystemSetting = () => {
name='WeChatServerAddress' name='WeChatServerAddress'
placeholder='例如https://yourdomain.com' placeholder='例如https://yourdomain.com'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='off' autoComplete='new-password'
value={inputs.WeChatServerAddress} value={inputs.WeChatServerAddress}
/> />
<Form.Input <Form.Input
@@ -326,7 +400,7 @@ const SystemSetting = () => {
name='WeChatServerToken' name='WeChatServerToken'
type='password' type='password'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='off' autoComplete='new-password'
value={inputs.WeChatServerToken} value={inputs.WeChatServerToken}
placeholder='敏感信息不会发送到前端显示' placeholder='敏感信息不会发送到前端显示'
/> />
@@ -334,7 +408,7 @@ const SystemSetting = () => {
label='微信公众号二维码图片链接' label='微信公众号二维码图片链接'
name='WeChatAccountQRCodeImageURL' name='WeChatAccountQRCodeImageURL'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='off' autoComplete='new-password'
value={inputs.WeChatAccountQRCodeImageURL} value={inputs.WeChatAccountQRCodeImageURL}
placeholder='输入一个图片链接' placeholder='输入一个图片链接'
/> />
@@ -358,7 +432,7 @@ const SystemSetting = () => {
label='Turnstile Site Key' label='Turnstile Site Key'
name='TurnstileSiteKey' name='TurnstileSiteKey'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='off' autoComplete='new-password'
value={inputs.TurnstileSiteKey} value={inputs.TurnstileSiteKey}
placeholder='输入你注册的 Turnstile Site Key' placeholder='输入你注册的 Turnstile Site Key'
/> />
@@ -367,7 +441,7 @@ const SystemSetting = () => {
name='TurnstileSecretKey' name='TurnstileSecretKey'
onChange={handleInputChange} onChange={handleInputChange}
type='password' type='password'
autoComplete='off' autoComplete='new-password'
value={inputs.TurnstileSecretKey} value={inputs.TurnstileSecretKey}
placeholder='敏感信息不会发送到前端显示' placeholder='敏感信息不会发送到前端显示'
/> />

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Label, Message, Pagination, Table } from 'semantic-ui-react'; import { Button, Form, Label, Modal, Pagination, Table } from 'semantic-ui-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { API, copy, showError, showInfo, showSuccess, showWarning, timestamp2string } from '../helpers'; import { API, copy, showError, showSuccess, showWarning, timestamp2string } from '../helpers';
import { ITEMS_PER_PAGE } from '../constants'; import { ITEMS_PER_PAGE } from '../constants';
@@ -34,6 +34,10 @@ const TokensTable = () => {
const [activePage, setActivePage] = useState(1); const [activePage, setActivePage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState(''); const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false); 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 loadTokens = async (startIdx) => {
const res = await API.get(`/api/token/?p=${startIdx}`); const res = await API.get(`/api/token/?p=${startIdx}`);
@@ -68,6 +72,13 @@ const TokensTable = () => {
.catch((reason) => { .catch((reason) => {
showError(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) => { const manageToken = async (id, action, idx) => {
@@ -140,6 +151,28 @@ const TokensTable = () => {
setLoading(false); 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 ( return (
<> <>
<Form onSubmit={searchTokens}> <Form onSubmit={searchTokens}>
@@ -184,10 +217,10 @@ const TokensTable = () => {
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => { onClick={() => {
sortToken('remain_times'); sortToken('remain_quota');
}} }}
> >
剩余次数 额度
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -197,14 +230,6 @@ const TokensTable = () => {
> >
创建时间 创建时间
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortToken('accessed_time');
}}
>
访问时间
</Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => { onClick={() => {
@@ -230,10 +255,9 @@ const TokensTable = () => {
<Table.Cell>{token.id}</Table.Cell> <Table.Cell>{token.id}</Table.Cell>
<Table.Cell>{token.name ? token.name : '无'}</Table.Cell> <Table.Cell>{token.name ? token.name : '无'}</Table.Cell>
<Table.Cell>{renderStatus(token.status)}</Table.Cell> <Table.Cell>{renderStatus(token.status)}</Table.Cell>
<Table.Cell>{token.unlimited_times ? "无限制" : token.remain_times}</Table.Cell> <Table.Cell>{token.unlimited_quota ? '无限制' : token.remain_quota}</Table.Cell>
<Table.Cell>{renderTimestamp(token.created_time)}</Table.Cell> <Table.Cell>{renderTimestamp(token.created_time)}</Table.Cell>
<Table.Cell>{renderTimestamp(token.accessed_time)}</Table.Cell> <Table.Cell>{token.expired_time === -1 ? '永不过期' : renderTimestamp(token.expired_time)}</Table.Cell>
<Table.Cell>{token.expired_time === -1 ? "永不过期" : renderTimestamp(token.expired_time)}</Table.Cell>
<Table.Cell> <Table.Cell>
<div> <div>
<Button <Button
@@ -243,13 +267,22 @@ const TokensTable = () => {
if (await copy(token.key)) { if (await copy(token.key)) {
showSuccess('已复制到剪贴板!'); showSuccess('已复制到剪贴板!');
} else { } else {
showWarning('无法复制到剪贴板,请手动复制,已将令牌填入搜索框。') showWarning('无法复制到剪贴板,请手动复制,已将令牌填入搜索框。');
setSearchKeyword(token.key); setSearchKeyword(token.key);
} }
}} }}
> >
复制 复制
</Button> </Button>
<Button
size={'small'}
color={'yellow'}
onClick={() => {
setTargetTokenIdx(idx);
setShowTopUpModal(true);
}}>
充值
</Button>
<Button <Button
size={'small'} size={'small'}
negative negative
@@ -306,6 +339,39 @@ const TokensTable = () => {
</Table.Row> </Table.Row>
</Table.Footer> </Table.Footer>
</Table> </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>
</> </>
); );
}; };

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Label, Pagination, Table } from 'semantic-ui-react'; import { Button, Form, Label, Pagination, Popup, Table } from 'semantic-ui-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { API, showError, showSuccess } from '../helpers'; import { API, showError, showSuccess } from '../helpers';
@@ -237,15 +237,25 @@ const UsersTable = () => {
> >
降级 降级
</Button> </Button>
<Button <Popup
size={'small'} trigger={
negative <Button size='small' negative>
onClick={() => { 删除
manageUser(user.username, 'delete', idx); </Button>
}} }
on='click'
flowing
hoverable
> >
删除 <Button
</Button> negative
onClick={() => {
manageUser(user.username, 'delete', idx);
}}
>
删除用户 {user.username}
</Button>
</Popup>
<Button <Button
size={'small'} size={'small'}
onClick={() => { onClick={() => {

View File

@@ -143,4 +143,22 @@ export function timestamp2string(timestamp) {
':' + ':' +
second second
); );
} }
export function downloadTextAsFile(text, filename) {
let blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
let url = URL.createObjectURL(blob);
let a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
}
export const verifyJSON = (str) => {
try {
JSON.parse(str);
} catch (e) {
return false;
}
return true;
};

View File

@@ -1,95 +0,0 @@
import React, { useState } from 'react';
import { Button, Form, Header, Segment } from 'semantic-ui-react';
import { API, showError, showSuccess } from '../../helpers';
import { CHANNEL_OPTIONS } from '../../constants';
const AddChannel = () => {
const originInputs = {
name: '',
type: 1,
key: '',
base_url: '',
};
const [inputs, setInputs] = useState(originInputs);
const { name, type, key } = inputs;
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const submit = async () => {
if (inputs.name === '' || inputs.key === '') return;
if (inputs.base_url.endsWith('/')) {
inputs.base_url = inputs.base_url.slice(0, inputs.base_url.length - 1);
}
const res = await API.post(`/api/channel/`, inputs);
const { success, message } = res.data;
if (success) {
showSuccess('渠道创建成功!');
setInputs(originInputs);
} else {
showError(message);
}
};
return (
<>
<Segment>
<Header as='h3'>创建新的渠道</Header>
<Form autoComplete='off'>
<Form.Field>
<Form.Select
label='类型'
name='type'
options={CHANNEL_OPTIONS}
value={inputs.type}
onChange={handleInputChange}
/>
</Form.Field>
{
type === 8 && (
<Form.Field>
<Form.Input
label='Base URL'
name='base_url'
placeholder={'请输入自定义渠道的 Base URL'}
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='off'
/>
</Form.Field>
)
}
<Form.Field>
<Form.Input
label='名称'
name='name'
placeholder={'请输入名称'}
onChange={handleInputChange}
value={name}
autoComplete='off'
required
/>
</Form.Field>
<Form.Field>
<Form.Input
label='密钥'
name='key'
placeholder={'请输入密钥'}
onChange={handleInputChange}
value={key}
// type='password'
autoComplete='off'
required
/>
</Form.Field>
<Button type={'submit'} onClick={submit}>
提交
</Button>
</Form>
</Segment>
</>
);
};
export default AddChannel;

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; 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 { useParams } from 'react-router-dom';
import { API, showError, showSuccess } from '../../helpers'; import { API, showError, showSuccess } from '../../helpers';
import { CHANNEL_OPTIONS } from '../../constants'; import { CHANNEL_OPTIONS } from '../../constants';
@@ -7,13 +7,15 @@ import { CHANNEL_OPTIONS } from '../../constants';
const EditChannel = () => { const EditChannel = () => {
const params = useParams(); const params = useParams();
const channelId = params.id; const channelId = params.id;
const [loading, setLoading] = useState(true); const isEdit = channelId !== undefined;
const [inputs, setInputs] = useState({ const [loading, setLoading] = useState(isEdit);
const originInputs = {
name: '', name: '',
key: '',
type: 1, type: 1,
base_url: '', key: '',
}); base_url: ''
};
const [inputs, setInputs] = useState(originInputs);
const handleInputChange = (e, { name, value }) => { const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value })); setInputs((inputs) => ({ ...inputs, [name]: value }));
}; };
@@ -30,17 +32,31 @@ const EditChannel = () => {
setLoading(false); setLoading(false);
}; };
useEffect(() => { useEffect(() => {
loadChannel().then(); if (isEdit) {
loadChannel().then();
}
}, []); }, []);
const submit = async () => { const submit = async () => {
if (inputs.base_url.endsWith('/')) { if (!isEdit && (inputs.name === '' || inputs.key === '')) return;
inputs.base_url = inputs.base_url.slice(0, inputs.base_url.length - 1); let localInputs = inputs;
if (localInputs.base_url.endsWith('/')) {
localInputs.base_url = localInputs.base_url.slice(0, localInputs.base_url.length - 1);
}
let res;
if (isEdit) {
res = await API.put(`/api/channel/`, { ...localInputs, id: parseInt(channelId) });
} else {
res = await API.post(`/api/channel/`, localInputs);
} }
let res = await API.put(`/api/channel/`, { ...inputs, id: parseInt(channelId) });
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
showSuccess('渠道更新成功!'); if (isEdit) {
showSuccess('渠道更新成功!');
} else {
showSuccess('渠道创建成功!');
setInputs(originInputs);
}
} else { } else {
showError(message); showError(message);
} }
@@ -49,8 +65,8 @@ const EditChannel = () => {
return ( return (
<> <>
<Segment loading={loading}> <Segment loading={loading}>
<Header as='h3'>更新渠道信息</Header> <Header as='h3'>{isEdit ? '更新渠道信息' : '创建新的渠道'}</Header>
<Form autoComplete='off'> <Form autoComplete='new-password'>
<Form.Field> <Form.Field>
<Form.Select <Form.Select
label='类型' label='类型'
@@ -60,16 +76,35 @@ const EditChannel = () => {
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Form.Field> </Form.Field>
{
inputs.type === 3 && (
<>
<Message>
注意创建资源时部署名称必须和 OpenAI 官方的模型名称保持一致因为 One API 会把请求体中的 model 参数替换为你的部署名称
</Message>
<Form.Field>
<Form.Input
label='AZURE_OPENAI_ENDPOINT'
name='base_url'
placeholder={'请输入 AZURE_OPENAI_ENDPOINT例如https://docs-test-001.openai.azure.com'}
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='new-password'
/>
</Form.Field>
</>
)
}
{ {
inputs.type === 8 && ( inputs.type === 8 && (
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='Base URL' label='Base URL'
name='base_url' name='base_url'
placeholder={'请输入新的自定义渠道的 Base URL'} placeholder={'请输入自定义渠道的 Base URL例如https://openai.justsong.cn'}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.base_url} value={inputs.base_url}
autoComplete='off' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
) )
@@ -78,21 +113,21 @@ const EditChannel = () => {
<Form.Input <Form.Input
label='名称' label='名称'
name='name' name='name'
placeholder={'请输入新的名称'} placeholder={'请输入名称'}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.name} value={inputs.name}
autoComplete='off' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='密钥' label='密钥'
name='key' name='key'
placeholder={'请输入新的密钥'} placeholder={'请输入密钥'}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.key} value={inputs.key}
// type='password' // type='password'
autoComplete='off' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
<Button onClick={submit}>提交</Button> <Button onClick={submit}>提交</Button>

View File

@@ -0,0 +1,121 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Segment } from 'semantic-ui-react';
import { useParams } from 'react-router-dom';
import { API, downloadTextAsFile, showError, showSuccess } from '../../helpers';
const EditRedemption = () => {
const params = useParams();
const redemptionId = params.id;
const isEdit = redemptionId !== undefined;
const [loading, setLoading] = useState(isEdit);
const originInputs = {
name: '',
quota: 100,
count: 1
};
const [inputs, setInputs] = useState(originInputs);
const { name, quota, count } = inputs;
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const loadRedemption = async () => {
let res = await API.get(`/api/redemption/${redemptionId}`);
const { success, message, data } = res.data;
if (success) {
setInputs(data);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
if (isEdit) {
loadRedemption().then();
}
}, []);
const submit = async () => {
if (!isEdit && inputs.name === '') return;
let localInputs = inputs;
localInputs.count = parseInt(localInputs.count);
localInputs.quota = parseInt(localInputs.quota);
let res;
if (isEdit) {
res = await API.put(`/api/redemption/`, { ...localInputs, id: parseInt(redemptionId) });
} else {
res = await API.post(`/api/redemption/`, {
...localInputs
});
}
const { success, message, data } = res.data;
if (success) {
if (isEdit) {
showSuccess('兑换码更新成功!');
} else {
showSuccess('兑换码创建成功!');
setInputs(originInputs);
}
} else {
showError(message);
}
if (!isEdit && data) {
let text = "";
for (let i = 0; i < data.length; i++) {
text += data[i] + "\n";
}
downloadTextAsFile(text, `${inputs.name}.txt`);
}
};
return (
<>
<Segment loading={loading}>
<Header as='h3'>{isEdit ? '更新兑换码信息' : '创建新的兑换码'}</Header>
<Form autoComplete='new-password'>
<Form.Field>
<Form.Input
label='名称'
name='name'
placeholder={'请输入名称'}
onChange={handleInputChange}
value={name}
autoComplete='new-password'
required={!isEdit}
/>
</Form.Field>
<Form.Field>
<Form.Input
label='额度'
name='quota'
placeholder={'请输入单个兑换码中包含的额度'}
onChange={handleInputChange}
value={quota}
autoComplete='new-password'
type='number'
/>
</Form.Field>
{
!isEdit && <>
<Form.Field>
<Form.Input
label='生成数量'
name='count'
placeholder={'请输入生成数量'}
onChange={handleInputChange}
value={count}
autoComplete='new-password'
type='number'
/>
</Form.Field>
</>
}
<Button onClick={submit}>提交</Button>
</Form>
</Segment>
</>
);
};
export default EditRedemption;

View File

@@ -0,0 +1,14 @@
import React from 'react';
import { Segment, Header } from 'semantic-ui-react';
import RedemptionsTable from '../../components/RedemptionsTable';
const Redemption = () => (
<>
<Segment>
<Header as='h3'>管理兑换码</Header>
<RedemptionsTable/>
</Segment>
</>
);
export default Redemption;

View File

@@ -10,13 +10,13 @@ const EditToken = () => {
const [loading, setLoading] = useState(isEdit); const [loading, setLoading] = useState(isEdit);
const originInputs = { const originInputs = {
name: '', name: '',
remain_times: 0, remain_quota: 0,
expired_time: -1, expired_time: -1,
unlimited_times: false unlimited_quota: false
}; };
const isAdminUser = isAdmin(); const isAdminUser = isAdmin();
const [inputs, setInputs] = useState(originInputs); const [inputs, setInputs] = useState(originInputs);
const { name, remain_times, expired_time, unlimited_times } = inputs; const { name, remain_quota, expired_time, unlimited_quota } = inputs;
const handleInputChange = (e, { name, value }) => { const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value })); setInputs((inputs) => ({ ...inputs, [name]: value }));
@@ -37,8 +37,8 @@ const EditToken = () => {
} }
}; };
const setUnlimitedTimes = () => { const setUnlimitedQuota = () => {
setInputs({ ...inputs, unlimited_times: !unlimited_times }); setInputs({ ...inputs, unlimited_quota: !unlimited_quota });
}; };
const loadToken = async () => { const loadToken = async () => {
@@ -63,7 +63,7 @@ const EditToken = () => {
const submit = async () => { const submit = async () => {
if (!isEdit && inputs.name === '') return; if (!isEdit && inputs.name === '') return;
let localInputs = inputs; let localInputs = inputs;
localInputs.remain_times = parseInt(localInputs.remain_times); localInputs.remain_quota = parseInt(localInputs.remain_quota);
if (localInputs.expired_time !== -1) { if (localInputs.expired_time !== -1) {
let time = Date.parse(localInputs.expired_time); let time = Date.parse(localInputs.expired_time);
if (isNaN(time)) { if (isNaN(time)) {
@@ -95,7 +95,7 @@ const EditToken = () => {
<> <>
<Segment loading={loading}> <Segment loading={loading}>
<Header as='h3'>{isEdit ? '更新令牌信息' : '创建新的令牌'}</Header> <Header as='h3'>{isEdit ? '更新令牌信息' : '创建新的令牌'}</Header>
<Form autoComplete='off'> <Form autoComplete='new-password'>
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='名称' label='名称'
@@ -103,7 +103,7 @@ const EditToken = () => {
placeholder={'请输入名称'} placeholder={'请输入名称'}
onChange={handleInputChange} onChange={handleInputChange}
value={name} value={name}
autoComplete='off' autoComplete='new-password'
required={!isEdit} required={!isEdit}
/> />
</Form.Field> </Form.Field>
@@ -111,19 +111,19 @@ const EditToken = () => {
isAdminUser && <> isAdminUser && <>
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='剩余次数' label='额度'
name='remain_times' name='remain_quota'
placeholder={'请输入剩余次数'} placeholder={'请输入额度'}
onChange={handleInputChange} onChange={handleInputChange}
value={remain_times} value={remain_quota}
autoComplete='off' autoComplete='new-password'
type='number' type='number'
disabled={unlimited_times} disabled={unlimited_quota}
/> />
</Form.Field> </Form.Field>
<Button type={'button'} onClick={() => { <Button type={'button'} style={{marginBottom: '14px'}} onClick={() => {
setUnlimitedTimes(); setUnlimitedQuota();
}}>{unlimited_times ? '取消无限' : '设置为无限'}</Button> }}>{unlimited_quota ? '取消无限额度' : '设置为无限额度'}</Button>
</> </>
} }
<Form.Field> <Form.Field>
@@ -133,7 +133,7 @@ const EditToken = () => {
placeholder={'请输入过期时间,格式为 yyyy-MM-dd HH:mm:ss-1 表示无限制'} placeholder={'请输入过期时间,格式为 yyyy-MM-dd HH:mm:ss-1 表示无限制'}
onChange={handleInputChange} onChange={handleInputChange}
value={expired_time} value={expired_time}
autoComplete='off' autoComplete='new-password'
type='datetime-local' type='datetime-local'
/> />
</Form.Field> </Form.Field>

View File

@@ -60,7 +60,7 @@ const EditUser = () => {
<> <>
<Segment loading={loading}> <Segment loading={loading}>
<Header as='h3'>更新用户信息</Header> <Header as='h3'>更新用户信息</Header>
<Form autoComplete='off'> <Form autoComplete='new-password'>
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='用户名' label='用户名'
@@ -68,7 +68,7 @@ const EditUser = () => {
placeholder={'请输入新的用户名'} placeholder={'请输入新的用户名'}
onChange={handleInputChange} onChange={handleInputChange}
value={username} value={username}
autoComplete='off' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
<Form.Field> <Form.Field>
@@ -79,7 +79,7 @@ const EditUser = () => {
placeholder={'请输入新的密码'} placeholder={'请输入新的密码'}
onChange={handleInputChange} onChange={handleInputChange}
value={password} value={password}
autoComplete='off' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
<Form.Field> <Form.Field>
@@ -89,7 +89,7 @@ const EditUser = () => {
placeholder={'请输入新的显示名称'} placeholder={'请输入新的显示名称'}
onChange={handleInputChange} onChange={handleInputChange}
value={display_name} value={display_name}
autoComplete='off' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
<Form.Field> <Form.Field>
@@ -97,7 +97,7 @@ const EditUser = () => {
label='已绑定的 GitHub 账户' label='已绑定的 GitHub 账户'
name='github_id' name='github_id'
value={github_id} value={github_id}
autoComplete='off' autoComplete='new-password'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改' placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
readOnly readOnly
/> />
@@ -107,7 +107,7 @@ const EditUser = () => {
label='已绑定的微信账户' label='已绑定的微信账户'
name='wechat_id' name='wechat_id'
value={wechat_id} value={wechat_id}
autoComplete='off' autoComplete='new-password'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改' placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
readOnly readOnly
/> />
@@ -117,7 +117,7 @@ const EditUser = () => {
label='已绑定的邮箱账户' label='已绑定的邮箱账户'
name='email' name='email'
value={email} value={email}
autoComplete='off' autoComplete='new-password'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改' placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
readOnly readOnly
/> />