feat: 添加安装向导功能
- 后端:安装状态检测API、数据库连接测试、配置文件生成 - 后端:支持SQLite/MySQL数据库选择 - 后端:JWT密钥自动生成或手动设置 - 前端:安装向导界面(数据库配置、安全配置、管理员账号) - 前端:路由守卫检测安装状态,未安装自动跳转安装页面
This commit is contained in:
@@ -35,6 +35,7 @@ func setDefaults() {
|
||||
viper.SetDefault("app.jwt_expire", "24h")
|
||||
|
||||
// 数据库配置
|
||||
viper.SetDefault("database.type", "auto")
|
||||
viper.SetDefault("database.host", "localhost")
|
||||
viper.SetDefault("database.port", "3306")
|
||||
viper.SetDefault("database.name", "verification_platform")
|
||||
|
||||
@@ -30,15 +30,92 @@ var (
|
||||
RDB *redis.Client
|
||||
)
|
||||
|
||||
// Init 初始化数据库连接
|
||||
func Init() {
|
||||
initMySQL()
|
||||
initDatabase()
|
||||
initRedis()
|
||||
}
|
||||
|
||||
// initMySQL 初始化MySQL连接
|
||||
func initMySQL() {
|
||||
// 尝试连接MySQL
|
||||
func initDatabase() {
|
||||
dbType := config.GetString("database.type")
|
||||
if dbType == "" {
|
||||
dbType = "auto"
|
||||
}
|
||||
|
||||
switch dbType {
|
||||
case "sqlite":
|
||||
initSQLite()
|
||||
case "mysql":
|
||||
initMySQLExplicit()
|
||||
default:
|
||||
initAutoDetect()
|
||||
}
|
||||
}
|
||||
|
||||
func initSQLite() {
|
||||
dbPath := "verification_platform.db"
|
||||
absPath, err := filepath.Abs(dbPath)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to get absolute path:", err.Error())
|
||||
}
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err == nil && filepath.Base(wd) == "cmd" {
|
||||
absPath = filepath.Join(filepath.Dir(wd), "verification_platform.db")
|
||||
}
|
||||
|
||||
var errOpen error
|
||||
DB, errOpen = gorm.Open(sqlite.Open(absPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
})
|
||||
if errOpen != nil {
|
||||
log.Fatal("Failed to create SQLite database:", errOpen.Error())
|
||||
}
|
||||
log.Printf("Using SQLite database: %s\n", absPath)
|
||||
|
||||
if sqlDB, err := DB.DB(); err == nil {
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
sqlDB.SetMaxOpenConns(100)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
}
|
||||
|
||||
runMigrations()
|
||||
}
|
||||
|
||||
func initMySQLExplicit() {
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=%s&parseTime=True&loc=Local",
|
||||
config.GetString("database.username"),
|
||||
config.GetString("database.password"),
|
||||
config.GetString("database.host"),
|
||||
config.GetString("database.port"),
|
||||
config.GetString("database.name"),
|
||||
config.GetString("database.charset"),
|
||||
)
|
||||
|
||||
var err error
|
||||
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to MySQL:", err.Error())
|
||||
}
|
||||
log.Printf("Connected to MySQL: %s@%s:%s/%s\n",
|
||||
config.GetString("database.username"),
|
||||
config.GetString("database.host"),
|
||||
config.GetString("database.port"),
|
||||
config.GetString("database.name"))
|
||||
|
||||
if sqlDB, err := DB.DB(); err == nil {
|
||||
sqlDB.SetMaxIdleConns(config.GetInt("database.max_idle_conns"))
|
||||
sqlDB.SetMaxOpenConns(config.GetInt("database.max_open_conns"))
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
}
|
||||
|
||||
runMigrations()
|
||||
}
|
||||
|
||||
func initAutoDetect() {
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=%s&parseTime=True&loc=Local",
|
||||
config.GetString("database.username"),
|
||||
config.GetString("database.password"),
|
||||
@@ -54,28 +131,18 @@ func initMySQL() {
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
})
|
||||
|
||||
// 如果MySQL连接失败,创建一个SQLite数据库用于开发
|
||||
if err != nil {
|
||||
log.Println("Failed to connect to MySQL, using SQLite database for development:", err.Error())
|
||||
|
||||
log.Println("Using file database for development")
|
||||
|
||||
dbPath := "verification_platform.db"
|
||||
|
||||
absPath, err := filepath.Abs(dbPath)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to get absolute path:", err.Error())
|
||||
}
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to get working directory:", err.Error())
|
||||
}
|
||||
log.Printf("Working directory: %s, basename: %s\n", wd, filepath.Base(wd))
|
||||
|
||||
if filepath.Base(wd) == "cmd" {
|
||||
dbPath = filepath.Join(filepath.Dir(wd), "verification_platform.db")
|
||||
absPath = dbPath
|
||||
if err == nil && filepath.Base(wd) == "cmd" {
|
||||
absPath = filepath.Join(filepath.Dir(wd), "verification_platform.db")
|
||||
}
|
||||
|
||||
DB, err = gorm.Open(sqlite.Open(absPath), &gorm.Config{
|
||||
@@ -88,14 +155,16 @@ func initMySQL() {
|
||||
log.Printf("Database file path: %s\n", absPath)
|
||||
}
|
||||
|
||||
// 设置连接池(仅对非内存数据库有效)
|
||||
if sqlDB, err := DB.DB(); err == nil {
|
||||
sqlDB.SetMaxIdleConns(config.GetInt("database.max_idle_conns"))
|
||||
sqlDB.SetMaxOpenConns(config.GetInt("database.max_open_conns"))
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
}
|
||||
|
||||
// 检查applications表结构
|
||||
runMigrations()
|
||||
}
|
||||
|
||||
func runMigrations() {
|
||||
var columns []struct {
|
||||
CID int
|
||||
Name string
|
||||
@@ -126,7 +195,7 @@ func initMySQL() {
|
||||
}
|
||||
|
||||
// 自动迁移数据库表
|
||||
err = DB.AutoMigrate(
|
||||
if err := DB.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.AppUser{},
|
||||
&model.UserLevel{},
|
||||
@@ -180,8 +249,7 @@ func initMySQL() {
|
||||
&model.EmailTemplate{},
|
||||
&model.VersionFile{},
|
||||
&model.CloudVariableRecord{},
|
||||
)
|
||||
if err != nil {
|
||||
); err != nil {
|
||||
log.Println("Failed to migrate database:", err.Error())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package admin
|
||||
package admin
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"verification-platform-backend/internal/config"
|
||||
"verification-platform-backend/internal/database"
|
||||
"verification-platform-backend/internal/model"
|
||||
"verification-platform-backend/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func SetupRoutes(r *gin.Engine) {
|
||||
install := r.Group("/api/install")
|
||||
{
|
||||
install.GET("/status", handleGetInstallStatus)
|
||||
install.POST("/setup", handleSetup)
|
||||
install.POST("/test-db", handleTestDatabase)
|
||||
}
|
||||
}
|
||||
|
||||
type InstallStatus struct {
|
||||
Installed bool `json:"installed"`
|
||||
DbType string `json:"db_type"`
|
||||
HasAdmin bool `json:"has_admin"`
|
||||
ConfigExist bool `json:"config_exist"`
|
||||
}
|
||||
|
||||
func handleGetInstallStatus(c *gin.Context) {
|
||||
configExist := checkConfigFile()
|
||||
hasAdmin := checkAdminExists()
|
||||
installed := hasAdmin
|
||||
|
||||
var dbType string
|
||||
if configExist {
|
||||
dbType = config.GetString("database.type")
|
||||
if dbType == "" {
|
||||
if strings.Contains(config.GetString("database.host"), "") && config.GetString("database.host") != "localhost" {
|
||||
dbType = "mysql"
|
||||
} else {
|
||||
dbType = "sqlite"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response.Success(c, InstallStatus{
|
||||
Installed: installed,
|
||||
DbType: dbType,
|
||||
HasAdmin: hasAdmin,
|
||||
ConfigExist: configExist,
|
||||
})
|
||||
}
|
||||
|
||||
type TestDBRequest struct {
|
||||
DbType string `json:"db_type"`
|
||||
Host string `json:"host"`
|
||||
Port string `json:"port"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func handleTestDatabase(c *gin.Context) {
|
||||
var req TestDBRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
if req.DbType == "sqlite" {
|
||||
dbPath := "verification_platform.db"
|
||||
absPath, _ := filepath.Abs(dbPath)
|
||||
testDB, err := gorm.Open(sqlite.Open(absPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, 400, "无法创建SQLite数据库: "+err.Error())
|
||||
return
|
||||
}
|
||||
sqlDB, _ := testDB.DB()
|
||||
sqlDB.Close()
|
||||
response.Success(c, gin.H{"message": "SQLite数据库连接成功"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.DbType == "mysql" {
|
||||
if req.Host == "" || req.Port == "" || req.Name == "" || req.Username == "" {
|
||||
response.Error(c, 400, "MySQL配置不完整")
|
||||
return
|
||||
}
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
req.Username, req.Password, req.Host, req.Port, req.Name)
|
||||
testDB, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, 400, "MySQL连接失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
sqlDB, _ := testDB.DB()
|
||||
sqlDB.Close()
|
||||
response.Success(c, gin.H{"message": "MySQL连接成功"})
|
||||
return
|
||||
}
|
||||
|
||||
response.Error(c, 400, "不支持的数据库类型")
|
||||
}
|
||||
|
||||
type SetupRequest struct {
|
||||
DbType string `json:"db_type"`
|
||||
DbHost string `json:"db_host"`
|
||||
DbPort string `json:"db_port"`
|
||||
DbName string `json:"db_name"`
|
||||
DbUsername string `json:"db_username"`
|
||||
DbPassword string `json:"db_password"`
|
||||
JwtSecret string `json:"jwt_secret"`
|
||||
AdminUser string `json:"admin_user"`
|
||||
AdminPass string `json:"admin_pass"`
|
||||
AdminEmail string `json:"admin_email"`
|
||||
UseRedis bool `json:"use_redis"`
|
||||
RedisHost string `json:"redis_host"`
|
||||
RedisPort string `json:"redis_port"`
|
||||
RedisPass string `json:"redis_password"`
|
||||
RedisDb int `json:"redis_db"`
|
||||
}
|
||||
|
||||
func handleSetup(c *gin.Context) {
|
||||
if checkAdminExists() {
|
||||
response.Error(c, 400, "系统已安装")
|
||||
return
|
||||
}
|
||||
|
||||
var req SetupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
if req.AdminUser == "" || req.AdminPass == "" {
|
||||
response.Error(c, 400, "管理员账号和密码不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.AdminPass) < 6 {
|
||||
response.Error(c, 400, "密码长度至少6位")
|
||||
return
|
||||
}
|
||||
|
||||
jwtSecret := req.JwtSecret
|
||||
if jwtSecret == "" {
|
||||
jwtSecret = generateRandomSecret(32)
|
||||
}
|
||||
|
||||
var newDB *gorm.DB
|
||||
var err error
|
||||
|
||||
if req.DbType == "sqlite" {
|
||||
dbPath := "verification_platform.db"
|
||||
absPath, _ := filepath.Abs(dbPath)
|
||||
newDB, err = gorm.Open(sqlite.Open(absPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, 500, "SQLite数据库创建失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
} else if req.DbType == "mysql" {
|
||||
if req.DbHost == "" || req.DbPort == "" || req.DbName == "" || req.DbUsername == "" {
|
||||
response.Error(c, 400, "MySQL配置不完整")
|
||||
return
|
||||
}
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
req.DbUsername, req.DbPassword, req.DbHost, req.DbPort, req.DbName)
|
||||
newDB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, 500, "MySQL连接失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
response.Error(c, 400, "不支持的数据库类型")
|
||||
return
|
||||
}
|
||||
|
||||
if sqlDB, err := newDB.DB(); err == nil {
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
sqlDB.SetMaxOpenConns(100)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
}
|
||||
|
||||
if err := runMigrations(newDB); err != nil {
|
||||
response.Error(c, 500, "数据库迁移失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.AdminPass), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
response.Error(c, 500, "密码加密失败")
|
||||
return
|
||||
}
|
||||
|
||||
admin := model.User{
|
||||
Username: req.AdminUser,
|
||||
Password: string(hashedPassword),
|
||||
Email: &req.AdminEmail,
|
||||
Role: "admin",
|
||||
Status: "active",
|
||||
}
|
||||
|
||||
if err := newDB.Create(&admin).Error; err != nil {
|
||||
response.Error(c, 500, "创建管理员失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
configContent := generateConfigFile(req.DbType, req, jwtSecret)
|
||||
if err := saveConfigFile(configContent); err != nil {
|
||||
log.Printf("Warning: failed to save config file: %v", err)
|
||||
}
|
||||
|
||||
database.DB = newDB
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"message": "安装成功",
|
||||
"jwt_secret": jwtSecret,
|
||||
})
|
||||
}
|
||||
|
||||
func checkConfigFile() bool {
|
||||
paths := []string{"config.yaml", "./config/config.yaml"}
|
||||
for _, p := range paths {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkAdminExists() bool {
|
||||
if database.DB == nil {
|
||||
return false
|
||||
}
|
||||
var count int64
|
||||
database.DB.Model(&model.User{}).Where("role = ?", "admin").Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func generateRandomSecret(length int) string {
|
||||
bytes := make([]byte, length)
|
||||
rand.Read(bytes)
|
||||
return hex.EncodeToString(bytes)[:length]
|
||||
}
|
||||
|
||||
func runMigrations(db *gorm.DB) error {
|
||||
return db.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.AppUser{},
|
||||
&model.Application{},
|
||||
&model.CardType{},
|
||||
&model.Card{},
|
||||
&model.RechargeRecord{},
|
||||
&model.UserDevice{},
|
||||
&model.DeviceSession{},
|
||||
&model.Setting{},
|
||||
&model.ExtensionAPIKey{},
|
||||
&model.AgentApplication{},
|
||||
&model.Package{},
|
||||
&model.Order{},
|
||||
)
|
||||
}
|
||||
|
||||
func generateConfigFile(dbType string, req SetupRequest, jwtSecret string) string {
|
||||
var content strings.Builder
|
||||
|
||||
content.WriteString("# 应用配置\n")
|
||||
content.WriteString("app:\n")
|
||||
content.WriteString(" name: verification-platform\n")
|
||||
content.WriteString(" env: production\n")
|
||||
content.WriteString(" port: \"8080\"\n")
|
||||
content.WriteString(fmt.Sprintf(" jwt_secret: %s\n", jwtSecret))
|
||||
content.WriteString(" jwt_expire: 24h\n\n")
|
||||
|
||||
content.WriteString("# 数据库配置\n")
|
||||
content.WriteString("database:\n")
|
||||
content.WriteString(fmt.Sprintf(" type: %s\n", dbType))
|
||||
|
||||
if dbType == "mysql" {
|
||||
content.WriteString(fmt.Sprintf(" host: %s\n", req.DbHost))
|
||||
content.WriteString(fmt.Sprintf(" port: %s\n", req.DbPort))
|
||||
content.WriteString(fmt.Sprintf(" name: %s\n", req.DbName))
|
||||
content.WriteString(fmt.Sprintf(" username: %s\n", req.DbUsername))
|
||||
content.WriteString(fmt.Sprintf(" password: %s\n", req.DbPassword))
|
||||
} else {
|
||||
content.WriteString(" host: localhost\n")
|
||||
content.WriteString(" port: \"3306\"\n")
|
||||
content.WriteString(" name: verification_platform\n")
|
||||
content.WriteString(" username: root\n")
|
||||
content.WriteString(" password: \"\"\n")
|
||||
}
|
||||
content.WriteString(" charset: utf8mb4\n")
|
||||
content.WriteString(" max_idle_conns: 10\n")
|
||||
content.WriteString(" max_open_conns: 100\n\n")
|
||||
|
||||
content.WriteString("# Redis配置\n")
|
||||
content.WriteString("redis:\n")
|
||||
if req.UseRedis {
|
||||
content.WriteString(fmt.Sprintf(" host: %s\n", req.RedisHost))
|
||||
content.WriteString(fmt.Sprintf(" port: %s\n", req.RedisPort))
|
||||
content.WriteString(fmt.Sprintf(" password: %s\n", req.RedisPass))
|
||||
content.WriteString(fmt.Sprintf(" db: %d\n", req.RedisDb))
|
||||
} else {
|
||||
content.WriteString(" host: localhost\n")
|
||||
content.WriteString(" port: \"6379\"\n")
|
||||
content.WriteString(" password: \"\"\n")
|
||||
content.WriteString(" db: 0\n")
|
||||
}
|
||||
|
||||
content.WriteString("\n# 日志配置\n")
|
||||
content.WriteString("log:\n")
|
||||
content.WriteString(" level: info\n")
|
||||
content.WriteString(" format: json\n")
|
||||
|
||||
return content.String()
|
||||
}
|
||||
|
||||
func saveConfigFile(content string) error {
|
||||
file, err := os.Create("config.yaml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
_, err = file.WriteString(content)
|
||||
return err
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"verification-platform-backend/internal/router/admin"
|
||||
"verification-platform-backend/internal/router/extension"
|
||||
"verification-platform-backend/internal/router/frontend"
|
||||
"verification-platform-backend/internal/router/install"
|
||||
"verification-platform-backend/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -25,6 +26,8 @@ func SetupRoutes(r *gin.Engine) {
|
||||
})
|
||||
})
|
||||
|
||||
install.SetupRoutes(r)
|
||||
|
||||
frontend.SetupRoutes(r)
|
||||
|
||||
api := r.Group("/api/v1")
|
||||
|
||||
Reference in New Issue
Block a user