├── VERSION
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── config.yml
│ ├── feature_request.md
│ └── bug_report.md
└── workflows
│ ├── macos-release.yml
│ ├── windows-release.yml
│ ├── docker-image-amd64-en.yml
│ ├── docker-image-amd64.yml
│ ├── linux-release.yml
│ └── docker-image-arm64.yml
├── img.png
├── img_1.png
├── img_10.png
├── img_11.png
├── img_12.png
├── img_13.png
├── img_14.png
├── img_15.png
├── img_16.png
├── img_17.png
├── img_18.png
├── img_19.png
├── img_2.png
├── img_20.png
├── img_21.png
├── img_22.png
├── img_23.png
├── img_24.png
├── img_25.png
├── img_26.png
├── img_27.png
├── img_28.png
├── img_29.png
├── img_3.png
├── img_30.png
├── img_31.png
├── img_32.png
├── img_4.png
├── img_5.png
├── img_6.png
├── img_7.png
├── img_8.png
├── img_9.png
├── web
├── vercel.json
├── public
│ ├── logo.png
│ ├── favicon.ico
│ ├── robots.txt
│ └── index.html
├── src
│ ├── helpers
│ │ ├── history.js
│ │ ├── index.js
│ │ ├── auth-header.js
│ │ ├── api.js
│ │ ├── render.js
│ │ └── utils.js
│ ├── constants
│ │ ├── common.constant.js
│ │ ├── index.js
│ │ ├── toast.constants.js
│ │ ├── user.constants.js
│ │ └── channel.constants.js
│ ├── pages
│ │ ├── Log
│ │ │ └── index.js
│ │ ├── Chat
│ │ │ └── index.js
│ │ ├── User
│ │ │ ├── index.js
│ │ │ └── AddUser.js
│ │ ├── Token
│ │ │ ├── index.js
│ │ │ └── EditToken.js
│ │ ├── Channel
│ │ │ └── index.js
│ │ ├── Redemption
│ │ │ ├── index.js
│ │ │ └── EditRedemption.js
│ │ ├── NotFound
│ │ │ └── index.js
│ │ ├── Setting
│ │ │ └── index.js
│ │ ├── About
│ │ │ └── index.js
│ │ ├── TopUp
│ │ │ └── index.js
│ │ └── Home
│ │ │ └── index.js
│ ├── components
│ │ ├── PrivateRoute.js
│ │ ├── Loading.js
│ │ ├── Footer.js
│ │ ├── GitHubOAuth.js
│ │ ├── PasswordResetForm.js
│ │ └── PasswordResetConfirm.js
│ ├── context
│ │ ├── User
│ │ │ ├── reducer.js
│ │ │ └── index.js
│ │ └── Status
│ │ │ ├── reducer.js
│ │ │ └── index.js
│ ├── index.css
│ └── index.js
├── .gitignore
├── README.md
└── package.json
├── .gitignore
├── qrcode_1730792856655.jpg
├── bin
├── migration_v0.2-v0.3.sql
├── migration_v0.3-v0.4.sql
└── time_test.sh
├── common
├── validate.go
├── crypto.go
├── gin.go
├── embed-file-system.go
├── group-ratio.go
├── init.go
├── logger.go
├── rate-limit.go
├── email.go
├── redis.go
├── custom-event.go
├── verification.go
├── model-ratio.go
└── utils.go
├── middleware
├── cache.go
├── cors.go
├── turnstile-check.go
├── rate-limit.go
├── auth.go
└── distributor.go
├── controller
├── group.go
├── billing.go
├── option.go
├── channel.go
├── wechat.go
├── log.go
├── redemption.go
├── relay-openai.go
├── relay-audio.go
├── github.go
├── token.go
├── relay-utils.go
└── misc.go
├── one-api.service
├── router
├── dashboard.go
├── main.go
├── web-router.go
├── relay-router.go
└── api-router.go
├── Dockerfile
├── docker-compose.yml
├── LICENSE
├── i18n
└── translate.py
├── model
├── ability.go
├── main.go
├── redemption.go
├── channel.go
├── cache.go
└── log.go
├── go.mod
└── main.go
/VERSION:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | custom: ['https://iamazing.cn/page/reward']
--------------------------------------------------------------------------------
/img.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img.png
--------------------------------------------------------------------------------
/img_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_1.png
--------------------------------------------------------------------------------
/img_10.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_10.png
--------------------------------------------------------------------------------
/img_11.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_11.png
--------------------------------------------------------------------------------
/img_12.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_12.png
--------------------------------------------------------------------------------
/img_13.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_13.png
--------------------------------------------------------------------------------
/img_14.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_14.png
--------------------------------------------------------------------------------
/img_15.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_15.png
--------------------------------------------------------------------------------
/img_16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_16.png
--------------------------------------------------------------------------------
/img_17.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_17.png
--------------------------------------------------------------------------------
/img_18.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_18.png
--------------------------------------------------------------------------------
/img_19.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_19.png
--------------------------------------------------------------------------------
/img_2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_2.png
--------------------------------------------------------------------------------
/img_20.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_20.png
--------------------------------------------------------------------------------
/img_21.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_21.png
--------------------------------------------------------------------------------
/img_22.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_22.png
--------------------------------------------------------------------------------
/img_23.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_23.png
--------------------------------------------------------------------------------
/img_24.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_24.png
--------------------------------------------------------------------------------
/img_25.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_25.png
--------------------------------------------------------------------------------
/img_26.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_26.png
--------------------------------------------------------------------------------
/img_27.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_27.png
--------------------------------------------------------------------------------
/img_28.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_28.png
--------------------------------------------------------------------------------
/img_29.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_29.png
--------------------------------------------------------------------------------
/img_3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_3.png
--------------------------------------------------------------------------------
/img_30.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_30.png
--------------------------------------------------------------------------------
/img_31.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_31.png
--------------------------------------------------------------------------------
/img_32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_32.png
--------------------------------------------------------------------------------
/img_4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_4.png
--------------------------------------------------------------------------------
/img_5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_5.png
--------------------------------------------------------------------------------
/img_6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_6.png
--------------------------------------------------------------------------------
/img_7.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_7.png
--------------------------------------------------------------------------------
/img_8.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_8.png
--------------------------------------------------------------------------------
/img_9.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/img_9.png
--------------------------------------------------------------------------------
/web/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "github": {
3 | "silent": true
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .vscode
3 | upload
4 | *.exe
5 | *.db
6 | build
7 | *.db-journal
--------------------------------------------------------------------------------
/web/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/web/public/logo.png
--------------------------------------------------------------------------------
/web/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/web/public/favicon.ico
--------------------------------------------------------------------------------
/web/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/qrcode_1730792856655.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akl7777777/shell-api/HEAD/qrcode_1730792856655.jpg
--------------------------------------------------------------------------------
/web/src/helpers/history.js:
--------------------------------------------------------------------------------
1 | import { createBrowserHistory } from 'history';
2 |
3 | export const history = createBrowserHistory();
--------------------------------------------------------------------------------
/web/src/constants/common.constant.js:
--------------------------------------------------------------------------------
1 | export const ITEMS_PER_PAGE = 10; // this value must keep same as the one defined in backend!
2 |
--------------------------------------------------------------------------------
/web/src/helpers/index.js:
--------------------------------------------------------------------------------
1 | export * from './history';
2 | export * from './auth-header';
3 | export * from './utils';
4 | export * from './api';
--------------------------------------------------------------------------------
/bin/migration_v0.2-v0.3.sql:
--------------------------------------------------------------------------------
1 | UPDATE users
2 | SET quota = quota + (
3 | SELECT SUM(remain_quota)
4 | FROM tokens
5 | WHERE tokens.user_id = users.id
6 | )
7 |
--------------------------------------------------------------------------------
/web/src/constants/index.js:
--------------------------------------------------------------------------------
1 | export * from './toast.constants';
2 | export * from './user.constants';
3 | export * from './common.constant';
4 | export * from './channel.constants';
--------------------------------------------------------------------------------
/common/validate.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import "github.com/go-playground/validator/v10"
4 |
5 | var Validate *validator.Validate
6 |
7 | func init() {
8 | Validate = validator.New()
9 | }
10 |
--------------------------------------------------------------------------------
/web/src/constants/toast.constants.js:
--------------------------------------------------------------------------------
1 | export const toastConstants = {
2 | SUCCESS_TIMEOUT: 1500,
3 | INFO_TIMEOUT: 3000,
4 | ERROR_TIMEOUT: 5000,
5 | WARNING_TIMEOUT: 10000,
6 | NOTICE_TIMEOUT: 20000
7 | };
8 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: 项目群聊
4 | url: https://openai.justsong.cn/
5 | about: QQ 群:828520184,自动审核,备注 One API
6 | - name: 赞赏支持
7 | url: https://iamazing.cn/page/reward
8 | about: 请作者喝杯咖啡,以激励作者持续开发
9 |
--------------------------------------------------------------------------------
/web/src/pages/Log/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Header, Segment } from 'semantic-ui-react';
3 | import LogsTable from '../../components/LogsTable';
4 |
5 | const Token = () => (
6 | <>
7 |
8 | >
9 | );
10 |
11 | export default Token;
12 |
--------------------------------------------------------------------------------
/web/src/helpers/auth-header.js:
--------------------------------------------------------------------------------
1 | export function authHeader() {
2 | // return authorization header with jwt token
3 | let user = JSON.parse(localStorage.getItem('user'));
4 |
5 | if (user && user.token) {
6 | return { 'Authorization': 'Bearer ' + user.token };
7 | } else {
8 | return {};
9 | }
10 | }
--------------------------------------------------------------------------------
/web/src/pages/Chat/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const Chat = () => {
4 | const chatLink = localStorage.getItem('chat_link');
5 |
6 | return (
7 |
11 | );
12 | };
13 |
14 |
15 | export default Chat;
16 |
--------------------------------------------------------------------------------
/web/src/helpers/api.js:
--------------------------------------------------------------------------------
1 | import { showError } from './utils';
2 | import axios from 'axios';
3 |
4 | export const API = axios.create({
5 | baseURL: process.env.REACT_APP_SERVER ? process.env.REACT_APP_SERVER : '',
6 | });
7 |
8 | API.interceptors.response.use(
9 | (response) => response,
10 | (error) => {
11 | showError(error);
12 | }
13 | );
14 |
--------------------------------------------------------------------------------
/web/src/pages/User/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Segment, Header } from 'semantic-ui-react';
3 | import UsersTable from '../../components/UsersTable';
4 |
5 | const User = () => (
6 | <>
7 |
8 |
9 |
10 |
11 | >
12 | );
13 |
14 | export default User;
15 |
--------------------------------------------------------------------------------
/web/src/components/PrivateRoute.js:
--------------------------------------------------------------------------------
1 | import { Navigate } from 'react-router-dom';
2 |
3 | import { history } from '../helpers';
4 |
5 |
6 | function PrivateRoute({ children }) {
7 | if (!localStorage.getItem('user')) {
8 | return ;
9 | }
10 | return children;
11 | }
12 |
13 | export { PrivateRoute };
--------------------------------------------------------------------------------
/web/src/pages/Token/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Segment, Header } from 'semantic-ui-react';
3 | import TokensTable from '../../components/TokensTable';
4 |
5 | const Token = () => (
6 | <>
7 |
8 |
9 |
10 |
11 | >
12 | );
13 |
14 | export default Token;
15 |
--------------------------------------------------------------------------------
/web/src/pages/Channel/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Header, Segment } from 'semantic-ui-react';
3 | import ChannelsTable from '../../components/ChannelsTable';
4 |
5 | const File = () => (
6 | <>
7 |
8 |
9 |
10 |
11 | >
12 | );
13 |
14 | export default File;
15 |
--------------------------------------------------------------------------------
/middleware/cache.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | )
6 |
7 | func Cache() func(c *gin.Context) {
8 | return func(c *gin.Context) {
9 | if c.Request.RequestURI == "/" {
10 | c.Header("Cache-Control", "no-cache")
11 | } else {
12 | c.Header("Cache-Control", "max-age=604800") // one week
13 | }
14 | c.Next()
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/web/src/pages/Redemption/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Segment, Header } from 'semantic-ui-react';
3 | import RedemptionsTable from '../../components/RedemptionsTable';
4 |
5 | const Redemption = () => (
6 | <>
7 |
8 |
9 |
10 |
11 | >
12 | );
13 |
14 | export default Redemption;
15 |
--------------------------------------------------------------------------------
/web/src/components/Loading.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Segment, Dimmer, Loader } from 'semantic-ui-react';
3 |
4 | const Loading = ({ prompt: name = 'page' }) => {
5 | return (
6 |
7 |
8 | 加载{name}中...
9 |
10 |
11 | );
12 | };
13 |
14 | export default Loading;
15 |
--------------------------------------------------------------------------------
/middleware/cors.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "github.com/gin-contrib/cors"
5 | "github.com/gin-gonic/gin"
6 | )
7 |
8 | func CORS() gin.HandlerFunc {
9 | config := cors.DefaultConfig()
10 | config.AllowAllOrigins = true
11 | config.AllowCredentials = true
12 | config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}
13 | config.AllowHeaders = []string{"*"}
14 | return cors.New(config)
15 | }
16 |
--------------------------------------------------------------------------------
/controller/group.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "net/http"
6 | "one-api/common"
7 | )
8 |
9 | func GetGroups(c *gin.Context) {
10 | groupNames := make([]string, 0)
11 | for groupName, _ := range common.GroupRatio {
12 | groupNames = append(groupNames, groupName)
13 | }
14 | c.JSON(http.StatusOK, gin.H{
15 | "success": true,
16 | "message": "",
17 | "data": groupNames,
18 | })
19 | }
20 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 功能请求
3 | about: 使用简练详细的语言描述希望加入的新功能
4 | title: ''
5 | labels: enhancement
6 | assignees: ''
7 |
8 | ---
9 |
10 | **例行检查**
11 |
12 | [//]: # (方框内删除已有的空格,填 x 号)
13 | + [ ] 我已确认目前没有类似 issue
14 | + [ ] 我已确认我已升级到最新版本
15 | + [ ] 我已完整查看过项目 README,已确定现有版本无法满足需求
16 | + [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈
17 | + [ ] 我理解并认可上述内容,并理解项目维护者精力有限,**不遵循规则的 issue 可能会被无视或直接关闭**
18 |
19 | **功能描述**
20 |
21 | **应用场景**
22 |
--------------------------------------------------------------------------------
/web/src/context/User/reducer.js:
--------------------------------------------------------------------------------
1 | export const reducer = (state, action) => {
2 | switch (action.type) {
3 | case 'login':
4 | return {
5 | ...state,
6 | user: action.payload
7 | };
8 | case 'logout':
9 | return {
10 | ...state,
11 | user: undefined
12 | };
13 |
14 | default:
15 | return state;
16 | }
17 | };
18 |
19 | export const initialState = {
20 | user: undefined
21 | };
--------------------------------------------------------------------------------
/web/src/context/Status/reducer.js:
--------------------------------------------------------------------------------
1 | export const reducer = (state, action) => {
2 | switch (action.type) {
3 | case 'set':
4 | return {
5 | ...state,
6 | status: action.payload,
7 | };
8 | case 'unset':
9 | return {
10 | ...state,
11 | status: undefined,
12 | };
13 | default:
14 | return state;
15 | }
16 | };
17 |
18 | export const initialState = {
19 | status: undefined,
20 | };
21 |
--------------------------------------------------------------------------------
/web/src/pages/NotFound/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Segment, Header } from 'semantic-ui-react';
3 |
4 | const NotFound = () => (
5 | <>
6 |
14 |
15 | 未找到所请求的页面
16 |
17 | >
18 | );
19 |
20 | export default NotFound;
21 |
--------------------------------------------------------------------------------
/web/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 | .idea
25 | package-lock.json
26 | yarn.lock
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 报告问题
3 | about: 使用简练详细的语言描述你遇到的问题
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 | **例行检查**
11 |
12 | [//]: # (方框内删除已有的空格,填 x 号)
13 | + [ ] 我已确认目前没有类似 issue
14 | + [ ] 我已确认我已升级到最新版本
15 | + [ ] 我已完整查看过项目 README,尤其是常见问题部分
16 | + [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈
17 | + [ ] 我理解并认可上述内容,并理解项目维护者精力有限,**不遵循规则的 issue 可能会被无视或直接关闭**
18 |
19 | **问题描述**
20 |
21 | **复现步骤**
22 |
23 | **预期结果**
24 |
25 | **相关截图**
26 | 如果没有的话,请删除此节。
--------------------------------------------------------------------------------
/common/crypto.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import "golang.org/x/crypto/bcrypt"
4 |
5 | func Password2Hash(password string) (string, error) {
6 | passwordBytes := []byte(password)
7 | hashedPassword, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost)
8 | return string(hashedPassword), err
9 | }
10 |
11 | func ValidatePasswordAndHash(password string, hash string) bool {
12 | err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
13 | return err == nil
14 | }
15 |
--------------------------------------------------------------------------------
/web/src/context/User/index.js:
--------------------------------------------------------------------------------
1 | // contexts/User/index.jsx
2 |
3 | import React from "react"
4 | import { reducer, initialState } from "./reducer"
5 |
6 | export const UserContext = React.createContext({
7 | state: initialState,
8 | dispatch: () => null
9 | })
10 |
11 | export const UserProvider = ({ children }) => {
12 | const [state, dispatch] = React.useReducer(reducer, initialState)
13 |
14 | return (
15 |
16 | { children }
17 |
18 | )
19 | }
--------------------------------------------------------------------------------
/web/src/context/Status/index.js:
--------------------------------------------------------------------------------
1 | // contexts/User/index.jsx
2 |
3 | import React from 'react';
4 | import { initialState, reducer } from './reducer';
5 |
6 | export const StatusContext = React.createContext({
7 | state: initialState,
8 | dispatch: () => null,
9 | });
10 |
11 | export const StatusProvider = ({ children }) => {
12 | const [state, dispatch] = React.useReducer(reducer, initialState);
13 |
14 | return (
15 |
16 | {children}
17 |
18 | );
19 | };
--------------------------------------------------------------------------------
/bin/migration_v0.3-v0.4.sql:
--------------------------------------------------------------------------------
1 | INSERT INTO abilities (`group`, model, channel_id, enabled)
2 | SELECT c.`group`, m.model, c.id, 1
3 | FROM channels c
4 | CROSS JOIN (
5 | SELECT 'gpt-3.5-turbo' AS model UNION ALL
6 | SELECT 'gpt-3.5-turbo-0301' AS model UNION ALL
7 | SELECT 'gpt-4' AS model UNION ALL
8 | SELECT 'gpt-4-0314' AS model
9 | ) AS m
10 | WHERE c.status = 1
11 | AND NOT EXISTS (
12 | SELECT 1
13 | FROM abilities a
14 | WHERE a.`group` = c.`group`
15 | AND a.model = m.model
16 | AND a.channel_id = c.id
17 | );
18 |
--------------------------------------------------------------------------------
/one-api.service:
--------------------------------------------------------------------------------
1 | # File path: /etc/systemd/system/one-api.service
2 | # sudo systemctl daemon-reload
3 | # sudo systemctl start one-api
4 | # sudo systemctl enable one-api
5 | # sudo systemctl status one-api
6 | [Unit]
7 | Description=One API Service
8 | After=network.target
9 |
10 | [Service]
11 | User=ubuntu # 注意修改用户名
12 | WorkingDirectory=/path/to/one-api # 注意修改路径
13 | ExecStart=/path/to/one-api/one-api --port 3000 --log-dir /path/to/one-api/logs # 注意修改路径和端口号
14 | Restart=always
15 | RestartSec=5
16 |
17 | [Install]
18 | WantedBy=multi-user.target
19 |
--------------------------------------------------------------------------------
/common/gin.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "github.com/gin-gonic/gin"
7 | "io"
8 | )
9 |
10 | func UnmarshalBodyReusable(c *gin.Context, v any) error {
11 | requestBody, err := io.ReadAll(c.Request.Body)
12 | if err != nil {
13 | return err
14 | }
15 | err = c.Request.Body.Close()
16 | if err != nil {
17 | return err
18 | }
19 | err = json.Unmarshal(requestBody, &v)
20 | if err != nil {
21 | return err
22 | }
23 | // Reset request body
24 | c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
25 | return nil
26 | }
27 |
--------------------------------------------------------------------------------
/web/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 | One API
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/web/README.md:
--------------------------------------------------------------------------------
1 | # React Template
2 |
3 | ## Basic Usages
4 |
5 | ```shell
6 | # Runs the app in the development mode
7 | npm start
8 |
9 | # Builds the app for production to the `build` folder
10 | npm run build
11 | ```
12 |
13 | If you want to change the default server, please set `REACT_APP_SERVER` environment variables before build,
14 | for example: `REACT_APP_SERVER=http://your.domain.com`.
15 |
16 | Before you start editing, make sure your `Actions on Save` options have `Optimize imports` & `Run Prettier` enabled.
17 |
18 | ## Reference
19 |
20 | 1. https://github.com/OIerDb-ng/OIerDb
21 | 2. https://github.com/cornflourblue/react-hooks-redux-registration-login-example
--------------------------------------------------------------------------------
/web/src/constants/user.constants.js:
--------------------------------------------------------------------------------
1 | export const userConstants = {
2 | REGISTER_REQUEST: 'USERS_REGISTER_REQUEST',
3 | REGISTER_SUCCESS: 'USERS_REGISTER_SUCCESS',
4 | REGISTER_FAILURE: 'USERS_REGISTER_FAILURE',
5 |
6 | LOGIN_REQUEST: 'USERS_LOGIN_REQUEST',
7 | LOGIN_SUCCESS: 'USERS_LOGIN_SUCCESS',
8 | LOGIN_FAILURE: 'USERS_LOGIN_FAILURE',
9 |
10 | LOGOUT: 'USERS_LOGOUT',
11 |
12 | GETALL_REQUEST: 'USERS_GETALL_REQUEST',
13 | GETALL_SUCCESS: 'USERS_GETALL_SUCCESS',
14 | GETALL_FAILURE: 'USERS_GETALL_FAILURE',
15 |
16 | DELETE_REQUEST: 'USERS_DELETE_REQUEST',
17 | DELETE_SUCCESS: 'USERS_DELETE_SUCCESS',
18 | DELETE_FAILURE: 'USERS_DELETE_FAILURE'
19 | };
20 |
--------------------------------------------------------------------------------
/common/embed-file-system.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "embed"
5 | "github.com/gin-contrib/static"
6 | "io/fs"
7 | "net/http"
8 | )
9 |
10 | // Credit: https://github.com/gin-contrib/static/issues/19
11 |
12 | type embedFileSystem struct {
13 | http.FileSystem
14 | }
15 |
16 | func (e embedFileSystem) Exists(prefix string, path string) bool {
17 | _, err := e.Open(path)
18 | if err != nil {
19 | return false
20 | }
21 | return true
22 | }
23 |
24 | func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
25 | efs, err := fs.Sub(fsEmbed, targetPath)
26 | if err != nil {
27 | panic(err)
28 | }
29 | return embedFileSystem{
30 | FileSystem: http.FS(efs),
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/router/dashboard.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "github.com/gin-contrib/gzip"
5 | "github.com/gin-gonic/gin"
6 | "one-api/controller"
7 | "one-api/middleware"
8 | )
9 |
10 | func SetDashboardRouter(router *gin.Engine) {
11 | apiRouter := router.Group("/")
12 | apiRouter.Use(gzip.Gzip(gzip.DefaultCompression))
13 | apiRouter.Use(middleware.GlobalAPIRateLimit())
14 | apiRouter.Use(middleware.TokenAuth())
15 | {
16 | apiRouter.GET("/dashboard/billing/subscription", controller.GetSubscription)
17 | apiRouter.GET("/v1/dashboard/billing/subscription", controller.GetSubscription)
18 | apiRouter.GET("/dashboard/billing/usage", controller.GetUsage)
19 | apiRouter.GET("/v1/dashboard/billing/usage", controller.GetUsage)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/common/group-ratio.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import "encoding/json"
4 |
5 | var GroupRatio = map[string]float64{
6 | "default": 1,
7 | "vip": 1,
8 | "svip": 1,
9 | }
10 |
11 | func GroupRatio2JSONString() string {
12 | jsonBytes, err := json.Marshal(GroupRatio)
13 | if err != nil {
14 | SysError("error marshalling model ratio: " + err.Error())
15 | }
16 | return string(jsonBytes)
17 | }
18 |
19 | func UpdateGroupRatioByJSONString(jsonStr string) error {
20 | GroupRatio = make(map[string]float64)
21 | return json.Unmarshal([]byte(jsonStr), &GroupRatio)
22 | }
23 |
24 | func GetGroupRatio(name string) float64 {
25 | ratio, ok := GroupRatio[name]
26 | if !ok {
27 | SysError("group ratio not found: " + name)
28 | return 1
29 | }
30 | return ratio
31 | }
32 |
--------------------------------------------------------------------------------
/web/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding-top: 55px;
4 | overflow-y: scroll;
5 | font-family: Lato, 'Helvetica Neue', Arial, Helvetica, "Microsoft YaHei", sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | scrollbar-width: none;
9 | }
10 |
11 | body::-webkit-scrollbar {
12 | display: none;
13 | }
14 |
15 | code {
16 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
17 | }
18 |
19 | .main-content {
20 | padding: 4px;
21 | }
22 |
23 | .small-icon .icon {
24 | font-size: 1em !important;
25 | }
26 |
27 | .custom-footer {
28 | font-size: 1.1em;
29 | }
30 |
31 | @media only screen and (max-width: 600px) {
32 | .hide-on-mobile {
33 | display: none !important;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16 as builder
2 |
3 | WORKDIR /build
4 | COPY web/package.json .
5 | RUN npm install
6 | COPY ./web .
7 | COPY ./VERSION .
8 | RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat VERSION) npm run build
9 |
10 | FROM golang AS builder2
11 |
12 | ENV GO111MODULE=on \
13 | CGO_ENABLED=1 \
14 | GOOS=linux
15 |
16 | WORKDIR /build
17 | ADD go.mod go.sum ./
18 | RUN go mod download
19 | COPY . .
20 | COPY --from=builder /build/build ./web/build
21 | RUN go build -ldflags "-s -w -X 'one-api/common.Version=$(cat VERSION)' -extldflags '-static'" -o one-api
22 |
23 | FROM alpine
24 |
25 | RUN apk update \
26 | && apk upgrade \
27 | && apk add --no-cache ca-certificates tzdata \
28 | && update-ca-certificates 2>/dev/null || true
29 |
30 | COPY --from=builder2 /build/one-api /
31 | EXPOSE 3000
32 | WORKDIR /data
33 | ENTRYPOINT ["/one-api"]
34 |
--------------------------------------------------------------------------------
/router/main.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "embed"
5 | "fmt"
6 | "github.com/gin-gonic/gin"
7 | "net/http"
8 | "one-api/common"
9 | "os"
10 | "strings"
11 | )
12 |
13 | func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
14 | SetApiRouter(router)
15 | SetDashboardRouter(router)
16 | SetRelayRouter(router)
17 | frontendBaseUrl := os.Getenv("FRONTEND_BASE_URL")
18 | if common.IsMasterNode && frontendBaseUrl != "" {
19 | frontendBaseUrl = ""
20 | common.SysLog("FRONTEND_BASE_URL is ignored on master node")
21 | }
22 | if frontendBaseUrl == "" {
23 | SetWebRouter(router, buildFS, indexPage)
24 | } else {
25 | frontendBaseUrl = strings.TrimSuffix(frontendBaseUrl, "/")
26 | router.NoRoute(func(c *gin.Context) {
27 | c.Redirect(http.StatusMovedPermanently, fmt.Sprintf("%s%s", frontendBaseUrl, c.Request.RequestURI))
28 | })
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/router/web-router.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "embed"
5 | "github.com/gin-contrib/gzip"
6 | "github.com/gin-contrib/static"
7 | "github.com/gin-gonic/gin"
8 | "net/http"
9 | "one-api/common"
10 | "one-api/controller"
11 | "one-api/middleware"
12 | "strings"
13 | )
14 |
15 | func SetWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
16 | router.Use(gzip.Gzip(gzip.DefaultCompression))
17 | router.Use(middleware.GlobalWebRateLimit())
18 | router.Use(middleware.Cache())
19 | router.Use(static.Serve("/", common.EmbedFolder(buildFS, "web/build")))
20 | router.NoRoute(func(c *gin.Context) {
21 | if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") {
22 | controller.RelayNotFound(c)
23 | return
24 | }
25 | c.Header("Cache-Control", "no-cache")
26 | c.Data(http.StatusOK, "text/html; charset=utf-8", indexPage)
27 | })
28 | }
29 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.4'
2 |
3 | services:
4 | one-api:
5 | image: akl7777777/one-api-private:v0.4.1
6 | container_name: one-api
7 | restart: always
8 | command: --log-dir /app/logs
9 | ports:
10 | - "3000:3000"
11 | volumes:
12 | - ./data:/data
13 | - ./logs:/app/logs
14 | environment:
15 | - SQL_DSN=root:XXXXXXX@tcp(localhost:3306)/oneapi # 修改此行,或注释掉以使用 SQLite 作为数据库
16 | - DW_DB_DSN=root:XXXXXXXX@tcp(localhost:3306)/oneapi # 修改此行,或注释掉以使用 SQLite 作为聊天记录数仓
17 | - TZ=Asia/Shanghai
18 | depends_on:
19 | - redis
20 | healthcheck:
21 | test: [ "CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o \"\\\"success\\\":\\s*true\" | awk -F: '{print $$2}'" ]
22 | interval: 30s
23 | timeout: 10s
24 | retries: 3
25 |
26 | redis:
27 | image: redis:latest
28 | container_name: redis
29 | restart: always
30 |
--------------------------------------------------------------------------------
/web/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import { BrowserRouter } from 'react-router-dom';
4 | import { Container } from 'semantic-ui-react';
5 | import App from './App';
6 | import Header from './components/Header';
7 | import Footer from './components/Footer';
8 | import 'semantic-ui-css/semantic.min.css';
9 | import './index.css';
10 | import { UserProvider } from './context/User';
11 | import { ToastContainer } from 'react-toastify';
12 | import 'react-toastify/dist/ReactToastify.css';
13 | import { StatusProvider } from './context/Status';
14 |
15 | const root = ReactDOM.createRoot(document.getElementById('root'));
16 | root.render(
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | );
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 JustSong
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/workflows/macos-release.yml:
--------------------------------------------------------------------------------
1 | name: macOS Release
2 | permissions:
3 | contents: write
4 |
5 | on:
6 | push:
7 | tags:
8 | - '*'
9 | - '!*-alpha*'
10 | jobs:
11 | release:
12 | runs-on: macos-latest
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v3
16 | with:
17 | fetch-depth: 0
18 | - uses: actions/setup-node@v3
19 | with:
20 | node-version: 16
21 | - name: Build Frontend
22 | env:
23 | CI: ""
24 | run: |
25 | cd web
26 | npm install
27 | REACT_APP_VERSION=$(git describe --tags) npm run build
28 | cd ..
29 | - name: Set up Go
30 | uses: actions/setup-go@v3
31 | with:
32 | go-version: '>=1.18.0'
33 | - name: Build Backend
34 | run: |
35 | go mod download
36 | go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o one-api-macos
37 | - name: Release
38 | uses: softprops/action-gh-release@v1
39 | if: startsWith(github.ref, 'refs/tags/')
40 | with:
41 | files: one-api-macos
42 | draft: true
43 | generate_release_notes: true
44 | env:
45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46 |
--------------------------------------------------------------------------------
/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-template",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "axios": "^0.27.2",
7 | "history": "^5.3.0",
8 | "marked": "^4.1.1",
9 | "react": "^18.2.0",
10 | "react-dom": "^18.2.0",
11 | "react-dropzone": "^14.2.3",
12 | "react-router-dom": "^6.3.0",
13 | "react-scripts": "5.0.1",
14 | "react-toastify": "^9.0.8",
15 | "react-turnstile": "^1.0.5",
16 | "semantic-ui-css": "^2.5.0",
17 | "semantic-ui-react": "^2.1.3"
18 | },
19 | "scripts": {
20 | "start": "react-scripts start",
21 | "build": "react-scripts build",
22 | "test": "react-scripts test",
23 | "eject": "react-scripts eject"
24 | },
25 | "eslintConfig": {
26 | "extends": [
27 | "react-app",
28 | "react-app/jest"
29 | ]
30 | },
31 | "browserslist": {
32 | "production": [
33 | ">0.2%",
34 | "not dead",
35 | "not op_mini all"
36 | ],
37 | "development": [
38 | "last 1 chrome version",
39 | "last 1 firefox version",
40 | "last 1 safari version"
41 | ]
42 | },
43 | "devDependencies": {
44 | "prettier": "^2.7.1"
45 | },
46 | "prettier": {
47 | "singleQuote": true,
48 | "jsxSingleQuote": true
49 | },
50 | "proxy": "http://localhost:9527"
51 | }
52 |
--------------------------------------------------------------------------------
/.github/workflows/windows-release.yml:
--------------------------------------------------------------------------------
1 | name: Windows Release
2 | permissions:
3 | contents: write
4 |
5 | on:
6 | push:
7 | tags:
8 | - '*'
9 | - '!*-alpha*'
10 | jobs:
11 | release:
12 | runs-on: windows-latest
13 | defaults:
14 | run:
15 | shell: bash
16 | steps:
17 | - name: Checkout
18 | uses: actions/checkout@v3
19 | with:
20 | fetch-depth: 0
21 | - uses: actions/setup-node@v3
22 | with:
23 | node-version: 16
24 | - name: Build Frontend
25 | env:
26 | CI: ""
27 | run: |
28 | cd web
29 | npm install
30 | REACT_APP_VERSION=$(git describe --tags) npm run build
31 | cd ..
32 | - name: Set up Go
33 | uses: actions/setup-go@v3
34 | with:
35 | go-version: '>=1.18.0'
36 | - name: Build Backend
37 | run: |
38 | go mod download
39 | go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o one-api.exe
40 | - name: Release
41 | uses: softprops/action-gh-release@v1
42 | if: startsWith(github.ref, 'refs/tags/')
43 | with:
44 | files: one-api.exe
45 | draft: true
46 | generate_release_notes: true
47 | env:
48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
--------------------------------------------------------------------------------
/bin/time_test.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | if [ $# -lt 3 ]; then
4 | echo "Usage: time_test.sh []"
5 | exit 1
6 | fi
7 |
8 | domain=$1
9 | key=$2
10 | count=$3
11 | model=${4:-"gpt-3.5-turbo"} # 设置默认模型为 gpt-3.5-turbo
12 |
13 | total_time=0
14 | times=()
15 |
16 | for ((i=1; i<=count; i++)); do
17 | result=$(curl -o /dev/null -s -w "%{http_code} %{time_total}\\n" \
18 | https://"$domain"/v1/chat/completions \
19 | -H "Content-Type: application/json" \
20 | -H "Authorization: Bearer $key" \
21 | -d '{"messages": [{"content": "echo hi", "role": "user"}], "model": "'"$model"'", "stream": false, "max_tokens": 1}')
22 | http_code=$(echo "$result" | awk '{print $1}')
23 | time=$(echo "$result" | awk '{print $2}')
24 | echo "HTTP status code: $http_code, Time taken: $time"
25 | total_time=$(bc <<< "$total_time + $time")
26 | times+=("$time")
27 | done
28 |
29 | average_time=$(echo "scale=4; $total_time / $count" | bc)
30 |
31 | sum_of_squares=0
32 | for time in "${times[@]}"; do
33 | difference=$(echo "scale=4; $time - $average_time" | bc)
34 | square=$(echo "scale=4; $difference * $difference" | bc)
35 | sum_of_squares=$(echo "scale=4; $sum_of_squares + $square" | bc)
36 | done
37 |
38 | standard_deviation=$(echo "scale=4; sqrt($sum_of_squares / $count)" | bc)
39 |
40 | echo "Average time: $average_time±$standard_deviation"
41 |
--------------------------------------------------------------------------------
/web/src/constants/channel.constants.js:
--------------------------------------------------------------------------------
1 | export const CHANNEL_OPTIONS = [
2 | { key: 1, text: 'OpenAI', value: 1, color: 'green' },
3 | { key: 14, text: 'Anthropic Claude', value: 14, color: 'black' },
4 | { key: 3, text: 'Azure OpenAI', value: 3, color: 'olive' },
5 | { key: 11, text: 'Google PaLM2', value: 11, color: 'orange' },
6 | { key: 15, text: '百度文心千帆', value: 15, color: 'blue' },
7 | { key: 17, text: '阿里通义千问', value: 17, color: 'orange' },
8 | { key: 18, text: '讯飞星火认知', value: 18, color: 'blue' },
9 | { key: 16, text: '智谱 ChatGLM', value: 16, color: 'violet' },
10 | { key: 19, text: '360 智脑', value: 19, color: 'blue' },
11 | { key: 8, text: '自定义渠道', value: 8, color: 'pink' },
12 | { key: 21, text: '知识库:AI Proxy', value: 21, color: 'purple' },
13 | { key: 20, text: '代理:OpenRouter', value: 20, color: 'black' },
14 | { key: 2, text: '代理:API2D', value: 2, color: 'blue' },
15 | { key: 5, text: '代理:OpenAI-SB', value: 5, color: 'brown' },
16 | { key: 7, text: '代理:OhMyGPT', value: 7, color: 'purple' },
17 | { key: 10, text: '代理:AI Proxy', value: 10, color: 'purple' },
18 | { key: 4, text: '代理:CloseAI', value: 4, color: 'teal' },
19 | { key: 6, text: '代理:OpenAI Max', value: 6, color: 'violet' },
20 | { key: 9, text: '代理:AI.LS', value: 9, color: 'yellow' },
21 | { key: 12, text: '代理:API2GPT', value: 12, color: 'blue' },
22 | { key: 13, text: '代理:AIGC2D', value: 13, color: 'purple' }
23 | ];
--------------------------------------------------------------------------------
/web/src/pages/Setting/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Segment, Tab } from 'semantic-ui-react';
3 | import SystemSetting from '../../components/SystemSetting';
4 | import { isRoot } from '../../helpers';
5 | import OtherSetting from '../../components/OtherSetting';
6 | import PersonalSetting from '../../components/PersonalSetting';
7 | import OperationSetting from '../../components/OperationSetting';
8 |
9 | const Setting = () => {
10 | let panes = [
11 | {
12 | menuItem: '个人设置',
13 | render: () => (
14 |
15 |
16 |
17 | )
18 | }
19 | ];
20 |
21 | if (isRoot()) {
22 | panes.push({
23 | menuItem: '运营设置',
24 | render: () => (
25 |
26 |
27 |
28 | )
29 | });
30 | panes.push({
31 | menuItem: '系统设置',
32 | render: () => (
33 |
34 |
35 |
36 | )
37 | });
38 | panes.push({
39 | menuItem: '其他设置',
40 | render: () => (
41 |
42 |
43 |
44 | )
45 | });
46 | }
47 |
48 | return (
49 |
50 |
51 |
52 | );
53 | };
54 |
55 | export default Setting;
56 |
--------------------------------------------------------------------------------
/.github/workflows/docker-image-amd64-en.yml:
--------------------------------------------------------------------------------
1 | name: Publish Docker image (amd64, English)
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 | workflow_dispatch:
8 | inputs:
9 | name:
10 | description: 'reason'
11 | required: false
12 | jobs:
13 | push_to_registries:
14 | name: Push Docker image to multiple registries
15 | runs-on: ubuntu-latest
16 | permissions:
17 | packages: write
18 | contents: read
19 | steps:
20 | - name: Check out the repo
21 | uses: actions/checkout@v3
22 |
23 | - name: Save version info
24 | run: |
25 | git describe --tags > VERSION
26 |
27 | - name: Translate
28 | run: |
29 | python ./i18n/translate.py --repository_path . --json_file_path ./i18n/en.json
30 | - name: Log in to Docker Hub
31 | uses: docker/login-action@v2
32 | with:
33 | username: ${{ secrets.DOCKERHUB_USERNAME }}
34 | password: ${{ secrets.DOCKERHUB_TOKEN }}
35 |
36 | - name: Extract metadata (tags, labels) for Docker
37 | id: meta
38 | uses: docker/metadata-action@v4
39 | with:
40 | images: |
41 | justsong/one-api-en
42 |
43 | - name: Build and push Docker images
44 | uses: docker/build-push-action@v3
45 | with:
46 | context: .
47 | push: true
48 | tags: ${{ steps.meta.outputs.tags }}
49 | labels: ${{ steps.meta.outputs.labels }}
--------------------------------------------------------------------------------
/common/init.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "log"
7 | "os"
8 | "path/filepath"
9 | )
10 |
11 | var (
12 | Port = flag.Int("port", 3000, "the listening port")
13 | PrintVersion = flag.Bool("version", false, "print version and exit")
14 | PrintHelp = flag.Bool("help", false, "print help and exit")
15 | LogDir = flag.String("log-dir", "", "specify the log directory")
16 | )
17 |
18 | func printHelp() {
19 | fmt.Println("One API " + Version + " - All in one API service for OpenAI API.")
20 | fmt.Println("Copyright (C) 2023 JustSong. All rights reserved.")
21 | fmt.Println("GitHub: https://github.com/songquanpeng/one-api")
22 | fmt.Println("Usage: one-api [--port ] [--log-dir ] [--version] [--help]")
23 | }
24 |
25 | func init() {
26 | flag.Parse()
27 |
28 | if *PrintVersion {
29 | fmt.Println(Version)
30 | os.Exit(0)
31 | }
32 |
33 | if *PrintHelp {
34 | printHelp()
35 | os.Exit(0)
36 | }
37 |
38 | if os.Getenv("SESSION_SECRET") != "" {
39 | SessionSecret = os.Getenv("SESSION_SECRET")
40 | }
41 | if os.Getenv("SQLITE_PATH") != "" {
42 | SQLitePath = os.Getenv("SQLITE_PATH")
43 | }
44 | if *LogDir != "" {
45 | var err error
46 | *LogDir, err = filepath.Abs(*LogDir)
47 | if err != nil {
48 | log.Fatal(err)
49 | }
50 | if _, err := os.Stat(*LogDir); os.IsNotExist(err) {
51 | err = os.Mkdir(*LogDir, 0777)
52 | if err != nil {
53 | log.Fatal(err)
54 | }
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/common/logger.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "fmt"
5 | "github.com/gin-gonic/gin"
6 | "io"
7 | "log"
8 | "os"
9 | "path/filepath"
10 | "time"
11 | )
12 |
13 | func SetupGinLog() {
14 | if *LogDir != "" {
15 | commonLogPath := filepath.Join(*LogDir, "common.log")
16 | errorLogPath := filepath.Join(*LogDir, "error.log")
17 | commonFd, err := os.OpenFile(commonLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
18 | if err != nil {
19 | log.Fatal("failed to open log file")
20 | }
21 | errorFd, err := os.OpenFile(errorLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
22 | if err != nil {
23 | log.Fatal("failed to open log file")
24 | }
25 | gin.DefaultWriter = io.MultiWriter(os.Stdout, commonFd)
26 | gin.DefaultErrorWriter = io.MultiWriter(os.Stderr, errorFd)
27 | }
28 | }
29 |
30 | func SysLog(s string) {
31 | t := time.Now()
32 | _, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
33 | }
34 |
35 | func SysError(s string) {
36 | t := time.Now()
37 | _, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
38 | }
39 |
40 | func FatalLog(v ...any) {
41 | t := time.Now()
42 | _, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
43 | os.Exit(1)
44 | }
45 |
46 | func LogQuota(quota int) string {
47 | if DisplayInCurrencyEnabled {
48 | return fmt.Sprintf("$%.6f 额度", float64(quota)/QuotaPerUnit)
49 | } else {
50 | return fmt.Sprintf("%d 点额度", quota)
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/.github/workflows/docker-image-amd64.yml:
--------------------------------------------------------------------------------
1 | name: Publish Docker image (amd64)
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 | workflow_dispatch:
8 | inputs:
9 | name:
10 | description: 'reason'
11 | required: false
12 | jobs:
13 | push_to_registries:
14 | name: Push Docker image to multiple registries
15 | runs-on: ubuntu-latest
16 | permissions:
17 | packages: write
18 | contents: read
19 | steps:
20 | - name: Check out the repo
21 | uses: actions/checkout@v3
22 |
23 | - name: Save version info
24 | run: |
25 | git describe --tags > VERSION
26 |
27 | - name: Log in to Docker Hub
28 | uses: docker/login-action@v2
29 | with:
30 | username: ${{ secrets.DOCKERHUB_USERNAME }}
31 | password: ${{ secrets.DOCKERHUB_TOKEN }}
32 |
33 | - name: Log in to the Container registry
34 | uses: docker/login-action@v2
35 | with:
36 | registry: ghcr.io
37 | username: ${{ github.actor }}
38 | password: ${{ secrets.GITHUB_TOKEN }}
39 |
40 | - name: Extract metadata (tags, labels) for Docker
41 | id: meta
42 | uses: docker/metadata-action@v4
43 | with:
44 | images: |
45 | justsong/one-api
46 | ghcr.io/${{ github.repository }}
47 |
48 | - name: Build and push Docker images
49 | uses: docker/build-push-action@v3
50 | with:
51 | context: .
52 | push: true
53 | tags: ${{ steps.meta.outputs.tags }}
54 | labels: ${{ steps.meta.outputs.labels }}
--------------------------------------------------------------------------------
/.github/workflows/linux-release.yml:
--------------------------------------------------------------------------------
1 | name: Linux Release
2 | permissions:
3 | contents: write
4 |
5 | on:
6 | push:
7 | tags:
8 | - '*'
9 | - '!*-alpha*'
10 | jobs:
11 | release:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v3
16 | with:
17 | fetch-depth: 0
18 | - uses: actions/setup-node@v3
19 | with:
20 | node-version: 16
21 | - name: Build Frontend
22 | env:
23 | CI: ""
24 | run: |
25 | cd web
26 | npm install
27 | REACT_APP_VERSION=$(git describe --tags) npm run build
28 | cd ..
29 | - name: Set up Go
30 | uses: actions/setup-go@v3
31 | with:
32 | go-version: '>=1.18.0'
33 | - name: Build Backend (amd64)
34 | run: |
35 | go mod download
36 | go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api
37 |
38 | - name: Build Backend (arm64)
39 | run: |
40 | sudo apt-get update
41 | sudo apt-get install gcc-aarch64-linux-gnu
42 | CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api-arm64
43 |
44 | - name: Release
45 | uses: softprops/action-gh-release@v1
46 | if: startsWith(github.ref, 'refs/tags/')
47 | with:
48 | files: |
49 | one-api
50 | one-api-arm64
51 | draft: true
52 | generate_release_notes: true
53 | env:
54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
--------------------------------------------------------------------------------
/common/rate-limit.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "sync"
5 | "time"
6 | )
7 |
8 | type InMemoryRateLimiter struct {
9 | store map[string]*[]int64
10 | mutex sync.Mutex
11 | expirationDuration time.Duration
12 | }
13 |
14 | func (l *InMemoryRateLimiter) Init(expirationDuration time.Duration) {
15 | if l.store == nil {
16 | l.mutex.Lock()
17 | if l.store == nil {
18 | l.store = make(map[string]*[]int64)
19 | l.expirationDuration = expirationDuration
20 | if expirationDuration > 0 {
21 | go l.clearExpiredItems()
22 | }
23 | }
24 | l.mutex.Unlock()
25 | }
26 | }
27 |
28 | func (l *InMemoryRateLimiter) clearExpiredItems() {
29 | for {
30 | time.Sleep(l.expirationDuration)
31 | l.mutex.Lock()
32 | now := time.Now().Unix()
33 | for key := range l.store {
34 | queue := l.store[key]
35 | size := len(*queue)
36 | if size == 0 || now-(*queue)[size-1] > int64(l.expirationDuration.Seconds()) {
37 | delete(l.store, key)
38 | }
39 | }
40 | l.mutex.Unlock()
41 | }
42 | }
43 |
44 | // Request parameter duration's unit is seconds
45 | func (l *InMemoryRateLimiter) Request(key string, maxRequestNum int, duration int64) bool {
46 | l.mutex.Lock()
47 | defer l.mutex.Unlock()
48 | // [old <-- new]
49 | queue, ok := l.store[key]
50 | now := time.Now().Unix()
51 | if ok {
52 | if len(*queue) < maxRequestNum {
53 | *queue = append(*queue, now)
54 | return true
55 | } else {
56 | if now-(*queue)[0] >= duration {
57 | *queue = (*queue)[1:]
58 | *queue = append(*queue, now)
59 | return true
60 | } else {
61 | return false
62 | }
63 | }
64 | } else {
65 | s := make([]int64, 0, maxRequestNum)
66 | l.store[key] = &s
67 | *(l.store[key]) = append(*(l.store[key]), now)
68 | }
69 | return true
70 | }
71 |
--------------------------------------------------------------------------------
/web/src/components/Footer.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 |
3 | import { Container, Segment } from 'semantic-ui-react';
4 | import { getFooterHTML, getSystemName } from '../helpers';
5 |
6 | const Footer = () => {
7 | const systemName = getSystemName();
8 | const [footer, setFooter] = useState(getFooterHTML());
9 | let remainCheckTimes = 5;
10 |
11 | const loadFooter = () => {
12 | let footer_html = localStorage.getItem('footer_html');
13 | if (footer_html) {
14 | setFooter(footer_html);
15 | }
16 | };
17 |
18 | useEffect(() => {
19 | const timer = setInterval(() => {
20 | if (remainCheckTimes <= 0) {
21 | clearInterval(timer);
22 | return;
23 | }
24 | remainCheckTimes--;
25 | loadFooter();
26 | }, 200);
27 | return () => clearTimeout(timer);
28 | }, []);
29 |
30 | return (
31 |
32 |
33 | {footer ? (
34 |
38 | ) : (
39 |
55 | )}
56 |
57 |
58 | );
59 | };
60 |
61 | export default Footer;
62 |
--------------------------------------------------------------------------------
/web/src/pages/About/index.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Header, Segment } from 'semantic-ui-react';
3 | import { API, showError } from '../../helpers';
4 | import { marked } from 'marked';
5 |
6 | const About = () => {
7 | const [about, setAbout] = useState('');
8 | const [aboutLoaded, setAboutLoaded] = useState(false);
9 |
10 | const displayAbout = async () => {
11 | setAbout(localStorage.getItem('about') || '');
12 | const res = await API.get('/api/about');
13 | const { success, message, data } = res.data;
14 | if (success) {
15 | let aboutContent = data;
16 | if (!data.startsWith('https://')) {
17 | aboutContent = marked.parse(data);
18 | }
19 | setAbout(aboutContent);
20 | localStorage.setItem('about', aboutContent);
21 | } else {
22 | showError(message);
23 | setAbout('加载关于内容失败...');
24 | }
25 | setAboutLoaded(true);
26 | };
27 |
28 | useEffect(() => {
29 | displayAbout().then();
30 | }, []);
31 |
32 | return (
33 | <>
34 | {
35 | aboutLoaded && about === '' ? <>
36 |
37 |
38 | 可在设置页面设置关于内容,支持 HTML & Markdown
39 | 项目仓库地址:
40 |
41 | https://github.com/songquanpeng/one-api
42 |
43 |
44 | > : <>
45 | {
46 | about.startsWith('https://') ? :
50 | }
51 | >
52 | }
53 | >
54 | );
55 | };
56 |
57 |
58 | export default About;
59 |
--------------------------------------------------------------------------------
/web/src/helpers/render.js:
--------------------------------------------------------------------------------
1 | import { Label } from 'semantic-ui-react';
2 |
3 | export function renderText(text, limit) {
4 | if (text.length > limit) {
5 | return text.slice(0, limit - 3) + '...';
6 | }
7 | return text;
8 | }
9 |
10 | export function renderGroup(group) {
11 | if (group === '') {
12 | return ;
13 | }
14 | let groups = group.split(',');
15 | groups.sort();
16 | return <>
17 | {groups.map((group) => {
18 | if (group === 'vip' || group === 'pro') {
19 | return ;
20 | } else if (group === 'svip' || group === 'premium') {
21 | return ;
22 | }
23 | return ;
24 | })}
25 | >;
26 | }
27 |
28 | export function renderNumber(num) {
29 | if (num >= 1000000000) {
30 | return (num / 1000000000).toFixed(1) + 'B';
31 | } else if (num >= 1000000) {
32 | return (num / 1000000).toFixed(1) + 'M';
33 | } else if (num >= 10000) {
34 | return (num / 1000).toFixed(1) + 'k';
35 | } else {
36 | return num;
37 | }
38 | }
39 |
40 | export function renderQuota(quota, digits = 2) {
41 | let quotaPerUnit = localStorage.getItem('quota_per_unit');
42 | let displayInCurrency = localStorage.getItem('display_in_currency');
43 | quotaPerUnit = parseFloat(quotaPerUnit);
44 | displayInCurrency = displayInCurrency === 'true';
45 | if (displayInCurrency) {
46 | return '$' + (quota / quotaPerUnit).toFixed(digits);
47 | }
48 | return renderNumber(quota);
49 | }
50 |
51 | export function renderQuotaWithPrompt(quota, digits) {
52 | let displayInCurrency = localStorage.getItem('display_in_currency');
53 | displayInCurrency = displayInCurrency === 'true';
54 | if (displayInCurrency) {
55 | return `(等价金额:${renderQuota(quota, digits)})`;
56 | }
57 | return '';
58 | }
--------------------------------------------------------------------------------
/.github/workflows/docker-image-arm64.yml:
--------------------------------------------------------------------------------
1 | name: Publish Docker image (arm64)
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 | - '!*-alpha*'
8 | workflow_dispatch:
9 | inputs:
10 | name:
11 | description: 'reason'
12 | required: false
13 | jobs:
14 | push_to_registries:
15 | name: Push Docker image to multiple registries
16 | runs-on: ubuntu-latest
17 | permissions:
18 | packages: write
19 | contents: read
20 | steps:
21 | - name: Check out the repo
22 | uses: actions/checkout@v3
23 |
24 | - name: Save version info
25 | run: |
26 | git describe --tags > VERSION
27 |
28 | - name: Set up QEMU
29 | uses: docker/setup-qemu-action@v2
30 |
31 | - name: Set up Docker Buildx
32 | uses: docker/setup-buildx-action@v2
33 |
34 | - name: Log in to Docker Hub
35 | uses: docker/login-action@v2
36 | with:
37 | username: ${{ secrets.DOCKERHUB_USERNAME }}
38 | password: ${{ secrets.DOCKERHUB_TOKEN }}
39 |
40 | - name: Log in to the Container registry
41 | uses: docker/login-action@v2
42 | with:
43 | registry: ghcr.io
44 | username: ${{ github.actor }}
45 | password: ${{ secrets.GITHUB_TOKEN }}
46 |
47 | - name: Extract metadata (tags, labels) for Docker
48 | id: meta
49 | uses: docker/metadata-action@v4
50 | with:
51 | images: |
52 | justsong/one-api
53 | ghcr.io/${{ github.repository }}
54 |
55 | - name: Build and push Docker images
56 | uses: docker/build-push-action@v3
57 | with:
58 | context: .
59 | platforms: linux/amd64,linux/arm64
60 | push: true
61 | tags: ${{ steps.meta.outputs.tags }}
62 | labels: ${{ steps.meta.outputs.labels }}
--------------------------------------------------------------------------------
/common/email.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "crypto/tls"
5 | "encoding/base64"
6 | "fmt"
7 | "net/smtp"
8 | "strings"
9 | )
10 |
11 | func SendEmail(subject string, receiver string, content string) error {
12 | if SMTPFrom == "" { // for compatibility
13 | SMTPFrom = SMTPAccount
14 | }
15 | encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject)))
16 | mail := []byte(fmt.Sprintf("To: %s\r\n"+
17 | "From: %s<%s>\r\n"+
18 | "Subject: %s\r\n"+
19 | "Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
20 | receiver, SystemName, SMTPFrom, encodedSubject, content))
21 | auth := smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
22 | addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort)
23 | to := strings.Split(receiver, ";")
24 | var err error
25 | if SMTPPort == 465 {
26 | tlsConfig := &tls.Config{
27 | InsecureSkipVerify: true,
28 | ServerName: SMTPServer,
29 | }
30 | conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", SMTPServer, SMTPPort), tlsConfig)
31 | if err != nil {
32 | return err
33 | }
34 | client, err := smtp.NewClient(conn, SMTPServer)
35 | if err != nil {
36 | return err
37 | }
38 | defer client.Close()
39 | if err = client.Auth(auth); err != nil {
40 | return err
41 | }
42 | if err = client.Mail(SMTPFrom); err != nil {
43 | return err
44 | }
45 | receiverEmails := strings.Split(receiver, ";")
46 | for _, receiver := range receiverEmails {
47 | if err = client.Rcpt(receiver); err != nil {
48 | return err
49 | }
50 | }
51 | w, err := client.Data()
52 | if err != nil {
53 | return err
54 | }
55 | _, err = w.Write(mail)
56 | if err != nil {
57 | return err
58 | }
59 | err = w.Close()
60 | if err != nil {
61 | return err
62 | }
63 | } else {
64 | err = smtp.SendMail(addr, auth, SMTPAccount, to, mail)
65 | }
66 | return err
67 | }
68 |
--------------------------------------------------------------------------------
/common/redis.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "context"
5 | "github.com/go-redis/redis/v8"
6 | "os"
7 | "time"
8 | )
9 |
10 | var RDB *redis.Client
11 | var RedisEnabled = true
12 |
13 | // InitRedisClient This function is called after init()
14 | func InitRedisClient() (err error) {
15 | if os.Getenv("REDIS_CONN_STRING") == "" {
16 | RedisEnabled = false
17 | SysLog("REDIS_CONN_STRING not set, Redis is not enabled")
18 | return nil
19 | }
20 | if os.Getenv("SYNC_FREQUENCY") == "" {
21 | RedisEnabled = false
22 | SysLog("SYNC_FREQUENCY not set, Redis is disabled")
23 | return nil
24 | }
25 | SysLog("Redis is enabled")
26 | opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
27 | if err != nil {
28 | FatalLog("failed to parse Redis connection string: " + err.Error())
29 | }
30 | RDB = redis.NewClient(opt)
31 |
32 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
33 | defer cancel()
34 |
35 | _, err = RDB.Ping(ctx).Result()
36 | if err != nil {
37 | FatalLog("Redis ping test failed: " + err.Error())
38 | }
39 | return err
40 | }
41 |
42 | func ParseRedisOption() *redis.Options {
43 | opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
44 | if err != nil {
45 | FatalLog("failed to parse Redis connection string: " + err.Error())
46 | }
47 | return opt
48 | }
49 |
50 | func RedisSet(key string, value string, expiration time.Duration) error {
51 | ctx := context.Background()
52 | return RDB.Set(ctx, key, value, expiration).Err()
53 | }
54 |
55 | func RedisGet(key string) (string, error) {
56 | ctx := context.Background()
57 | return RDB.Get(ctx, key).Result()
58 | }
59 |
60 | func RedisDel(key string) error {
61 | ctx := context.Background()
62 | return RDB.Del(ctx, key).Err()
63 | }
64 |
65 | func RedisDecrease(key string, value int64) error {
66 | ctx := context.Background()
67 | return RDB.DecrBy(ctx, key, value).Err()
68 | }
69 |
--------------------------------------------------------------------------------
/web/src/components/GitHubOAuth.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useEffect, useState } from 'react';
2 | import { Dimmer, Loader, Segment } from 'semantic-ui-react';
3 | import { useNavigate, useSearchParams } from 'react-router-dom';
4 | import { API, showError, showSuccess } from '../helpers';
5 | import { UserContext } from '../context/User';
6 |
7 | const GitHubOAuth = () => {
8 | const [searchParams, setSearchParams] = useSearchParams();
9 |
10 | const [userState, userDispatch] = useContext(UserContext);
11 | const [prompt, setPrompt] = useState('处理中...');
12 | const [processing, setProcessing] = useState(true);
13 |
14 | let navigate = useNavigate();
15 |
16 | const sendCode = async (code, count) => {
17 | const res = await API.get(`/api/oauth/github?code=${code}`);
18 | const { success, message, data } = res.data;
19 | if (success) {
20 | if (message === 'bind') {
21 | showSuccess('绑定成功!');
22 | navigate('/setting');
23 | } else {
24 | userDispatch({ type: 'login', payload: data });
25 | localStorage.setItem('user', JSON.stringify(data));
26 | showSuccess('登录成功!');
27 | navigate('/');
28 | }
29 | } else {
30 | showError(message);
31 | if (count === 0) {
32 | setPrompt(`操作失败,重定向至登录界面中...`);
33 | navigate('/setting'); // in case this is failed to bind GitHub
34 | return;
35 | }
36 | count++;
37 | setPrompt(`出现错误,第 ${count} 次重试中...`);
38 | await new Promise((resolve) => setTimeout(resolve, count * 2000));
39 | await sendCode(code, count);
40 | }
41 | };
42 |
43 | useEffect(() => {
44 | let code = searchParams.get('code');
45 | sendCode(code, 0).then();
46 | }, []);
47 |
48 | return (
49 |
50 |
51 | {prompt}
52 |
53 |
54 | );
55 | };
56 |
57 | export default GitHubOAuth;
58 |
--------------------------------------------------------------------------------
/common/custom-event.go:
--------------------------------------------------------------------------------
1 | // Copyright 2014 Manu Martinez-Almeida. All rights reserved.
2 | // Use of this source code is governed by a MIT style
3 | // license that can be found in the LICENSE file.
4 |
5 | package common
6 |
7 | import (
8 | "fmt"
9 | "io"
10 | "net/http"
11 | "strings"
12 | )
13 |
14 | type stringWriter interface {
15 | io.Writer
16 | writeString(string) (int, error)
17 | }
18 |
19 | type stringWrapper struct {
20 | io.Writer
21 | }
22 |
23 | func (w stringWrapper) writeString(str string) (int, error) {
24 | return w.Writer.Write([]byte(str))
25 | }
26 |
27 | func checkWriter(writer io.Writer) stringWriter {
28 | if w, ok := writer.(stringWriter); ok {
29 | return w
30 | } else {
31 | return stringWrapper{writer}
32 | }
33 | }
34 |
35 | // Server-Sent Events
36 | // W3C Working Draft 29 October 2009
37 | // http://www.w3.org/TR/2009/WD-eventsource-20091029/
38 |
39 | var contentType = []string{"text/event-stream"}
40 | var noCache = []string{"no-cache"}
41 |
42 | var fieldReplacer = strings.NewReplacer(
43 | "\n", "\\n",
44 | "\r", "\\r")
45 |
46 | var dataReplacer = strings.NewReplacer(
47 | "\n", "\ndata:",
48 | "\r", "\\r")
49 |
50 | type CustomEvent struct {
51 | Event string
52 | Id string
53 | Retry uint
54 | Data interface{}
55 | }
56 |
57 | func encode(writer io.Writer, event CustomEvent) error {
58 | w := checkWriter(writer)
59 | return writeData(w, event.Data)
60 | }
61 |
62 | func writeData(w stringWriter, data interface{}) error {
63 | dataReplacer.WriteString(w, fmt.Sprint(data))
64 | if strings.HasPrefix(data.(string), "data") {
65 | w.writeString("\n\n")
66 | }
67 | return nil
68 | }
69 |
70 | func (r CustomEvent) Render(w http.ResponseWriter) error {
71 | r.WriteContentType(w)
72 | return encode(w, r)
73 | }
74 |
75 | func (r CustomEvent) WriteContentType(w http.ResponseWriter) {
76 | header := w.Header()
77 | header["Content-Type"] = contentType
78 |
79 | if _, exist := header["Cache-Control"]; !exist {
80 | header["Cache-Control"] = noCache
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/middleware/turnstile-check.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "encoding/json"
5 | "github.com/gin-contrib/sessions"
6 | "github.com/gin-gonic/gin"
7 | "net/http"
8 | "net/url"
9 | "one-api/common"
10 | )
11 |
12 | type turnstileCheckResponse struct {
13 | Success bool `json:"success"`
14 | }
15 |
16 | func TurnstileCheck() gin.HandlerFunc {
17 | return func(c *gin.Context) {
18 | if common.TurnstileCheckEnabled {
19 | session := sessions.Default(c)
20 | turnstileChecked := session.Get("turnstile")
21 | if turnstileChecked != nil {
22 | c.Next()
23 | return
24 | }
25 | response := c.Query("turnstile")
26 | if response == "" {
27 | c.JSON(http.StatusOK, gin.H{
28 | "success": false,
29 | "message": "Turnstile token 为空",
30 | })
31 | c.Abort()
32 | return
33 | }
34 | rawRes, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", url.Values{
35 | "secret": {common.TurnstileSecretKey},
36 | "response": {response},
37 | "remoteip": {c.ClientIP()},
38 | })
39 | if err != nil {
40 | common.SysError(err.Error())
41 | c.JSON(http.StatusOK, gin.H{
42 | "success": false,
43 | "message": err.Error(),
44 | })
45 | c.Abort()
46 | return
47 | }
48 | defer rawRes.Body.Close()
49 | var res turnstileCheckResponse
50 | err = json.NewDecoder(rawRes.Body).Decode(&res)
51 | if err != nil {
52 | common.SysError(err.Error())
53 | c.JSON(http.StatusOK, gin.H{
54 | "success": false,
55 | "message": err.Error(),
56 | })
57 | c.Abort()
58 | return
59 | }
60 | if !res.Success {
61 | c.JSON(http.StatusOK, gin.H{
62 | "success": false,
63 | "message": "Turnstile 校验失败,请刷新重试!",
64 | })
65 | c.Abort()
66 | return
67 | }
68 | session.Set("turnstile", true)
69 | err = session.Save()
70 | if err != nil {
71 | c.JSON(http.StatusOK, gin.H{
72 | "message": "无法保存会话信息,请重试",
73 | "success": false,
74 | })
75 | return
76 | }
77 | }
78 | c.Next()
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/common/verification.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "github.com/google/uuid"
5 | "strings"
6 | "sync"
7 | "time"
8 | )
9 |
10 | type verificationValue struct {
11 | code string
12 | time time.Time
13 | }
14 |
15 | const (
16 | EmailVerificationPurpose = "v"
17 | PasswordResetPurpose = "r"
18 | )
19 |
20 | var verificationMutex sync.Mutex
21 | var verificationMap map[string]verificationValue
22 | var verificationMapMaxSize = 10
23 | var VerificationValidMinutes = 10
24 |
25 | func GenerateVerificationCode(length int) string {
26 | code := uuid.New().String()
27 | code = strings.Replace(code, "-", "", -1)
28 | if length == 0 {
29 | return code
30 | }
31 | return code[:length]
32 | }
33 |
34 | func RegisterVerificationCodeWithKey(key string, code string, purpose string) {
35 | verificationMutex.Lock()
36 | defer verificationMutex.Unlock()
37 | verificationMap[purpose+key] = verificationValue{
38 | code: code,
39 | time: time.Now(),
40 | }
41 | if len(verificationMap) > verificationMapMaxSize {
42 | removeExpiredPairs()
43 | }
44 | }
45 |
46 | func VerifyCodeWithKey(key string, code string, purpose string) bool {
47 | verificationMutex.Lock()
48 | defer verificationMutex.Unlock()
49 | value, okay := verificationMap[purpose+key]
50 | now := time.Now()
51 | if !okay || int(now.Sub(value.time).Seconds()) >= VerificationValidMinutes*60 {
52 | return false
53 | }
54 | return code == value.code
55 | }
56 |
57 | func DeleteKey(key string, purpose string) {
58 | verificationMutex.Lock()
59 | defer verificationMutex.Unlock()
60 | delete(verificationMap, purpose+key)
61 | }
62 |
63 | // no lock inside, so the caller must lock the verificationMap before calling!
64 | func removeExpiredPairs() {
65 | now := time.Now()
66 | for key := range verificationMap {
67 | if int(now.Sub(verificationMap[key].time).Seconds()) >= VerificationValidMinutes*60 {
68 | delete(verificationMap, key)
69 | }
70 | }
71 | }
72 |
73 | func init() {
74 | verificationMutex.Lock()
75 | defer verificationMutex.Unlock()
76 | verificationMap = make(map[string]verificationValue)
77 | }
78 |
--------------------------------------------------------------------------------
/router/relay-router.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "one-api/controller"
5 | "one-api/middleware"
6 |
7 | "github.com/gin-gonic/gin"
8 | )
9 |
10 | func SetRelayRouter(router *gin.Engine) {
11 | // https://platform.openai.com/docs/api-reference/introduction
12 | modelsRouter := router.Group("/v1/models")
13 | modelsRouter.Use(middleware.TokenAuth())
14 | {
15 | modelsRouter.GET("", controller.ListModels)
16 | modelsRouter.GET("/:model", controller.RetrieveModel)
17 | }
18 | relayV1Router := router.Group("/v1")
19 | relayV1Router.Use(middleware.TokenAuth(), middleware.Distribute())
20 | {
21 | relayV1Router.POST("/completions", controller.Relay)
22 | relayV1Router.POST("/chat/completions", controller.Relay)
23 | relayV1Router.POST("/edits", controller.Relay)
24 | relayV1Router.POST("/images/generations", controller.Relay)
25 | relayV1Router.POST("/images/edits", controller.RelayNotImplemented)
26 | relayV1Router.POST("/images/variations", controller.RelayNotImplemented)
27 | relayV1Router.POST("/embeddings", controller.Relay)
28 | relayV1Router.POST("/engines/:model/embeddings", controller.Relay)
29 | relayV1Router.POST("/audio/transcriptions", controller.Relay)
30 | relayV1Router.POST("/audio/translations", controller.Relay)
31 | relayV1Router.GET("/files", controller.RelayNotImplemented)
32 | relayV1Router.POST("/files", controller.RelayNotImplemented)
33 | relayV1Router.DELETE("/files/:id", controller.RelayNotImplemented)
34 | relayV1Router.GET("/files/:id", controller.RelayNotImplemented)
35 | relayV1Router.GET("/files/:id/content", controller.RelayNotImplemented)
36 | relayV1Router.POST("/fine-tunes", controller.RelayNotImplemented)
37 | relayV1Router.GET("/fine-tunes", controller.RelayNotImplemented)
38 | relayV1Router.GET("/fine-tunes/:id", controller.RelayNotImplemented)
39 | relayV1Router.POST("/fine-tunes/:id/cancel", controller.RelayNotImplemented)
40 | relayV1Router.GET("/fine-tunes/:id/events", controller.RelayNotImplemented)
41 | relayV1Router.DELETE("/models/:model", controller.RelayNotImplemented)
42 | relayV1Router.POST("/moderations", controller.Relay)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/i18n/translate.py:
--------------------------------------------------------------------------------
1 | import argparse
2 | import json
3 | import os
4 |
5 | def list_file_paths(path):
6 | file_paths = []
7 | for root, dirs, files in os.walk(path):
8 | if "node_modules" in dirs:
9 | dirs.remove("node_modules")
10 | if "build" in dirs:
11 | dirs.remove("build")
12 | if "i18n" in dirs:
13 | dirs.remove("i18n")
14 | for file in files:
15 | file_path = os.path.join(root, file)
16 | if file_path.endswith("png") or file_path.endswith("ico") or file_path.endswith("db") or file_path.endswith("exe"):
17 | continue
18 | file_paths.append(file_path)
19 |
20 | for dir in dirs:
21 | dir_path = os.path.join(root, dir)
22 | file_paths += list_file_paths(dir_path)
23 |
24 | return file_paths
25 |
26 |
27 | def replace_keys_in_repository(repo_path, json_file_path):
28 | with open(json_file_path, 'r', encoding="utf-8") as json_file:
29 | key_value_pairs = json.load(json_file)
30 |
31 | pairs = []
32 | for key, value in key_value_pairs.items():
33 | pairs.append((key, value))
34 | pairs.sort(key=lambda x: len(x[0]), reverse=True)
35 |
36 | files = list_file_paths(repo_path)
37 | print('Total files: {}'.format(len(files)))
38 | for file_path in files:
39 | replace_keys_in_file(file_path, pairs)
40 |
41 |
42 | def replace_keys_in_file(file_path, pairs):
43 | try:
44 | with open(file_path, 'r', encoding="utf-8") as file:
45 | content = file.read()
46 |
47 | for key, value in pairs:
48 | content = content.replace(key, value)
49 |
50 | with open(file_path, 'w', encoding="utf-8") as file:
51 | file.write(content)
52 | except UnicodeDecodeError:
53 | print('UnicodeDecodeError: {}'.format(file_path))
54 |
55 |
56 | if __name__ == "__main__":
57 | parser = argparse.ArgumentParser(description='Replace keys in repository.')
58 | parser.add_argument('--repository_path', help='Path to repository')
59 | parser.add_argument('--json_file_path', help='Path to JSON file')
60 | args = parser.parse_args()
61 | replace_keys_in_repository(args.repository_path, args.json_file_path)
62 |
--------------------------------------------------------------------------------
/controller/billing.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "one-api/common"
6 | "one-api/model"
7 | )
8 |
9 | func GetSubscription(c *gin.Context) {
10 | var remainQuota int
11 | var usedQuota int
12 | var err error
13 | var token *model.Token
14 | var expiredTime int64
15 | if common.DisplayTokenStatEnabled {
16 | tokenId := c.GetInt("token_id")
17 | token, err = model.GetTokenById(tokenId)
18 | expiredTime = token.ExpiredTime
19 | remainQuota = token.RemainQuota
20 | usedQuota = token.UsedQuota
21 | } else {
22 | userId := c.GetInt("id")
23 | remainQuota, err = model.GetUserQuota(userId)
24 | usedQuota, err = model.GetUserUsedQuota(userId)
25 | }
26 | if expiredTime <= 0 {
27 | expiredTime = 0
28 | }
29 | if err != nil {
30 | openAIError := OpenAIError{
31 | Message: err.Error(),
32 | Type: "one_api_error",
33 | }
34 | c.JSON(200, gin.H{
35 | "error": openAIError,
36 | })
37 | return
38 | }
39 | quota := remainQuota + usedQuota
40 | amount := float64(quota)
41 | if common.DisplayInCurrencyEnabled {
42 | amount /= common.QuotaPerUnit
43 | }
44 | if token != nil && token.UnlimitedQuota {
45 | amount = 100000000
46 | }
47 | subscription := OpenAISubscriptionResponse{
48 | Object: "billing_subscription",
49 | HasPaymentMethod: true,
50 | SoftLimitUSD: amount,
51 | HardLimitUSD: amount,
52 | SystemHardLimitUSD: amount,
53 | AccessUntil: expiredTime,
54 | }
55 | c.JSON(200, subscription)
56 | return
57 | }
58 |
59 | func GetUsage(c *gin.Context) {
60 | var quota int
61 | var err error
62 | var token *model.Token
63 | if common.DisplayTokenStatEnabled {
64 | tokenId := c.GetInt("token_id")
65 | token, err = model.GetTokenById(tokenId)
66 | quota = token.UsedQuota
67 | } else {
68 | userId := c.GetInt("id")
69 | quota, err = model.GetUserUsedQuota(userId)
70 | }
71 | if err != nil {
72 | openAIError := OpenAIError{
73 | Message: err.Error(),
74 | Type: "one_api_error",
75 | }
76 | c.JSON(200, gin.H{
77 | "error": openAIError,
78 | })
79 | return
80 | }
81 | amount := float64(quota)
82 | if common.DisplayInCurrencyEnabled {
83 | amount /= common.QuotaPerUnit
84 | }
85 | usage := OpenAIUsageResponse{
86 | Object: "list",
87 | TotalUsage: amount * 100,
88 | }
89 | c.JSON(200, usage)
90 | return
91 | }
92 |
--------------------------------------------------------------------------------
/web/src/pages/User/AddUser.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Button, Form, Header, Segment } from 'semantic-ui-react';
3 | import { API, showError, showSuccess } from '../../helpers';
4 |
5 | const AddUser = () => {
6 | const originInputs = {
7 | username: '',
8 | display_name: '',
9 | password: '',
10 | };
11 | const [inputs, setInputs] = useState(originInputs);
12 | const { username, display_name, password } = inputs;
13 |
14 | const handleInputChange = (e, { name, value }) => {
15 | setInputs((inputs) => ({ ...inputs, [name]: value }));
16 | };
17 |
18 | const submit = async () => {
19 | if (inputs.username === '' || inputs.password === '') return;
20 | const res = await API.post(`/api/user/`, inputs);
21 | const { success, message } = res.data;
22 | if (success) {
23 | showSuccess('用户账户创建成功!');
24 | setInputs(originInputs);
25 | } else {
26 | showError(message);
27 | }
28 | };
29 |
30 | return (
31 | <>
32 |
33 |
34 |
36 |
45 |
46 |
47 |
55 |
56 |
57 |
67 |
68 |
71 |
72 |
73 | >
74 | );
75 | };
76 |
77 | export default AddUser;
78 |
--------------------------------------------------------------------------------
/controller/option.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "encoding/json"
5 | "net/http"
6 | "one-api/common"
7 | "one-api/model"
8 | "strings"
9 |
10 | "github.com/gin-gonic/gin"
11 | )
12 |
13 | func GetOptions(c *gin.Context) {
14 | var options []*model.Option
15 | common.OptionMapRWMutex.Lock()
16 | for k, v := range common.OptionMap {
17 | if strings.HasSuffix(k, "Token") || strings.HasSuffix(k, "Secret") {
18 | continue
19 | }
20 | options = append(options, &model.Option{
21 | Key: k,
22 | Value: common.Interface2String(v),
23 | })
24 | }
25 | common.OptionMapRWMutex.Unlock()
26 | c.JSON(http.StatusOK, gin.H{
27 | "success": true,
28 | "message": "",
29 | "data": options,
30 | })
31 | return
32 | }
33 |
34 | func UpdateOption(c *gin.Context) {
35 | var option model.Option
36 | err := json.NewDecoder(c.Request.Body).Decode(&option)
37 | if err != nil {
38 | c.JSON(http.StatusBadRequest, gin.H{
39 | "success": false,
40 | "message": "无效的参数",
41 | })
42 | return
43 | }
44 | switch option.Key {
45 | case "GitHubOAuthEnabled":
46 | if option.Value == "true" && common.GitHubClientId == "" {
47 | c.JSON(http.StatusOK, gin.H{
48 | "success": false,
49 | "message": "无法启用 GitHub OAuth,请先填入 GitHub Client ID 以及 GitHub Client Secret!",
50 | })
51 | return
52 | }
53 | case "EmailDomainRestrictionEnabled":
54 | if option.Value == "true" && len(common.EmailDomainWhitelist) == 0 {
55 | c.JSON(http.StatusOK, gin.H{
56 | "success": false,
57 | "message": "无法启用邮箱域名限制,请先填入限制的邮箱域名!",
58 | })
59 | return
60 | }
61 | case "WeChatAuthEnabled":
62 | if option.Value == "true" && common.WeChatServerAddress == "" {
63 | c.JSON(http.StatusOK, gin.H{
64 | "success": false,
65 | "message": "无法启用微信登录,请先填入微信登录相关配置信息!",
66 | })
67 | return
68 | }
69 | case "TurnstileCheckEnabled":
70 | if option.Value == "true" && common.TurnstileSiteKey == "" {
71 | c.JSON(http.StatusOK, gin.H{
72 | "success": false,
73 | "message": "无法启用 Turnstile 校验,请先填入 Turnstile 校验相关配置信息!",
74 | })
75 | return
76 | }
77 | }
78 | err = model.UpdateOption(option.Key, option.Value)
79 | if err != nil {
80 | c.JSON(http.StatusOK, gin.H{
81 | "success": false,
82 | "message": err.Error(),
83 | })
84 | return
85 | }
86 | c.JSON(http.StatusOK, gin.H{
87 | "success": true,
88 | "message": "",
89 | })
90 | return
91 | }
92 |
--------------------------------------------------------------------------------
/model/ability.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "one-api/common"
5 | "strings"
6 | )
7 |
8 | type Ability struct {
9 | Group string `json:"group" gorm:"type:varchar(32);primaryKey;autoIncrement:false"`
10 | Model string `json:"model" gorm:"primaryKey;autoIncrement:false"`
11 | ChannelId int `json:"channel_id" gorm:"primaryKey;autoIncrement:false;index"`
12 | Enabled bool `json:"enabled"`
13 | // 新增排序字段,默认为0
14 | Sort *int `json:"sort" gorm:"default:0"`
15 | }
16 |
17 | func GetRandomSatisfiedChannel(group string, model string) (*Channel, error) {
18 | ability := Ability{}
19 | var err error = nil
20 | if common.UsingSQLite {
21 | // 根据sort和random排序
22 | err = DB.Where("`group` = ? and model = ? and enabled = 1", group, model).Order("sort desc, RANDOM()").Limit(1).First(&ability).Error
23 | } else {
24 | // 根据sort和random排序
25 | err = DB.Where("`group` = ? and model = ? and enabled = 1", group, model).Order("sort desc, RAND()").Limit(1).First(&ability).Error
26 | }
27 | if err != nil {
28 | return nil, err
29 | }
30 | channel := Channel{}
31 | channel.Id = ability.ChannelId
32 | err = DB.First(&channel, "id = ?", ability.ChannelId).Error
33 | return &channel, err
34 | }
35 |
36 | func (channel *Channel) AddAbilities() error {
37 | models_ := strings.Split(channel.Models, ",")
38 | groups_ := strings.Split(channel.Group, ",")
39 | abilities := make([]Ability, 0, len(models_))
40 | for _, model := range models_ {
41 | for _, group := range groups_ {
42 | ability := Ability{
43 | Group: group,
44 | Model: model,
45 | ChannelId: channel.Id,
46 | Enabled: channel.Status == common.ChannelStatusEnabled,
47 | Sort: channel.Sort,
48 | }
49 | abilities = append(abilities, ability)
50 | }
51 | }
52 | return DB.Create(&abilities).Error
53 | }
54 |
55 | func (channel *Channel) DeleteAbilities() error {
56 | return DB.Where("channel_id = ?", channel.Id).Delete(&Ability{}).Error
57 | }
58 |
59 | // UpdateAbilities updates abilities of this channel.
60 | // Make sure the channel is completed before calling this function.
61 | func (channel *Channel) UpdateAbilities() error {
62 | // A quick and dirty way to update abilities
63 | // First delete all abilities of this channel
64 | err := channel.DeleteAbilities()
65 | if err != nil {
66 | return err
67 | }
68 | // Then add new abilities
69 | err = channel.AddAbilities()
70 | if err != nil {
71 | return err
72 | }
73 | return nil
74 | }
75 |
76 | func UpdateAbilityStatus(channelId int, status bool) error {
77 | return DB.Model(&Ability{}).Where("channel_id = ?", channelId).Select("enabled").Update("enabled", status).Error
78 | }
79 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module one-api
2 |
3 | // +heroku goVersion go1.18
4 | go 1.18
5 |
6 | require (
7 | github.com/gin-contrib/cors v1.4.0
8 | github.com/gin-contrib/gzip v0.0.6
9 | github.com/gin-contrib/sessions v0.0.5
10 | github.com/gin-contrib/static v0.0.1
11 | github.com/gin-gonic/gin v1.9.1
12 | github.com/go-playground/validator/v10 v10.14.0
13 | github.com/go-redis/redis/v8 v8.11.5
14 | github.com/golang-jwt/jwt v3.2.2+incompatible
15 | github.com/google/uuid v1.3.0
16 | github.com/gorilla/websocket v1.5.0
17 | github.com/pkoukk/tiktoken-go v0.1.5
18 | golang.org/x/crypto v0.9.0
19 | gorm.io/driver/mysql v1.4.3
20 | gorm.io/driver/sqlite v1.4.3
21 | gorm.io/gorm v1.25.0
22 | )
23 |
24 | require (
25 | github.com/bytedance/sonic v1.9.1 // indirect
26 | github.com/cespare/xxhash/v2 v2.1.2 // indirect
27 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
28 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
29 | github.com/dlclark/regexp2 v1.10.0 // indirect
30 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect
31 | github.com/gin-contrib/sse v0.1.0 // indirect
32 | github.com/go-playground/locales v0.14.1 // indirect
33 | github.com/go-playground/universal-translator v0.18.1 // indirect
34 | github.com/go-sql-driver/mysql v1.6.0 // indirect
35 | github.com/goccy/go-json v0.10.2 // indirect
36 | github.com/gorilla/context v1.1.1 // indirect
37 | github.com/gorilla/securecookie v1.1.1 // indirect
38 | github.com/gorilla/sessions v1.2.1 // indirect
39 | github.com/jackc/pgpassfile v1.0.0 // indirect
40 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
41 | github.com/jackc/pgx/v5 v5.3.1 // indirect
42 | github.com/jinzhu/inflection v1.0.0 // indirect
43 | github.com/jinzhu/now v1.1.5 // indirect
44 | github.com/json-iterator/go v1.1.12 // indirect
45 | github.com/klauspost/cpuid/v2 v2.2.4 // indirect
46 | github.com/leodido/go-urn v1.2.4 // indirect
47 | github.com/mattn/go-isatty v0.0.19 // indirect
48 | github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
49 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
50 | github.com/modern-go/reflect2 v1.0.2 // indirect
51 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect
52 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
53 | github.com/ugorji/go/codec v1.2.11 // indirect
54 | golang.org/x/arch v0.3.0 // indirect
55 | golang.org/x/net v0.10.0 // indirect
56 | golang.org/x/sys v0.8.0 // indirect
57 | golang.org/x/text v0.9.0 // indirect
58 | google.golang.org/protobuf v1.30.0 // indirect
59 | gopkg.in/yaml.v3 v3.0.1 // indirect
60 | gorm.io/driver/postgres v1.5.2 // indirect
61 | )
62 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "embed"
5 | "github.com/gin-contrib/sessions"
6 | "github.com/gin-contrib/sessions/cookie"
7 | "github.com/gin-gonic/gin"
8 | "one-api/common"
9 | "one-api/controller"
10 | "one-api/middleware"
11 | "one-api/model"
12 | "one-api/router"
13 | "os"
14 | "strconv"
15 | )
16 |
17 | //go:embed web/build
18 | var buildFS embed.FS
19 |
20 | //go:embed web/build/index.html
21 | var indexPage []byte
22 |
23 | func main() {
24 | common.SetupGinLog()
25 | common.SysLog("One API " + common.Version + " started")
26 | if os.Getenv("GIN_MODE") != "debug" {
27 | gin.SetMode(gin.ReleaseMode)
28 | }
29 | if common.DebugEnabled {
30 | common.SysLog("running in debug mode")
31 | }
32 | // Initialize SQL Database
33 | err := model.InitDB()
34 | if err != nil {
35 | common.FatalLog("failed to initialize database: " + err.Error())
36 | }
37 | defer func() {
38 | err := model.CloseDB()
39 | if err != nil {
40 | common.FatalLog("failed to close database: " + err.Error())
41 | }
42 | }()
43 |
44 | // Initialize Redis
45 | err = common.InitRedisClient()
46 | if err != nil {
47 | common.FatalLog("failed to initialize Redis: " + err.Error())
48 | }
49 |
50 | // Initialize options
51 | model.InitOptionMap()
52 | if common.RedisEnabled {
53 | model.InitChannelCache()
54 | }
55 | if os.Getenv("SYNC_FREQUENCY") != "" {
56 | frequency, err := strconv.Atoi(os.Getenv("SYNC_FREQUENCY"))
57 | if err != nil {
58 | common.FatalLog("failed to parse SYNC_FREQUENCY: " + err.Error())
59 | }
60 | common.SyncFrequency = frequency
61 | go model.SyncOptions(frequency)
62 | if common.RedisEnabled {
63 | go model.SyncChannelCache(frequency)
64 | }
65 | }
66 | if os.Getenv("CHANNEL_UPDATE_FREQUENCY") != "" {
67 | frequency, err := strconv.Atoi(os.Getenv("CHANNEL_UPDATE_FREQUENCY"))
68 | if err != nil {
69 | common.FatalLog("failed to parse CHANNEL_UPDATE_FREQUENCY: " + err.Error())
70 | }
71 | go controller.AutomaticallyUpdateChannels(frequency)
72 | }
73 | if os.Getenv("CHANNEL_TEST_FREQUENCY") != "" {
74 | frequency, err := strconv.Atoi(os.Getenv("CHANNEL_TEST_FREQUENCY"))
75 | if err != nil {
76 | common.FatalLog("failed to parse CHANNEL_TEST_FREQUENCY: " + err.Error())
77 | }
78 | go controller.AutomaticallyTestChannels(frequency)
79 | }
80 | controller.InitTokenEncoders()
81 |
82 | // Initialize HTTP server
83 | server := gin.Default()
84 | // This will cause SSE not to work!!!
85 | //server.Use(gzip.Gzip(gzip.DefaultCompression))
86 | server.Use(middleware.CORS())
87 |
88 | // Initialize session store
89 | store := cookie.NewStore([]byte(common.SessionSecret))
90 | server.Use(sessions.Sessions("session", store))
91 |
92 | router.SetRouter(server, buildFS, indexPage)
93 | var port = os.Getenv("PORT")
94 | if port == "" {
95 | port = strconv.Itoa(*common.Port)
96 | }
97 | err = server.Run(":" + port)
98 | if err != nil {
99 | common.FatalLog("failed to start HTTP server: " + err.Error())
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/web/src/pages/TopUp/index.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Button, Form, Grid, Header, Segment, Statistic } from 'semantic-ui-react';
3 | import { API, showError, showInfo, showSuccess } from '../../helpers';
4 | import { renderQuota } from '../../helpers/render';
5 |
6 | const TopUp = () => {
7 | const [redemptionCode, setRedemptionCode] = useState('');
8 | const [topUpLink, setTopUpLink] = useState('');
9 | const [userQuota, setUserQuota] = useState(0);
10 | const [isSubmitting, setIsSubmitting] = useState(false);
11 |
12 | const topUp = async () => {
13 | if (redemptionCode === '') {
14 | showInfo('请输入充值码!')
15 | return;
16 | }
17 | setIsSubmitting(true);
18 | try {
19 | const res = await API.post('/api/user/topup', {
20 | key: redemptionCode
21 | });
22 | const { success, message, data } = res.data;
23 | if (success) {
24 | showSuccess('充值成功!');
25 | setUserQuota((quota) => {
26 | return quota + data;
27 | });
28 | setRedemptionCode('');
29 | } else {
30 | showError(message);
31 | }
32 | } catch (err) {
33 | showError('请求失败');
34 | } finally {
35 | setIsSubmitting(false);
36 | }
37 | };
38 |
39 | const openTopUpLink = () => {
40 | if (!topUpLink) {
41 | showError('超级管理员未设置充值链接!');
42 | return;
43 | }
44 | window.open(topUpLink, '_blank');
45 | };
46 |
47 | const getUserQuota = async ()=>{
48 | let res = await API.get(`/api/user/self`);
49 | const {success, message, data} = res.data;
50 | if (success) {
51 | setUserQuota(data.quota);
52 | } else {
53 | showError(message);
54 | }
55 | }
56 |
57 | useEffect(() => {
58 | let status = localStorage.getItem('status');
59 | if (status) {
60 | status = JSON.parse(status);
61 | if (status.top_up_link) {
62 | setTopUpLink(status.top_up_link);
63 | }
64 | }
65 | getUserQuota().then();
66 | }, []);
67 |
68 | return (
69 |
70 |
71 |
72 |
73 | {
79 | setRedemptionCode(e.target.value);
80 | }}
81 | />
82 |
85 |
88 |
89 |
90 |
91 |
92 |
93 | {renderQuota(userQuota)}
94 | 剩余额度
95 |
96 |
97 |
98 |
99 |
100 | );
101 | };
102 |
103 | export default TopUp;
--------------------------------------------------------------------------------
/model/main.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "gorm.io/driver/mysql"
5 | "gorm.io/driver/postgres"
6 | "gorm.io/driver/sqlite"
7 | "gorm.io/gorm"
8 | "one-api/common"
9 | "os"
10 | "strings"
11 | "time"
12 | )
13 |
14 | var DB *gorm.DB
15 |
16 | func createRootAccountIfNeed() error {
17 | var user User
18 | //if user.Status != common.UserStatusEnabled {
19 | if err := DB.First(&user).Error; err != nil {
20 | common.SysLog("no user exists, create a root user for you: username is root, password is 123456")
21 | hashedPassword, err := common.Password2Hash("123456")
22 | if err != nil {
23 | return err
24 | }
25 | rootUser := User{
26 | Username: "root",
27 | Password: hashedPassword,
28 | Role: common.RoleRootUser,
29 | Status: common.UserStatusEnabled,
30 | DisplayName: "Root User",
31 | AccessToken: common.GetUUID(),
32 | Quota: 100000000,
33 | }
34 | DB.Create(&rootUser)
35 | }
36 | return nil
37 | }
38 |
39 | func chooseDB() (*gorm.DB, error) {
40 | if os.Getenv("SQL_DSN") != "" {
41 | dsn := os.Getenv("SQL_DSN")
42 | if strings.HasPrefix(dsn, "postgres://") {
43 | // Use PostgreSQL
44 | common.SysLog("using PostgreSQL as database")
45 | return gorm.Open(postgres.New(postgres.Config{
46 | DSN: dsn,
47 | PreferSimpleProtocol: true, // disables implicit prepared statement usage
48 | }), &gorm.Config{
49 | PrepareStmt: true, // precompile SQL
50 | })
51 | }
52 | // Use MySQL
53 | common.SysLog("using MySQL as database")
54 | return gorm.Open(mysql.Open(dsn), &gorm.Config{
55 | PrepareStmt: true, // precompile SQL
56 | })
57 | }
58 | // Use SQLite
59 | common.SysLog("SQL_DSN not set, using SQLite as database")
60 | common.UsingSQLite = true
61 | return gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{
62 | PrepareStmt: true, // precompile SQL
63 | })
64 | }
65 |
66 | func InitDB() (err error) {
67 | db, err := chooseDB()
68 | if err == nil {
69 | if common.DebugEnabled {
70 | db = db.Debug()
71 | }
72 | DB = db
73 | sqlDB, err := DB.DB()
74 | if err != nil {
75 | return err
76 | }
77 | sqlDB.SetMaxIdleConns(common.GetOrDefault("SQL_MAX_IDLE_CONNS", 100))
78 | sqlDB.SetMaxOpenConns(common.GetOrDefault("SQL_MAX_OPEN_CONNS", 1000))
79 | sqlDB.SetConnMaxLifetime(time.Second * time.Duration(common.GetOrDefault("SQL_MAX_LIFETIME", 60)))
80 |
81 | if !common.IsMasterNode {
82 | return nil
83 | }
84 | err = db.AutoMigrate(&Channel{})
85 | if err != nil {
86 | return err
87 | }
88 | err = db.AutoMigrate(&Token{})
89 | if err != nil {
90 | return err
91 | }
92 | err = db.AutoMigrate(&User{})
93 | if err != nil {
94 | return err
95 | }
96 | err = db.AutoMigrate(&Option{})
97 | if err != nil {
98 | return err
99 | }
100 | err = db.AutoMigrate(&Redemption{})
101 | if err != nil {
102 | return err
103 | }
104 | err = db.AutoMigrate(&Ability{})
105 | if err != nil {
106 | return err
107 | }
108 | err = db.AutoMigrate(&Log{})
109 | if err != nil {
110 | return err
111 | }
112 | common.SysLog("database migrated")
113 | err = createRootAccountIfNeed()
114 | return err
115 | } else {
116 | common.FatalLog(err)
117 | }
118 | return err
119 | }
120 |
121 | func CloseDB() error {
122 | sqlDB, err := DB.DB()
123 | if err != nil {
124 | return err
125 | }
126 | err = sqlDB.Close()
127 | return err
128 | }
129 |
--------------------------------------------------------------------------------
/web/src/components/PasswordResetForm.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
3 | import { API, showError, showInfo, showSuccess } from '../helpers';
4 | import Turnstile from 'react-turnstile';
5 |
6 | const PasswordResetForm = () => {
7 | const [inputs, setInputs] = useState({
8 | email: ''
9 | });
10 | const { email } = inputs;
11 |
12 | const [loading, setLoading] = useState(false);
13 | const [turnstileEnabled, setTurnstileEnabled] = useState(false);
14 | const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
15 | const [turnstileToken, setTurnstileToken] = useState('');
16 | const [disableButton, setDisableButton] = useState(false);
17 | const [countdown, setCountdown] = useState(30);
18 |
19 | useEffect(() => {
20 | let countdownInterval = null;
21 | if (disableButton && countdown > 0) {
22 | countdownInterval = setInterval(() => {
23 | setCountdown(countdown - 1);
24 | }, 1000);
25 | } else if (countdown === 0) {
26 | setDisableButton(false);
27 | setCountdown(30);
28 | }
29 | return () => clearInterval(countdownInterval);
30 | }, [disableButton, countdown]);
31 |
32 | function handleChange(e) {
33 | const { name, value } = e.target;
34 | setInputs(inputs => ({ ...inputs, [name]: value }));
35 | }
36 |
37 | async function handleSubmit(e) {
38 | setDisableButton(true);
39 | if (!email) return;
40 | if (turnstileEnabled && turnstileToken === '') {
41 | showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
42 | return;
43 | }
44 | setLoading(true);
45 | const res = await API.get(
46 | `/api/reset_password?email=${email}&turnstile=${turnstileToken}`
47 | );
48 | const { success, message } = res.data;
49 | if (success) {
50 | showSuccess('重置邮件发送成功,请检查邮箱!');
51 | setInputs({ ...inputs, email: '' });
52 | } else {
53 | showError(message);
54 | }
55 | setLoading(false);
56 | }
57 |
58 | return (
59 |
60 |
61 |
64 |
97 |
98 |
99 | );
100 | };
101 |
102 | export default PasswordResetForm;
103 |
--------------------------------------------------------------------------------
/middleware/rate-limit.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "github.com/gin-gonic/gin"
7 | "net/http"
8 | "one-api/common"
9 | "time"
10 | )
11 |
12 | var timeFormat = "2006-01-02T15:04:05.000Z"
13 |
14 | var inMemoryRateLimiter common.InMemoryRateLimiter
15 |
16 | func redisRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark string) {
17 | ctx := context.Background()
18 | rdb := common.RDB
19 | key := "rateLimit:" + mark + c.ClientIP()
20 | listLength, err := rdb.LLen(ctx, key).Result()
21 | if err != nil {
22 | fmt.Println(err.Error())
23 | c.Status(http.StatusInternalServerError)
24 | c.Abort()
25 | return
26 | }
27 | if listLength < int64(maxRequestNum) {
28 | rdb.LPush(ctx, key, time.Now().Format(timeFormat))
29 | rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
30 | } else {
31 | oldTimeStr, _ := rdb.LIndex(ctx, key, -1).Result()
32 | oldTime, err := time.Parse(timeFormat, oldTimeStr)
33 | if err != nil {
34 | fmt.Println(err)
35 | c.Status(http.StatusInternalServerError)
36 | c.Abort()
37 | return
38 | }
39 | nowTimeStr := time.Now().Format(timeFormat)
40 | nowTime, err := time.Parse(timeFormat, nowTimeStr)
41 | if err != nil {
42 | fmt.Println(err)
43 | c.Status(http.StatusInternalServerError)
44 | c.Abort()
45 | return
46 | }
47 | // time.Since will return negative number!
48 | // See: https://stackoverflow.com/questions/50970900/why-is-time-since-returning-negative-durations-on-windows
49 | if int64(nowTime.Sub(oldTime).Seconds()) < duration {
50 | rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
51 | c.Status(http.StatusTooManyRequests)
52 | c.Abort()
53 | return
54 | } else {
55 | rdb.LPush(ctx, key, time.Now().Format(timeFormat))
56 | rdb.LTrim(ctx, key, 0, int64(maxRequestNum-1))
57 | rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
58 | }
59 | }
60 | }
61 |
62 | func memoryRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark string) {
63 | key := mark + c.ClientIP()
64 | if !inMemoryRateLimiter.Request(key, maxRequestNum, duration) {
65 | c.Status(http.StatusTooManyRequests)
66 | c.Abort()
67 | return
68 | }
69 | }
70 |
71 | func rateLimitFactory(maxRequestNum int, duration int64, mark string) func(c *gin.Context) {
72 | if common.RedisEnabled {
73 | return func(c *gin.Context) {
74 | redisRateLimiter(c, maxRequestNum, duration, mark)
75 | }
76 | } else {
77 | // It's safe to call multi times.
78 | inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration)
79 | return func(c *gin.Context) {
80 | memoryRateLimiter(c, maxRequestNum, duration, mark)
81 | }
82 | }
83 | }
84 |
85 | func GlobalWebRateLimit() func(c *gin.Context) {
86 | return rateLimitFactory(common.GlobalWebRateLimitNum, common.GlobalWebRateLimitDuration, "GW")
87 | }
88 |
89 | func GlobalAPIRateLimit() func(c *gin.Context) {
90 | return rateLimitFactory(common.GlobalApiRateLimitNum, common.GlobalApiRateLimitDuration, "GA")
91 | }
92 |
93 | func CriticalRateLimit() func(c *gin.Context) {
94 | return rateLimitFactory(common.CriticalRateLimitNum, common.CriticalRateLimitDuration, "CT")
95 | }
96 |
97 | func DownloadRateLimit() func(c *gin.Context) {
98 | return rateLimitFactory(common.DownloadRateLimitNum, common.DownloadRateLimitDuration, "DW")
99 | }
100 |
101 | func UploadRateLimit() func(c *gin.Context) {
102 | return rateLimitFactory(common.UploadRateLimitNum, common.UploadRateLimitDuration, "UP")
103 | }
104 |
--------------------------------------------------------------------------------
/middleware/auth.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "github.com/gin-contrib/sessions"
5 | "github.com/gin-gonic/gin"
6 | "net/http"
7 | "one-api/common"
8 | "one-api/model"
9 | "strings"
10 | )
11 |
12 | func authHelper(c *gin.Context, minRole int) {
13 | session := sessions.Default(c)
14 | username := session.Get("username")
15 | role := session.Get("role")
16 | id := session.Get("id")
17 | status := session.Get("status")
18 | if username == nil {
19 | // Check access token
20 | accessToken := c.Request.Header.Get("Authorization")
21 | if accessToken == "" {
22 | c.JSON(http.StatusUnauthorized, gin.H{
23 | "success": false,
24 | "message": "无权进行此操作,未登录且未提供 access token",
25 | })
26 | c.Abort()
27 | return
28 | }
29 | user := model.ValidateAccessToken(accessToken)
30 | if user != nil && user.Username != "" {
31 | // Token is valid
32 | username = user.Username
33 | role = user.Role
34 | id = user.Id
35 | status = user.Status
36 | } else {
37 | c.JSON(http.StatusOK, gin.H{
38 | "success": false,
39 | "message": "无权进行此操作,access token 无效",
40 | })
41 | c.Abort()
42 | return
43 | }
44 | }
45 | if status.(int) == common.UserStatusDisabled {
46 | c.JSON(http.StatusOK, gin.H{
47 | "success": false,
48 | "message": "用户已被封禁",
49 | })
50 | c.Abort()
51 | return
52 | }
53 | if role.(int) < minRole {
54 | c.JSON(http.StatusOK, gin.H{
55 | "success": false,
56 | "message": "无权进行此操作,权限不足",
57 | })
58 | c.Abort()
59 | return
60 | }
61 | c.Set("username", username)
62 | c.Set("role", role)
63 | c.Set("id", id)
64 | c.Next()
65 | }
66 |
67 | func UserAuth() func(c *gin.Context) {
68 | return func(c *gin.Context) {
69 | authHelper(c, common.RoleCommonUser)
70 | }
71 | }
72 |
73 | func AdminAuth() func(c *gin.Context) {
74 | return func(c *gin.Context) {
75 | authHelper(c, common.RoleAdminUser)
76 | }
77 | }
78 |
79 | func RootAuth() func(c *gin.Context) {
80 | return func(c *gin.Context) {
81 | authHelper(c, common.RoleRootUser)
82 | }
83 | }
84 |
85 | func TokenAuth() func(c *gin.Context) {
86 | return func(c *gin.Context) {
87 | key := c.Request.Header.Get("Authorization")
88 | key = strings.TrimPrefix(key, "Bearer ")
89 | key = strings.TrimPrefix(key, "sk-")
90 | parts := strings.Split(key, "-")
91 | key = parts[0]
92 | token, err := model.ValidateUserToken(key)
93 | if err != nil {
94 | c.JSON(http.StatusUnauthorized, gin.H{
95 | "error": gin.H{
96 | "message": err.Error(),
97 | "type": "one_api_error",
98 | },
99 | })
100 | c.Abort()
101 | return
102 | }
103 | if !model.CacheIsUserEnabled(token.UserId) {
104 | c.JSON(http.StatusForbidden, gin.H{
105 | "error": gin.H{
106 | "message": "用户已被封禁",
107 | "type": "one_api_error",
108 | },
109 | })
110 | c.Abort()
111 | return
112 | }
113 | c.Set("id", token.UserId)
114 | c.Set("token_id", token.Id)
115 | c.Set("token_name", token.Name)
116 | requestURL := c.Request.URL.String()
117 | consumeQuota := true
118 | if strings.HasPrefix(requestURL, "/v1/models") {
119 | consumeQuota = false
120 | }
121 | c.Set("consume_quota", consumeQuota)
122 | if len(parts) > 1 {
123 | if model.IsAdmin(token.UserId) {
124 | c.Set("channelId", parts[1])
125 | } else {
126 | c.JSON(http.StatusForbidden, gin.H{
127 | "error": gin.H{
128 | "message": "普通用户不支持指定渠道",
129 | "type": "one_api_error",
130 | },
131 | })
132 | c.Abort()
133 | return
134 | }
135 | }
136 | c.Next()
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/controller/channel.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "net/http"
6 | "one-api/common"
7 | "one-api/model"
8 | "strconv"
9 | "strings"
10 | )
11 |
12 | func GetAllChannels(c *gin.Context) {
13 | p, _ := strconv.Atoi(c.Query("p"))
14 | if p < 0 {
15 | p = 0
16 | }
17 | channels, err := model.GetAllChannels(p*common.ItemsPerPage, common.ItemsPerPage, false)
18 | if err != nil {
19 | c.JSON(http.StatusOK, gin.H{
20 | "success": false,
21 | "message": err.Error(),
22 | })
23 | return
24 | }
25 | c.JSON(http.StatusOK, gin.H{
26 | "success": true,
27 | "message": "",
28 | "data": channels,
29 | })
30 | return
31 | }
32 |
33 | func SearchChannels(c *gin.Context) {
34 | keyword := c.Query("keyword")
35 | channels, err := model.SearchChannels(keyword)
36 | if err != nil {
37 | c.JSON(http.StatusOK, gin.H{
38 | "success": false,
39 | "message": err.Error(),
40 | })
41 | return
42 | }
43 | c.JSON(http.StatusOK, gin.H{
44 | "success": true,
45 | "message": "",
46 | "data": channels,
47 | })
48 | return
49 | }
50 |
51 | func GetChannel(c *gin.Context) {
52 | id, err := strconv.Atoi(c.Param("id"))
53 | if err != nil {
54 | c.JSON(http.StatusOK, gin.H{
55 | "success": false,
56 | "message": err.Error(),
57 | })
58 | return
59 | }
60 | channel, err := model.GetChannelById(id, false)
61 | if err != nil {
62 | c.JSON(http.StatusOK, gin.H{
63 | "success": false,
64 | "message": err.Error(),
65 | })
66 | return
67 | }
68 | c.JSON(http.StatusOK, gin.H{
69 | "success": true,
70 | "message": "",
71 | "data": channel,
72 | })
73 | return
74 | }
75 |
76 | func AddChannel(c *gin.Context) {
77 | channel := model.Channel{}
78 | err := c.ShouldBindJSON(&channel)
79 | if err != nil {
80 | c.JSON(http.StatusOK, gin.H{
81 | "success": false,
82 | "message": err.Error(),
83 | })
84 | return
85 | }
86 | channel.CreatedTime = common.GetTimestamp()
87 | keys := strings.Split(channel.Key, "\n")
88 | channels := make([]model.Channel, 0, len(keys))
89 | for _, key := range keys {
90 | if key == "" {
91 | continue
92 | }
93 | localChannel := channel
94 | localChannel.Key = key
95 | channels = append(channels, localChannel)
96 | }
97 | err = model.BatchInsertChannels(channels)
98 | if err != nil {
99 | c.JSON(http.StatusOK, gin.H{
100 | "success": false,
101 | "message": err.Error(),
102 | })
103 | return
104 | }
105 | c.JSON(http.StatusOK, gin.H{
106 | "success": true,
107 | "message": "",
108 | })
109 | return
110 | }
111 |
112 | func DeleteChannel(c *gin.Context) {
113 | id, _ := strconv.Atoi(c.Param("id"))
114 | channel := model.Channel{Id: id}
115 | err := channel.Delete()
116 | if err != nil {
117 | c.JSON(http.StatusOK, gin.H{
118 | "success": false,
119 | "message": err.Error(),
120 | })
121 | return
122 | }
123 | c.JSON(http.StatusOK, gin.H{
124 | "success": true,
125 | "message": "",
126 | })
127 | return
128 | }
129 |
130 | func UpdateChannel(c *gin.Context) {
131 | channel := model.Channel{}
132 | err := c.ShouldBindJSON(&channel)
133 | if err != nil {
134 | c.JSON(http.StatusOK, gin.H{
135 | "success": false,
136 | "message": err.Error(),
137 | })
138 | return
139 | }
140 | err = channel.Update()
141 | if err != nil {
142 | c.JSON(http.StatusOK, gin.H{
143 | "success": false,
144 | "message": err.Error(),
145 | })
146 | return
147 | }
148 | c.JSON(http.StatusOK, gin.H{
149 | "success": true,
150 | "message": "",
151 | "data": channel,
152 | })
153 | return
154 | }
155 |
--------------------------------------------------------------------------------
/web/src/components/PasswordResetConfirm.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
3 | import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers';
4 | import { useSearchParams } from 'react-router-dom';
5 |
6 | const PasswordResetConfirm = () => {
7 | const [inputs, setInputs] = useState({
8 | email: '',
9 | token: '',
10 | });
11 | const { email, token } = inputs;
12 |
13 | const [loading, setLoading] = useState(false);
14 |
15 | const [disableButton, setDisableButton] = useState(false);
16 | const [countdown, setCountdown] = useState(30);
17 |
18 | const [newPassword, setNewPassword] = useState('');
19 |
20 | const [searchParams, setSearchParams] = useSearchParams();
21 | useEffect(() => {
22 | let token = searchParams.get('token');
23 | let email = searchParams.get('email');
24 | setInputs({
25 | token,
26 | email,
27 | });
28 | }, []);
29 |
30 | useEffect(() => {
31 | let countdownInterval = null;
32 | if (disableButton && countdown > 0) {
33 | countdownInterval = setInterval(() => {
34 | setCountdown(countdown - 1);
35 | }, 1000);
36 | } else if (countdown === 0) {
37 | setDisableButton(false);
38 | setCountdown(30);
39 | }
40 | return () => clearInterval(countdownInterval);
41 | }, [disableButton, countdown]);
42 |
43 | async function handleSubmit(e) {
44 | setDisableButton(true);
45 | if (!email) return;
46 | setLoading(true);
47 | const res = await API.post(`/api/user/reset`, {
48 | email,
49 | token,
50 | });
51 | const { success, message } = res.data;
52 | if (success) {
53 | let password = res.data.data;
54 | setNewPassword(password);
55 | await copy(password);
56 | showNotice(`新密码已复制到剪贴板:${password}`);
57 | } else {
58 | showError(message);
59 | }
60 | setLoading(false);
61 | }
62 |
63 | return (
64 |
65 |
66 |
69 |
108 |
109 |
110 | );
111 | };
112 |
113 | export default PasswordResetConfirm;
114 |
--------------------------------------------------------------------------------
/model/redemption.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "gorm.io/gorm"
7 | "one-api/common"
8 | )
9 |
10 | type Redemption struct {
11 | Id int `json:"id"`
12 | UserId int `json:"user_id"`
13 | Key string `json:"key" gorm:"type:char(32);uniqueIndex"`
14 | Status int `json:"status" gorm:"default:1"`
15 | Name string `json:"name" gorm:"index"`
16 | Quota int `json:"quota" gorm:"default:100"`
17 | CreatedTime int64 `json:"created_time" gorm:"bigint"`
18 | RedeemedTime int64 `json:"redeemed_time" gorm:"bigint"`
19 | Count int `json:"count" gorm:"-:all"` // only for api request
20 | }
21 |
22 | func GetAllRedemptions(startIdx int, num int) ([]*Redemption, error) {
23 | var redemptions []*Redemption
24 | var err error
25 | err = DB.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error
26 | return redemptions, err
27 | }
28 |
29 | func SearchRedemptions(keyword string) (redemptions []*Redemption, err error) {
30 | err = DB.Where("id = ? or name LIKE ?", keyword, keyword+"%").Find(&redemptions).Error
31 | return redemptions, err
32 | }
33 |
34 | func GetRedemptionById(id int) (*Redemption, error) {
35 | if id == 0 {
36 | return nil, errors.New("id 为空!")
37 | }
38 | redemption := Redemption{Id: id}
39 | var err error = nil
40 | err = DB.First(&redemption, "id = ?", id).Error
41 | return &redemption, err
42 | }
43 |
44 | func Redeem(key string, userId int) (quota int, err error) {
45 | if key == "" {
46 | return 0, errors.New("未提供兑换码")
47 | }
48 | if userId == 0 {
49 | return 0, errors.New("无效的 user id")
50 | }
51 | redemption := &Redemption{}
52 |
53 | err = DB.Transaction(func(tx *gorm.DB) error {
54 | err := tx.Set("gorm:query_option", "FOR UPDATE").Where("`key` = ?", key).First(redemption).Error
55 | if err != nil {
56 | return errors.New("无效的兑换码")
57 | }
58 | if redemption.Status != common.RedemptionCodeStatusEnabled {
59 | return errors.New("该兑换码已被使用")
60 | }
61 | err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
62 | if err != nil {
63 | return err
64 | }
65 | redemption.RedeemedTime = common.GetTimestamp()
66 | redemption.Status = common.RedemptionCodeStatusUsed
67 | err = tx.Save(redemption).Error
68 | return err
69 | })
70 | if err != nil {
71 | return 0, errors.New("兑换失败," + err.Error())
72 | }
73 | RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s", common.LogQuota(redemption.Quota)))
74 | return redemption.Quota, nil
75 | }
76 |
77 | func (redemption *Redemption) Insert() error {
78 | var err error
79 | err = DB.Create(redemption).Error
80 | return err
81 | }
82 |
83 | func (redemption *Redemption) SelectUpdate() error {
84 | // This can update zero values
85 | return DB.Model(redemption).Select("redeemed_time", "status").Updates(redemption).Error
86 | }
87 |
88 | // Update Make sure your token's fields is completed, because this will update non-zero values
89 | func (redemption *Redemption) Update() error {
90 | var err error
91 | err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time").Updates(redemption).Error
92 | return err
93 | }
94 |
95 | func (redemption *Redemption) Delete() error {
96 | var err error
97 | err = DB.Delete(redemption).Error
98 | return err
99 | }
100 |
101 | func DeleteRedemptionById(id int) (err error) {
102 | if id == 0 {
103 | return errors.New("id 为空!")
104 | }
105 | redemption := Redemption{Id: id}
106 | err = DB.Where(redemption).First(&redemption).Error
107 | if err != nil {
108 | return err
109 | }
110 | return redemption.Delete()
111 | }
112 |
--------------------------------------------------------------------------------
/common/model-ratio.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "encoding/json"
5 | "strings"
6 | )
7 |
8 | // ModelRatio
9 | // https://platform.openai.com/docs/models/model-endpoint-compatibility
10 | // https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Blfmc9dlf
11 | // https://openai.com/pricing
12 | // TODO: when a new api is enabled, check the pricing here
13 | // 1 === $0.002 / 1K tokens
14 | // 1 === ¥0.014 / 1k tokens
15 | var ModelRatio = map[string]float64{
16 | "gpt-4": 15,
17 | "gpt-4-0314": 15,
18 | "gpt-4-0613": 15,
19 | "gpt-4-32k": 30,
20 | "gpt-4-32k-0314": 30,
21 | "gpt-4-32k-0613": 30,
22 | "gpt-3.5-turbo": 0.75, // $0.0015 / 1K tokens
23 | "gpt-3.5-turbo-0301": 0.75,
24 | "gpt-3.5-turbo-0613": 0.75,
25 | "gpt-3.5-turbo-16k": 1.5, // $0.003 / 1K tokens
26 | "gpt-3.5-turbo-16k-0613": 1.5,
27 | "text-ada-001": 0.2,
28 | "text-babbage-001": 0.25,
29 | "text-curie-001": 1,
30 | "text-davinci-002": 10,
31 | "text-davinci-003": 10,
32 | "text-davinci-edit-001": 10,
33 | "code-davinci-edit-001": 10,
34 | "whisper-1": 15, // $0.006 / minute -> $0.006 / 150 words -> $0.006 / 200 tokens -> $0.03 / 1k tokens
35 | "davinci": 10,
36 | "curie": 10,
37 | "babbage": 10,
38 | "ada": 10,
39 | "text-embedding-ada-002": 0.05,
40 | "text-search-ada-doc-001": 10,
41 | "text-moderation-stable": 0.1,
42 | "text-moderation-latest": 0.1,
43 | "dall-e": 8,
44 | "claude-instant-1": 0.815, // $1.63 / 1M tokens
45 | "claude-2": 5.51, // $11.02 / 1M tokens
46 | "ERNIE-Bot": 0.8572, // ¥0.012 / 1k tokens
47 | "ERNIE-Bot-turbo": 0.5715, // ¥0.008 / 1k tokens
48 | "Embedding-V1": 0.1429, // ¥0.002 / 1k tokens
49 | "PaLM-2": 1,
50 | "chatglm_pro": 0.7143, // ¥0.01 / 1k tokens
51 | "chatglm_std": 0.3572, // ¥0.005 / 1k tokens
52 | "chatglm_lite": 0.1429, // ¥0.002 / 1k tokens
53 | "qwen-v1": 0.8572, // TBD: https://help.aliyun.com/document_detail/2399482.html?spm=a2c4g.2399482.0.0.1ad347feilAgag
54 | "qwen-plus-v1": 0.5715, // Same as above
55 | "SparkDesk": 0.8572, // TBD
56 | "360GPT_S2_V9": 0.8572, // ¥0.012 / 1k tokens
57 | "embedding-bert-512-v1": 0.0715, // ¥0.001 / 1k tokens
58 | "embedding_s1_v1": 0.0715, // ¥0.001 / 1k tokens
59 | "semantic_similarity_s1_v1": 0.0715, // ¥0.001 / 1k tokens
60 | "360GPT_S2_V9.4": 0.8572, // ¥0.012 / 1k tokens
61 | }
62 |
63 | func ModelRatio2JSONString() string {
64 | jsonBytes, err := json.Marshal(ModelRatio)
65 | if err != nil {
66 | SysError("error marshalling model ratio: " + err.Error())
67 | }
68 | return string(jsonBytes)
69 | }
70 |
71 | func UpdateModelRatioByJSONString(jsonStr string) error {
72 | ModelRatio = make(map[string]float64)
73 | return json.Unmarshal([]byte(jsonStr), &ModelRatio)
74 | }
75 |
76 | func GetModelRatio(name string) float64 {
77 | ratio, ok := ModelRatio[name]
78 | if !ok {
79 | SysError("model ratio not found: " + name)
80 | return 30
81 | }
82 | return ratio
83 | }
84 |
85 | func GetCompletionRatio(name string) float64 {
86 | if strings.HasPrefix(name, "gpt-3.5") {
87 | return 1.333333
88 | }
89 | if strings.HasPrefix(name, "gpt-4") {
90 | return 2
91 | }
92 | if strings.HasPrefix(name, "claude-instant-1") {
93 | return 3.38
94 | }
95 | if strings.HasPrefix(name, "claude-2") {
96 | return 2.965517
97 | }
98 | return 1
99 | }
100 |
--------------------------------------------------------------------------------
/controller/wechat.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "github.com/gin-gonic/gin"
8 | "net/http"
9 | "one-api/common"
10 | "one-api/model"
11 | "strconv"
12 | "time"
13 | )
14 |
15 | type wechatLoginResponse struct {
16 | Success bool `json:"success"`
17 | Message string `json:"message"`
18 | Data string `json:"data"`
19 | }
20 |
21 | func getWeChatIdByCode(code string) (string, error) {
22 | if code == "" {
23 | return "", errors.New("无效的参数")
24 | }
25 | req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/wechat/user?code=%s", common.WeChatServerAddress, code), nil)
26 | if err != nil {
27 | return "", err
28 | }
29 | req.Header.Set("Authorization", common.WeChatServerToken)
30 | client := http.Client{
31 | Timeout: 5 * time.Second,
32 | }
33 | httpResponse, err := client.Do(req)
34 | if err != nil {
35 | return "", err
36 | }
37 | defer httpResponse.Body.Close()
38 | var res wechatLoginResponse
39 | err = json.NewDecoder(httpResponse.Body).Decode(&res)
40 | if err != nil {
41 | return "", err
42 | }
43 | if !res.Success {
44 | return "", errors.New(res.Message)
45 | }
46 | if res.Data == "" {
47 | return "", errors.New("验证码错误或已过期")
48 | }
49 | return res.Data, nil
50 | }
51 |
52 | func WeChatAuth(c *gin.Context) {
53 | if !common.WeChatAuthEnabled {
54 | c.JSON(http.StatusOK, gin.H{
55 | "message": "管理员未开启通过微信登录以及注册",
56 | "success": false,
57 | })
58 | return
59 | }
60 | code := c.Query("code")
61 | wechatId, err := getWeChatIdByCode(code)
62 | if err != nil {
63 | c.JSON(http.StatusOK, gin.H{
64 | "message": err.Error(),
65 | "success": false,
66 | })
67 | return
68 | }
69 | user := model.User{
70 | WeChatId: wechatId,
71 | }
72 | if model.IsWeChatIdAlreadyTaken(wechatId) {
73 | err := user.FillUserByWeChatId()
74 | if err != nil {
75 | c.JSON(http.StatusOK, gin.H{
76 | "success": false,
77 | "message": err.Error(),
78 | })
79 | return
80 | }
81 | } else {
82 | if common.RegisterEnabled {
83 | user.Username = "wechat_" + strconv.Itoa(model.GetMaxUserId()+1)
84 | user.DisplayName = "WeChat User"
85 | user.Role = common.RoleCommonUser
86 | user.Status = common.UserStatusEnabled
87 |
88 | if err := user.Insert(0); err != nil {
89 | c.JSON(http.StatusOK, gin.H{
90 | "success": false,
91 | "message": err.Error(),
92 | })
93 | return
94 | }
95 | } else {
96 | c.JSON(http.StatusOK, gin.H{
97 | "success": false,
98 | "message": "管理员关闭了新用户注册",
99 | })
100 | return
101 | }
102 | }
103 |
104 | if user.Status != common.UserStatusEnabled {
105 | c.JSON(http.StatusOK, gin.H{
106 | "message": "用户已被封禁",
107 | "success": false,
108 | })
109 | return
110 | }
111 | setupLogin(&user, c)
112 | }
113 |
114 | func WeChatBind(c *gin.Context) {
115 | if !common.WeChatAuthEnabled {
116 | c.JSON(http.StatusOK, gin.H{
117 | "message": "管理员未开启通过微信登录以及注册",
118 | "success": false,
119 | })
120 | return
121 | }
122 | code := c.Query("code")
123 | wechatId, err := getWeChatIdByCode(code)
124 | if err != nil {
125 | c.JSON(http.StatusOK, gin.H{
126 | "message": err.Error(),
127 | "success": false,
128 | })
129 | return
130 | }
131 | if model.IsWeChatIdAlreadyTaken(wechatId) {
132 | c.JSON(http.StatusOK, gin.H{
133 | "success": false,
134 | "message": "该微信账号已被绑定",
135 | })
136 | return
137 | }
138 | id := c.GetInt("id")
139 | user := model.User{
140 | Id: id,
141 | }
142 | err = user.FillUserById()
143 | if err != nil {
144 | c.JSON(http.StatusOK, gin.H{
145 | "success": false,
146 | "message": err.Error(),
147 | })
148 | return
149 | }
150 | user.WeChatId = wechatId
151 | err = user.Update(false)
152 | if err != nil {
153 | c.JSON(http.StatusOK, gin.H{
154 | "success": false,
155 | "message": err.Error(),
156 | })
157 | return
158 | }
159 | c.JSON(http.StatusOK, gin.H{
160 | "success": true,
161 | "message": "",
162 | })
163 | return
164 | }
165 |
--------------------------------------------------------------------------------
/middleware/distributor.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "one-api/common"
7 | "one-api/model"
8 | "strconv"
9 | "strings"
10 |
11 | "github.com/gin-gonic/gin"
12 | )
13 |
14 | type ModelRequest struct {
15 | Model string `json:"model"`
16 | }
17 |
18 | func Distribute() func(c *gin.Context) {
19 | return func(c *gin.Context) {
20 | userId := c.GetInt("id")
21 | userGroup, _ := model.CacheGetUserGroup(userId)
22 | c.Set("group", userGroup)
23 | var channel *model.Channel
24 | channelId, ok := c.Get("channelId")
25 | if ok {
26 | id, err := strconv.Atoi(channelId.(string))
27 | if err != nil {
28 | c.JSON(http.StatusBadRequest, gin.H{
29 | "error": gin.H{
30 | "message": "无效的渠道 ID",
31 | "type": "one_api_error",
32 | },
33 | })
34 | c.Abort()
35 | return
36 | }
37 | channel, err = model.GetChannelById(id, true)
38 | if err != nil {
39 | c.JSON(http.StatusBadRequest, gin.H{
40 | "error": gin.H{
41 | "message": "无效的渠道 ID",
42 | "type": "one_api_error",
43 | },
44 | })
45 | c.Abort()
46 | return
47 | }
48 | if channel.Status != common.ChannelStatusEnabled {
49 | c.JSON(http.StatusForbidden, gin.H{
50 | "error": gin.H{
51 | "message": "该渠道已被禁用",
52 | "type": "one_api_error",
53 | },
54 | })
55 | c.Abort()
56 | return
57 | }
58 | } else {
59 | // Select a channel for the user
60 | var modelRequest ModelRequest
61 | var err error
62 | if !strings.HasPrefix(c.Request.URL.Path, "/v1/audio") {
63 | err = common.UnmarshalBodyReusable(c, &modelRequest)
64 | }
65 | if err != nil {
66 | c.JSON(http.StatusBadRequest, gin.H{
67 | "error": gin.H{
68 | "message": "无效的请求",
69 | "type": "one_api_error",
70 | },
71 | })
72 | c.Abort()
73 | return
74 | }
75 | if strings.HasPrefix(c.Request.URL.Path, "/v1/moderations") {
76 | if modelRequest.Model == "" {
77 | modelRequest.Model = "text-moderation-stable"
78 | }
79 | }
80 | if strings.HasSuffix(c.Request.URL.Path, "embeddings") {
81 | if modelRequest.Model == "" {
82 | modelRequest.Model = c.Param("model")
83 | }
84 | }
85 | if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") {
86 | if modelRequest.Model == "" {
87 | modelRequest.Model = "dall-e"
88 | }
89 | }
90 | if strings.HasPrefix(c.Request.URL.Path, "/v1/audio") {
91 | if modelRequest.Model == "" {
92 | modelRequest.Model = "whisper-1"
93 | }
94 | }
95 | channel, err = model.CacheGetRandomSatisfiedChannel(userGroup, modelRequest.Model)
96 | if err != nil {
97 | message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", userGroup, modelRequest.Model)
98 | if channel != nil {
99 | common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id))
100 | message = "数据库一致性已被破坏,请联系管理员"
101 | }
102 | c.JSON(http.StatusServiceUnavailable, gin.H{
103 | "error": gin.H{
104 | "message": message,
105 | "type": "one_api_error",
106 | },
107 | })
108 | c.Abort()
109 | return
110 | }
111 | }
112 | c.Set("channel", channel.Type)
113 | c.Set("channel_id", channel.Id)
114 | c.Set("channel_name", channel.Name)
115 | c.Set("model_mapping", channel.ModelMapping)
116 | if channel.RetryInterval != nil {
117 | c.Set("retryInterval", *channel.RetryInterval)
118 | }
119 | if channel.OverFrequencyAutoDisable != nil {
120 | c.Set("overFrequencyAutoDisable", *channel.OverFrequencyAutoDisable)
121 | }
122 | c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key))
123 | if channel.OpenAIOrganization != nil {
124 | c.Request.Header.Set("OpenAI-Organization", *channel.OpenAIOrganization)
125 | }
126 | c.Set("base_url", channel.BaseURL)
127 | switch channel.Type {
128 | case common.ChannelTypeAzure:
129 | c.Set("api_version", channel.Other)
130 | case common.ChannelTypeXunfei:
131 | c.Set("api_version", channel.Other)
132 | case common.ChannelTypeAIProxyLibrary:
133 | c.Set("library_id", channel.Other)
134 | }
135 | c.Next()
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/controller/log.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "one-api/common"
6 | "one-api/model"
7 | "strconv"
8 | )
9 |
10 | func GetAllLogs(c *gin.Context) {
11 | p, _ := strconv.Atoi(c.Query("p"))
12 | if p < 0 {
13 | p = 0
14 | }
15 | logType, _ := strconv.Atoi(c.Query("type"))
16 | startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
17 | endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
18 | username := c.Query("username")
19 | tokenName := c.Query("token_name")
20 | modelName := c.Query("model_name")
21 | channelName := c.Query("channel_name")
22 | logs, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channelName, p*common.ItemsPerPage, common.ItemsPerPage)
23 | if err != nil {
24 | c.JSON(200, gin.H{
25 | "success": false,
26 | "message": err.Error(),
27 | })
28 | return
29 | }
30 | c.JSON(200, gin.H{
31 | "success": true,
32 | "message": "",
33 | "data": logs,
34 | })
35 | }
36 |
37 | func GetUserLogs(c *gin.Context) {
38 | p, _ := strconv.Atoi(c.Query("p"))
39 | if p < 0 {
40 | p = 0
41 | }
42 | userId := c.GetInt("id")
43 | logType, _ := strconv.Atoi(c.Query("type"))
44 | startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
45 | endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
46 | tokenName := c.Query("token_name")
47 | modelName := c.Query("model_name")
48 | logs, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, p*common.ItemsPerPage, common.ItemsPerPage)
49 | if err != nil {
50 | c.JSON(200, gin.H{
51 | "success": false,
52 | "message": err.Error(),
53 | })
54 | return
55 | }
56 | c.JSON(200, gin.H{
57 | "success": true,
58 | "message": "",
59 | "data": logs,
60 | })
61 | }
62 |
63 | func SearchAllLogs(c *gin.Context) {
64 | keyword := c.Query("keyword")
65 | logs, err := model.SearchAllLogs(keyword)
66 | if err != nil {
67 | c.JSON(200, gin.H{
68 | "success": false,
69 | "message": err.Error(),
70 | })
71 | return
72 | }
73 | c.JSON(200, gin.H{
74 | "success": true,
75 | "message": "",
76 | "data": logs,
77 | })
78 | }
79 |
80 | func SearchUserLogs(c *gin.Context) {
81 | keyword := c.Query("keyword")
82 | userId := c.GetInt("id")
83 | logs, err := model.SearchUserLogs(userId, keyword)
84 | if err != nil {
85 | c.JSON(200, gin.H{
86 | "success": false,
87 | "message": err.Error(),
88 | })
89 | return
90 | }
91 | c.JSON(200, gin.H{
92 | "success": true,
93 | "message": "",
94 | "data": logs,
95 | })
96 | }
97 |
98 | func GetLogsStat(c *gin.Context) {
99 | logType, _ := strconv.Atoi(c.Query("type"))
100 | startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
101 | endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
102 | tokenName := c.Query("token_name")
103 | username := c.Query("username")
104 | modelName := c.Query("model_name")
105 | quotaNum := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName)
106 | //tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, "")
107 | c.JSON(200, gin.H{
108 | "success": true,
109 | "message": "",
110 | "data": gin.H{
111 | "quota": quotaNum,
112 | //"token": tokenNum,
113 | },
114 | })
115 | }
116 |
117 | func GetLogsSelfStat(c *gin.Context) {
118 | username := c.GetString("username")
119 | logType, _ := strconv.Atoi(c.Query("type"))
120 | startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
121 | endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
122 | tokenName := c.Query("token_name")
123 | modelName := c.Query("model_name")
124 | quotaNum := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName)
125 | //tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, tokenName)
126 | c.JSON(200, gin.H{
127 | "success": true,
128 | "message": "",
129 | "data": gin.H{
130 | "quota": quotaNum,
131 | //"token": tokenNum,
132 | },
133 | })
134 | }
135 |
--------------------------------------------------------------------------------
/web/src/pages/Redemption/EditRedemption.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Button, Form, Header, Segment } from 'semantic-ui-react';
3 | import { useParams, useNavigate } from 'react-router-dom';
4 | import { API, downloadTextAsFile, showError, showSuccess } from '../../helpers';
5 | import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
6 |
7 | const EditRedemption = () => {
8 | const params = useParams();
9 | const navigate = useNavigate();
10 | const redemptionId = params.id;
11 | const isEdit = redemptionId !== undefined;
12 | const [loading, setLoading] = useState(isEdit);
13 | const originInputs = {
14 | name: '',
15 | quota: 100000,
16 | count: 1
17 | };
18 | const [inputs, setInputs] = useState(originInputs);
19 | const { name, quota, count } = inputs;
20 |
21 | const handleCancel = () => {
22 | navigate('/redemption');
23 | };
24 |
25 | const handleInputChange = (e, { name, value }) => {
26 | setInputs((inputs) => ({ ...inputs, [name]: value }));
27 | };
28 |
29 | const loadRedemption = async () => {
30 | let res = await API.get(`/api/redemption/${redemptionId}`);
31 | const { success, message, data } = res.data;
32 | if (success) {
33 | setInputs(data);
34 | } else {
35 | showError(message);
36 | }
37 | setLoading(false);
38 | };
39 | useEffect(() => {
40 | if (isEdit) {
41 | loadRedemption().then();
42 | }
43 | }, []);
44 |
45 | const submit = async () => {
46 | if (!isEdit && inputs.name === '') return;
47 | let localInputs = inputs;
48 | localInputs.count = parseInt(localInputs.count);
49 | localInputs.quota = parseInt(localInputs.quota);
50 | let res;
51 | if (isEdit) {
52 | res = await API.put(`/api/redemption/`, { ...localInputs, id: parseInt(redemptionId) });
53 | } else {
54 | res = await API.post(`/api/redemption/`, {
55 | ...localInputs
56 | });
57 | }
58 | const { success, message, data } = res.data;
59 | if (success) {
60 | if (isEdit) {
61 | showSuccess('兑换码更新成功!');
62 | } else {
63 | showSuccess('兑换码创建成功!');
64 | setInputs(originInputs);
65 | }
66 | } else {
67 | showError(message);
68 | }
69 | if (!isEdit && data) {
70 | let text = "";
71 | for (let i = 0; i < data.length; i++) {
72 | text += data[i] + "\n";
73 | }
74 | downloadTextAsFile(text, `${inputs.name}.txt`);
75 | }
76 | };
77 |
78 | return (
79 | <>
80 |
81 | {isEdit ? '更新兑换码信息' : '创建新的兑换码'}
82 |
84 |
93 |
94 |
95 |
104 |
105 | {
106 | !isEdit && <>
107 |
108 |
117 |
118 | >
119 | }
120 |
121 |
122 |
123 |
124 | >
125 | );
126 | };
127 |
128 | export default EditRedemption;
129 |
--------------------------------------------------------------------------------
/common/utils.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "fmt"
5 | "github.com/google/uuid"
6 | "html/template"
7 | "log"
8 | "math/rand"
9 | "net"
10 | "os"
11 | "os/exec"
12 | "runtime"
13 | "strconv"
14 | "strings"
15 | "time"
16 | )
17 |
18 | func OpenBrowser(url string) {
19 | var err error
20 |
21 | switch runtime.GOOS {
22 | case "linux":
23 | err = exec.Command("xdg-open", url).Start()
24 | case "windows":
25 | err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
26 | case "darwin":
27 | err = exec.Command("open", url).Start()
28 | }
29 | if err != nil {
30 | log.Println(err)
31 | }
32 | }
33 |
34 | func GetIp() (ip string) {
35 | ips, err := net.InterfaceAddrs()
36 | if err != nil {
37 | log.Println(err)
38 | return ip
39 | }
40 |
41 | for _, a := range ips {
42 | if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
43 | if ipNet.IP.To4() != nil {
44 | ip = ipNet.IP.String()
45 | if strings.HasPrefix(ip, "10") {
46 | return
47 | }
48 | if strings.HasPrefix(ip, "172") {
49 | return
50 | }
51 | if strings.HasPrefix(ip, "192.168") {
52 | return
53 | }
54 | ip = ""
55 | }
56 | }
57 | }
58 | return
59 | }
60 |
61 | var sizeKB = 1024
62 | var sizeMB = sizeKB * 1024
63 | var sizeGB = sizeMB * 1024
64 |
65 | func Bytes2Size(num int64) string {
66 | numStr := ""
67 | unit := "B"
68 | if num/int64(sizeGB) > 1 {
69 | numStr = fmt.Sprintf("%.2f", float64(num)/float64(sizeGB))
70 | unit = "GB"
71 | } else if num/int64(sizeMB) > 1 {
72 | numStr = fmt.Sprintf("%d", int(float64(num)/float64(sizeMB)))
73 | unit = "MB"
74 | } else if num/int64(sizeKB) > 1 {
75 | numStr = fmt.Sprintf("%d", int(float64(num)/float64(sizeKB)))
76 | unit = "KB"
77 | } else {
78 | numStr = fmt.Sprintf("%d", num)
79 | }
80 | return numStr + " " + unit
81 | }
82 |
83 | func Seconds2Time(num int) (time string) {
84 | if num/31104000 > 0 {
85 | time += strconv.Itoa(num/31104000) + " 年 "
86 | num %= 31104000
87 | }
88 | if num/2592000 > 0 {
89 | time += strconv.Itoa(num/2592000) + " 个月 "
90 | num %= 2592000
91 | }
92 | if num/86400 > 0 {
93 | time += strconv.Itoa(num/86400) + " 天 "
94 | num %= 86400
95 | }
96 | if num/3600 > 0 {
97 | time += strconv.Itoa(num/3600) + " 小时 "
98 | num %= 3600
99 | }
100 | if num/60 > 0 {
101 | time += strconv.Itoa(num/60) + " 分钟 "
102 | num %= 60
103 | }
104 | time += strconv.Itoa(num) + " 秒"
105 | return
106 | }
107 |
108 | func Interface2String(inter interface{}) string {
109 | switch inter.(type) {
110 | case string:
111 | return inter.(string)
112 | case int:
113 | return fmt.Sprintf("%d", inter.(int))
114 | case float64:
115 | return fmt.Sprintf("%f", inter.(float64))
116 | }
117 | return "Not Implemented"
118 | }
119 |
120 | func UnescapeHTML(x string) interface{} {
121 | return template.HTML(x)
122 | }
123 |
124 | func IntMax(a int, b int) int {
125 | if a >= b {
126 | return a
127 | } else {
128 | return b
129 | }
130 | }
131 |
132 | func GetUUID() string {
133 | code := uuid.New().String()
134 | code = strings.Replace(code, "-", "", -1)
135 | return code
136 | }
137 |
138 | const keyChars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
139 |
140 | func init() {
141 | rand.Seed(time.Now().UnixNano())
142 | }
143 |
144 | func GenerateKey() string {
145 | rand.Seed(time.Now().UnixNano())
146 | key := make([]byte, 48)
147 | for i := 0; i < 16; i++ {
148 | key[i] = keyChars[rand.Intn(len(keyChars))]
149 | }
150 | uuid_ := GetUUID()
151 | for i := 0; i < 32; i++ {
152 | c := uuid_[i]
153 | if i%2 == 0 && c >= 'a' && c <= 'z' {
154 | c = c - 'a' + 'A'
155 | }
156 | key[i+16] = c
157 | }
158 | return string(key)
159 | }
160 |
161 | func GetRandomString(length int) string {
162 | rand.Seed(time.Now().UnixNano())
163 | key := make([]byte, length)
164 | for i := 0; i < length; i++ {
165 | key[i] = keyChars[rand.Intn(len(keyChars))]
166 | }
167 | return string(key)
168 | }
169 |
170 | func GetTimestamp() int64 {
171 | return time.Now().Unix()
172 | }
173 |
174 | func Max(a int, b int) int {
175 | if a >= b {
176 | return a
177 | } else {
178 | return b
179 | }
180 | }
181 |
182 | func GetOrDefault(env string, defaultValue int) int {
183 | if env == "" || os.Getenv(env) == "" {
184 | return defaultValue
185 | }
186 | num, err := strconv.Atoi(os.Getenv(env))
187 | if err != nil {
188 | SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %d", env, err.Error(), defaultValue))
189 | return defaultValue
190 | }
191 | return num
192 | }
193 |
--------------------------------------------------------------------------------
/controller/redemption.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "net/http"
6 | "one-api/common"
7 | "one-api/model"
8 | "strconv"
9 | )
10 |
11 | func GetAllRedemptions(c *gin.Context) {
12 | p, _ := strconv.Atoi(c.Query("p"))
13 | if p < 0 {
14 | p = 0
15 | }
16 | redemptions, err := model.GetAllRedemptions(p*common.ItemsPerPage, common.ItemsPerPage)
17 | if err != nil {
18 | c.JSON(http.StatusOK, gin.H{
19 | "success": false,
20 | "message": err.Error(),
21 | })
22 | return
23 | }
24 | c.JSON(http.StatusOK, gin.H{
25 | "success": true,
26 | "message": "",
27 | "data": redemptions,
28 | })
29 | return
30 | }
31 |
32 | func SearchRedemptions(c *gin.Context) {
33 | keyword := c.Query("keyword")
34 | redemptions, err := model.SearchRedemptions(keyword)
35 | if err != nil {
36 | c.JSON(http.StatusOK, gin.H{
37 | "success": false,
38 | "message": err.Error(),
39 | })
40 | return
41 | }
42 | c.JSON(http.StatusOK, gin.H{
43 | "success": true,
44 | "message": "",
45 | "data": redemptions,
46 | })
47 | return
48 | }
49 |
50 | func GetRedemption(c *gin.Context) {
51 | id, err := strconv.Atoi(c.Param("id"))
52 | if err != nil {
53 | c.JSON(http.StatusOK, gin.H{
54 | "success": false,
55 | "message": err.Error(),
56 | })
57 | return
58 | }
59 | redemption, err := model.GetRedemptionById(id)
60 | if err != nil {
61 | c.JSON(http.StatusOK, gin.H{
62 | "success": false,
63 | "message": err.Error(),
64 | })
65 | return
66 | }
67 | c.JSON(http.StatusOK, gin.H{
68 | "success": true,
69 | "message": "",
70 | "data": redemption,
71 | })
72 | return
73 | }
74 |
75 | func AddRedemption(c *gin.Context) {
76 | redemption := model.Redemption{}
77 | err := c.ShouldBindJSON(&redemption)
78 | if err != nil {
79 | c.JSON(http.StatusOK, gin.H{
80 | "success": false,
81 | "message": err.Error(),
82 | })
83 | return
84 | }
85 | if len(redemption.Name) == 0 || len(redemption.Name) > 20 {
86 | c.JSON(http.StatusOK, gin.H{
87 | "success": false,
88 | "message": "兑换码名称长度必须在1-20之间",
89 | })
90 | return
91 | }
92 | if redemption.Count <= 0 {
93 | c.JSON(http.StatusOK, gin.H{
94 | "success": false,
95 | "message": "兑换码个数必须大于0",
96 | })
97 | return
98 | }
99 | if redemption.Count > 100 {
100 | c.JSON(http.StatusOK, gin.H{
101 | "success": false,
102 | "message": "一次兑换码批量生成的个数不能大于 100",
103 | })
104 | return
105 | }
106 | var keys []string
107 | for i := 0; i < redemption.Count; i++ {
108 | key := common.GetUUID()
109 | cleanRedemption := model.Redemption{
110 | UserId: c.GetInt("id"),
111 | Name: redemption.Name,
112 | Key: key,
113 | CreatedTime: common.GetTimestamp(),
114 | Quota: redemption.Quota,
115 | }
116 | err = cleanRedemption.Insert()
117 | if err != nil {
118 | c.JSON(http.StatusOK, gin.H{
119 | "success": false,
120 | "message": err.Error(),
121 | "data": keys,
122 | })
123 | return
124 | }
125 | keys = append(keys, key)
126 | }
127 | c.JSON(http.StatusOK, gin.H{
128 | "success": true,
129 | "message": "",
130 | "data": keys,
131 | })
132 | return
133 | }
134 |
135 | func DeleteRedemption(c *gin.Context) {
136 | id, _ := strconv.Atoi(c.Param("id"))
137 | err := model.DeleteRedemptionById(id)
138 | if err != nil {
139 | c.JSON(http.StatusOK, gin.H{
140 | "success": false,
141 | "message": err.Error(),
142 | })
143 | return
144 | }
145 | c.JSON(http.StatusOK, gin.H{
146 | "success": true,
147 | "message": "",
148 | })
149 | return
150 | }
151 |
152 | func UpdateRedemption(c *gin.Context) {
153 | statusOnly := c.Query("status_only")
154 | redemption := model.Redemption{}
155 | err := c.ShouldBindJSON(&redemption)
156 | if err != nil {
157 | c.JSON(http.StatusOK, gin.H{
158 | "success": false,
159 | "message": err.Error(),
160 | })
161 | return
162 | }
163 | cleanRedemption, err := model.GetRedemptionById(redemption.Id)
164 | if err != nil {
165 | c.JSON(http.StatusOK, gin.H{
166 | "success": false,
167 | "message": err.Error(),
168 | })
169 | return
170 | }
171 | if statusOnly != "" {
172 | cleanRedemption.Status = redemption.Status
173 | } else {
174 | // If you add more fields, please also update redemption.Update()
175 | cleanRedemption.Name = redemption.Name
176 | cleanRedemption.Quota = redemption.Quota
177 | }
178 | err = cleanRedemption.Update()
179 | if err != nil {
180 | c.JSON(http.StatusOK, gin.H{
181 | "success": false,
182 | "message": err.Error(),
183 | })
184 | return
185 | }
186 | c.JSON(http.StatusOK, gin.H{
187 | "success": true,
188 | "message": "",
189 | "data": cleanRedemption,
190 | })
191 | return
192 | }
193 |
--------------------------------------------------------------------------------
/controller/relay-openai.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "encoding/json"
7 | "github.com/gin-gonic/gin"
8 | "io"
9 | "net/http"
10 | "one-api/common"
11 | "strings"
12 | )
13 |
14 | func openaiStreamHandler(c *gin.Context, resp *http.Response, relayMode int) (*OpenAIErrorWithStatusCode, string) {
15 | responseText := ""
16 | scanner := bufio.NewScanner(resp.Body)
17 | scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
18 | if atEOF && len(data) == 0 {
19 | return 0, nil, nil
20 | }
21 | if i := strings.Index(string(data), "\n"); i >= 0 {
22 | return i + 1, data[0:i], nil
23 | }
24 | if atEOF {
25 | return len(data), data, nil
26 | }
27 | return 0, nil, nil
28 | })
29 | dataChan := make(chan string)
30 | stopChan := make(chan bool)
31 | go func() {
32 | for scanner.Scan() {
33 | data := scanner.Text()
34 | if len(data) < 6 { // ignore blank line or wrong format
35 | continue
36 | }
37 | if data[:6] != "data: " && data[:6] != "[DONE]" {
38 | continue
39 | }
40 | dataChan <- data
41 | data = data[6:]
42 | if !strings.HasPrefix(data, "[DONE]") {
43 | switch relayMode {
44 | case RelayModeChatCompletions:
45 | var streamResponse ChatCompletionsStreamResponse
46 | err := json.Unmarshal([]byte(data), &streamResponse)
47 | if err != nil {
48 | common.SysError("error unmarshalling stream response: " + err.Error())
49 | continue // just ignore the error
50 | }
51 | for _, choice := range streamResponse.Choices {
52 | responseText += choice.Delta.Content
53 | }
54 | case RelayModeCompletions:
55 | var streamResponse CompletionsStreamResponse
56 | err := json.Unmarshal([]byte(data), &streamResponse)
57 | if err != nil {
58 | common.SysError("error unmarshalling stream response: " + err.Error())
59 | continue
60 | }
61 | for _, choice := range streamResponse.Choices {
62 | responseText += choice.Text
63 | }
64 | }
65 | }
66 | }
67 | stopChan <- true
68 | }()
69 | setEventStreamHeaders(c)
70 | c.Stream(func(w io.Writer) bool {
71 | select {
72 | case data := <-dataChan:
73 | if strings.HasPrefix(data, "data: [DONE]") {
74 | data = data[:12]
75 | }
76 | // some implementations may add \r at the end of data
77 | data = strings.TrimSuffix(data, "\r")
78 | c.Render(-1, common.CustomEvent{Data: data})
79 | return true
80 | case <-stopChan:
81 | return false
82 | }
83 | })
84 | err := resp.Body.Close()
85 | if err != nil {
86 | return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), ""
87 | }
88 | return nil, responseText
89 | }
90 |
91 | func openaiHandler(c *gin.Context, resp *http.Response, consumeQuota bool, promptTokens int, model string) (*OpenAIErrorWithStatusCode, *Usage) {
92 | var textResponse TextResponse
93 | if consumeQuota {
94 | responseBody, err := io.ReadAll(resp.Body)
95 | if err != nil {
96 | return errorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil
97 | }
98 | err = resp.Body.Close()
99 | if err != nil {
100 | return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil
101 | }
102 | err = json.Unmarshal(responseBody, &textResponse)
103 | if err != nil {
104 | return errorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
105 | }
106 | if textResponse.Error.Type != "" {
107 | return &OpenAIErrorWithStatusCode{
108 | OpenAIError: textResponse.Error,
109 | StatusCode: resp.StatusCode,
110 | }, nil
111 | }
112 | // Reset response body
113 | resp.Body = io.NopCloser(bytes.NewBuffer(responseBody))
114 | }
115 | // We shouldn't set the header before we parse the response body, because the parse part may fail.
116 | // And then we will have to send an error response, but in this case, the header has already been set.
117 | // So the httpClient will be confused by the response.
118 | // For example, Postman will report error, and we cannot check the response at all.
119 | for k, v := range resp.Header {
120 | c.Writer.Header().Set(k, v[0])
121 | }
122 | c.Writer.WriteHeader(resp.StatusCode)
123 | _, err := io.Copy(c.Writer, resp.Body)
124 | if err != nil {
125 | return errorWrapper(err, "copy_response_body_failed", http.StatusInternalServerError), nil
126 | }
127 | err = resp.Body.Close()
128 | if err != nil {
129 | return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil
130 | }
131 |
132 | if textResponse.Usage.TotalTokens == 0 {
133 | completionTokens := 0
134 | for _, choice := range textResponse.Choices {
135 | completionTokens += countTokenText(choice.Message.Content, model)
136 | }
137 | textResponse.Usage = Usage{
138 | PromptTokens: promptTokens,
139 | CompletionTokens: completionTokens,
140 | TotalTokens: promptTokens + completionTokens,
141 | }
142 | }
143 | return nil, &textResponse.Usage
144 | }
145 |
--------------------------------------------------------------------------------
/web/src/pages/Home/index.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useEffect, useState } from 'react';
2 | import { Card, Grid, Header, Segment } from 'semantic-ui-react';
3 | import { API, showError, showNotice, timestamp2string } from '../../helpers';
4 | import { StatusContext } from '../../context/Status';
5 | import { marked } from 'marked';
6 |
7 | const Home = () => {
8 | const [statusState, statusDispatch] = useContext(StatusContext);
9 | const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
10 | const [homePageContent, setHomePageContent] = useState('');
11 |
12 | const displayNotice = async () => {
13 | const res = await API.get('/api/notice');
14 | const { success, message, data } = res.data;
15 | if (success) {
16 | let oldNotice = localStorage.getItem('notice');
17 | if (data !== oldNotice && data !== '') {
18 | const htmlNotice = marked(data);
19 | showNotice(htmlNotice, true);
20 | localStorage.setItem('notice', data);
21 | }
22 | } else {
23 | showError(message);
24 | }
25 | };
26 |
27 | const displayHomePageContent = async () => {
28 | setHomePageContent(localStorage.getItem('home_page_content') || '');
29 | const res = await API.get('/api/home_page_content');
30 | const { success, message, data } = res.data;
31 | if (success) {
32 | let content = data;
33 | if (!data.startsWith('https://')) {
34 | content = marked.parse(data);
35 | }
36 | setHomePageContent(content);
37 | localStorage.setItem('home_page_content', content);
38 | } else {
39 | showError(message);
40 | setHomePageContent('加载首页内容失败...');
41 | }
42 | setHomePageContentLoaded(true);
43 | };
44 |
45 | const getStartTimeString = () => {
46 | const timestamp = statusState?.status?.start_time;
47 | return timestamp2string(timestamp);
48 | };
49 |
50 | useEffect(() => {
51 | displayNotice().then();
52 | displayHomePageContent().then();
53 | }, []);
54 | return (
55 | <>
56 | {
57 | homePageContentLoaded && homePageContent === '' ? <>
58 |
59 |
60 |
61 |
62 |
63 |
64 | 系统信息
65 | 系统信息总览
66 |
67 | 名称:{statusState?.status?.system_name}
68 | 版本:{statusState?.status?.version ? statusState?.status?.version : "unknown"}
69 |
70 | 源码:
71 |
75 | https://github.com/songquanpeng/one-api
76 |
77 |
78 | 启动时间:{getStartTimeString()}
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 | 系统配置
87 | 系统配置总览
88 |
89 |
90 | 邮箱验证:
91 | {statusState?.status?.email_verification === true
92 | ? '已启用'
93 | : '未启用'}
94 |
95 |
96 | GitHub 身份验证:
97 | {statusState?.status?.github_oauth === true
98 | ? '已启用'
99 | : '未启用'}
100 |
101 |
102 | 微信身份验证:
103 | {statusState?.status?.wechat_login === true
104 | ? '已启用'
105 | : '未启用'}
106 |
107 |
108 | Turnstile 用户校验:
109 | {statusState?.status?.turnstile_check === true
110 | ? '已启用'
111 | : '未启用'}
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 | > : <>
120 | {
121 | homePageContent.startsWith('https://') ? :
125 | }
126 | >
127 | }
128 |
129 | >
130 | );
131 | };
132 |
133 | export default Home;
134 |
--------------------------------------------------------------------------------
/controller/relay-audio.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "io"
8 | "net/http"
9 | "one-api/common"
10 | "one-api/model"
11 |
12 | "github.com/gin-gonic/gin"
13 | )
14 |
15 | func relayAudioHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
16 | audioModel := "whisper-1"
17 |
18 | tokenId := c.GetInt("token_id")
19 | channelType := c.GetInt("channel")
20 | userId := c.GetInt("id")
21 | group := c.GetString("group")
22 |
23 | preConsumedTokens := common.PreConsumedQuota
24 | modelRatio := common.GetModelRatio(audioModel)
25 | groupRatio := common.GetGroupRatio(group)
26 | ratio := modelRatio * groupRatio
27 | preConsumedQuota := int(float64(preConsumedTokens) * ratio)
28 | userQuota, err := model.CacheGetUserQuota(userId)
29 | if err != nil {
30 | return errorWrapper(err, "get_user_quota_failed", http.StatusInternalServerError)
31 | }
32 | err = model.CacheDecreaseUserQuota(userId, preConsumedQuota)
33 | if err != nil {
34 | return errorWrapper(err, "decrease_user_quota_failed", http.StatusInternalServerError)
35 | }
36 | if userQuota > 100*preConsumedQuota {
37 | // in this case, we do not pre-consume quota
38 | // because the user has enough quota
39 | preConsumedQuota = 0
40 | }
41 | if preConsumedQuota > 0 {
42 | err := model.PreConsumeTokenQuota(tokenId, preConsumedQuota)
43 | if err != nil {
44 | return errorWrapper(err, "pre_consume_token_quota_failed", http.StatusForbidden)
45 | }
46 | }
47 |
48 | // map model name
49 | modelMapping := c.GetString("model_mapping")
50 | if modelMapping != "" {
51 | modelMap := make(map[string]string)
52 | err := json.Unmarshal([]byte(modelMapping), &modelMap)
53 | if err != nil {
54 | return errorWrapper(err, "unmarshal_model_mapping_failed", http.StatusInternalServerError)
55 | }
56 | if modelMap[audioModel] != "" {
57 | audioModel = modelMap[audioModel]
58 | }
59 | }
60 |
61 | baseURL := common.ChannelBaseURLs[channelType]
62 | requestURL := c.Request.URL.String()
63 |
64 | if c.GetString("base_url") != "" {
65 | baseURL = c.GetString("base_url")
66 | }
67 |
68 | fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL)
69 | requestBody := c.Request.Body
70 |
71 | req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
72 | if err != nil {
73 | return errorWrapper(err, "new_request_failed", http.StatusInternalServerError)
74 | }
75 | req.Header.Set("Authorization", c.Request.Header.Get("Authorization"))
76 | req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
77 | req.Header.Set("Accept", c.Request.Header.Get("Accept"))
78 |
79 | resp, err := httpClient.Do(req)
80 | if err != nil {
81 | return errorWrapper(err, "do_request_failed", http.StatusInternalServerError)
82 | }
83 |
84 | err = req.Body.Close()
85 | if err != nil {
86 | return errorWrapper(err, "close_request_body_failed", http.StatusInternalServerError)
87 | }
88 | err = c.Request.Body.Close()
89 | if err != nil {
90 | return errorWrapper(err, "close_request_body_failed", http.StatusInternalServerError)
91 | }
92 | var audioResponse AudioResponse
93 |
94 | defer func() {
95 | go func() {
96 | quota := countTokenText(audioResponse.Text, audioModel)
97 | quotaDelta := quota - preConsumedQuota
98 | err := model.PostConsumeTokenQuota(tokenId, quotaDelta)
99 | if err != nil {
100 | common.SysError("error consuming token remain quota: " + err.Error())
101 | }
102 | err = model.CacheUpdateUserQuota(userId)
103 | if err != nil {
104 | common.SysError("error update user quota cache: " + err.Error())
105 | }
106 | if quota != 0 {
107 | tokenName := c.GetString("token_name")
108 | channelName := c.GetString("channel_name")
109 | logContent := fmt.Sprintf("模型倍率 %.2f,分组倍率 %.2f", modelRatio, groupRatio)
110 | model.RecordConsumeLog(userId, 0, 0, audioModel, tokenName, channelName, quota, logContent)
111 | model.UpdateUserUsedQuotaAndRequestCount(userId, quota)
112 | channelId := c.GetInt("channel_id")
113 | model.UpdateChannelUsedQuota(channelId, quota)
114 | }
115 | }()
116 | }()
117 |
118 | responseBody, err := io.ReadAll(resp.Body)
119 |
120 | if err != nil {
121 | return errorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
122 | }
123 | err = resp.Body.Close()
124 | if err != nil {
125 | return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError)
126 | }
127 | err = json.Unmarshal(responseBody, &audioResponse)
128 | if err != nil {
129 | return errorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError)
130 | }
131 |
132 | resp.Body = io.NopCloser(bytes.NewBuffer(responseBody))
133 |
134 | for k, v := range resp.Header {
135 | c.Writer.Header().Set(k, v[0])
136 | }
137 | c.Writer.WriteHeader(resp.StatusCode)
138 |
139 | _, err = io.Copy(c.Writer, resp.Body)
140 | if err != nil {
141 | return errorWrapper(err, "copy_response_body_failed", http.StatusInternalServerError)
142 | }
143 | err = resp.Body.Close()
144 | if err != nil {
145 | return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError)
146 | }
147 | return nil
148 | }
149 |
--------------------------------------------------------------------------------
/router/api-router.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "one-api/controller"
5 | "one-api/middleware"
6 |
7 | "github.com/gin-contrib/gzip"
8 | "github.com/gin-gonic/gin"
9 | )
10 |
11 | func SetApiRouter(router *gin.Engine) {
12 | apiRouter := router.Group("/api")
13 | apiRouter.Use(gzip.Gzip(gzip.DefaultCompression))
14 | apiRouter.Use(middleware.GlobalAPIRateLimit())
15 | {
16 | apiRouter.GET("/status", controller.GetStatus)
17 | apiRouter.GET("/notice", controller.GetNotice)
18 | apiRouter.GET("/about", controller.GetAbout)
19 | apiRouter.GET("/home_page_content", controller.GetHomePageContent)
20 | apiRouter.GET("/verification", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
21 | apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
22 | apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
23 | apiRouter.GET("/oauth/github", middleware.CriticalRateLimit(), controller.GitHubOAuth)
24 | apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth)
25 | apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.WeChatBind)
26 | apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.EmailBind)
27 |
28 | userRoute := apiRouter.Group("/user")
29 | {
30 | userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
31 | userRoute.POST("/login", middleware.CriticalRateLimit(), controller.Login)
32 | userRoute.GET("/logout", controller.Logout)
33 |
34 | selfRoute := userRoute.Group("/")
35 | selfRoute.Use(middleware.UserAuth())
36 | {
37 | selfRoute.GET("/self", controller.GetSelf)
38 | selfRoute.PUT("/self", controller.UpdateSelf)
39 | selfRoute.DELETE("/self", controller.DeleteSelf)
40 | selfRoute.GET("/token", controller.GenerateAccessToken)
41 | selfRoute.GET("/aff", controller.GetAffCode)
42 | selfRoute.POST("/topup", controller.TopUp)
43 | }
44 |
45 | adminRoute := userRoute.Group("/")
46 | adminRoute.Use(middleware.AdminAuth())
47 | {
48 | adminRoute.GET("/", controller.GetAllUsers)
49 | adminRoute.GET("/search", controller.SearchUsers)
50 | adminRoute.GET("/:id", controller.GetUser)
51 | adminRoute.POST("/", controller.CreateUser)
52 | adminRoute.POST("/manage", controller.ManageUser)
53 | adminRoute.PUT("/", controller.UpdateUser)
54 | adminRoute.DELETE("/:id", controller.DeleteUser)
55 | }
56 | }
57 | optionRoute := apiRouter.Group("/option")
58 | optionRoute.Use(middleware.RootAuth())
59 | {
60 | optionRoute.GET("/", controller.GetOptions)
61 | optionRoute.PUT("/", controller.UpdateOption)
62 | }
63 | channelRoute := apiRouter.Group("/channel")
64 | channelRoute.Use(middleware.AdminAuth())
65 | {
66 | channelRoute.GET("/", controller.GetAllChannels)
67 | channelRoute.GET("/search", controller.SearchChannels)
68 | channelRoute.GET("/models", controller.ListModels)
69 | channelRoute.GET("/:id", controller.GetChannel)
70 | channelRoute.GET("/test", controller.TestAllChannels)
71 | channelRoute.GET("/test/:id", controller.TestChannel)
72 | channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance)
73 | channelRoute.GET("/update_balance/:id", controller.UpdateChannelBalance)
74 | channelRoute.POST("/", controller.AddChannel)
75 | channelRoute.PUT("/", controller.UpdateChannel)
76 | channelRoute.DELETE("/:id", controller.DeleteChannel)
77 | }
78 | tokenRoute := apiRouter.Group("/token")
79 | tokenRoute.Use(middleware.UserAuth())
80 | {
81 | tokenRoute.GET("/", controller.GetAllTokens)
82 | tokenRoute.GET("/search", controller.SearchTokens)
83 | tokenRoute.GET("/:id", controller.GetToken)
84 | tokenRoute.POST("/", controller.AddToken)
85 | tokenRoute.PUT("/", controller.UpdateToken)
86 | tokenRoute.DELETE("/:id", controller.DeleteToken)
87 | }
88 | redemptionRoute := apiRouter.Group("/redemption")
89 | redemptionRoute.Use(middleware.AdminAuth())
90 | {
91 | redemptionRoute.GET("/", controller.GetAllRedemptions)
92 | redemptionRoute.GET("/search", controller.SearchRedemptions)
93 | redemptionRoute.GET("/:id", controller.GetRedemption)
94 | redemptionRoute.POST("/", controller.AddRedemption)
95 | redemptionRoute.PUT("/", controller.UpdateRedemption)
96 | redemptionRoute.DELETE("/:id", controller.DeleteRedemption)
97 | }
98 | logRoute := apiRouter.Group("/log")
99 | logRoute.GET("/", middleware.AdminAuth(), controller.GetAllLogs)
100 | logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat)
101 | logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat)
102 | logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs)
103 | logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs)
104 | logRoute.GET("/self/search", middleware.UserAuth(), controller.SearchUserLogs)
105 | groupRoute := apiRouter.Group("/group")
106 | groupRoute.Use(middleware.AdminAuth())
107 | {
108 | groupRoute.GET("/", controller.GetGroups)
109 | }
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/web/src/helpers/utils.js:
--------------------------------------------------------------------------------
1 | import { toast } from 'react-toastify';
2 | import { toastConstants } from '../constants';
3 | import React from 'react';
4 |
5 | const HTMLToastContent = ({ htmlContent }) => {
6 | return ;
7 | };
8 | export default HTMLToastContent;
9 | export function isAdmin() {
10 | let user = localStorage.getItem('user');
11 | if (!user) return false;
12 | user = JSON.parse(user);
13 | return user.role >= 10;
14 | }
15 |
16 | export function isRoot() {
17 | let user = localStorage.getItem('user');
18 | if (!user) return false;
19 | user = JSON.parse(user);
20 | return user.role >= 100;
21 | }
22 |
23 | export function getSystemName() {
24 | let system_name = localStorage.getItem('system_name');
25 | if (!system_name) return 'One API';
26 | return system_name;
27 | }
28 |
29 | export function getLogo() {
30 | let logo = localStorage.getItem('logo');
31 | if (!logo) return '/logo.png';
32 | return logo
33 | }
34 |
35 | export function getFooterHTML() {
36 | return localStorage.getItem('footer_html');
37 | }
38 |
39 | export async function copy(text) {
40 | let okay = true;
41 | try {
42 | await navigator.clipboard.writeText(text);
43 | } catch (e) {
44 | okay = false;
45 | console.error(e);
46 | }
47 | return okay;
48 | }
49 |
50 | export function isMobile() {
51 | return window.innerWidth <= 600;
52 | }
53 |
54 | let showErrorOptions = { autoClose: toastConstants.ERROR_TIMEOUT };
55 | let showWarningOptions = { autoClose: toastConstants.WARNING_TIMEOUT };
56 | let showSuccessOptions = { autoClose: toastConstants.SUCCESS_TIMEOUT };
57 | let showInfoOptions = { autoClose: toastConstants.INFO_TIMEOUT };
58 | let showNoticeOptions = { autoClose: false };
59 |
60 | if (isMobile()) {
61 | showErrorOptions.position = 'top-center';
62 | // showErrorOptions.transition = 'flip';
63 |
64 | showSuccessOptions.position = 'top-center';
65 | // showSuccessOptions.transition = 'flip';
66 |
67 | showInfoOptions.position = 'top-center';
68 | // showInfoOptions.transition = 'flip';
69 |
70 | showNoticeOptions.position = 'top-center';
71 | // showNoticeOptions.transition = 'flip';
72 | }
73 |
74 | export function showError(error) {
75 | console.error(error);
76 | if (error.message) {
77 | if (error.name === 'AxiosError') {
78 | switch (error.response.status) {
79 | case 401:
80 | // toast.error('错误:未登录或登录已过期,请重新登录!', showErrorOptions);
81 | window.location.href = '/login?expired=true';
82 | break;
83 | case 429:
84 | toast.error('错误:请求次数过多,请稍后再试!', showErrorOptions);
85 | break;
86 | case 500:
87 | toast.error('错误:服务器内部错误,请联系管理员!', showErrorOptions);
88 | break;
89 | case 405:
90 | toast.info('本站仅作演示之用,无服务端!');
91 | break;
92 | default:
93 | toast.error('错误:' + error.message, showErrorOptions);
94 | }
95 | return;
96 | }
97 | toast.error('错误:' + error.message, showErrorOptions);
98 | } else {
99 | toast.error('错误:' + error, showErrorOptions);
100 | }
101 | }
102 |
103 | export function showWarning(message) {
104 | toast.warn(message, showWarningOptions);
105 | }
106 |
107 | export function showSuccess(message) {
108 | toast.success(message, showSuccessOptions);
109 | }
110 |
111 | export function showInfo(message) {
112 | toast.info(message, showInfoOptions);
113 | }
114 |
115 | export function showNotice(message, isHTML = false) {
116 | if (isHTML) {
117 | toast(, showNoticeOptions);
118 | } else {
119 | toast.info(message, showNoticeOptions);
120 | }
121 | }
122 |
123 | export function openPage(url) {
124 | window.open(url);
125 | }
126 |
127 | export function removeTrailingSlash(url) {
128 | if (url.endsWith('/')) {
129 | return url.slice(0, -1);
130 | } else {
131 | return url;
132 | }
133 | }
134 |
135 | export function timestamp2string(timestamp) {
136 | let date = new Date(timestamp * 1000);
137 | let year = date.getFullYear().toString();
138 | let month = (date.getMonth() + 1).toString();
139 | let day = date.getDate().toString();
140 | let hour = date.getHours().toString();
141 | let minute = date.getMinutes().toString();
142 | let second = date.getSeconds().toString();
143 | if (month.length === 1) {
144 | month = '0' + month;
145 | }
146 | if (day.length === 1) {
147 | day = '0' + day;
148 | }
149 | if (hour.length === 1) {
150 | hour = '0' + hour;
151 | }
152 | if (minute.length === 1) {
153 | minute = '0' + minute;
154 | }
155 | if (second.length === 1) {
156 | second = '0' + second;
157 | }
158 | return (
159 | year +
160 | '-' +
161 | month +
162 | '-' +
163 | day +
164 | ' ' +
165 | hour +
166 | ':' +
167 | minute +
168 | ':' +
169 | second
170 | );
171 | }
172 |
173 | export function downloadTextAsFile(text, filename) {
174 | let blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
175 | let url = URL.createObjectURL(blob);
176 | let a = document.createElement('a');
177 | a.href = url;
178 | a.download = filename;
179 | a.click();
180 | }
181 |
182 | export const verifyJSON = (str) => {
183 | try {
184 | JSON.parse(str);
185 | } catch (e) {
186 | return false;
187 | }
188 | return true;
189 | };
--------------------------------------------------------------------------------
/model/channel.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "gorm.io/gorm"
5 | "one-api/common"
6 | )
7 |
8 | type Channel struct {
9 | Id int `json:"id"`
10 | Type int `json:"type" gorm:"default:0"`
11 | Key string `json:"key" gorm:"not null;index"`
12 | OpenAIOrganization *string `json:"openai_organization"`
13 | Status int `json:"status" gorm:"default:1"`
14 | Name string `json:"name" gorm:"index"`
15 | Weight int `json:"weight"`
16 | CreatedTime int64 `json:"created_time" gorm:"bigint"`
17 | TestTime int64 `json:"test_time" gorm:"bigint"`
18 | ResponseTime int `json:"response_time"` // in milliseconds
19 | BaseURL string `json:"base_url" gorm:"column:base_url"`
20 | Other string `json:"other"`
21 | Balance float64 `json:"balance"` // in USD
22 | BalanceUpdatedTime int64 `json:"balance_updated_time" gorm:"bigint"`
23 | Models string `json:"models"`
24 | Group string `json:"group" gorm:"type:varchar(32);default:'default'"`
25 | UsedQuota int64 `json:"used_quota" gorm:"bigint;default:0"`
26 | ModelMapping string `json:"model_mapping" gorm:"type:varchar(1024);default:''"`
27 | Sort *int `json:"sort"`
28 | OverFrequencyAutoDisable *bool `json:"overFrequencyAutoDisable" gorm:"default:0"`
29 | RetryInterval *int `json:"retryInterval" gorm:"default:300"`
30 | }
31 |
32 | func GetAllChannels(startIdx int, num int, selectAll bool) ([]*Channel, error) {
33 | var channels []*Channel
34 | var err error
35 | if selectAll {
36 | err = DB.Order("id desc").Find(&channels).Error
37 | } else {
38 | err = DB.Order("id desc").Limit(num).Offset(startIdx).Omit("key").Find(&channels).Error
39 | }
40 | return channels, err
41 | }
42 |
43 | func SearchChannels(keyword string) (channels []*Channel, err error) {
44 | err = DB.Omit("key").Where("id = ? or name LIKE ? or `key` = ?", keyword, keyword+"%", keyword).Find(&channels).Error
45 | return channels, err
46 | }
47 |
48 | func GetChannelById(id int, selectAll bool) (*Channel, error) {
49 | channel := Channel{Id: id}
50 | var err error = nil
51 | if selectAll {
52 | err = DB.First(&channel, "id = ?", id).Error
53 | } else {
54 | err = DB.Omit("key").First(&channel, "id = ?", id).Error
55 | }
56 | return &channel, err
57 | }
58 |
59 | func GetRandomChannel() (*Channel, error) {
60 | channel := Channel{}
61 | var err error = nil
62 | if common.UsingSQLite {
63 | err = DB.Where("status = ? and `group` = ?", common.ChannelStatusEnabled, "default").Order("RANDOM()").Limit(1).First(&channel).Error
64 | } else {
65 | err = DB.Where("status = ? and `group` = ?", common.ChannelStatusEnabled, "default").Order("RAND()").Limit(1).First(&channel).Error
66 | }
67 | return &channel, err
68 | }
69 |
70 | func BatchInsertChannels(channels []Channel) error {
71 | var err error
72 | err = DB.Create(&channels).Error
73 | if err != nil {
74 | return err
75 | }
76 | for _, channel_ := range channels {
77 | err = channel_.AddAbilities()
78 | if err != nil {
79 | return err
80 | }
81 | }
82 | return nil
83 | }
84 |
85 | func (channel *Channel) Insert() error {
86 | var err error
87 | err = DB.Create(channel).Error
88 | if err != nil {
89 | return err
90 | }
91 | err = channel.AddAbilities()
92 | return err
93 | }
94 |
95 | func (channel *Channel) Update() error {
96 | var err error
97 | err = DB.Model(channel).Updates(channel).Error
98 | if err != nil {
99 | return err
100 | }
101 | DB.Model(channel).First(channel, "id = ?", channel.Id)
102 | err = channel.UpdateAbilities()
103 | return err
104 | }
105 |
106 | func (channel *Channel) UpdateResponseTime(responseTime int64) {
107 | err := DB.Model(channel).Select("response_time", "test_time").Updates(Channel{
108 | TestTime: common.GetTimestamp(),
109 | ResponseTime: int(responseTime),
110 | }).Error
111 | if err != nil {
112 | common.SysError("failed to update response time: " + err.Error())
113 | }
114 | }
115 |
116 | func (channel *Channel) UpdateBalance(balance float64) {
117 | err := DB.Model(channel).Select("balance_updated_time", "balance").Updates(Channel{
118 | BalanceUpdatedTime: common.GetTimestamp(),
119 | Balance: balance,
120 | }).Error
121 | if err != nil {
122 | common.SysError("failed to update balance: " + err.Error())
123 | }
124 | }
125 |
126 | func (channel *Channel) Delete() error {
127 | var err error
128 | err = DB.Delete(channel).Error
129 | if err != nil {
130 | return err
131 | }
132 | err = channel.DeleteAbilities()
133 | return err
134 | }
135 |
136 | func UpdateChannelStatusById(id int, status int) {
137 | err := UpdateAbilityStatus(id, status == common.ChannelStatusEnabled)
138 | if err != nil {
139 | common.SysError("failed to update ability status: " + err.Error())
140 | }
141 | err = DB.Model(&Channel{}).Where("id = ?", id).Update("status", status).Error
142 | if err != nil {
143 | common.SysError("failed to update channel status: " + err.Error())
144 | }
145 | }
146 |
147 | func UpdateChannelUsedQuota(id int, quota int) {
148 | err := DB.Model(&Channel{}).Where("id = ?", id).Update("used_quota", gorm.Expr("used_quota + ?", quota)).Error
149 | if err != nil {
150 | common.SysError("failed to update channel used quota: " + err.Error())
151 | }
152 | }
153 |
--------------------------------------------------------------------------------
/controller/github.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "github.com/gin-contrib/sessions"
9 | "github.com/gin-gonic/gin"
10 | "net/http"
11 | "one-api/common"
12 | "one-api/model"
13 | "strconv"
14 | "time"
15 | )
16 |
17 | type GitHubOAuthResponse struct {
18 | AccessToken string `json:"access_token"`
19 | Scope string `json:"scope"`
20 | TokenType string `json:"token_type"`
21 | }
22 |
23 | type GitHubUser struct {
24 | Login string `json:"login"`
25 | Name string `json:"name"`
26 | Email string `json:"email"`
27 | }
28 |
29 | func getGitHubUserInfoByCode(code string) (*GitHubUser, error) {
30 | if code == "" {
31 | return nil, errors.New("无效的参数")
32 | }
33 | values := map[string]string{"client_id": common.GitHubClientId, "client_secret": common.GitHubClientSecret, "code": code}
34 | jsonData, err := json.Marshal(values)
35 | if err != nil {
36 | return nil, err
37 | }
38 | req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", bytes.NewBuffer(jsonData))
39 | if err != nil {
40 | return nil, err
41 | }
42 | req.Header.Set("Content-Type", "application/json")
43 | req.Header.Set("Accept", "application/json")
44 | client := http.Client{
45 | Timeout: 5 * time.Second,
46 | }
47 | res, err := client.Do(req)
48 | if err != nil {
49 | common.SysLog(err.Error())
50 | return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!")
51 | }
52 | defer res.Body.Close()
53 | var oAuthResponse GitHubOAuthResponse
54 | err = json.NewDecoder(res.Body).Decode(&oAuthResponse)
55 | if err != nil {
56 | return nil, err
57 | }
58 | req, err = http.NewRequest("GET", "https://api.github.com/user", nil)
59 | if err != nil {
60 | return nil, err
61 | }
62 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", oAuthResponse.AccessToken))
63 | res2, err := client.Do(req)
64 | if err != nil {
65 | common.SysLog(err.Error())
66 | return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!")
67 | }
68 | defer res2.Body.Close()
69 | var githubUser GitHubUser
70 | err = json.NewDecoder(res2.Body).Decode(&githubUser)
71 | if err != nil {
72 | return nil, err
73 | }
74 | if githubUser.Login == "" {
75 | return nil, errors.New("返回值非法,用户字段为空,请稍后重试!")
76 | }
77 | return &githubUser, nil
78 | }
79 |
80 | func GitHubOAuth(c *gin.Context) {
81 | session := sessions.Default(c)
82 | username := session.Get("username")
83 | if username != nil {
84 | GitHubBind(c)
85 | return
86 | }
87 |
88 | if !common.GitHubOAuthEnabled {
89 | c.JSON(http.StatusOK, gin.H{
90 | "success": false,
91 | "message": "管理员未开启通过 GitHub 登录以及注册",
92 | })
93 | return
94 | }
95 | code := c.Query("code")
96 | githubUser, err := getGitHubUserInfoByCode(code)
97 | if err != nil {
98 | c.JSON(http.StatusOK, gin.H{
99 | "success": false,
100 | "message": err.Error(),
101 | })
102 | return
103 | }
104 | user := model.User{
105 | GitHubId: githubUser.Login,
106 | }
107 | if model.IsGitHubIdAlreadyTaken(user.GitHubId) {
108 | err := user.FillUserByGitHubId()
109 | if err != nil {
110 | c.JSON(http.StatusOK, gin.H{
111 | "success": false,
112 | "message": err.Error(),
113 | })
114 | return
115 | }
116 | } else {
117 | if common.RegisterEnabled {
118 | user.Username = "github_" + strconv.Itoa(model.GetMaxUserId()+1)
119 | if githubUser.Name != "" {
120 | user.DisplayName = githubUser.Name
121 | } else {
122 | user.DisplayName = "GitHub User"
123 | }
124 | user.Email = githubUser.Email
125 | user.Role = common.RoleCommonUser
126 | user.Status = common.UserStatusEnabled
127 |
128 | if err := user.Insert(0); err != nil {
129 | c.JSON(http.StatusOK, gin.H{
130 | "success": false,
131 | "message": err.Error(),
132 | })
133 | return
134 | }
135 | } else {
136 | c.JSON(http.StatusOK, gin.H{
137 | "success": false,
138 | "message": "管理员关闭了新用户注册",
139 | })
140 | return
141 | }
142 | }
143 |
144 | if user.Status != common.UserStatusEnabled {
145 | c.JSON(http.StatusOK, gin.H{
146 | "message": "用户已被封禁",
147 | "success": false,
148 | })
149 | return
150 | }
151 | setupLogin(&user, c)
152 | }
153 |
154 | func GitHubBind(c *gin.Context) {
155 | if !common.GitHubOAuthEnabled {
156 | c.JSON(http.StatusOK, gin.H{
157 | "success": false,
158 | "message": "管理员未开启通过 GitHub 登录以及注册",
159 | })
160 | return
161 | }
162 | code := c.Query("code")
163 | githubUser, err := getGitHubUserInfoByCode(code)
164 | if err != nil {
165 | c.JSON(http.StatusOK, gin.H{
166 | "success": false,
167 | "message": err.Error(),
168 | })
169 | return
170 | }
171 | user := model.User{
172 | GitHubId: githubUser.Login,
173 | }
174 | if model.IsGitHubIdAlreadyTaken(user.GitHubId) {
175 | c.JSON(http.StatusOK, gin.H{
176 | "success": false,
177 | "message": "该 GitHub 账户已被绑定",
178 | })
179 | return
180 | }
181 | session := sessions.Default(c)
182 | id := session.Get("id")
183 | // id := c.GetInt("id") // critical bug!
184 | user.Id = id.(int)
185 | err = user.FillUserById()
186 | if err != nil {
187 | c.JSON(http.StatusOK, gin.H{
188 | "success": false,
189 | "message": err.Error(),
190 | })
191 | return
192 | }
193 | user.GitHubId = githubUser.Login
194 | err = user.Update(false)
195 | if err != nil {
196 | c.JSON(http.StatusOK, gin.H{
197 | "success": false,
198 | "message": err.Error(),
199 | })
200 | return
201 | }
202 | c.JSON(http.StatusOK, gin.H{
203 | "success": true,
204 | "message": "bind",
205 | })
206 | return
207 | }
208 |
--------------------------------------------------------------------------------
/model/cache.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "math/rand"
8 | "one-api/common"
9 | "strconv"
10 | "strings"
11 | "sync"
12 | "time"
13 | )
14 |
15 | var (
16 | TokenCacheSeconds = common.SyncFrequency
17 | UserId2GroupCacheSeconds = common.SyncFrequency
18 | UserId2QuotaCacheSeconds = common.SyncFrequency
19 | UserId2StatusCacheSeconds = common.SyncFrequency
20 | )
21 |
22 | func CacheGetTokenByKey(key string) (*Token, error) {
23 | var token Token
24 | if !common.RedisEnabled {
25 | err := DB.Where("`key` = ?", key).First(&token).Error
26 | return &token, err
27 | }
28 | tokenObjectString, err := common.RedisGet(fmt.Sprintf("token:%s", key))
29 | if err != nil {
30 | err := DB.Where("`key` = ?", key).First(&token).Error
31 | if err != nil {
32 | return nil, err
33 | }
34 | jsonBytes, err := json.Marshal(token)
35 | if err != nil {
36 | return nil, err
37 | }
38 | err = common.RedisSet(fmt.Sprintf("token:%s", key), string(jsonBytes), time.Duration(TokenCacheSeconds)*time.Second)
39 | if err != nil {
40 | common.SysError("Redis set token error: " + err.Error())
41 | }
42 | return &token, nil
43 | }
44 | err = json.Unmarshal([]byte(tokenObjectString), &token)
45 | return &token, err
46 | }
47 |
48 | func CacheGetUserGroup(id int) (group string, err error) {
49 | if !common.RedisEnabled {
50 | return GetUserGroup(id)
51 | }
52 | group, err = common.RedisGet(fmt.Sprintf("user_group:%d", id))
53 | if err != nil {
54 | group, err = GetUserGroup(id)
55 | if err != nil {
56 | return "", err
57 | }
58 | err = common.RedisSet(fmt.Sprintf("user_group:%d", id), group, time.Duration(UserId2GroupCacheSeconds)*time.Second)
59 | if err != nil {
60 | common.SysError("Redis set user group error: " + err.Error())
61 | }
62 | }
63 | return group, err
64 | }
65 |
66 | func CacheGetUserQuota(id int) (quota int, err error) {
67 | if !common.RedisEnabled {
68 | return GetUserQuota(id)
69 | }
70 | quotaString, err := common.RedisGet(fmt.Sprintf("user_quota:%d", id))
71 | if err != nil {
72 | quota, err = GetUserQuota(id)
73 | if err != nil {
74 | return 0, err
75 | }
76 | err = common.RedisSet(fmt.Sprintf("user_quota:%d", id), fmt.Sprintf("%d", quota), time.Duration(UserId2QuotaCacheSeconds)*time.Second)
77 | if err != nil {
78 | common.SysError("Redis set user quota error: " + err.Error())
79 | }
80 | return quota, err
81 | }
82 | quota, err = strconv.Atoi(quotaString)
83 | return quota, err
84 | }
85 |
86 | func CacheUpdateUserQuota(id int) error {
87 | if !common.RedisEnabled {
88 | return nil
89 | }
90 | quota, err := GetUserQuota(id)
91 | if err != nil {
92 | return err
93 | }
94 | err = common.RedisSet(fmt.Sprintf("user_quota:%d", id), fmt.Sprintf("%d", quota), time.Duration(UserId2QuotaCacheSeconds)*time.Second)
95 | return err
96 | }
97 |
98 | func CacheDecreaseUserQuota(id int, quota int) error {
99 | if !common.RedisEnabled {
100 | return nil
101 | }
102 | err := common.RedisDecrease(fmt.Sprintf("user_quota:%d", id), int64(quota))
103 | return err
104 | }
105 |
106 | func CacheIsUserEnabled(userId int) bool {
107 | if !common.RedisEnabled {
108 | return IsUserEnabled(userId)
109 | }
110 | enabled, err := common.RedisGet(fmt.Sprintf("user_enabled:%d", userId))
111 | if err != nil {
112 | status := common.UserStatusDisabled
113 | if IsUserEnabled(userId) {
114 | status = common.UserStatusEnabled
115 | }
116 | enabled = fmt.Sprintf("%d", status)
117 | err = common.RedisSet(fmt.Sprintf("user_enabled:%d", userId), enabled, time.Duration(UserId2StatusCacheSeconds)*time.Second)
118 | if err != nil {
119 | common.SysError("Redis set user enabled error: " + err.Error())
120 | }
121 | }
122 | return enabled == "1"
123 | }
124 |
125 | var group2model2channels map[string]map[string][]*Channel
126 | var channelSyncLock sync.RWMutex
127 |
128 | func InitChannelCache() {
129 | newChannelId2channel := make(map[int]*Channel)
130 | var channels []*Channel
131 | DB.Where("status = ?", common.ChannelStatusEnabled).Find(&channels)
132 | for _, channel := range channels {
133 | newChannelId2channel[channel.Id] = channel
134 | }
135 | var abilities []*Ability
136 | DB.Find(&abilities)
137 | groups := make(map[string]bool)
138 | for _, ability := range abilities {
139 | groups[ability.Group] = true
140 | }
141 | newGroup2model2channels := make(map[string]map[string][]*Channel)
142 | for group := range groups {
143 | newGroup2model2channels[group] = make(map[string][]*Channel)
144 | }
145 | for _, channel := range channels {
146 | groups := strings.Split(channel.Group, ",")
147 | for _, group := range groups {
148 | models := strings.Split(channel.Models, ",")
149 | for _, model := range models {
150 | if _, ok := newGroup2model2channels[group][model]; !ok {
151 | newGroup2model2channels[group][model] = make([]*Channel, 0)
152 | }
153 | newGroup2model2channels[group][model] = append(newGroup2model2channels[group][model], channel)
154 | }
155 | }
156 | }
157 | channelSyncLock.Lock()
158 | group2model2channels = newGroup2model2channels
159 | channelSyncLock.Unlock()
160 | common.SysLog("channels synced from database")
161 | }
162 |
163 | func SyncChannelCache(frequency int) {
164 | for {
165 | time.Sleep(time.Duration(frequency) * time.Second)
166 | common.SysLog("syncing channels from database")
167 | InitChannelCache()
168 | }
169 | }
170 |
171 | func CacheGetRandomSatisfiedChannel(group string, model string) (*Channel, error) {
172 | if !common.RedisEnabled {
173 | return GetRandomSatisfiedChannel(group, model)
174 | }
175 | channelSyncLock.RLock()
176 | defer channelSyncLock.RUnlock()
177 | channels := group2model2channels[group][model]
178 | if len(channels) == 0 {
179 | return nil, errors.New("channel not found")
180 | }
181 | idx := rand.Intn(len(channels))
182 | return channels[idx], nil
183 | }
184 |
--------------------------------------------------------------------------------
/controller/token.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "net/http"
6 | "one-api/common"
7 | "one-api/model"
8 | "strconv"
9 | )
10 |
11 | func GetAllTokens(c *gin.Context) {
12 | userId := c.GetInt("id")
13 | p, _ := strconv.Atoi(c.Query("p"))
14 | if p < 0 {
15 | p = 0
16 | }
17 | tokens, err := model.GetAllUserTokens(userId, p*common.ItemsPerPage, common.ItemsPerPage)
18 | if err != nil {
19 | c.JSON(http.StatusOK, gin.H{
20 | "success": false,
21 | "message": err.Error(),
22 | })
23 | return
24 | }
25 | c.JSON(http.StatusOK, gin.H{
26 | "success": true,
27 | "message": "",
28 | "data": tokens,
29 | })
30 | return
31 | }
32 |
33 | func SearchTokens(c *gin.Context) {
34 | userId := c.GetInt("id")
35 | keyword := c.Query("keyword")
36 | tokens, err := model.SearchUserTokens(userId, keyword)
37 | if err != nil {
38 | c.JSON(http.StatusOK, gin.H{
39 | "success": false,
40 | "message": err.Error(),
41 | })
42 | return
43 | }
44 | c.JSON(http.StatusOK, gin.H{
45 | "success": true,
46 | "message": "",
47 | "data": tokens,
48 | })
49 | return
50 | }
51 |
52 | func GetToken(c *gin.Context) {
53 | id, err := strconv.Atoi(c.Param("id"))
54 | userId := c.GetInt("id")
55 | if err != nil {
56 | c.JSON(http.StatusOK, gin.H{
57 | "success": false,
58 | "message": err.Error(),
59 | })
60 | return
61 | }
62 | token, err := model.GetTokenByIds(id, userId)
63 | if err != nil {
64 | c.JSON(http.StatusOK, gin.H{
65 | "success": false,
66 | "message": err.Error(),
67 | })
68 | return
69 | }
70 | c.JSON(http.StatusOK, gin.H{
71 | "success": true,
72 | "message": "",
73 | "data": token,
74 | })
75 | return
76 | }
77 |
78 | func GetTokenStatus(c *gin.Context) {
79 | tokenId := c.GetInt("token_id")
80 | userId := c.GetInt("id")
81 | token, err := model.GetTokenByIds(tokenId, userId)
82 | if err != nil {
83 | c.JSON(http.StatusOK, gin.H{
84 | "success": false,
85 | "message": err.Error(),
86 | })
87 | return
88 | }
89 | expiredAt := token.ExpiredTime
90 | if expiredAt == -1 {
91 | expiredAt = 0
92 | }
93 | c.JSON(http.StatusOK, gin.H{
94 | "object": "credit_summary",
95 | "total_granted": token.RemainQuota,
96 | "total_used": 0, // not supported currently
97 | "total_available": token.RemainQuota,
98 | "expires_at": expiredAt * 1000,
99 | })
100 | }
101 |
102 | func AddToken(c *gin.Context) {
103 | token := model.Token{}
104 | err := c.ShouldBindJSON(&token)
105 | if err != nil {
106 | c.JSON(http.StatusOK, gin.H{
107 | "success": false,
108 | "message": err.Error(),
109 | })
110 | return
111 | }
112 | if len(token.Name) > 30 {
113 | c.JSON(http.StatusOK, gin.H{
114 | "success": false,
115 | "message": "令牌名称过长",
116 | })
117 | return
118 | }
119 | cleanToken := model.Token{
120 | UserId: c.GetInt("id"),
121 | Name: token.Name,
122 | Key: common.GenerateKey(),
123 | CreatedTime: common.GetTimestamp(),
124 | AccessedTime: common.GetTimestamp(),
125 | ExpiredTime: token.ExpiredTime,
126 | RemainQuota: token.RemainQuota,
127 | UnlimitedQuota: token.UnlimitedQuota,
128 | }
129 | err = cleanToken.Insert()
130 | if err != nil {
131 | c.JSON(http.StatusOK, gin.H{
132 | "success": false,
133 | "message": err.Error(),
134 | })
135 | return
136 | }
137 | c.JSON(http.StatusOK, gin.H{
138 | "success": true,
139 | "message": "",
140 | })
141 | return
142 | }
143 |
144 | func DeleteToken(c *gin.Context) {
145 | id, _ := strconv.Atoi(c.Param("id"))
146 | userId := c.GetInt("id")
147 | err := model.DeleteTokenById(id, userId)
148 | if err != nil {
149 | c.JSON(http.StatusOK, gin.H{
150 | "success": false,
151 | "message": err.Error(),
152 | })
153 | return
154 | }
155 | c.JSON(http.StatusOK, gin.H{
156 | "success": true,
157 | "message": "",
158 | })
159 | return
160 | }
161 |
162 | func UpdateToken(c *gin.Context) {
163 | userId := c.GetInt("id")
164 | statusOnly := c.Query("status_only")
165 | token := model.Token{}
166 | err := c.ShouldBindJSON(&token)
167 | if err != nil {
168 | c.JSON(http.StatusOK, gin.H{
169 | "success": false,
170 | "message": err.Error(),
171 | })
172 | return
173 | }
174 | if len(token.Name) > 30 {
175 | c.JSON(http.StatusOK, gin.H{
176 | "success": false,
177 | "message": "令牌名称过长",
178 | })
179 | return
180 | }
181 | cleanToken, err := model.GetTokenByIds(token.Id, userId)
182 | if err != nil {
183 | c.JSON(http.StatusOK, gin.H{
184 | "success": false,
185 | "message": err.Error(),
186 | })
187 | return
188 | }
189 | if token.Status == common.TokenStatusEnabled {
190 | if cleanToken.Status == common.TokenStatusExpired && cleanToken.ExpiredTime <= common.GetTimestamp() && cleanToken.ExpiredTime != -1 {
191 | c.JSON(http.StatusOK, gin.H{
192 | "success": false,
193 | "message": "令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期",
194 | })
195 | return
196 | }
197 | if cleanToken.Status == common.TokenStatusExhausted && cleanToken.RemainQuota <= 0 && !cleanToken.UnlimitedQuota {
198 | c.JSON(http.StatusOK, gin.H{
199 | "success": false,
200 | "message": "令牌可用额度已用尽,无法启用,请先修改令牌剩余额度,或者设置为无限额度",
201 | })
202 | return
203 | }
204 | }
205 | if statusOnly != "" {
206 | cleanToken.Status = token.Status
207 | } else {
208 | // If you add more fields, please also update token.Update()
209 | cleanToken.Name = token.Name
210 | cleanToken.ExpiredTime = token.ExpiredTime
211 | cleanToken.RemainQuota = token.RemainQuota
212 | cleanToken.UnlimitedQuota = token.UnlimitedQuota
213 | }
214 | err = cleanToken.Update()
215 | if err != nil {
216 | c.JSON(http.StatusOK, gin.H{
217 | "success": false,
218 | "message": err.Error(),
219 | })
220 | return
221 | }
222 | c.JSON(http.StatusOK, gin.H{
223 | "success": true,
224 | "message": "",
225 | "data": cleanToken,
226 | })
227 | return
228 | }
229 |
--------------------------------------------------------------------------------
/controller/relay-utils.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "github.com/gin-gonic/gin"
7 | "github.com/pkoukk/tiktoken-go"
8 | "io"
9 | "net/http"
10 | "one-api/common"
11 | "strconv"
12 | )
13 |
14 | var stopFinishReason = "stop"
15 |
16 | var tokenEncoderMap = map[string]*tiktoken.Tiktoken{}
17 |
18 | func InitTokenEncoders() {
19 | common.SysLog("initializing token encoders")
20 | fallbackTokenEncoder, err := tiktoken.EncodingForModel("gpt-3.5-turbo")
21 | if err != nil {
22 | common.FatalLog(fmt.Sprintf("failed to get fallback token encoder: %s", err.Error()))
23 | }
24 | for model, _ := range common.ModelRatio {
25 | tokenEncoder, err := tiktoken.EncodingForModel(model)
26 | if err != nil {
27 | common.SysError(fmt.Sprintf("using fallback encoder for model %s", model))
28 | tokenEncoderMap[model] = fallbackTokenEncoder
29 | continue
30 | }
31 | tokenEncoderMap[model] = tokenEncoder
32 | }
33 | common.SysLog("token encoders initialized")
34 | }
35 |
36 | func getTokenEncoder(model string) *tiktoken.Tiktoken {
37 | if tokenEncoder, ok := tokenEncoderMap[model]; ok {
38 | return tokenEncoder
39 | }
40 | tokenEncoder, err := tiktoken.EncodingForModel(model)
41 | if err != nil {
42 | common.SysError(fmt.Sprintf("failed to get token encoder for model %s: %s, using encoder for gpt-3.5-turbo", model, err.Error()))
43 | tokenEncoder, err = tiktoken.EncodingForModel("gpt-3.5-turbo")
44 | if err != nil {
45 | common.FatalLog(fmt.Sprintf("failed to get token encoder for model gpt-3.5-turbo: %s", err.Error()))
46 | }
47 | }
48 | tokenEncoderMap[model] = tokenEncoder
49 | return tokenEncoder
50 | }
51 |
52 | func getTokenNum(tokenEncoder *tiktoken.Tiktoken, text string) int {
53 | if common.ApproximateTokenEnabled {
54 | return int(float64(len(text)) * 0.38)
55 | }
56 | return len(tokenEncoder.Encode(text, nil, nil))
57 | }
58 |
59 | func countTokenMessages(messages []Message, model string) int {
60 | tokenEncoder := getTokenEncoder(model)
61 | // Reference:
62 | // https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
63 | // https://github.com/pkoukk/tiktoken-go/issues/6
64 | //
65 | // Every message follows <|start|>{role/name}\n{content}<|end|>\n
66 | var tokensPerMessage int
67 | var tokensPerName int
68 | if model == "gpt-3.5-turbo-0301" {
69 | tokensPerMessage = 4
70 | tokensPerName = -1 // If there's a name, the role is omitted
71 | } else {
72 | tokensPerMessage = 3
73 | tokensPerName = 1
74 | }
75 | tokenNum := 0
76 | for _, message := range messages {
77 | tokenNum += tokensPerMessage
78 | tokenNum += getTokenNum(tokenEncoder, message.Content)
79 | tokenNum += getTokenNum(tokenEncoder, message.Role)
80 | if message.Name != nil {
81 | tokenNum += tokensPerName
82 | tokenNum += getTokenNum(tokenEncoder, *message.Name)
83 | }
84 | }
85 | tokenNum += 3 // Every reply is primed with <|start|>assistant<|message|>
86 | return tokenNum
87 | }
88 |
89 | func countTokenInput(input any, model string) int {
90 | switch input.(type) {
91 | case string:
92 | return countTokenText(input.(string), model)
93 | case []string:
94 | text := ""
95 | for _, s := range input.([]string) {
96 | text += s
97 | }
98 | return countTokenText(text, model)
99 | }
100 | return 0
101 | }
102 |
103 | func countTokenText(text string, model string) int {
104 | tokenEncoder := getTokenEncoder(model)
105 | return getTokenNum(tokenEncoder, text)
106 | }
107 |
108 | func errorWrapper(err error, code string, statusCode int) *OpenAIErrorWithStatusCode {
109 | openAIError := OpenAIError{
110 | Message: err.Error(),
111 | Type: "one_api_error",
112 | Code: code,
113 | }
114 | return &OpenAIErrorWithStatusCode{
115 | OpenAIError: openAIError,
116 | StatusCode: statusCode,
117 | }
118 | }
119 |
120 | func shouldDisableChannel(err *OpenAIError, statusCode int) bool {
121 | if !common.AutomaticDisableChannelEnabled {
122 | return false
123 | }
124 | if err == nil {
125 | return false
126 | }
127 | if statusCode == http.StatusUnauthorized {
128 | return true
129 | }
130 | if err.Type == "insufficient_quota" || err.Code == "invalid_api_key" || err.Code == "account_deactivated" {
131 | return true
132 | }
133 | return false
134 | }
135 |
136 | func shouldTemporarilyDisableChannel(c *gin.Context, err *OpenAIError, statusCode int) bool {
137 | if !c.GetBool("overFrequencyAutoDisable") {
138 | return false
139 | }
140 | if err == nil {
141 | return false
142 | }
143 | if statusCode == http.StatusTooManyRequests {
144 | return true
145 | }
146 | if err.Type == "insufficient_quota" || err.Code == "invalid_api_key" || err.Code == "account_deactivated" {
147 | return true
148 | }
149 | return false
150 | }
151 |
152 | func setEventStreamHeaders(c *gin.Context) {
153 | c.Writer.Header().Set("Content-Type", "text/event-stream")
154 | c.Writer.Header().Set("Cache-Control", "no-cache")
155 | c.Writer.Header().Set("Connection", "keep-alive")
156 | c.Writer.Header().Set("Transfer-Encoding", "chunked")
157 | c.Writer.Header().Set("X-Accel-Buffering", "no")
158 | }
159 |
160 | func relayErrorHandler(resp *http.Response) (openAIErrorWithStatusCode *OpenAIErrorWithStatusCode) {
161 | openAIErrorWithStatusCode = &OpenAIErrorWithStatusCode{
162 | StatusCode: resp.StatusCode,
163 | OpenAIError: OpenAIError{
164 | Message: fmt.Sprintf("bad response status code %d", resp.StatusCode),
165 | Type: "one_api_error",
166 | Code: "bad_response_status_code",
167 | Param: strconv.Itoa(resp.StatusCode),
168 | },
169 | }
170 | responseBody, err := io.ReadAll(resp.Body)
171 | if err != nil {
172 | return
173 | }
174 | err = resp.Body.Close()
175 | if err != nil {
176 | return
177 | }
178 | var textResponse TextResponse
179 | err = json.Unmarshal(responseBody, &textResponse)
180 | if err != nil {
181 | return
182 | }
183 | openAIErrorWithStatusCode.OpenAIError = textResponse.Error
184 | return
185 | }
186 |
--------------------------------------------------------------------------------
/web/src/pages/Token/EditToken.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Button, Form, Header, Message, Segment } from 'semantic-ui-react';
3 | import { useParams, useNavigate } from 'react-router-dom';
4 | import { API, showError, showSuccess, timestamp2string } from '../../helpers';
5 | import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
6 |
7 | const EditToken = () => {
8 | const params = useParams();
9 | const tokenId = params.id;
10 | const isEdit = tokenId !== undefined;
11 | const [loading, setLoading] = useState(isEdit);
12 | const originInputs = {
13 | name: '',
14 | remain_quota: isEdit ? 0 : 500000,
15 | expired_time: -1,
16 | unlimited_quota: false
17 | };
18 | const [inputs, setInputs] = useState(originInputs);
19 | const { name, remain_quota, expired_time, unlimited_quota } = inputs;
20 | const navigate = useNavigate();
21 | const handleInputChange = (e, { name, value }) => {
22 | setInputs((inputs) => ({ ...inputs, [name]: value }));
23 | };
24 | const handleCancel = () => {
25 | navigate("/token");
26 | }
27 | const setExpiredTime = (month, day, hour, minute) => {
28 | let now = new Date();
29 | let timestamp = now.getTime() / 1000;
30 | let seconds = month * 30 * 24 * 60 * 60;
31 | seconds += day * 24 * 60 * 60;
32 | seconds += hour * 60 * 60;
33 | seconds += minute * 60;
34 | if (seconds !== 0) {
35 | timestamp += seconds;
36 | setInputs({ ...inputs, expired_time: timestamp2string(timestamp) });
37 | } else {
38 | setInputs({ ...inputs, expired_time: -1 });
39 | }
40 | };
41 |
42 | const setUnlimitedQuota = () => {
43 | setInputs({ ...inputs, unlimited_quota: !unlimited_quota });
44 | };
45 |
46 | const loadToken = async () => {
47 | let res = await API.get(`/api/token/${tokenId}`);
48 | const { success, message, data } = res.data;
49 | if (success) {
50 | if (data.expired_time !== -1) {
51 | data.expired_time = timestamp2string(data.expired_time);
52 | }
53 | setInputs(data);
54 | } else {
55 | showError(message);
56 | }
57 | setLoading(false);
58 | };
59 | useEffect(() => {
60 | if (isEdit) {
61 | loadToken().then();
62 | }
63 | }, []);
64 |
65 | const submit = async () => {
66 | if (!isEdit && inputs.name === '') return;
67 | let localInputs = inputs;
68 | localInputs.remain_quota = parseInt(localInputs.remain_quota);
69 | if (localInputs.expired_time !== -1) {
70 | let time = Date.parse(localInputs.expired_time);
71 | if (isNaN(time)) {
72 | showError('过期时间格式错误!');
73 | return;
74 | }
75 | localInputs.expired_time = Math.ceil(time / 1000);
76 | }
77 | let res;
78 | if (isEdit) {
79 | res = await API.put(`/api/token/`, { ...localInputs, id: parseInt(tokenId) });
80 | } else {
81 | res = await API.post(`/api/token/`, localInputs);
82 | }
83 | const { success, message } = res.data;
84 | if (success) {
85 | if (isEdit) {
86 | showSuccess('令牌更新成功!');
87 | } else {
88 | showSuccess('令牌创建成功,请在列表页面点击复制获取令牌!');
89 | setInputs(originInputs);
90 | }
91 | } else {
92 | showError(message);
93 | }
94 | };
95 |
96 | return (
97 | <>
98 |
99 | {isEdit ? '更新令牌信息' : '创建新的令牌'}
100 |
102 |
111 |
112 |
113 |
122 |
123 |
124 |
127 |
130 |
133 |
136 |
139 |
140 | 注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。
141 |
142 |
152 |
153 |
156 |
157 |
158 |
159 |
160 | >
161 | );
162 | };
163 |
164 | export default EditToken;
165 |
--------------------------------------------------------------------------------
/model/log.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "gorm.io/gorm"
5 | "one-api/common"
6 | )
7 |
8 | type Log struct {
9 | Id int `json:"id"`
10 | UserId int `json:"user_id"`
11 | CreatedAt int64 `json:"created_at" gorm:"bigint;index"`
12 | Type int `json:"type" gorm:"index"`
13 | Content string `json:"content"`
14 | Username string `json:"username" gorm:"index;default:''"`
15 | TokenName string `json:"token_name" gorm:"index;default:''"`
16 | ChannelName string `json:"channel_name" gorm:"index;default:''"`
17 | ModelName string `json:"model_name" gorm:"index;default:''"`
18 | Quota int `json:"quota" gorm:"default:0"`
19 | PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
20 | CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
21 | }
22 |
23 | const (
24 | LogTypeUnknown = iota
25 | LogTypeTopup
26 | LogTypeConsume
27 | LogTypeManage
28 | LogTypeSystem
29 | )
30 |
31 | func RecordLog(userId int, logType int, content string) {
32 | if logType == LogTypeConsume && !common.LogConsumeEnabled {
33 | return
34 | }
35 | log := &Log{
36 | UserId: userId,
37 | Username: GetUsernameById(userId),
38 | CreatedAt: common.GetTimestamp(),
39 | Type: logType,
40 | Content: content,
41 | }
42 | err := DB.Create(log).Error
43 | if err != nil {
44 | common.SysError("failed to record log: " + err.Error())
45 | }
46 | }
47 |
48 | func RecordConsumeLog(userId int, promptTokens int, completionTokens int, modelName string, tokenName string, channelName string, quota int, content string) {
49 | if !common.LogConsumeEnabled {
50 | return
51 | }
52 | log := &Log{
53 | UserId: userId,
54 | Username: GetUsernameById(userId),
55 | CreatedAt: common.GetTimestamp(),
56 | Type: LogTypeConsume,
57 | Content: content,
58 | PromptTokens: promptTokens,
59 | CompletionTokens: completionTokens,
60 | TokenName: tokenName,
61 | ChannelName: channelName,
62 | ModelName: modelName,
63 | Quota: quota,
64 | }
65 | err := DB.Create(log).Error
66 | if err != nil {
67 | common.SysError("failed to record log: " + err.Error())
68 | }
69 | }
70 |
71 | func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channelName string, startIdx int, num int) (logs []*Log, err error) {
72 | var tx *gorm.DB
73 | if logType == LogTypeUnknown {
74 | tx = DB
75 | } else {
76 | tx = DB.Where("type = ?", logType)
77 | }
78 | if modelName != "" {
79 | tx = tx.Where("model_name = ?", modelName)
80 | }
81 | if username != "" {
82 | tx = tx.Where("username = ?", username)
83 | }
84 | if tokenName != "" {
85 | tx = tx.Where("token_name = ?", tokenName)
86 | }
87 | if startTimestamp != 0 {
88 | tx = tx.Where("created_at >= ?", startTimestamp)
89 | }
90 | if endTimestamp != 0 {
91 | tx = tx.Where("created_at <= ?", endTimestamp)
92 | }
93 | if channelName != "" {
94 | tx = tx.Where("channel_name = ?", channelName)
95 | }
96 | err = tx.Order("id desc").Limit(num).Offset(startIdx).Find(&logs).Error
97 | return logs, err
98 | }
99 |
100 | func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int) (logs []*Log, err error) {
101 | var tx *gorm.DB
102 | if logType == LogTypeUnknown {
103 | tx = DB.Where("user_id = ?", userId)
104 | } else {
105 | tx = DB.Where("user_id = ? and type = ?", userId, logType)
106 | }
107 | if modelName != "" {
108 | tx = tx.Where("model_name = ?", modelName)
109 | }
110 | if tokenName != "" {
111 | tx = tx.Where("token_name = ?", tokenName)
112 | }
113 | if startTimestamp != 0 {
114 | tx = tx.Where("created_at >= ?", startTimestamp)
115 | }
116 | if endTimestamp != 0 {
117 | tx = tx.Where("created_at <= ?", endTimestamp)
118 | }
119 | err = tx.Order("id desc").Limit(num).Offset(startIdx).Omit("id").Find(&logs).Error
120 | return logs, err
121 | }
122 |
123 | func SearchAllLogs(keyword string) (logs []*Log, err error) {
124 | err = DB.Where("type = ? or content LIKE ?", keyword, keyword+"%").Order("id desc").Limit(common.MaxRecentItems).Find(&logs).Error
125 | return logs, err
126 | }
127 |
128 | func SearchUserLogs(userId int, keyword string) (logs []*Log, err error) {
129 | err = DB.Where("user_id = ? and type = ?", userId, keyword).Order("id desc").Limit(common.MaxRecentItems).Omit("id").Find(&logs).Error
130 | return logs, err
131 | }
132 |
133 | func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string) (quota int) {
134 | tx := DB.Table("logs").Select("sum(quota)")
135 | if username != "" {
136 | tx = tx.Where("username = ?", username)
137 | }
138 | if tokenName != "" {
139 | tx = tx.Where("token_name = ?", tokenName)
140 | }
141 | if startTimestamp != 0 {
142 | tx = tx.Where("created_at >= ?", startTimestamp)
143 | }
144 | if endTimestamp != 0 {
145 | tx = tx.Where("created_at <= ?", endTimestamp)
146 | }
147 | if modelName != "" {
148 | tx = tx.Where("model_name = ?", modelName)
149 | }
150 | tx.Where("type = ?", LogTypeConsume).Scan("a)
151 | return quota
152 | }
153 |
154 | func SumUsedToken(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string) (token int) {
155 | tx := DB.Table("logs").Select("sum(prompt_tokens) + sum(completion_tokens)")
156 | if username != "" {
157 | tx = tx.Where("username = ?", username)
158 | }
159 | if tokenName != "" {
160 | tx = tx.Where("token_name = ?", tokenName)
161 | }
162 | if startTimestamp != 0 {
163 | tx = tx.Where("created_at >= ?", startTimestamp)
164 | }
165 | if endTimestamp != 0 {
166 | tx = tx.Where("created_at <= ?", endTimestamp)
167 | }
168 | if modelName != "" {
169 | tx = tx.Where("model_name = ?", modelName)
170 | }
171 | tx.Where("type = ?", LogTypeConsume).Scan(&token)
172 | return token
173 | }
174 |
--------------------------------------------------------------------------------
/controller/misc.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 | "one-api/common"
8 | "one-api/model"
9 | "strings"
10 |
11 | "github.com/gin-gonic/gin"
12 | )
13 |
14 | func GetStatus(c *gin.Context) {
15 | c.JSON(http.StatusOK, gin.H{
16 | "success": true,
17 | "message": "",
18 | "data": gin.H{
19 | "version": common.Version,
20 | "start_time": common.StartTime,
21 | "email_verification": common.EmailVerificationEnabled,
22 | "github_oauth": common.GitHubOAuthEnabled,
23 | "github_client_id": common.GitHubClientId,
24 | "system_name": common.SystemName,
25 | "logo": common.Logo,
26 | "footer_html": common.Footer,
27 | "wechat_qrcode": common.WeChatAccountQRCodeImageURL,
28 | "wechat_login": common.WeChatAuthEnabled,
29 | "server_address": common.ServerAddress,
30 | "turnstile_check": common.TurnstileCheckEnabled,
31 | "turnstile_site_key": common.TurnstileSiteKey,
32 | "top_up_link": common.TopUpLink,
33 | "chat_link": common.ChatLink,
34 | "quota_per_unit": common.QuotaPerUnit,
35 | "display_in_currency": common.DisplayInCurrencyEnabled,
36 | },
37 | })
38 | return
39 | }
40 |
41 | func GetNotice(c *gin.Context) {
42 | common.OptionMapRWMutex.RLock()
43 | defer common.OptionMapRWMutex.RUnlock()
44 | c.JSON(http.StatusOK, gin.H{
45 | "success": true,
46 | "message": "",
47 | "data": common.OptionMap["Notice"],
48 | })
49 | return
50 | }
51 |
52 | func GetAbout(c *gin.Context) {
53 | common.OptionMapRWMutex.RLock()
54 | defer common.OptionMapRWMutex.RUnlock()
55 | c.JSON(http.StatusOK, gin.H{
56 | "success": true,
57 | "message": "",
58 | "data": common.OptionMap["About"],
59 | })
60 | return
61 | }
62 |
63 | func GetHomePageContent(c *gin.Context) {
64 | common.OptionMapRWMutex.RLock()
65 | defer common.OptionMapRWMutex.RUnlock()
66 | c.JSON(http.StatusOK, gin.H{
67 | "success": true,
68 | "message": "",
69 | "data": common.OptionMap["HomePageContent"],
70 | })
71 | return
72 | }
73 |
74 | func SendEmailVerification(c *gin.Context) {
75 | email := c.Query("email")
76 | if err := common.Validate.Var(email, "required,email"); err != nil {
77 | c.JSON(http.StatusOK, gin.H{
78 | "success": false,
79 | "message": "无效的参数",
80 | })
81 | return
82 | }
83 | if common.EmailDomainRestrictionEnabled {
84 | allowed := false
85 | for _, domain := range common.EmailDomainWhitelist {
86 | if strings.HasSuffix(email, "@"+domain) {
87 | allowed = true
88 | break
89 | }
90 | }
91 | if !allowed {
92 | c.JSON(http.StatusOK, gin.H{
93 | "success": false,
94 | "message": "管理员启用了邮箱域名白名单,您的邮箱地址的域名不在白名单中",
95 | })
96 | return
97 | }
98 | }
99 | if model.IsEmailAlreadyTaken(email) {
100 | c.JSON(http.StatusOK, gin.H{
101 | "success": false,
102 | "message": "邮箱地址已被占用",
103 | })
104 | return
105 | }
106 | code := common.GenerateVerificationCode(6)
107 | common.RegisterVerificationCodeWithKey(email, code, common.EmailVerificationPurpose)
108 | subject := fmt.Sprintf("%s邮箱验证邮件", common.SystemName)
109 | content := fmt.Sprintf("您好,你正在进行%s邮箱验证。
"+
110 | "您的验证码为: %s
"+
111 | "验证码 %d 分钟内有效,如果不是本人操作,请忽略。
", common.SystemName, code, common.VerificationValidMinutes)
112 | err := common.SendEmail(subject, email, content)
113 | if err != nil {
114 | c.JSON(http.StatusOK, gin.H{
115 | "success": false,
116 | "message": err.Error(),
117 | })
118 | return
119 | }
120 | c.JSON(http.StatusOK, gin.H{
121 | "success": true,
122 | "message": "",
123 | })
124 | return
125 | }
126 |
127 | func SendPasswordResetEmail(c *gin.Context) {
128 | email := c.Query("email")
129 | if err := common.Validate.Var(email, "required,email"); err != nil {
130 | c.JSON(http.StatusOK, gin.H{
131 | "success": false,
132 | "message": "无效的参数",
133 | })
134 | return
135 | }
136 | if !model.IsEmailAlreadyTaken(email) {
137 | c.JSON(http.StatusOK, gin.H{
138 | "success": false,
139 | "message": "该邮箱地址未注册",
140 | })
141 | return
142 | }
143 | code := common.GenerateVerificationCode(0)
144 | common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
145 | link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", common.ServerAddress, email, code)
146 | subject := fmt.Sprintf("%s密码重置", common.SystemName)
147 | content := fmt.Sprintf("您好,你正在进行%s密码重置。
"+
148 | "点击 此处 进行密码重置。
"+
149 | "如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:
%s
"+
150 | "重置链接 %d 分钟内有效,如果不是本人操作,请忽略。
", common.SystemName, link, link, common.VerificationValidMinutes)
151 | err := common.SendEmail(subject, email, content)
152 | if err != nil {
153 | c.JSON(http.StatusOK, gin.H{
154 | "success": false,
155 | "message": err.Error(),
156 | })
157 | return
158 | }
159 | c.JSON(http.StatusOK, gin.H{
160 | "success": true,
161 | "message": "",
162 | })
163 | return
164 | }
165 |
166 | type PasswordResetRequest struct {
167 | Email string `json:"email"`
168 | Token string `json:"token"`
169 | }
170 |
171 | func ResetPassword(c *gin.Context) {
172 | var req PasswordResetRequest
173 | err := json.NewDecoder(c.Request.Body).Decode(&req)
174 | if req.Email == "" || req.Token == "" {
175 | c.JSON(http.StatusOK, gin.H{
176 | "success": false,
177 | "message": "无效的参数",
178 | })
179 | return
180 | }
181 | if !common.VerifyCodeWithKey(req.Email, req.Token, common.PasswordResetPurpose) {
182 | c.JSON(http.StatusOK, gin.H{
183 | "success": false,
184 | "message": "重置链接非法或已过期",
185 | })
186 | return
187 | }
188 | password := common.GenerateVerificationCode(12)
189 | err = model.ResetUserPasswordByEmail(req.Email, password)
190 | if err != nil {
191 | c.JSON(http.StatusOK, gin.H{
192 | "success": false,
193 | "message": err.Error(),
194 | })
195 | return
196 | }
197 | common.DeleteKey(req.Email, common.PasswordResetPurpose)
198 | c.JSON(http.StatusOK, gin.H{
199 | "success": true,
200 | "message": "",
201 | "data": password,
202 | })
203 | return
204 | }
205 |
--------------------------------------------------------------------------------