Merge remote-tracking branch 'origin/main'

This commit is contained in:
CaIon
2025-04-03 19:09:26 +08:00
11 changed files with 491 additions and 1 deletions

3
constant/setup.go Normal file
View File

@@ -0,0 +1,3 @@
package constant
var Setup = false

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/model"
"one-api/setting"
"one-api/setting/operation_setting"
@@ -72,6 +73,7 @@ func GetStatus(c *gin.Context) {
"oidc_enabled": system_setting.GetOIDCSettings().Enabled,
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
"setup": constant.Setup,
},
})
return

167
controller/setup.go Normal file
View File

@@ -0,0 +1,167 @@
package controller
import (
"github.com/gin-gonic/gin"
"one-api/common"
"one-api/constant"
"one-api/model"
"one-api/setting/operation_setting"
)
type Setup struct {
Status bool `json:"status"`
RootInit bool `json:"root_init"`
DatabaseType string `json:"database_type"`
}
type SetupRequest struct {
Username string `json:"username"`
Password string `json:"password"`
ConfirmPassword string `json:"confirmPassword"`
SelfUseModeEnabled bool `json:"SelfUseModeEnabled"`
DemoSiteEnabled bool `json:"DemoSiteEnabled"`
}
func GetSetup(c *gin.Context) {
setup := Setup{
Status: constant.Setup,
}
if constant.Setup {
c.JSON(200, gin.H{
"success": true,
"data": setup,
})
return
}
setup.RootInit = model.RootUserExists()
if common.UsingMySQL {
setup.DatabaseType = "mysql"
}
if common.UsingPostgreSQL {
setup.DatabaseType = "postgres"
}
if common.UsingSQLite {
setup.DatabaseType = "sqlite"
}
c.JSON(200, gin.H{
"success": true,
"data": setup,
})
}
func PostSetup(c *gin.Context) {
// Check if setup is already completed
if constant.Setup {
c.JSON(400, gin.H{
"success": false,
"message": "系统已经初始化完成",
})
return
}
// Check if root user already exists
rootExists := model.RootUserExists()
var req SetupRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(400, gin.H{
"success": false,
"message": "请求参数有误",
})
return
}
// If root doesn't exist, validate and create admin account
if !rootExists {
// Validate password
if req.Password != req.ConfirmPassword {
c.JSON(400, gin.H{
"success": false,
"message": "两次输入的密码不一致",
})
return
}
if len(req.Password) < 8 {
c.JSON(400, gin.H{
"success": false,
"message": "密码长度至少为8个字符",
})
return
}
// Create root user
hashedPassword, err := common.Password2Hash(req.Password)
if err != nil {
c.JSON(500, gin.H{
"success": false,
"message": "系统错误: " + err.Error(),
})
return
}
rootUser := model.User{
Username: req.Username,
Password: hashedPassword,
Role: common.RoleRootUser,
Status: common.UserStatusEnabled,
DisplayName: "Root User",
AccessToken: nil,
Quota: 100000000,
}
err = model.DB.Create(&rootUser).Error
if err != nil {
c.JSON(500, gin.H{
"success": false,
"message": "创建管理员账号失败: " + err.Error(),
})
return
}
}
// Set operation modes
operation_setting.SelfUseModeEnabled = req.SelfUseModeEnabled
operation_setting.DemoSiteEnabled = req.DemoSiteEnabled
// Save operation modes to database for persistence
err = model.UpdateOption("self_use_mode", boolToString(req.SelfUseModeEnabled))
if err != nil {
c.JSON(500, gin.H{
"success": false,
"message": "保存自用模式设置失败: " + err.Error(),
})
return
}
err = model.UpdateOption("demo_site_mode", boolToString(req.DemoSiteEnabled))
if err != nil {
c.JSON(500, gin.H{
"success": false,
"message": "保存演示站点模式设置失败: " + err.Error(),
})
return
}
// Update setup status
constant.Setup = true
err = model.UpdateOption("setup", "true")
if err != nil {
c.JSON(500, gin.H{
"success": false,
"message": "设置初始化状态失败: " + err.Error(),
})
return
}
c.JSON(200, gin.H{
"success": true,
"message": "系统初始化成功",
})
}
func boolToString(b bool) string {
if b {
return "true"
}
return "false"
}

View File

@@ -3,6 +3,7 @@ package model
import (
"log"
"one-api/common"
"one-api/constant"
"os"
"strings"
"sync"
@@ -55,6 +56,26 @@ func createRootAccountIfNeed() error {
return nil
}
func checkSetup() {
if GetSetup() == nil {
if RootUserExists() {
common.SysLog("system is not initialized, but root user exists")
// Create setup record
setup := Setup{
Version: common.Version,
InitializedAt: time.Now().Unix(),
}
err := DB.Create(&setup).Error
if err != nil {
common.SysLog("failed to create setup record: " + err.Error())
}
constant.Setup = true
} else {
constant.Setup = false
}
}
}
func chooseDB(envName string) (*gorm.DB, error) {
defer func() {
initCol()
@@ -214,8 +235,10 @@ func migrateDB() error {
if err != nil {
return err
}
err = DB.AutoMigrate(&Setup{})
common.SysLog("database migrated")
err = createRootAccountIfNeed()
checkSetup()
//err = createRootAccountIfNeed()
return err
}

16
model/setup.go Normal file
View File

@@ -0,0 +1,16 @@
package model
type Setup struct {
ID uint `json:"id" gorm:"primaryKey"`
Version string `json:"version" gorm:"type:varchar(50);not null"`
InitializedAt int64 `json:"initialized_at" gorm:"type:bigint;not null"`
}
func GetSetup() *Setup {
var setup Setup
err := DB.First(&setup).Error
if err != nil {
return nil
}
return &setup
}

View File

@@ -808,3 +808,12 @@ func (user *User) FillUserByLinuxDOId() error {
err := DB.Where("linux_do_id = ?", user.LinuxDOId).First(user).Error
return err
}
func RootUserExists() bool {
var user User
err := DB.Where("role = ?", common.RoleRootUser).First(&user).Error
if err != nil {
return false
}
return true
}

View File

@@ -13,6 +13,8 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.Use(gzip.Gzip(gzip.DefaultCompression))
apiRouter.Use(middleware.GlobalAPIRateLimit())
{
apiRouter.GET("/setup", controller.GetSetup)
apiRouter.POST("/setup", controller.PostSetup)
apiRouter.GET("/status", controller.GetStatus)
apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels)
apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus)

View File

@@ -25,6 +25,7 @@ import Task from "./pages/Task/index.js";
import Playground from './pages/Playground/Playground.js';
import OAuth2Callback from "./components/OAuth2Callback.js";
import PersonalSetting from './components/PersonalSetting.js';
import Setup from './pages/Setup/index.js';
const Home = lazy(() => import('./pages/Home'));
const Detail = lazy(() => import('./pages/Detail'));
@@ -44,6 +45,14 @@ function App() {
</Suspense>
}
/>
<Route
path='/setup'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<Setup />
</Suspense>
}
/>
<Route
path='/channel'
element={

View File

@@ -60,6 +60,9 @@ export const StyleProvider = ({ children }) => {
if (pathname === '' || pathname === '/' || pathname.includes('/home') || pathname.includes('/chat')) {
dispatch({ type: 'SET_SIDER', payload: false });
dispatch({ type: 'SET_INNER_PADDING', payload: false });
} else if (pathname === '/setup') {
dispatch({ type: 'SET_SIDER', payload: false });
dispatch({ type: 'SET_INNER_PADDING', payload: false });
} else {
// Only show sidebar on non-mobile devices by default
dispatch({ type: 'SET_SIDER', payload: !isMobile() });

View File

@@ -66,6 +66,10 @@ const Home = () => {
};
useEffect(() => {
if (statusState.status?.setup === false) {
window.location.href = '/setup';
return;
}
displayNotice().then();
displayHomePageContent().then();
});

View File

@@ -0,0 +1,252 @@
import React, { useContext, useEffect, useState, useRef } from 'react';
import { Card, Col, Row, Form, Button, Typography, Space, RadioGroup, Radio, Modal, Banner } from '@douyinfe/semi-ui';
import { API, showError, showNotice, timestamp2string } from '../../helpers';
import { StatusContext } from '../../context/Status';
import { marked } from 'marked';
import { StyleContext } from '../../context/Style/index.js';
import { useTranslation } from 'react-i18next';
import { IconHelpCircle, IconInfoCircle, IconAlertTriangle } from '@douyinfe/semi-icons';
const Setup = () => {
const { t, i18n } = useTranslation();
const [statusState] = useContext(StatusContext);
const [styleState, styleDispatch] = useContext(StyleContext);
const [loading, setLoading] = useState(false);
const [selfUseModeInfoVisible, setUsageModeInfoVisible] = useState(false);
const [setupStatus, setSetupStatus] = useState({
status: false,
root_init: false,
database_type: ''
});
const { Text, Title } = Typography;
const formRef = useRef(null);
const [formData, setFormData] = useState({
username: '',
password: '',
confirmPassword: '',
usageMode: 'external'
});
useEffect(() => {
fetchSetupStatus();
}, []);
const fetchSetupStatus = async () => {
try {
const res = await API.get('/api/setup');
const { success, data } = res.data;
if (success) {
setSetupStatus(data);
// If setup is already completed, redirect to home
if (data.status) {
window.location.href = '/';
}
} else {
showError(t('获取初始化状态失败'));
}
} catch (error) {
console.error('Failed to fetch setup status:', error);
showError(t('获取初始化状态失败'));
}
};
const handleUsageModeChange = (val) => {
setFormData({...formData, usageMode: val});
};
const onSubmit = () => {
if (!formRef.current) {
console.error("Form reference is null");
showError(t('表单引用错误,请刷新页面重试'));
return;
}
const values = formRef.current.getValues();
console.log("Form values:", values);
// For root_init=false, validate admin username and password
if (!setupStatus.root_init) {
if (!values.username || !values.username.trim()) {
showError(t('请输入管理员用户名'));
return;
}
if (!values.password || values.password.length < 8) {
showError(t('密码长度至少为8个字符'));
return;
}
if (values.password !== values.confirmPassword) {
showError(t('两次输入的密码不一致'));
return;
}
}
// Prepare submission data
const formValues = {...values};
formValues.SelfUseModeEnabled = values.usageMode === 'self';
formValues.DemoSiteEnabled = values.usageMode === 'demo';
// Remove usageMode as it's not needed by the backend
delete formValues.usageMode;
console.log("Submitting data to backend:", formValues);
setLoading(true);
// Submit to backend
API.post('/api/setup', formValues)
.then(res => {
const { success, message } = res.data;
console.log("API response:", res.data);
if (success) {
showNotice(t('系统初始化成功,正在跳转...'));
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
showError(message || t('初始化失败,请重试'));
}
})
.catch(error => {
console.error('API error:', error);
showError(t('系统初始化失败,请重试'));
setLoading(false);
})
.finally(() => {
// setLoading(false);
});
};
return (
<>
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
<Card>
<Title heading={2} style={{ marginBottom: '24px' }}>{t('系统初始化')}</Title>
{setupStatus.database_type === 'sqlite' && (
<Banner
type="warning"
icon={<IconAlertTriangle size="large" />}
closeIcon={null}
title={t('数据库警告')}
description={
<div>
<p>{t('您正在使用 SQLite 数据库。如果您在容器环境中运行,请确保已正确设置数据库文件的持久化映射,否则容器重启后所有数据将丢失!')}</p>
<p>{t('建议在生产环境中使用 MySQL 或 PostgreSQL 数据库,或确保 SQLite 数据库文件已映射到宿主机的持久化存储。')}</p>
</div>
}
style={{ marginBottom: '24px' }}
/>
)}
<Form
getFormApi={(formApi) => { formRef.current = formApi; console.log("Form API set:", formApi); }}
initValues={formData}
>
{setupStatus.root_init ? (
<Banner
type="info"
icon={<IconInfoCircle />}
closeIcon={null}
description={t('管理员账号已经初始化过,请继续设置系统参数')}
style={{ marginBottom: '24px' }}
/>
) : (
<Form.Section text={t('管理员账号')}>
<Form.Input
field="username"
label={t('用户名')}
placeholder={t('请输入管理员用户名')}
showClear
onChange={(value) => setFormData({...formData, username: value})}
/>
<Form.Input
field="password"
label={t('密码')}
placeholder={t('请输入管理员密码')}
type="password"
showClear
onChange={(value) => setFormData({...formData, password: value})}
/>
<Form.Input
field="confirmPassword"
label={t('确认密码')}
placeholder={t('请确认管理员密码')}
type="password"
showClear
onChange={(value) => setFormData({...formData, confirmPassword: value})}
/>
</Form.Section>
)}
<Form.Section text={
<div style={{ display: 'flex', alignItems: 'center' }}>
{t('系统设置')}
</div>
}>
<Form.RadioGroup
field="usageMode"
label={
<div style={{ display: 'flex', alignItems: 'center' }}>
{t('使用模式')}
<IconHelpCircle
style={{ marginLeft: '4px', color: 'var(--semi-color-primary)', verticalAlign: 'middle', cursor: 'pointer' }}
onClick={(e) => {
// e.preventDefault();
// e.stopPropagation();
setUsageModeInfoVisible(true);
}}
/>
</div>
}
extraText={t('可在初始化后修改')}
initValue="external"
onChange={handleUsageModeChange}
>
<Form.Radio value="external">{t('对外运营模式')}</Form.Radio>
<Form.Radio value="self">{t('自用模式')}</Form.Radio>
<Form.Radio value="demo">{t('演示站点模式')}</Form.Radio>
</Form.RadioGroup>
</Form.Section>
</Form>
<div style={{ marginTop: '24px', textAlign: 'right' }}>
<Button type="primary" onClick={onSubmit} loading={loading}>
{t('初始化系统')}
</Button>
</div>
</Card>
</div>
<Modal
title={t('使用模式说明')}
visible={selfUseModeInfoVisible}
onOk={() => setUsageModeInfoVisible(false)}
onCancel={() => setUsageModeInfoVisible(false)}
closeOnEsc={true}
okText={t('确定')}
cancelText={null}
>
<div style={{ padding: '8px 0' }}>
<Title heading={6}>{t('对外运营模式')}</Title>
<p>{t('默认模式,适用于为多个用户提供服务的场景。')}</p>
<p>{t('此模式下,系统将计算每次调用的用量,您需要对每个模型都设置价格,如果没有设置价格,用户将无法使用该模型。')}</p>
</div>
<div style={{ padding: '8px 0' }}>
<Title heading={6}>{t('自用模式')}</Title>
<p>{t('适用于个人使用的场景。')}</p>
<p>{t('不需要设置模型价格,系统将弱化用量计算,您可专注于使用模型。')}</p>
</div>
<div style={{ padding: '8px 0' }}>
<Title heading={6}>{t('演示站点模式')}</Title>
<p>{t('适用于展示系统功能的场景。')}</p>
</div>
</Modal>
</>
);
};
export default Setup;