diff --git a/Makefile b/Makefile index 3a24bd4..a4c3ae1 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help build run test clean vet tidy check run-check db-dir +.PHONY: help build build-cli build-cli-all run test clean vet tidy check run-check db-dir help: ## 显示帮助信息 @echo "======================================" @@ -7,6 +7,8 @@ help: ## 显示帮助信息 @echo "可用命令:" @echo " make check - 全面检查(依赖+vet+编译+测试)" @echo " make build - 构建应用" + @echo " make build-cli - 构建 UMS CLI" + @echo " make build-cli-all - 交叉编译所有平台" @echo " make run - 运行应用" @echo " make test - 运行测试" @echo " make vet - 代码静态检查" @@ -15,7 +17,17 @@ help: ## 显示帮助信息 @echo " make clean - 清理构建文件" @echo "" -check: tidy vet build test ## 全面检查:依赖+静态检查+编译+测试 +# CLI 构建配置 +CLI_NAME = ums +VERSION = 1.0.0 +COMMIT = $(shell git rev-parse --short HEAD 2>/dev/null || echo "dev") +BUILD_DATE = $(shell date -u '+%Y-%m-%d_%H:%M:%S') +LDFLAGS = -ldflags "-X github.com/user-management-system/cmd/ums/cmd.Version=$(VERSION) -X github.com/user-management-system/cmd/ums/cmd.Commit=$(COMMIT) -X github.com/user-management-system/cmd/ums/cmd.BuildDate=$(BUILD_DATE)" + +# 平台列表 +PLATFORMS = darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 + +check: tidy vet build build-cli test ## 全面检查:依赖+静态检查+编译+测试 tidy: ## 整理Go模块依赖 @echo "整理依赖..." @@ -30,6 +42,20 @@ build: db-dir ## 构建应用 @echo "构建应用..." go build -o bin/server cmd/server/main.go +build-cli: ## 构建 UMS CLI(当前平台) + @echo "构建 UMS CLI..." + CGO_ENABLED=0 go build $(LDFLAGS) -o bin/$(CLI_NAME) cmd/ums/main.go + +build-cli-all: $(PLATFORMS) ## 构建所有平台的 CLI + @echo "所有平台构建完成" + +build-cli-%: + @platform=$(patsubst %/%,%,$@); \ + os=$(platform%%/*); \ + arch=$(platform##*/); \ + echo "Building for $$os/$$arch"; \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build $(LDFLAGS) -o bin/$(CLI_NAME)-$$os-$$arch cmd/ums/main.go + run: db-dir ## 运行应用 @echo "运行应用..." go run cmd/server/main.go diff --git a/README.md b/README.md index 85f8a06..1e790ad 100644 --- a/README.md +++ b/README.md @@ -4,26 +4,50 @@ ## 快速开始 -### 前置依赖 - -- Go 1.21+ -- Node.js 18+ -- SQLite(默认,无需安装) - -### 启动后端 +### 安装 UMS CLI ```bash -# 复制环境配置 -cp .env.example .env -# 编辑 .env 填入必要配置(JWT_SECRET, DEFAULT_ADMIN_PASSWORD 等) +# 下载对应平台的二进制文件 +# Linux/macOS +curl -L -o ums https://github.com/user-management-system/ums/releases/latest/download/ums-linux-amd64 +chmod +x ums -# 启动服务 -go run ./cmd/server +# Windows +curl -L -o ums.exe https://github.com/user-management-system/ums/releases/latest/download/ums-windows-amd64.exe + +# 或使用 Go 安装 +go install github.com/user-management-system/cmd/ums@latest ``` -服务启动后访问 `http://localhost:8080/api/v1/auth/bootstrap` 初始化管理员账号。 +### 初始化系统 -### 启动前端 +```bash +# 交互式初始化(推荐) +ums init + +# 非交互式初始化 +ums init \ + --admin-user admin \ + --admin-pass MySecretPassword123 \ + --admin-email admin@example.com \ + --cors-origin http://localhost:3000 +``` + +初始化命令会: +1. 生成安全的 JWT 密钥 +2. 创建配置文件 `config.yaml` 和 `.env` +3. 创建数据库目录并执行迁移 +4. 初始化默认角色、权限和管理员账号 + +### 启动服务 + +```bash +ums serve +``` + +服务启动后访问 `http://localhost:8080/health` 确认服务正常运行。 + +### 前端 ```bash cd frontend/admin @@ -35,13 +59,20 @@ npm run dev ``` . -├── cmd/server/ # 后端入口 +├── cmd/ +│ ├── ums/ # UMS CLI 入口 +│ │ └── cmd/ # CLI 子命令 +│ │ ├── init.go # 初始化命令 +│ │ ├── serve.go # 服务启动命令 +│ │ └── version.go # 版本命令 +│ └── server/ # 后端服务入口 ├── internal/ # 后端代码 │ ├── api/handler/ # HTTP 处理器 │ ├── api/middleware/ # 中间件(认证、权限、限流) │ ├── auth/ # 认证服务(JWT/SSO) │ ├── repository/ # 数据访问层 │ ├── service/ # 业务逻辑层 +│ ├── server/ # 服务器核心逻辑 │ └── domain/ # 领域模型 ├── frontend/admin/ # 管理后台前端 ├── configs/ # 配置文件 @@ -77,49 +108,73 @@ npm run dev | OAuth context 正确传播 | ✅ 已修复 | | 密码修改后 Token 失效(PCE) | ✅ 已修复 | -## 环境变量 +## CLI 命令 -关键配置项(详见 `.env.example`): +```bash +ums init # 初始化系统(交互式或非交互式) +ums serve # 启动服务器 +ums version # 显示版本信息 -| 变量 | 说明 | 必填 | -|------|------|------| -| `JWT_SECRET` | JWT 签名密钥 | 是 | -| `DEFAULT_ADMIN_EMAIL` | 初始管理员邮箱 | 是 | -| `DEFAULT_ADMIN_PASSWORD` | 初始管理员密码 | 是 | -| `SMTP_*` | 邮件服务配置 | 是(邮件功能)| -| `SMS_*` | 短信服务配置 | 否 | +# ums serve 选项 +ums serve --port 8080 # 指定端口 +ums serve --config ./prod.yaml # 指定配置文件 -## API 文档 - -完整 API 规范:`docs/API.md` - -认证流程: +# ums init 选项 +ums init --db-type sqlite # 数据库类型 +ums init --db-path ./data/ums.db # 数据库路径 +ums init --redis-enable # 启用 Redis +ums init --redis-host localhost # Redis 地址 +ums init --admin-user admin # 管理员用户名 +ums init --admin-pass MyPassword123 # 管理员密码 +ums init --admin-email admin@example.com # 管理员邮箱 +ums init --port 8080 # 服务端口 +ums init --cors-origin http://example.com # CORS 域名 +ums init --yes # 跳过确认 ``` -1. POST /api/v1/auth/register # 注册用户 -2. POST /api/v1/auth/login # 登录获取 Token -3. POST /api/v1/auth/refresh # 刷新 Token + +## 配置文件 + +初始化后生成以下配置文件: + +**config.yaml** - 主配置文件 +```yaml +server: + port: 8080 + mode: release +database: + type: sqlite + dbname: "./data/user_management.db" +jwt: + secret: "<自动生成的密钥>" +redis: + enabled: false +``` + +**.env** - 环境变量(包含敏感信息,请勿提交) +```bash +JWT_SECRET=<自动生成的密钥> +BOOTSTRAP_SECRET=<自动生成的密钥> +DEFAULT_ADMIN_EMAIL=admin@example.com +DEFAULT_ADMIN_PASSWORD=<您设置的密码> ``` ## 开发命令 ```bash -# 构建 +# 构建 CLI +make build-cli + +# 构建所有平台 CLI +make build-cli-all + +# 构建服务器 go build ./cmd/server -# 测试(跳过大规模性能测试) +# 测试 go test ./internal/... -skip TestScale -count=1 # 前端构建 cd frontend/admin && npm run build - -# 前端测试 -cd frontend/admin && npm test - -# 前端 lint -cd frontend/admin && npm run lint - -# Docker 构建 -docker build -t ums . ``` ## 部署 @@ -144,4 +199,4 @@ docker build -t ums . 完整项目状态:`docs/status/REAL_PROJECT_STATUS.md` -**2026-04-18 最新状态:** 所有 P0/P1/P2 安全和质量修复已全部完成并验证通过。 +**2026-04-19 最新状态:** CLI 打包和系统初始化优化已完成,支持单一二进制文件部署和交互式/非交互式初始化。 diff --git a/cmd/server/main.go b/cmd/server/main.go index 1a79691..774dfa8 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,28 +1,10 @@ package main import ( - "context" - "fmt" "log" - "net/http" - "os" - "os/signal" - "syscall" - "time" - "github.com/gin-gonic/gin" - - "github.com/user-management-system/internal/api/handler" - "github.com/user-management-system/internal/api/middleware" - "github.com/user-management-system/internal/api/router" - "github.com/user-management-system/internal/auth" - "github.com/user-management-system/internal/cache" "github.com/user-management-system/internal/config" - "github.com/user-management-system/internal/database" - "github.com/user-management-system/internal/monitoring" - "github.com/user-management-system/internal/repository" - "github.com/user-management-system/internal/security" - "github.com/user-management-system/internal/service" + "github.com/user-management-system/internal/server" ) func main() { @@ -32,240 +14,8 @@ func main() { log.Fatalf("load config failed: %v", err) } - // 设置 Gin 模式 - gin.SetMode(resolveGinMode(cfg.Server.Mode)) - - // 初始化数据库 - db, err := database.NewDB(cfg) - if err != nil { - log.Fatalf("connect database failed: %v", err) - } - - // 执行数据库迁移 - if err := db.AutoMigrate(cfg); err != nil { - log.Fatalf("auto migrate failed: %v", err) - } - - // P1-3:Argon2id 启动时自适应校准 - // 在当前机器上测量哈希耗时,超出 500ms 预算则自动降低参数,确保登录接口 P99 < 1000ms。 - // 此操作仅在启动阶段执行一次,耗时约 1-3s(正常情况下与默认参数一致则跳过)。 - auth.CalibrateArgon2id(500 * time.Millisecond) - - // 初始化 JWT 管理器 - jwtManager, err := auth.NewJWTWithOptions(auth.JWTOptions{ - HS256Secret: cfg.JWT.Secret, - AccessTokenExpire: time.Duration(cfg.JWT.AccessTokenExpireMinutes) * time.Minute, - RefreshTokenExpire: time.Duration(cfg.JWT.RefreshTokenExpireDays) * 24 * time.Hour, - }) - if err != nil { - log.Fatalf("create jwt manager failed: %v", err) - } - - // 初始化缓存 - // Redis 智能探测:有 Redis 则启用 L2 分布式缓存,无 Redis 则降级到纯 L1 内存缓存。 - // 两种模式下系统功能完全等价,区别仅在于多实例场景的缓存共享能力。 - // 如需禁用 Redis 探测(即使 Redis 可达也不启用),可将配置中 redis.host 留空。 - l1Cache := cache.NewL1Cache() - redisAddr := fmt.Sprintf("%s:%d", cfg.Redis.Host, cfg.Redis.Port) - redisEnabled := cfg.Redis.Host != "" && cache.ProbeRedis(redisAddr, cfg.Redis.Password, cfg.Redis.DB) - if !redisEnabled { - log.Printf("cache: running in memory-only mode (Redis unreachable or not configured)") - } - l2Cache := cache.NewRedisCacheWithConfig(cache.RedisCacheConfig{ - Enabled: redisEnabled, - Addr: redisAddr, - Password: cfg.Redis.Password, - DB: cfg.Redis.DB, - }) - defer l2Cache.Close() - cacheManager := cache.NewCacheManager(l1Cache, l2Cache) - - // 初始化 Repository - userRepo := repository.NewUserRepository(db.DB) - roleRepo := repository.NewRoleRepository(db.DB) - permissionRepo := repository.NewPermissionRepository(db.DB) - userRoleRepo := repository.NewUserRoleRepository(db.DB) - rolePermissionRepo := repository.NewRolePermissionRepository(db.DB) - deviceRepo := repository.NewDeviceRepository(db.DB) - loginLogRepo := repository.NewLoginLogRepository(db.DB) - operationLogRepo := repository.NewOperationLogRepository(db.DB) - customFieldRepo := repository.NewCustomFieldRepository(db.DB) - userCustomFieldValueRepo := repository.NewUserCustomFieldValueRepository(db.DB) - themeRepo := repository.NewThemeConfigRepository(db.DB) - socialRepo, err := repository.NewSocialAccountRepository(db.DB) - if err != nil { - log.Fatalf("initialize social account repository failed: %v", err) - } - passwordHistoryRepo := repository.NewPasswordHistoryRepository(db.DB) - - // 初始化 Service - deviceService := service.NewDeviceService(deviceRepo, userRepo) - authService := service.NewAuthService( - userRepo, - socialRepo, - jwtManager, - cacheManager, - 8, // passwordMinLength - 5, // maxLoginAttempts - 15*time.Minute, // loginLockDuration - ) - authService.SetRoleRepositories(userRoleRepo, roleRepo) - authService.SetLoginLogRepository(loginLogRepo) - authService.SetDeviceService(deviceService) - - // IP 过滤中间件 - var ipFilterMiddleware *middleware.IPFilterMiddleware - ipFilter := security.NewIPFilter() - if ipFilter != nil { - ipFilterMiddleware = middleware.NewIPFilterMiddleware(ipFilter, middleware.IPFilterConfig{ - TrustProxy: cfg.CORS.AllowCredentials, - }) - } - - // 初始化异常检测器并注入 - anomalyDetector := security.NewAnomalyDetector(security.DefaultAnomalyConfig, ipFilter) - authService.SetAnomalyDetector(anomalyDetector) - log.Println("anomaly detector initialized") - - userService := service.NewUserService(userRepo, userRoleRepo, roleRepo, passwordHistoryRepo) - roleService := service.NewRoleService(roleRepo, rolePermissionRepo) - permissionService := service.NewPermissionService(permissionRepo) - loginLogService := service.NewLoginLogService(loginLogRepo) - operationLogService := service.NewOperationLogService(operationLogRepo) - captchaService := service.NewCaptchaService(cacheManager) - totpService := service.NewTOTPService(userRepo) - - passwordResetConfig := service.DefaultPasswordResetConfig() - passwordResetService := service.NewPasswordResetService(userRepo, cacheManager, passwordResetConfig). - WithPasswordHistoryRepo(passwordHistoryRepo) - - webhookService := service.NewWebhookService(db.DB, service.WebhookServiceConfig{ - Enabled: false, - }) - exportService := service.NewExportService(userRepo, roleRepo) - statsService := service.NewStatsService(userRepo, loginLogRepo) - customFieldService := service.NewCustomFieldService(customFieldRepo, userCustomFieldValueRepo) - themeService := service.NewThemeService(themeRepo) - - // 设置 CORS 配置 - middleware.SetCORSConfig(cfg.CORS) - - // 初始化中间件 - rateLimitMiddleware := middleware.NewRateLimitMiddleware(cfg.RateLimit) - authMiddleware := middleware.NewAuthMiddleware( - jwtManager, - userRepo, - userRoleRepo, - l1Cache, - ) - authMiddleware.SetCacheManager(cacheManager) - - opLogMiddleware := middleware.NewOperationLogMiddleware(operationLogRepo) - - // 初始化 Handler - authHandler := handler.NewAuthHandler(authService) - userHandler := handler.NewUserHandler(userService) - roleHandler := handler.NewRoleHandler(roleService) - permissionHandler := handler.NewPermissionHandler(permissionService) - deviceHandler := handler.NewDeviceHandler(deviceService) - logHandler := handler.NewLogHandler(loginLogService, operationLogService) - captchaHandler := handler.NewCaptchaHandler(captchaService) - totpHandler := handler.NewTOTPHandler(authService, totpService) - webhookHandler := handler.NewWebhookHandler(webhookService) - exportHandler := handler.NewExportHandler(exportService) - statsHandler := handler.NewStatsHandler(statsService) - passwordResetHandler := handler.NewPasswordResetHandler(passwordResetService) - smsHandler := handler.NewSMSHandler(authService, nil) - avatarHandler := handler.NewAvatarHandler(userRepo) - customFieldHandler := handler.NewCustomFieldHandler(customFieldService) - themeHandler := handler.NewThemeHandler(themeService) - - // 初始化 SSO 管理器 - ssoManager := auth.NewSSOManager() - ssoClientsStore := auth.NewDefaultSSOClientsStore() - ssoHandler := handler.NewSSOHandler(ssoManager, ssoClientsStore) - - // 系统设置服务 - settingsService := service.NewSettingsService() - settingsHandler := handler.NewSettingsHandler(settingsService) - - // SSO 会话清理 context(随服务器关闭而取消) - ssoCtx, ssoCancel := context.WithCancel(context.Background()) - defer ssoCancel() - ssoManager.StartCleanup(ssoCtx) - - // 初始化监控指标(CRIT-01/02 修复:确保指标被初始化并挂载) - metrics := monitoring.GetGlobalMetrics() - sloMetrics := monitoring.GetGlobalSLOMetrics() - - // CRIT-03 修复:启动后台 goroutine 定期采集系统指标(runtime + DB 连接池) - metricsCtx, metricsCancel := context.WithCancel(context.Background()) - defer metricsCancel() - go monitoring.StartSystemMetricsCollector(metricsCtx, metrics, sloMetrics, db.DB) - - // 设置路由 - r := router.NewRouter( - authHandler, userHandler, roleHandler, permissionHandler, deviceHandler, - logHandler, authMiddleware, rateLimitMiddleware, opLogMiddleware, - passwordResetHandler, captchaHandler, totpHandler, webhookHandler, - ipFilterMiddleware, exportHandler, statsHandler, smsHandler, customFieldHandler, themeHandler, ssoHandler, - settingsHandler, metrics, avatarHandler, - ) - engine := r.Setup() - - // 健康检查(增强版:存活/就绪分离,检查数据库连接) - healthCheck := monitoring.NewHealthCheck(db.DB) - engine.GET("/health", healthCheck.Handler) - engine.GET("/health/live", healthCheck.LivenessHandler) - engine.GET("/health/ready", healthCheck.ReadinessHandler) - // 启动服务器 - addr := fmt.Sprintf(":%d", cfg.Server.Port) - srv := &http.Server{ - Addr: addr, - Handler: engine, - ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, - } - - go func() { - log.Printf("server listening on %s", addr) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("listen failed: %v", err) - } - }() - - // 等待中断信号 - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - <-quit - - log.Println("shutting down server...") - - // 关闭 Webhook 服务,等待投递任务完成 - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer shutdownCancel() - if err := webhookService.Shutdown(shutdownCtx); err != nil { - log.Printf("webhook service shutdown: %v", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - if err := srv.Shutdown(ctx); err != nil { - log.Fatalf("server forced to shutdown: %v", err) - } - - log.Println("server exited") -} - -func resolveGinMode(mode string) string { - switch mode { - case "debug": - return gin.DebugMode - case "test": - return gin.TestMode - default: - return gin.ReleaseMode + if err := server.Serve(cfg); err != nil { + log.Fatalf("server failed: %v", err) } } diff --git a/cmd/ums/cmd/init.go b/cmd/ums/cmd/init.go new file mode 100644 index 0000000..79d9199 --- /dev/null +++ b/cmd/ums/cmd/init.go @@ -0,0 +1,500 @@ +package cmd + +import ( + "bufio" + "crypto/rand" + "encoding/hex" + "fmt" + "net" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/user-management-system/internal/config" + "github.com/user-management-system/internal/database" +) + +// initCmd is the init subcommand +var initCmd = &cobra.Command{ + Use: "init", + Short: "Initialize the User Management System", + Long: `Interactively initialize UMS: generate secrets, configure database, +bootstrap admin user, and create configuration files. + +This command can run in two modes: +1. Interactive mode (no flags): Prompts for all required values +2. Non-interactive mode (with flags): Uses provided values directly + +Examples: + # Interactive mode + ums init + + # Non-interactive mode + ums init --admin-user admin --admin-pass MySecret123 --admin-email admin@example.com`, + RunE: func(cmd *cobra.Command, args []string) error { + return runInit(cmd) + }, +} + +var ( + initDBType string + initDBPath string + initRedisEnable bool + initRedisHost string + initRedisPort int + initRedisPassword string + adminUser string + adminPass string + adminEmail string + initPort int + initCorsOrigin string + yesFlag bool +) + +func init() { + initCmd.Flags().StringVar(&initDBType, "db-type", "sqlite", "database type (sqlite, postgresql, mysql)") + initCmd.Flags().StringVar(&initDBPath, "db-path", "./data/user_management.db", "database path or connection string") + initCmd.Flags().BoolVar(&initRedisEnable, "redis-enable", false, "enable Redis") + initCmd.Flags().StringVar(&initRedisHost, "redis-host", "localhost", "Redis host") + initCmd.Flags().IntVar(&initRedisPort, "redis-port", 6379, "Redis port") + initCmd.Flags().StringVar(&initRedisPassword, "redis-password", "", "Redis password") + initCmd.Flags().StringVar(&adminUser, "admin-user", "", "admin username") + initCmd.Flags().StringVar(&adminPass, "admin-pass", "", "admin password") + initCmd.Flags().StringVar(&adminEmail, "admin-email", "", "admin email") + initCmd.Flags().IntVar(&initPort, "port", 8080, "server port") + initCmd.Flags().StringVar(&initCorsOrigin, "cors-origin", "http://localhost:3000", "CORS allowed origin") + initCmd.Flags().BoolVar(&yesFlag, "yes", false, "skip confirmation") + + rootCmd.AddCommand(initCmd) +} + +type initConfig struct { + dbType string + dbPath string + redisEnable bool + redisHost string + redisPort int + redisPassword string + adminUser string + adminPass string + adminEmail string + port int + corsOrigin string + jwtSecret string + bootstrapSecret string +} + +func runInit(cmd *cobra.Command) error { + cfg, err := gatherInitConfig() + if err != nil { + return err + } + + // 显示配置摘要 + fmt.Println("\n=== Configuration Summary ===") + fmt.Printf("Database Type: %s\n", cfg.dbType) + fmt.Printf("Database Path: %s\n", cfg.dbPath) + fmt.Printf("Redis Enabled: %t\n", cfg.redisEnable) + if cfg.redisEnable { + fmt.Printf("Redis Host: %s:%d\n", cfg.redisHost, cfg.redisPort) + } + fmt.Printf("Admin User: %s\n", cfg.adminUser) + fmt.Printf("Admin Email: %s\n", cfg.adminEmail) + fmt.Printf("Server Port: %d\n", cfg.port) + fmt.Printf("CORS Origin: %s\n", cfg.corsOrigin) + fmt.Println("===========================") + + if !yesFlag { + fmt.Print("Proceed with initialization? [y/N]: ") + reader := bufio.NewReader(os.Stdin) + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(strings.ToLower(input)) + if input != "y" && input != "yes" { + fmt.Println("Initialization cancelled.") + return nil + } + } + + // 1. 创建数据目录 + if err := createDataDirectory(cfg); err != nil { + return fmt.Errorf("create data directory failed: %w", err) + } + + // 2. 生成密钥 + cfg.jwtSecret = generateSecret(32) + cfg.bootstrapSecret = generateSecret(16) + + // 3. 创建配置文件 + if err := createConfigFiles(cfg); err != nil { + return fmt.Errorf("create config files failed: %w", err) + } + + // 4. 执行数据库迁移 + if err := runMigrations(cfg); err != nil { + return fmt.Errorf("run migrations failed: %w", err) + } + + // 5. 创建管理员账号 + if err := createAdminUser(cfg); err != nil { + return fmt.Errorf("create admin user failed: %w", err) + } + + fmt.Println("\n=== Initialization Complete ===") + fmt.Println("Configuration files created:") + fmt.Printf(" - %s\n", getConfigPath()) + fmt.Printf(" - %s\n", getEnvPath()) + fmt.Println("\nTo start the server:") + fmt.Println(" ums serve") + fmt.Println("=============================") + + return nil +} + +func gatherInitConfig() (*initConfig, error) { + isInteractive := adminUser == "" || adminPass == "" || adminEmail == "" + + if isInteractive { + return gatherInteractiveConfig() + } + + // 非交互式模式:验证必需参数 + if adminUser == "" { + return nil, fmt.Errorf("--admin-user is required") + } + if adminPass == "" { + return nil, fmt.Errorf("--admin-pass is required") + } + if adminEmail == "" { + return nil, fmt.Errorf("--admin-email is required") + } + + // 验证密码强度 + if len(adminPass) < 8 { + return nil, fmt.Errorf("password must be at least 8 characters") + } + + return &initConfig{ + dbType: initDBType, + dbPath: initDBPath, + redisEnable: initRedisEnable, + redisHost: initRedisHost, + redisPort: initRedisPort, + redisPassword: initRedisPassword, + adminUser: adminUser, + adminPass: adminPass, + adminEmail: adminEmail, + port: initPort, + corsOrigin: initCorsOrigin, + }, nil +} + +func gatherInteractiveConfig() (*initConfig, error) { + reader := bufio.NewReader(os.Stdin) + + cfg := &initConfig{ + dbType: "sqlite", + dbPath: "./data/user_management.db", + redisEnable: false, + redisHost: "localhost", + redisPort: 6379, + port: 8080, + corsOrigin: "http://localhost:3000", + } + + fmt.Println("=== UMS Initialization ===") + + // 数据库类型 + fmt.Printf("Database type [%s]: ", cfg.dbType) + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + if input != "" { + cfg.dbType = input + } + + // 数据库路径 + fmt.Printf("Database path [%s]: ", cfg.dbPath) + input, _ = reader.ReadString('\n') + input = strings.TrimSpace(input) + if input != "" { + cfg.dbPath = input + } + + // Redis 启用 + fmt.Printf("Enable Redis? [y/N]: ") + input, _ = reader.ReadString('\n') + input = strings.TrimSpace(strings.ToLower(input)) + cfg.redisEnable = input == "y" || input == "yes" + + if cfg.redisEnable { + fmt.Printf("Redis host [%s]: ", cfg.redisHost) + input, _ = reader.ReadString('\n') + input = strings.TrimSpace(input) + if input != "" { + cfg.redisHost = input + } + + fmt.Printf("Redis port [%d]: ", cfg.redisPort) + input, _ = reader.ReadString('\n') + input = strings.TrimSpace(input) + if input != "" { + var port int + fmt.Sscanf(input, "%d", &port) + cfg.redisPort = port + } + + fmt.Printf("Redis password (empty for none): ") + input, _ = reader.ReadString('\n') + input = strings.TrimSpace(input) + cfg.redisPassword = input + } + + // 管理员信息 + fmt.Printf("Admin username [admin]: ") + input, _ = reader.ReadString('\n') + input = strings.TrimSpace(input) + if input != "" { + cfg.adminUser = input + } else { + cfg.adminUser = "admin" + } + + fmt.Print("Admin password: ") + password, err := readPassword() + fmt.Println() + if err != nil { + return nil, fmt.Errorf("read password failed: %w", err) + } + if len(password) < 8 { + return nil, fmt.Errorf("password must be at least 8 characters") + } + cfg.adminPass = password + + fmt.Printf("Admin email [%s]: ", cfg.adminEmail) + input, _ = reader.ReadString('\n') + input = strings.TrimSpace(input) + if input != "" { + cfg.adminEmail = input + } else if cfg.adminEmail == "" { + cfg.adminEmail = "admin@example.com" + } + + // 服务器端口 + fmt.Printf("Server port [%d]: ", cfg.port) + input, _ = reader.ReadString('\n') + input = strings.TrimSpace(input) + if input != "" { + var port int + fmt.Sscanf(input, "%d", &port) + cfg.port = port + } + + // CORS 域名 + fmt.Printf("CORS origin [%s]: ", cfg.corsOrigin) + input, _ = reader.ReadString('\n') + input = strings.TrimSpace(input) + if input != "" { + cfg.corsOrigin = input + } + + return cfg, nil +} + +func readPassword() (string, error) { + intrFd := int(os.Stdin.Fd()) + if term.IsTerminal(intrFd) { + password, err := term.ReadPassword(intrFd) + if err != nil { + return "", err + } + return string(password), nil + } + // 非终端模式,使用普通输入 + reader := bufio.NewReader(os.Stdin) + input, _ := reader.ReadString('\n') + return strings.TrimSpace(input), nil +} + +func generateSecret(length int) string { + bytes := make([]byte, length) + rand.Read(bytes) + return hex.EncodeToString(bytes) +} + +func createDataDirectory(cfg *initConfig) error { + var dir string + if cfg.dbType == "sqlite" { + dir = filepath.Dir(cfg.dbPath) + if dir == "." || dir == "" { + dir = "./data" + } + } else { + dir = "./data" + } + + if dir == "" || dir == "." { + return nil + } + + return os.MkdirAll(dir, 0755) +} + +func getConfigPath() string { + return "./config.yaml" +} + +func getEnvPath() string { + return "./.env" +} + +func createConfigFiles(cfg *initConfig) error { + // 创建 .env 文件 + envContent := fmt.Sprintf(`# Auto-generated by ums init +# DO NOT COMMIT THIS FILE + +# JWT Configuration +JWT_SECRET=%s +JWT_REFRESH_SECRET=%s + +# Bootstrap secret for admin initialization +BOOTSTRAP_SECRET=%s + +# Database +DATABASE_PATH=%s + +# Admin credentials +DEFAULT_ADMIN_EMAIL=%s +DEFAULT_ADMIN_PASSWORD=%s + +# Redis +REDIS_ENABLED=%t +REDIS_HOST=%s +REDIS_PORT=%d +REDIS_PASSWORD=%s + +# CORS +CORS_ALLOWED_ORIGINS=%s + +# Server +SERVER_PORT=%d +GIN_MODE=release +`, + cfg.jwtSecret, + cfg.jwtSecret, // refresh secret same as access for simplicity + cfg.bootstrapSecret, + cfg.dbPath, + cfg.adminEmail, + cfg.adminPass, + cfg.redisEnable, + cfg.redisHost, + cfg.redisPort, + cfg.redisPassword, + cfg.corsOrigin, + cfg.port, + ) + + if err := os.WriteFile(getEnvPath(), []byte(envContent), 0600); err != nil { + return err + } + fmt.Printf("Created: %s\n", getEnvPath()) + + // 创建 config.yaml + configYAML := fmt.Sprintf(`server: + port: %d + mode: release + host: 0.0.0.0 + +database: + type: %s + dbname: "%s" + +redis: + enabled: %t + host: "%s" + port: %d + password: "%s" + db: 0 + +jwt: + secret: "%s" + access_token_expire_minutes: 120 + refresh_token_expire_days: 7 + +cors: + allowed_origins: + - "%s" + +default: + admin_email: "%s" + admin_password: "%s" +`, + cfg.port, + cfg.dbType, + cfg.dbPath, + cfg.redisEnable, + cfg.redisHost, + cfg.redisPort, + cfg.redisPassword, + cfg.jwtSecret, + cfg.corsOrigin, + cfg.adminEmail, + cfg.adminPass, + ) + + if err := os.WriteFile(getConfigPath(), []byte(configYAML), 0644); err != nil { + return err + } + fmt.Printf("Created: %s\n", getConfigPath()) + + return nil +} + +func runMigrations(cfg *initConfig) error { + fmt.Println("\nRunning database migrations...") + + // 创建一个临时 config 对象用于迁移 + tempCfg := &config.Config{ + Database: config.DatabaseConfig{ + DBName: cfg.dbPath, + }, + Default: config.DefaultConfig{ + AdminEmail: cfg.adminEmail, + AdminPassword: cfg.adminPass, + }, + } + + db, err := database.NewDB(tempCfg) + if err != nil { + return fmt.Errorf("connect database failed: %w", err) + } + + if err := db.AutoMigrate(tempCfg); err != nil { + return fmt.Errorf("auto migrate failed: %w", err) + } + + fmt.Println("Database migrations completed.") + return nil +} + +func createAdminUser(cfg *initConfig) error { + fmt.Println("\nCreating admin user...") + + // 验证 Redis 连接(如果启用) + if cfg.redisEnable { + addr := net.JoinHostPort(cfg.redisHost, fmt.Sprintf("%d", cfg.redisPort)) + conn, err := net.Dial("tcp", addr) + if err != nil { + fmt.Printf("Warning: Redis connection failed: %v\n", err) + fmt.Println("System will run without Redis caching.") + } else { + conn.Close() + fmt.Println("Redis connection verified.") + } + } + + // 密码验证已在 gatherConfig 中完成 + fmt.Println("Admin user creation skipped (handled by migrations).") + return nil +} diff --git a/cmd/ums/cmd/root.go b/cmd/ums/cmd/root.go new file mode 100644 index 0000000..01e89a0 --- /dev/null +++ b/cmd/ums/cmd/root.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +var ( + cfgFile string + dataDir string +) + +// rootCmd is the single instance of the root command +var rootCmd = &cobra.Command{ + Use: "ums", + Short: "UMS CLI - User Management System", + Long: `UMS CLI - Command line interface for User Management System + +Supported commands: + ums init Initialize the system (interactive or with flags) + ums serve Start the UMS server + ums version Print version information`, +} + +func init() { + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "./config.yaml", "config file path") + rootCmd.PersistentFlags().StringVar(&dataDir, "data-dir", "./data", "data directory") + + rootCmd.AddCommand( + newVersionCmd(), + initCmd, + serveCmd, + ) +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/cmd/ums/cmd/serve.go b/cmd/ums/cmd/serve.go new file mode 100644 index 0000000..8aae1f6 --- /dev/null +++ b/cmd/ums/cmd/serve.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/user-management-system/internal/config" + "github.com/user-management-system/internal/server" +) + +var ( + servePort string +) + +// serveCmd is the serve subcommand +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "Start the UMS server", + Long: `Start the User Management System HTTP server using config.yaml`, + RunE: func(cmd *cobra.Command, args []string) error { + // 设置配置文件路径 + viper.SetConfigFile(cfgFile) + + // 加载配置 + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("load config failed: %w", err) + } + + // 允许通过 --port 覆盖配置 + if servePort != "" { + var portInt int + if _, err := fmt.Sscanf(servePort, "%d", &portInt); err != nil { + return fmt.Errorf("invalid port: %w", err) + } + cfg.Server.Port = portInt + } + + // 启动服务器 + return server.Serve(cfg) + }, +} + +func init() { + serveCmd.Flags().StringVar(&servePort, "port", "", "server port (overrides config)") + rootCmd.AddCommand(serveCmd) +} diff --git a/cmd/ums/cmd/version.go b/cmd/ums/cmd/version.go new file mode 100644 index 0000000..86ddbcf --- /dev/null +++ b/cmd/ums/cmd/version.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "fmt" + "runtime" + + "github.com/spf13/cobra" +) + +var ( + Version = "1.0.0" + Commit = "dev" + BuildDate = "unknown" +) + +func newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print version information", + Long: `Print the UMS version, Go version, and build information`, + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Printf("UMS CLI - User Management System\n") + fmt.Printf("Version: %s\n", Version) + fmt.Printf("Commit: %s\n", Commit) + fmt.Printf("Build Date: %s\n", BuildDate) + fmt.Printf("Go Version: %s\n", runtime.Version()) + fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) + return nil + }, + } +} diff --git a/cmd/ums/main.go b/cmd/ums/main.go new file mode 100644 index 0000000..a0acc80 --- /dev/null +++ b/cmd/ums/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "github.com/user-management-system/cmd/ums/cmd" +) + +func main() { + cmd.Execute() +} diff --git a/go.mod b/go.mod index 169fa5e..a5def34 100644 --- a/go.mod +++ b/go.mod @@ -60,6 +60,7 @@ require ( github.com/hashicorp/hcl v1.0.0 // indirect github.com/icholy/digest v1.1.0 // indirect github.com/imroc/req/v3 v3.57.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -89,7 +90,8 @@ require ( github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.57 // indirect github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.3.57 // indirect @@ -110,6 +112,7 @@ require ( golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/tools v0.43.0 // indirect google.golang.org/appengine v1.6.8 // indirect diff --git a/go.sum b/go.sum index 844b825..84c1fd4 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,7 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -171,6 +172,8 @@ github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI= github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -229,6 +232,7 @@ github.com/prometheus/procfs v0.13.0 h1:GqzLlQyfsPbaEHaQkO7tbDlriv/4o5Hudv6OXHGK github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= @@ -244,6 +248,7 @@ github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= @@ -257,8 +262,12 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -423,6 +432,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..b72ceb0 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,261 @@ +package server + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gin-gonic/gin" + + "github.com/user-management-system/internal/api/handler" + "github.com/user-management-system/internal/api/middleware" + "github.com/user-management-system/internal/api/router" + "github.com/user-management-system/internal/auth" + "github.com/user-management-system/internal/cache" + "github.com/user-management-system/internal/config" + "github.com/user-management-system/internal/database" + "github.com/user-management-system/internal/monitoring" + "github.com/user-management-system/internal/repository" + "github.com/user-management-system/internal/security" + "github.com/user-management-system/internal/service" +) + +func Serve(cfg *config.Config) error { + // 设置 Gin 模式 + gin.SetMode(resolveGinMode(cfg.Server.Mode)) + + // 初始化数据库 + db, err := database.NewDB(cfg) + if err != nil { + return fmt.Errorf("connect database failed: %w", err) + } + + // 执行数据库迁移 + if err := db.AutoMigrate(cfg); err != nil { + return fmt.Errorf("auto migrate failed: %w", err) + } + + // P1-3:Argon2id 启动时自适应校准 + auth.CalibrateArgon2id(500 * time.Millisecond) + + // 初始化 JWT 管理器 + jwtManager, err := auth.NewJWTWithOptions(auth.JWTOptions{ + HS256Secret: cfg.JWT.Secret, + AccessTokenExpire: time.Duration(cfg.JWT.AccessTokenExpireMinutes) * time.Minute, + RefreshTokenExpire: time.Duration(cfg.JWT.RefreshTokenExpireDays) * 24 * time.Hour, + }) + if err != nil { + return fmt.Errorf("create jwt manager failed: %w", err) + } + + // 初始化缓存 + l1Cache := cache.NewL1Cache() + redisAddr := fmt.Sprintf("%s:%d", cfg.Redis.Host, cfg.Redis.Port) + redisEnabled := cfg.Redis.Host != "" && cache.ProbeRedis(redisAddr, cfg.Redis.Password, cfg.Redis.DB) + if !redisEnabled { + log.Printf("cache: running in memory-only mode (Redis unreachable or not configured)") + } + l2Cache := cache.NewRedisCacheWithConfig(cache.RedisCacheConfig{ + Enabled: redisEnabled, + Addr: redisAddr, + Password: cfg.Redis.Password, + DB: cfg.Redis.DB, + }) + defer l2Cache.Close() + cacheManager := cache.NewCacheManager(l1Cache, l2Cache) + + // 初始化 Repository + userRepo := repository.NewUserRepository(db.DB) + roleRepo := repository.NewRoleRepository(db.DB) + permissionRepo := repository.NewPermissionRepository(db.DB) + userRoleRepo := repository.NewUserRoleRepository(db.DB) + rolePermissionRepo := repository.NewRolePermissionRepository(db.DB) + deviceRepo := repository.NewDeviceRepository(db.DB) + loginLogRepo := repository.NewLoginLogRepository(db.DB) + operationLogRepo := repository.NewOperationLogRepository(db.DB) + customFieldRepo := repository.NewCustomFieldRepository(db.DB) + userCustomFieldValueRepo := repository.NewUserCustomFieldValueRepository(db.DB) + themeRepo := repository.NewThemeConfigRepository(db.DB) + socialRepo, err := repository.NewSocialAccountRepository(db.DB) + if err != nil { + return fmt.Errorf("initialize social account repository failed: %w", err) + } + passwordHistoryRepo := repository.NewPasswordHistoryRepository(db.DB) + + // 初始化 Service + deviceService := service.NewDeviceService(deviceRepo, userRepo) + authService := service.NewAuthService( + userRepo, + socialRepo, + jwtManager, + cacheManager, + 8, // passwordMinLength + 5, // maxLoginAttempts + 15*time.Minute, // loginLockDuration + ) + authService.SetRoleRepositories(userRoleRepo, roleRepo) + authService.SetLoginLogRepository(loginLogRepo) + authService.SetDeviceService(deviceService) + + // IP 过滤中间件 + var ipFilterMiddleware *middleware.IPFilterMiddleware + ipFilter := security.NewIPFilter() + if ipFilter != nil { + ipFilterMiddleware = middleware.NewIPFilterMiddleware(ipFilter, middleware.IPFilterConfig{ + TrustProxy: cfg.CORS.AllowCredentials, + }) + } + + // 初始化异常检测器并注入 + anomalyDetector := security.NewAnomalyDetector(security.DefaultAnomalyConfig, ipFilter) + authService.SetAnomalyDetector(anomalyDetector) + log.Println("anomaly detector initialized") + + userService := service.NewUserService(userRepo, userRoleRepo, roleRepo, passwordHistoryRepo) + roleService := service.NewRoleService(roleRepo, rolePermissionRepo) + permissionService := service.NewPermissionService(permissionRepo) + loginLogService := service.NewLoginLogService(loginLogRepo) + operationLogService := service.NewOperationLogService(operationLogRepo) + captchaService := service.NewCaptchaService(cacheManager) + totpService := service.NewTOTPService(userRepo) + + passwordResetConfig := service.DefaultPasswordResetConfig() + passwordResetService := service.NewPasswordResetService(userRepo, cacheManager, passwordResetConfig). + WithPasswordHistoryRepo(passwordHistoryRepo) + + webhookService := service.NewWebhookService(db.DB, service.WebhookServiceConfig{ + Enabled: false, + }) + exportService := service.NewExportService(userRepo, roleRepo) + statsService := service.NewStatsService(userRepo, loginLogRepo) + customFieldService := service.NewCustomFieldService(customFieldRepo, userCustomFieldValueRepo) + themeService := service.NewThemeService(themeRepo) + + // 设置 CORS 配置 + middleware.SetCORSConfig(cfg.CORS) + + // 初始化中间件 + rateLimitMiddleware := middleware.NewRateLimitMiddleware(cfg.RateLimit) + authMiddleware := middleware.NewAuthMiddleware( + jwtManager, + userRepo, + userRoleRepo, + l1Cache, + ) + authMiddleware.SetCacheManager(cacheManager) + + opLogMiddleware := middleware.NewOperationLogMiddleware(operationLogRepo) + + // 初始化 Handler + authHandler := handler.NewAuthHandler(authService) + userHandler := handler.NewUserHandler(userService) + roleHandler := handler.NewRoleHandler(roleService) + permissionHandler := handler.NewPermissionHandler(permissionService) + deviceHandler := handler.NewDeviceHandler(deviceService) + logHandler := handler.NewLogHandler(loginLogService, operationLogService) + captchaHandler := handler.NewCaptchaHandler(captchaService) + totpHandler := handler.NewTOTPHandler(authService, totpService) + webhookHandler := handler.NewWebhookHandler(webhookService) + exportHandler := handler.NewExportHandler(exportService) + statsHandler := handler.NewStatsHandler(statsService) + passwordResetHandler := handler.NewPasswordResetHandler(passwordResetService) + smsHandler := handler.NewSMSHandler(authService, nil) + avatarHandler := handler.NewAvatarHandler(userRepo) + customFieldHandler := handler.NewCustomFieldHandler(customFieldService) + themeHandler := handler.NewThemeHandler(themeService) + + // 初始化 SSO 管理器 + ssoManager := auth.NewSSOManager() + ssoClientsStore := auth.NewDefaultSSOClientsStore() + ssoHandler := handler.NewSSOHandler(ssoManager, ssoClientsStore) + + // 系统设置服务 + settingsService := service.NewSettingsService() + settingsHandler := handler.NewSettingsHandler(settingsService) + + // SSO 会话清理 context(随服务器关闭而取消) + ssoCtx, ssoCancel := context.WithCancel(context.Background()) + defer ssoCancel() + ssoManager.StartCleanup(ssoCtx) + + // 初始化监控指标 + metrics := monitoring.GetGlobalMetrics() + sloMetrics := monitoring.GetGlobalSLOMetrics() + + // 启动后台 goroutine 定期采集系统指标 + metricsCtx, metricsCancel := context.WithCancel(context.Background()) + defer metricsCancel() + go monitoring.StartSystemMetricsCollector(metricsCtx, metrics, sloMetrics, db.DB) + + // 设置路由 + r := router.NewRouter( + authHandler, userHandler, roleHandler, permissionHandler, deviceHandler, + logHandler, authMiddleware, rateLimitMiddleware, opLogMiddleware, + passwordResetHandler, captchaHandler, totpHandler, webhookHandler, + ipFilterMiddleware, exportHandler, statsHandler, smsHandler, customFieldHandler, themeHandler, ssoHandler, + settingsHandler, metrics, avatarHandler, + ) + engine := r.Setup() + + // 健康检查 + healthCheck := monitoring.NewHealthCheck(db.DB) + engine.GET("/health", healthCheck.Handler) + engine.GET("/health/live", healthCheck.LivenessHandler) + engine.GET("/health/ready", healthCheck.ReadinessHandler) + + // 启动服务器 + addr := fmt.Sprintf(":%d", cfg.Server.Port) + srv := &http.Server{ + Addr: addr, + Handler: engine, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + } + + go func() { + log.Printf("server listening on %s", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen failed: %v", err) + } + }() + + // 等待中断信号 + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("shutting down server...") + + // 关闭 Webhook 服务 + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + if err := webhookService.Shutdown(shutdownCtx); err != nil { + log.Printf("webhook service shutdown: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + return fmt.Errorf("server forced to shutdown: %w", err) + } + + log.Println("server exited") + return nil +} + +func resolveGinMode(mode string) string { + switch mode { + case "debug": + return gin.DebugMode + case "test": + return gin.TestMode + default: + return gin.ReleaseMode + } +}