Explorar o código

✨ feat: Implement system setup functionality

CaIon hai 9 meses
pai
achega
a882e680ae

+ 3 - 0
constant/setup.go

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

+ 2 - 0
controller/misc.go

@@ -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 - 0
controller/setup.go

@@ -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"
+}

+ 24 - 1
model/main.go

@@ -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 - 0
model/setup.go

@@ -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
+}

+ 9 - 0
model/user.go

@@ -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
+}

+ 2 - 0
router/api-router.go

@@ -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)

+ 9 - 0
web/src/App.js

@@ -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={

+ 3 - 0
web/src/context/Style/index.js

@@ -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() });

+ 4 - 0
web/src/pages/Home/index.js

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

+ 251 - 0
web/src/pages/Setup/index.js

@@ -0,0 +1,251 @@
+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>
+        </div>
+        <div style={{ padding: '8px 0' }}>
+          <Title heading={6}>{t('演示站点模式')}</Title>
+          <p>{t('适用于展示系统功能的场景。')}</p>
+        </div>
+      </Modal>
+    </>
+  );
+};
+
+export default Setup;