diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 4eab60bb..7ed33b36 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -23,6 +23,7 @@ sidebar: false ### 新增 +- 系统完成初始化后输出 OpenFlare 启动 Banner,汇总版本、构建与运行环境、数据库迁移状态、运行模式和 HTTP 监听地址。 - 新增 `make prettier`,统一使用 `gofmt` 与项目固定版本的 Prettier 自动格式化前后端源码。 - WAF 规则支持可视化 DAG 编排、版本冲突保护和有序路由绑定,发布时编译为 OpenResty 纯内存运行图。 - WAF IP 组支持 checksum 驱动的 Worker 内存热刷新,并补充 City MMDB 地区匹配数据源。 diff --git a/internal/cmd/all.go b/internal/cmd/all.go index b29f7a93..06f35f76 100644 --- a/internal/cmd/all.go +++ b/internal/cmd/all.go @@ -31,7 +31,9 @@ var allCmd = &cobra.Command{ go func() { defer wg.Done() log.Println("[All] 启动 API 服务") - router.Serve() + router.Serve(func() { + printStartupBanner(startupState{mode: "API + Worker + Scheduler", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB, listensForHTTP: true}) + }) }() // 启动 Asynq Worker 任务处理服务 diff --git a/internal/cmd/api.go b/internal/cmd/api.go index 373721af..86604d08 100644 --- a/internal/cmd/api.go +++ b/internal/cmd/api.go @@ -16,6 +16,8 @@ var apiCmd = &cobra.Command{ Run: func(_ *cobra.Command, _ []string) { bootstrap.RegisterAPI() runBootstrap(bootstrap.Options{API: true}) - router.Serve() + router.Serve(func() { + printStartupBanner(startupState{mode: "API", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB, listensForHTTP: true}) + }) }, } diff --git a/internal/cmd/banner.go b/internal/cmd/banner.go new file mode 100644 index 00000000..d0f21152 --- /dev/null +++ b/internal/cmd/banner.go @@ -0,0 +1,68 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "log" + "runtime" + "strings" + + "github.com/Rain-kl/Wavelet/internal/buildinfo" + "github.com/Rain-kl/Wavelet/internal/config" + "github.com/Rain-kl/Wavelet/internal/db/migrator" +) + +type startupState struct { + mode string + relationalDB migrator.Report + clickHouseDB migrator.Report + listensForHTTP bool +} + +func printStartupBanner(state startupState) { + log.Print(formatStartupBanner(state)) +} + +func formatStartupBanner(state startupState) string { + lines := []string{ + "", + " ____ ________ ", + " / __ \\____ ___ ____ / ____/ /___ _________ ", + " / / / / __ \\/ _ \\/ __ \\/ /_ / / __ `/ ___/ _ \\", + "/ /_/ / /_/ / __/ / / / __/ / / /_/ / / / __/", + "\\____/ .___/\\___/_/ /_/_/ /_/\\__,_/_/ \\___/ ", + " /_/ ", + fmt.Sprintf(" OpenFlare %s", buildinfo.Version), + "", + fmt.Sprintf(" Environment: %s", config.Config.App.Env), + fmt.Sprintf(" Runtime: %s/%s (%s)", runtime.GOOS, runtime.GOARCH, runtime.Version()), + fmt.Sprintf(" Build time: %s", buildTime()), + fmt.Sprintf(" Database: %s", formatMigration(state.relationalDB)), + fmt.Sprintf(" Analytics: %s", formatMigration(state.clickHouseDB)), + } + if state.listensForHTTP { + lines = append(lines, fmt.Sprintf(" Listening: http://%s", config.Config.App.Addr)) + } + lines = append(lines, fmt.Sprintf(" Mode: %s", state.mode), "") + return strings.Join(lines, "\n") +} + +func buildTime() string { + if buildinfo.BuildTime == "" { + return "development build" + } + return buildinfo.BuildTime +} + +func formatMigration(report migrator.Report) string { + if !report.Enabled { + return "disabled" + } + state := "up to date" + if report.Applied { + state = "upgraded" + } + return fmt.Sprintf("%s (version %d, %s)", report.Backend, report.Version, state) +} diff --git a/internal/cmd/banner_test.go b/internal/cmd/banner_test.go new file mode 100644 index 00000000..6e0cd1b2 --- /dev/null +++ b/internal/cmd/banner_test.go @@ -0,0 +1,57 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "strings" + "testing" + + "github.com/Rain-kl/Wavelet/internal/buildinfo" + "github.com/Rain-kl/Wavelet/internal/config" + "github.com/Rain-kl/Wavelet/internal/db/migrator" +) + +func TestFormatStartupBanner(t *testing.T) { + previousVersion := buildinfo.Version + previousBuildTime := buildinfo.BuildTime + previousEnv := config.Config.App.Env + previousAddr := config.Config.App.Addr + t.Cleanup(func() { + buildinfo.Version = previousVersion + buildinfo.BuildTime = previousBuildTime + config.Config.App.Env = previousEnv + config.Config.App.Addr = previousAddr + }) + + buildinfo.Version = "v3.2.1" + buildinfo.BuildTime = "2026-07-13T08:00:00Z" + config.Config.App.Env = "production" + config.Config.App.Addr = ":3000" + + banner := formatStartupBanner(startupState{ + mode: "API", + relationalDB: migrator.Report{ + Backend: "PostgreSQL", + Enabled: true, + Version: 202607150003, + Applied: true, + }, + clickHouseDB: migrator.Report{Backend: "ClickHouse"}, + listensForHTTP: true, + }) + + for _, want := range []string{ + "OpenFlare v3.2.1", + "Environment: production", + "Build time: 2026-07-13T08:00:00Z", + "Database: PostgreSQL (version 202607150003, upgraded)", + "Analytics: disabled", + "Listening: http://:3000", + "Mode: API", + } { + if !strings.Contains(banner, want) { + t.Errorf("banner missing %q:\n%s", want, banner) + } + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c287cf84..c798d5af 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -39,8 +39,7 @@ var rootCmd = &cobra.Command{ }) }, PreRun: func(_ *cobra.Command, _ []string) { - migrator.Migrate() - migrator.MigrateClickHouse() + runMigrations() }, PersistentPostRun: func(_ *cobra.Command, _ []string) { shutdownTraceProvider() @@ -51,6 +50,16 @@ var rootCmd = &cobra.Command{ }, } +var latestMigrationState struct { + relationalDB migrator.Report + clickHouseDB migrator.Report +} + +func runMigrations() { + latestMigrationState.relationalDB = migrator.Migrate() + latestMigrationState.clickHouseDB = migrator.MigrateClickHouse() +} + func shutdownTraceProvider() { ctx, cancel := context.WithTimeout(context.Background(), traceShutdownTimeout) defer cancel() @@ -63,8 +72,7 @@ func init() { // 1. 为需要迁移的子命令动态绑定原先 rootCmd.PreRun 拥有的数据库迁移行为 migratePreRun := func(_ *cobra.Command, _ []string) { - migrator.Migrate() - migrator.MigrateClickHouse() + runMigrations() } allCmd.PreRun = migratePreRun apiCmd.PreRun = migratePreRun diff --git a/internal/cmd/scheduler.go b/internal/cmd/scheduler.go index f2715a4a..f3ad56c4 100644 --- a/internal/cmd/scheduler.go +++ b/internal/cmd/scheduler.go @@ -18,6 +18,7 @@ var schedulerCmd = &cobra.Command{ Short: "wavelet Scheduler", Run: func(_ *cobra.Command, _ []string) { runBootstrap(bootstrap.Options{}) + printStartupBanner(startupState{mode: "Scheduler", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB}) log.Println("[Scheduler] 启动定时任务调度服务") if err := scheduler.StartScheduler(); err != nil { log.Fatalf("[调度器] 启动失败: %v", err) diff --git a/internal/cmd/worker.go b/internal/cmd/worker.go index 196d17f7..65705a37 100644 --- a/internal/cmd/worker.go +++ b/internal/cmd/worker.go @@ -18,6 +18,7 @@ var workerCmd = &cobra.Command{ Short: "wavelet Worker", Run: func(_ *cobra.Command, _ []string) { runBootstrap(bootstrap.Options{}) + printStartupBanner(startupState{mode: "Worker", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB}) log.Println("[Worker] 启动任务处理服务") if err := worker.StartWorker(); err != nil { log.Fatalf("[工作器] 启动失败: %v", err) diff --git a/internal/db/migrator/clickhouse.go b/internal/db/migrator/clickhouse.go index d301ba50..78fa8cf8 100644 --- a/internal/db/migrator/clickhouse.go +++ b/internal/db/migrator/clickhouse.go @@ -30,9 +30,9 @@ const ( var clickhouseMigrationFS embed.FS // MigrateClickHouse runs goose migrations against ClickHouse when enabled. -func MigrateClickHouse() { +func MigrateClickHouse() Report { if !config.Config.ClickHouse.Enabled { - return + return Report{Backend: "ClickHouse"} } cfg := config.Config.ClickHouse @@ -74,14 +74,30 @@ func MigrateClickHouse() { closeClickHouseDB(sqlDB) log.Fatalf("[ClickHouse] create goose provider failed: %v\n", err) } + previousVersion, err := provider.GetDBVersion(context.Background()) + if err != nil { + closeClickHouseDB(sqlDB) + log.Fatalf("[ClickHouse] get goose version failed: %v\n", err) + } if _, err := provider.Up(context.Background()); err != nil { closeClickHouseDB(sqlDB) log.Fatalf("[ClickHouse] goose migrate failed: %v\n", err) } + currentVersion, err := provider.GetDBVersion(context.Background()) + if err != nil { + closeClickHouseDB(sqlDB) + log.Fatalf("[ClickHouse] get migrated goose version failed: %v\n", err) + } closeClickHouseDB(sqlDB) log.Println("[ClickHouse] goose migrate success") + return Report{ + Backend: "ClickHouse", + Enabled: true, + Version: currentVersion, + Applied: currentVersion != previousVersion, + } } func closeClickHouseDB(sqlDB *sql.DB) { diff --git a/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go index d42793f8..dcc1a397 100644 --- a/internal/db/migrator/migrator.go +++ b/internal/db/migrator/migrator.go @@ -42,6 +42,14 @@ const ( zoneDropLegacySQLVersion int64 = 202607130001 ) +// Report describes the database migration state observed during startup. +type Report struct { + Backend string + Enabled bool + Version int64 + Applied bool +} + func gooseDialect() string { if !config.Config.Database.Enabled { return dialectSqlite @@ -57,7 +65,7 @@ func migrationDir() string { } // Migrate 执行数据库迁移:全部结构变更走 goose SQL;Zone 历史域名导入在 SQL 之后自动执行。 -func Migrate() { +func Migrate() Report { gormDB := db.DB(context.Background()) if gormDB == nil { log.Fatalf("[%s] database not initialized\n", dbType()) @@ -75,6 +83,10 @@ func Migrate() { if err := resyncGooseVersionSequence(sqlDB); err != nil { log.Fatalf("[%s] resync goose_db_version sequence failed: %v\n", dbType(), err) } + previousVersion, err := goose.GetDBVersion(sqlDB) + if err != nil { + log.Fatalf("[%s] get goose version failed: %v\n", dbType(), err) + } // 1) SQL up to zone-import marker (includes of_zones DDL; still has legacy columns). if err := goose.UpTo(sqlDB, migrationDir(), zoneImportSQLVersion); err != nil { log.Fatalf("[%s] goose migrate (up to zone import) failed: %v\n", dbType(), err) @@ -91,8 +103,18 @@ func Migrate() { } clearSystemConfigCache() + currentVersion, err := goose.GetDBVersion(sqlDB) + if err != nil { + log.Fatalf("[%s] get migrated goose version failed: %v\n", dbType(), err) + } log.Printf("[%s] goose migrate success\n", dbType()) + return Report{ + Backend: dbType(), + Enabled: true, + Version: currentVersion, + Applied: currentVersion != previousVersion, + } } // maybeImportZoneDomains runs legacy→Zone import only when the DB is still in the diff --git a/internal/router/router.go b/internal/router/router.go index 135609b1..1f5bdd0a 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -8,6 +8,7 @@ import ( "context" "errors" "log" + "net" "net/http" "os" "os/signal" @@ -29,8 +30,8 @@ import ( "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" ) -// Serve 启动 HTTP API 服务 -func Serve() { +// Serve 启动 HTTP API 服务。onStarted 仅会在 HTTP 地址成功绑定后调用。 +func Serve(onStarted func()) { // 运行模式 if config.Config.App.IsProduction() { gin.SetMode(gin.ReleaseMode) @@ -85,9 +86,17 @@ func Serve() { ReadHeaderTimeout: 10 * time.Second, } + listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", config.Config.App.Addr) + if err != nil { + log.Fatalf("[API] server failed to listen on %s: %v\n", config.Config.App.Addr, err) + } + if onStarted != nil { + onStarted() + } + go func() { - log.Printf("[API] server starting on %s\n", config.Config.App.Addr) - if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Printf("[API] server listening on %s\n", config.Config.App.Addr) + if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("[API] server failed: %v\n", err) } }()