diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 7184f2e..b3b2a96 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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") diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go index 4dc4c41..3654162 100644 --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -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()) } diff --git a/backend/internal/router/admin/dashboard.go b/backend/internal/router/admin/dashboard.go index f1750dd..782c132 100644 --- a/backend/internal/router/admin/dashboard.go +++ b/backend/internal/router/admin/dashboard.go @@ -1,4 +1,4 @@ -package admin +package admin import ( "time" diff --git a/backend/internal/router/install/install.go b/backend/internal/router/install/install.go new file mode 100644 index 0000000..ed4c4f3 --- /dev/null +++ b/backend/internal/router/install/install.go @@ -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 +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 352f171..a972d34 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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") diff --git a/frontend/src/pages/install/index.vue b/frontend/src/pages/install/index.vue new file mode 100644 index 0000000..efb7055 --- /dev/null +++ b/frontend/src/pages/install/index.vue @@ -0,0 +1,395 @@ + + + + + + + 检查安装状态... + + + + + + + + + + + 系统安装向导 + + + 欢迎使用软件授权管理平台,请完成以下配置 + + + + + + + + {{ s }} + + + + + + + + + + + 数据库配置 + + + + + 数据库类型 + + + {{ db.label }} + {{ db.desc }} + + + + + + + + 主机地址 + + + + 端口 + + + + + 数据库名 + + + + + 用户名 + + + + 密码 + + + + + + 测试连接 + + + + + + + + + 安全配置 + + + + + + JWT 密钥 + + + 自动生成 + + + + + + + + + + JWT 密钥用于签名认证令牌,请妥善保管 + + + + + + + Redis 缓存(可选) + + + + 启用 Redis + + + + + 主机 + + + + 端口 + + + + + 密码 + + + + + + + + + + + 管理员账号 + + + + + 用户名 + + + + 密码 + + + 密码长度至少6位 + + + + 邮箱(可选) + + + + + + + + + + + 安装完成 + + 系统已成功安装,您现在可以使用管理员账号登录 + + + 前往登录 + + + + + + + + + 上一步 + + + + + 下一步 + + + + + {{ loading ? '安装中...' : '开始安装' }} + + + + + + diff --git a/frontend/src/router/guard/index.ts b/frontend/src/router/guard/index.ts index 9e702c5..efd9f11 100644 --- a/frontend/src/router/guard/index.ts +++ b/frontend/src/router/guard/index.ts @@ -1,7 +1,35 @@ import type { Router } from 'vue-router' +let installChecked = false +let isInstalled = false + +async function checkInstallStatus(): Promise { + if (installChecked) return isInstalled + try { + const res = await fetch('/api/install/status') + const data = await res.json() + isInstalled = data?.data?.installed || false + installChecked = true + return isInstalled + } + catch { + installChecked = true + isInstalled = true + return true + } +} + export function createRouterGuard(router: Router) { - router.beforeEach((to) => { + router.beforeEach(async (to) => { + if (to.path === '/install') { + return true + } + + const installed = await checkInstallStatus() + if (!installed) { + return '/install' + } + const token = localStorage.getItem('token') const userStr = localStorage.getItem('user') diff --git a/frontend/src/types/route-map.d.ts b/frontend/src/types/route-map.d.ts index 61a1c97..c371c71 100644 --- a/frontend/src/types/route-map.d.ts +++ b/frontend/src/types/route-map.d.ts @@ -14,9 +14,6 @@ import type { ParamValueZeroOrMore, ParamValueZeroOrOne, } from 'vue-router' -import type { - _ExtractParamParserType, -} from 'vue-router/experimental' declare module 'vue-router' { interface TypesConfig { @@ -621,6 +618,13 @@ declare module 'vue-router/auto-routes' { Record, | never >, + '/install/': RouteRecordInfo< + '/install/', + '/install', + Record, + Record, + | never + >, '/profile/': RouteRecordInfo< '/profile/', '/profile', @@ -1155,6 +1159,12 @@ declare module 'vue-router/auto-routes' { views: | never } + 'src/pages/install/index.vue': { + routes: + | '/install/' + views: + | never + } 'src/pages/profile/index.vue': { routes: | '/profile/'
+ 欢迎使用软件授权管理平台,请完成以下配置 +
+ JWT 密钥用于签名认证令牌,请妥善保管 +
+ 密码长度至少6位 +
+ 系统已成功安装,您现在可以使用管理员账号登录 +