Compare commits

...

8 Commits

Author SHA1 Message Date
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
16 changed files with 237 additions and 24 deletions

View File

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

View File

@@ -11,6 +11,7 @@ var Version = "v0.0.0" // this hard coding will be replaced automatic
var SystemName = "One API"
var ServerAddress = "http://localhost:3000"
var Footer = ""
var TopUpLink = ""
var UsingSQLite = false
@@ -46,6 +47,8 @@ var WeChatAccountQRCodeImageURL = ""
var TurnstileSiteKey = ""
var TurnstileSecretKey = ""
var QuotaForNewUser = 100
const (
RoleGuestUser = 0
RoleCommonUser = 1
@@ -63,7 +66,7 @@ var (
// All duration's unit is seconds
// Shouldn't larger then RateLimitKeyExpirationDuration
var (
GlobalApiRateLimitNum = 60000 // TODO: temporary set to 60000
GlobalApiRateLimitNum = 180
GlobalApiRateLimitDuration int64 = 3 * 60
GlobalWebRateLimitNum = 60

View File

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

View File

@@ -104,6 +104,19 @@ func AddToken(c *gin.Context) {
if isAdmin {
cleanToken.RemainTimes = token.RemainTimes
cleanToken.UnlimitedTimes = token.UnlimitedTimes
} 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
}
if quota > 0 {
cleanToken.RemainTimes = quota
}
}
err = cleanToken.Insert()
if err != nil {
@@ -113,6 +126,10 @@ func AddToken(c *gin.Context) {
})
return
}
if !isAdmin {
// update user quota
err = model.DecreaseUserQuota(c.GetInt("id"), cleanToken.RemainTimes)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",

View File

@@ -243,6 +243,42 @@ func GetUser(c *gin.Context) {
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) {
id := c.GetInt("id")
user, err := model.GetUserById(id, false)
@@ -523,6 +559,13 @@ func ManageUser(c *gin.Context) {
}
user.Role = common.RoleAdminUser
case "demote":
if user.Role == common.RoleRootUser {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法降级超级管理员用户",
})
return
}
user.Role = common.RoleCommonUser
}

View File

@@ -16,12 +16,31 @@ func authHelper(c *gin.Context, minRole int) {
id := session.Get("id")
status := session.Get("status")
if username == nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,未登录",
})
c.Abort()
return
// Check access token
accessToken := c.Request.Header.Get("Authorization")
if accessToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"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 {
c.JSON(http.StatusOK, gin.H{
@@ -79,6 +98,16 @@ func TokenAuth() func(c *gin.Context) {
c.Abort()
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("token_id", token.Id)
c.Set("unlimited_times", token.UnlimitedTimes)

View File

@@ -25,6 +25,7 @@ func createRootAccountIfNeed() error {
Role: common.RoleRootUser,
Status: common.UserStatusEnabled,
DisplayName: "Root User",
AccessToken: common.GetUUID(),
}
DB.Create(&rootUser)
}

View File

@@ -46,6 +46,8 @@ func InitOptionMap() {
common.OptionMap["WeChatAccountQRCodeImageURL"] = ""
common.OptionMap["TurnstileSiteKey"] = ""
common.OptionMap["TurnstileSecretKey"] = ""
common.OptionMap["QuotaForNewUser"] = strconv.Itoa(common.QuotaForNewUser)
common.OptionMap["TopUpLink"] = common.TopUpLink
common.OptionMapRWMutex.Unlock()
options, _ := AllOption()
for _, option := range options {
@@ -131,5 +133,9 @@ func updateOptionMap(key string, value string) {
common.TurnstileSiteKey = value
case "TurnstileSecretKey":
common.TurnstileSecretKey = value
case "QuotaForNewUser":
common.QuotaForNewUser, _ = strconv.Atoi(value)
case "TopUpLink":
common.TopUpLink = value
}
}

View File

@@ -9,7 +9,7 @@ import (
type Redemption struct {
Id int `json:"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"`
Name string `json:"name" gorm:"index"`
Quota int `json:"quota" gorm:"default:100"`
@@ -48,7 +48,7 @@ func Redeem(key string, tokenId int) (quota int, err error) {
return 0, errors.New("未提供 token id")
}
redemption := &Redemption{}
err = DB.Where("key = ?", key).First(redemption).Error
err = DB.Where("`key` = ?", key).First(redemption).Error
if err != nil {
return 0, errors.New("无效的兑换码")
}

View File

@@ -11,7 +11,7 @@ import (
type Token struct {
Id int `json:"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"`
Name string `json:"name" gorm:"index" `
CreatedTime int64 `json:"created_time" gorm:"bigint"`
@@ -39,7 +39,7 @@ func ValidateUserToken(key string) (token *Token, err error) {
}
key = strings.Replace(key, "Bearer ", "", 1)
token = &Token{}
err = DB.Where("key = ?", key).First(token).Error
err = DB.Where("`key` = ?", key).First(token).Error
if err == nil {
if token.Status != common.TokenStatusEnabled {
return nil, errors.New("该 token 状态不可用")

View File

@@ -2,7 +2,9 @@ package model
import (
"errors"
"gorm.io/gorm"
"one-api/common"
"strings"
)
// 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"`
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"`
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 {
@@ -67,6 +71,8 @@ func (user *User) Insert() error {
return err
}
}
user.Quota = common.QuotaForNewUser
user.AccessToken = common.GetUUID()
err = DB.Create(user).Error
return err
}
@@ -188,3 +194,38 @@ func IsAdmin(userId int) bool {
}
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 DecreaseUserQuota(id int, quota int) (err error) {
err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota - ?", quota)).Error
return err
}

View File

@@ -35,6 +35,7 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.GET("/self", controller.GetSelf)
selfRoute.PUT("/self", controller.UpdateSelf)
selfRoute.DELETE("/self", controller.DeleteSelf)
selfRoute.GET("/token", controller.GenerateAccessToken)
}
adminRoute := userRoute.Group("/")

View File

@@ -8,12 +8,12 @@ import (
func SetRelayRouter(router *gin.Engine) {
relayV1Router := router.Group("/v1")
relayV1Router.Use(middleware.GlobalAPIRateLimit(), middleware.TokenAuth(), middleware.Distribute())
relayV1Router.Use(middleware.TokenAuth(), middleware.Distribute())
{
relayV1Router.Any("/*path", controller.Relay)
}
relayDashboardRouter := router.Group("/dashboard")
relayDashboardRouter.Use(middleware.GlobalAPIRateLimit(), middleware.TokenAuth(), middleware.Distribute())
relayDashboardRouter.Use(middleware.TokenAuth(), middleware.Distribute())
{
relayDashboardRouter.Any("/*path", controller.Relay)
}

View File

@@ -1,5 +1,5 @@
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 { API, copy, showError, showInfo, showSuccess } from '../helpers';
import Turnstile from 'react-turnstile';
@@ -34,6 +34,17 @@ const PersonalSetting = () => {
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 () => {
if (inputs.wechat_verification_code === '') return;
const res = await API.get(
@@ -92,9 +103,13 @@ const PersonalSetting = () => {
return (
<div style={{ lineHeight: '40px' }}>
<Header as='h3'>通用设置</Header>
<Message>
注意此处生成的令牌用于系统管理而非用于请求 OpenAI 相关的服务请知悉
</Message>
<Button as={Link} to={`/user/edit/`}>
更新个人信息
</Button>
<Button onClick={generateAccessToken}>生成系统访问令牌</Button>
<Divider />
<Header as='h3'>账号绑定</Header>
<Button

View File

@@ -24,6 +24,8 @@ const SystemSetting = () => {
TurnstileSiteKey: '',
TurnstileSecretKey: '',
RegisterEnabled: '',
QuotaForNewUser: 0,
TopUpLink: ''
});
let originInputs = {};
let [loading, setLoading] = useState(false);
@@ -64,7 +66,7 @@ const SystemSetting = () => {
}
const res = await API.put('/api/option', {
key,
value,
value
});
const { success, message } = res.data;
if (success) {
@@ -86,7 +88,9 @@ const SystemSetting = () => {
name === 'WeChatServerToken' ||
name === 'WeChatAccountQRCodeImageURL' ||
name === 'TurnstileSiteKey' ||
name === 'TurnstileSecretKey'
name === 'TurnstileSecretKey' ||
name === 'QuotaForNewUser' ||
name === 'TopUpLink'
) {
setInputs((inputs) => ({ ...inputs, [name]: value }));
} else {
@@ -99,6 +103,15 @@ const SystemSetting = () => {
await updateOption('ServerAddress', ServerAddress);
};
const submitOperationConfig = async () => {
if (originInputs['QuotaForNewUser'] !== inputs.QuotaForNewUser) {
await updateOption('QuotaForNewUser', inputs.QuotaForNewUser);
}
if (originInputs['TopUpLink'] !== inputs.TopUpLink) {
await updateOption('TopUpLink', inputs.TopUpLink);
}
}
const submitSMTP = async () => {
if (originInputs['SMTPServer'] !== inputs.SMTPServer) {
await updateOption('SMTPServer', inputs.SMTPServer);
@@ -228,6 +241,32 @@ const SystemSetting = () => {
/>
</Form.Group>
<Divider />
<Header as='h3'>
运营设置
</Header>
<Form.Group widths={3}>
<Form.Input
label='新用户初始配额'
name='QuotaForNewUser'
onChange={handleInputChange}
autoComplete='off'
value={inputs.QuotaForNewUser}
type='number'
min='0'
placeholder='例如100'
/>
<Form.Input
label='充值链接'
name='TopUpLink'
onChange={handleInputChange}
autoComplete='off'
value={inputs.TopUpLink}
type='link'
placeholder='例如发卡网站的购买链接'
/>
</Form.Group>
<Form.Button onClick={submitOperationConfig}>保存运营设置</Form.Button>
<Divider />
<Header as='h3'>
配置 SMTP
<Header.Subheader>用以支持系统的邮件发送</Header.Subheader>

View File

@@ -37,6 +37,7 @@ const TokensTable = () => {
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}`);
@@ -71,6 +72,13 @@ 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) => {
@@ -342,6 +350,11 @@ const TokensTable = () => {
<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