From ed8491addfc23ff990a3164a8a0c89e0fc08068a Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 29 Aug 2026 10:53:53 +0800 Subject: [PATCH] feat(core): implement cordis configuration extension and migrate all plugins --- .agents/skills/new-setting/SKILL.md | 20 +- AGENTS.md | 3 +- backend/cmd/all.go | 10 +- backend/cmd/api.go | 10 +- backend/cmd/app.go | 79 +++-- backend/cmd/app_test.go | 75 +++- backend/cmd/banner.go | 20 +- backend/cmd/banner_test.go | 9 +- backend/cmd/redis_plug_test.go | 115 ++++-- backend/cmd/root.go | 66 +++- backend/cmd/scheduler.go | 10 +- backend/cmd/worker.go | 10 +- backend/core/config.go | 69 ++++ backend/downstream/README.md | 2 +- backend/pkg/config/config.go | 259 -------------- backend/pkg/config/config_test.go | 17 - backend/pkg/config/model.go | 140 -------- backend/pkg/config/parity_test.go | 327 ------------------ backend/pkg/idgen/snowflake.go | 36 +- backend/pkg/idgen/snowflake_test.go | 15 + backend/plugins/domain/admin/config.go | 12 + backend/plugins/domain/admin/handler/db.go | 3 +- backend/plugins/domain/admin/model/config.go | 20 ++ backend/plugins/domain/admin/plugin.go | 16 + backend/plugins/domain/admin/repository/db.go | 37 +- .../domain/admin/repository/flush_log_test.go | 7 + backend/plugins/domain/admin/service/db.go | 41 ++- .../domain/admin/service/log_switch.go | 7 +- .../plugins/domain/admin/service/status.go | 5 +- backend/plugins/domain/auth/config.go | 14 + backend/plugins/domain/auth/plugin.go | 12 + backend/plugins/domain/auth/session.go | 37 +- backend/plugins/domain/cap/plugin.go | 16 + backend/plugins/domain/cap/service.go | 29 +- backend/plugins/domain/domain_test.go | 19 + .../plugins/domain/message_gateway/plugin.go | 15 + .../domain/message_gateway/service/service.go | 20 +- .../domain/risk_control/logstore/gorm_test.go | 2 + .../domain/risk_control/logstore/provider.go | 31 +- .../plugins/domain/risk_control/middleware.go | 18 +- .../domain/risk_control/middleware_test.go | 22 +- backend/plugins/domain/risk_control/plugin.go | 24 ++ backend/plugins/domain/system/plugin.go | 5 +- .../domain/upload/handler/routers_test.go | 2 + .../domain/upload/ingest/ingest_test.go | 5 + backend/plugins/domain/user/plugin_test.go | 4 + .../plugins/domain/user/users_by_ids_test.go | 2 + .../drivers/driver_asynq_cron/plugin.go | 46 ++- .../drivers/driver_asynq_worker/config.go | 28 ++ .../driver_asynq_worker/executor_test.go | 5 + .../drivers/driver_asynq_worker/plugin.go | 34 ++ .../drivers/driver_asynq_worker/utils.go | 36 +- .../drivers/driver_asynq_worker/utils_test.go | 9 +- backend/plugins/drivers/driver_http/config.go | 27 ++ backend/plugins/drivers/driver_http/engine.go | 114 +++--- .../drivers/driver_http/middlewares.go | 24 +- backend/plugins/drivers/driver_http/plugin.go | 33 +- .../drivers/driver_inproc_cron/plugin.go | 16 + .../drivers/driver_inproc_worker/plugin.go | 16 + .../driver_inproc_worker/plugin_test.go | 2 + backend/plugins/drivers/drivers_test.go | 4 + backend/plugins/infra/cache/config.go | 25 ++ backend/plugins/infra/cache/plugin.go | 19 +- backend/plugins/infra/cache/plugin_test.go | 6 +- backend/plugins/infra/cache/redis.go | 33 +- backend/plugins/infra/cache_memory/plugin.go | 17 + backend/plugins/infra/database/clickhouse.go | 34 +- backend/plugins/infra/database/config.go | 57 +++ backend/plugins/infra/database/plugin.go | 30 +- backend/plugins/infra/database/plugin_test.go | 6 +- backend/plugins/infra/database/postgres.go | 79 +++-- backend/plugins/infra/infra_test.go | 43 ++- docs/WAVELET_WHITE_PAPER.md | 21 ++ ...26-08-29-cordis-config-extension-design.md | 18 + 74 files changed, 1354 insertions(+), 1145 deletions(-) delete mode 100644 backend/pkg/config/config.go delete mode 100644 backend/pkg/config/config_test.go delete mode 100644 backend/pkg/config/model.go delete mode 100644 backend/pkg/config/parity_test.go create mode 100644 backend/plugins/domain/admin/config.go create mode 100644 backend/plugins/domain/admin/model/config.go create mode 100644 backend/plugins/domain/auth/config.go create mode 100644 backend/plugins/drivers/driver_asynq_worker/config.go create mode 100644 backend/plugins/drivers/driver_http/config.go create mode 100644 backend/plugins/infra/cache/config.go create mode 100644 backend/plugins/infra/database/config.go diff --git a/.agents/skills/new-setting/SKILL.md b/.agents/skills/new-setting/SKILL.md index f951221e..332718de 100644 --- a/.agents/skills/new-setting/SKILL.md +++ b/.agents/skills/new-setting/SKILL.md @@ -22,18 +22,28 @@ Wavelet 提供两种维度的配置能力: ## 2. 插件内配置声明与绑定 -### 2.1 静态配置绑定 (`ctx.Config().Bind`) +### 2.1 静态配置声明与绑定 (`DeclareConfig` 与 `ctx.Config().Bind`) + +静态启动配置遵循插件自包含声明与解耦规范: ```go type OrderStaticConfig struct { - PaymentGatewayURL string `yaml:"payment_gateway_url" json:"payment_gateway_url"` - TimeoutSeconds int `yaml:"timeout_seconds" json:"timeout_seconds"` + PaymentGatewayURL string `config:"payment_gateway_url" env:"ORDER_PAYMENT_URL" default:"https://pay.example.com"` + TimeoutSeconds int `config:"timeout_seconds" env:"ORDER_TIMEOUT" default:"30"` + ApiKey string `config:"api_key" env:"ORDER_API_KEY" secret:"true"` +} + +// 可选:实现 DeclareConfig 声明配置模式(若需门禁求值则实现 core.ConfigGatedPlugin) +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "plugins.order", Target: &OrderStaticConfig{}}, + } } func (p *Plugin) Apply(ctx *core.Context) error { var cfg OrderStaticConfig - // 从 config.yaml 中的 plugins.order 节点绑定配置 - ctx.Config().Bind("plugins.order", &cfg) + // 从统一配置源绑定 plugins.order 节点配置(支持 YAML 与环境变量覆盖) + _ = ctx.Config().Bind("plugins.order", &cfg) return nil } ``` diff --git a/AGENTS.md b/AGENTS.md index e5a91785..09cdf6e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,8 @@ Strong success criteria let you loop independently. Weak criteria ("make it work - **扩展点自包含注册**: - **HTTP 路由**:插件自包含在 `Apply` 中通过 `ctx.Router().Group(...)` 挂载路由与中间件,禁止跨插件散落注册。 - **异步与定时任务**:插件自包含在 `Apply` 中通过 `ctx.Task().Register(...)` 与 `ctx.Schedule().RegisterCron(...)` 声明。 - - **动态配置**:插件自包含在 `Apply` 中通过 `ctx.Settings().Register(core.SettingSchema{...})` 声明配置模式,通过 `ctx.Config().Bind(...)` 绑定 YAML 配置。 + - **静态启动配置**:插件自包含在 `Apply` 中通过 `ctx.Config().Bind("", &cfg)` 读取**自己声明**的配置,字段以 tag 表达来源:`config`(yaml 路径)、`env`(覆盖变量名)、`default`、`autoEnable`(该变量存在即置真)、`secret`(导出脱敏)。需要在 `Apply` 之前被门禁求值的键,必须在 `DeclareConfig()` 中提前声明并实现 `core.ConfigGatedPlugin`。新增基础设施 key 保持顶层命名(`redis.*`),插件私有配置归 `plugins..*`。**严禁**再造全局配置单例或在 `backend/pkg/` 读取配置。 + - **动态设置**:插件自包含在 `Apply` 中通过 `ctx.Settings().Register(core.SettingSchema{...})` 声明可热更新的管理台设置模式(与上面的静态启动配置分属两层)。 - **数据迁移**:插件自包含在内部维护 `migrations/*.sql`,通过 `//go:embed` 打包并在 `Apply` 中通过 `ctx.Migrations().Register(pluginID, embedFS)` 注入。 - **表单一所有者原则 (Single Owner Principle)**: - 每张数据表有且仅由一个所有者插件声明与维护(表名使用插件前缀如 `w_order_*`)。 diff --git a/backend/cmd/all.go b/backend/cmd/all.go index 0db6b9c4..66984459 100644 --- a/backend/cmd/all.go +++ b/backend/cmd/all.go @@ -6,7 +6,6 @@ package cmd import ( "Wavelet/core" - "log" "github.com/spf13/cobra" ) @@ -15,13 +14,6 @@ var allCmd = &cobra.Command{ Use: "all", Short: "以融合模式同时启动 API、Worker 和 Scheduler", Run: func(_ *cobra.Command, _ []string) { - printStartupBanner(startupState{ - mode: "all (API + Worker + Scheduler)", - listensForHTTP: true, - }) - app := newWaveletApp(core.ProfileAll) - if err := app.Run(); err != nil { - log.Fatalf("[All] run failed: %v\n", err) - } + runProfileApp(core.ProfileAll, "all (API + Worker + Scheduler)", true) }, } diff --git a/backend/cmd/api.go b/backend/cmd/api.go index 5209d552..c335b81b 100644 --- a/backend/cmd/api.go +++ b/backend/cmd/api.go @@ -5,7 +5,6 @@ package cmd import ( "Wavelet/core" - "log" "github.com/spf13/cobra" ) @@ -14,13 +13,6 @@ var apiCmd = &cobra.Command{ Use: "api", Short: "wavelet API", Run: func(_ *cobra.Command, _ []string) { - printStartupBanner(startupState{ - mode: "api", - listensForHTTP: true, - }) - app := newWaveletApp(core.ProfileAPI) - if err := app.Run(); err != nil { - log.Fatalf("[API] run failed: %v\n", err) - } + runProfileApp(core.ProfileAPI, "api", true) }, } diff --git a/backend/cmd/app.go b/backend/cmd/app.go index 81e8964a..099a0154 100644 --- a/backend/cmd/app.go +++ b/backend/cmd/app.go @@ -6,7 +6,6 @@ package cmd import ( "Wavelet/core" "Wavelet/core/contracts" - "Wavelet/pkg/config" "Wavelet/plugins/domain/admin" "Wavelet/plugins/domain/auth" "Wavelet/plugins/domain/cap" @@ -22,6 +21,7 @@ import ( "Wavelet/plugins/drivers/driver_inproc_worker" "Wavelet/plugins/infra/cache" "Wavelet/plugins/infra/cache_memory" + "Wavelet/plugins/infra/config" "Wavelet/plugins/infra/logger" "Wavelet/plugins/infra/storage" "context" @@ -38,14 +38,48 @@ import ( infradb "Wavelet/plugins/infra/database" ) +const ( + defaultShutdownTimeout = 15 * time.Second + defaultHTTPAddr = "127.0.0.1:3000" +) + +// runProfileApp prepares and runs the application for a given profile. +func runProfileApp(profile core.Profile, mode string, listensForHTTP bool) { + app := newWaveletApp(profile) + if err := app.Prepare(); err != nil { + log.Fatalf("[%s] prepare failed: %v\n", mode, err) + } + state := startupState{ + mode: mode, + listensForHTTP: listensForHTTP, + env: app.Context().Config().String("app.env", "production"), + } + if listensForHTTP { + state.addr = app.Context().Config().String("app.addr", defaultHTTPAddr) + } + printStartupBanner(state) + if err := app.Run(); err != nil { + log.Fatalf("[%s] run failed: %v\n", mode, err) + } +} + // newWaveletApp creates a core.App wired with Wavelet platform infrastructure, domain plugins, and profile drivers. // //nolint:contextcheck -func newWaveletApp(profile core.Profile) *core.App { - app := core.NewApp( +func newWaveletApp(profile core.Profile, opts ...core.AppOption) *core.App { + src, err := config.NewSource() + if err != nil { + log.Fatalf("[App] load config source failed: %v\n", err) + } + + appOpts := []core.AppOption{ core.WithProfile(profile), - core.WithShutdownTimeout(time.Duration(config.Config.App.GracefulShutdownTimeout)*time.Second), - ) + core.WithConfigSource(src), + core.WithShutdownTimeout(defaultShutdownTimeout), + } + appOpts = append(appOpts, opts...) + + app := core.NewApp(appOpts...) // 1. Register standard infrastructure plugins app.Use( @@ -54,20 +88,15 @@ func newWaveletApp(profile core.Profile) *core.App { storage.New(), ) - // 2. Register Cache and Async/Cron Drivers based on Redis configuration - if config.Config.Redis.Enabled { - app.Use( - cache.New(), - driver_asynq_worker.New(), - driver_asynq_cron.New(), - ) - } else { - app.Use( - cache_memory.New(), - driver_inproc_worker.New(), - driver_inproc_cron.New(), - ) - } + // 2. Register Cache and Async/Cron Drivers (both gated: cache vs cache_memory, asynq vs inproc) + app.Use( + cache.New(), + cache_memory.New(), + driver_asynq_worker.New(), + driver_inproc_worker.New(), + driver_asynq_cron.New(), + driver_inproc_cron.New(), + ) // 3. Register all 8 domain business plugins (admin first to ensure schema and base config tables exist) app.Use( @@ -86,7 +115,7 @@ func newWaveletApp(profile core.Profile) *core.App { // 5. Mount HTTP runtime driver app.Use( - driver_http.New(driver_http.WithAddr(config.Config.App.Addr)), + driver_http.New(), ) return app @@ -236,7 +265,7 @@ func (e *gooseEngine) Migrate(ctx *core.Context, entries []core.MigrationEntry) return fmt.Errorf("migration: get underlying DB from GORM: %w", err) } - dialect := gooseDialect() + dialect := gooseDialect(ctx) dialectStr := string(dialect) for _, entry := range entries { @@ -267,11 +296,11 @@ func (e *gooseEngine) Migrate(ctx *core.Context, entries []core.MigrationEntry) } // gooseDialect returns the goose dialect based on the configured database engine. -func gooseDialect() goose.Dialect { - if !config.Config.Database.Enabled { - return goose.DialectSQLite3 +func gooseDialect(ctx *core.Context) goose.Dialect { + if ctx != nil && ctx.Config() != nil && ctx.Config().Bool("database.enabled", false) { + return goose.DialectPostgres } - return goose.DialectPostgres + return goose.DialectSQLite3 } func findMigrationFS(rootFS fs.FS, dialect goose.Dialect) fs.FS { diff --git a/backend/cmd/app_test.go b/backend/cmd/app_test.go index 89665419..2625dea2 100644 --- a/backend/cmd/app_test.go +++ b/backend/cmd/app_test.go @@ -5,7 +5,6 @@ package cmd import ( "Wavelet/core" - "Wavelet/pkg/config" "testing" "github.com/stretchr/testify/assert" @@ -22,13 +21,22 @@ func TestNewWaveletAppProfiles(t *testing.T) { for _, prof := range profiles { t.Run(string(prof), func(t *testing.T) { - app := newWaveletApp(prof) + app := newWaveletApp(prof, core.WithConfigValues(map[string]any{ + "app": map[string]any{ + "addr": "127.0.0.1:0", + }, + "redis": map[string]any{ + "enabled": false, + }, + })) require.NotNil(t, app) assert.Equal(t, prof, app.Profile()) - // 3 infra + (1 cache + 2 worker/cron) + 8 domain + 1 http driver = 15 plugins + // 3 infra + 2 cache + 4 worker/cron + 8 domain + 1 http driver = 18 plugins plugins := app.Plugins() - assert.Len(t, plugins, 15) + assert.Len(t, plugins, 18) + + require.NoError(t, app.Reconcile()) // Verify standard infra plugins _, ok := app.Plugin("database") @@ -41,14 +49,29 @@ func TestNewWaveletAppProfiles(t *testing.T) { assert.True(t, ok, "storage plugin missing") // In zero-Redis mode (default in test) - _, ok = app.Plugin("cache_memory") - assert.True(t, ok, "cache_memory plugin missing") + f, ok := app.Fiber("cache_memory") + assert.True(t, ok, "cache_memory fiber missing") + assert.Equal(t, core.FiberActive, f.State()) - _, ok = app.Plugin("driver_inproc_worker") - assert.True(t, ok, "inproc worker driver missing") + f, ok = app.Fiber("cache") + assert.True(t, ok, "cache fiber missing") + assert.Equal(t, core.FiberSkipped, f.State()) - _, ok = app.Plugin("driver_inproc_cron") - assert.True(t, ok, "inproc scheduler driver missing") + f, ok = app.Fiber("driver_inproc_worker") + assert.True(t, ok, "inproc worker driver fiber missing") + assert.Equal(t, core.FiberActive, f.State()) + + f, ok = app.Fiber("driver_asynq_worker") + assert.True(t, ok, "asynq worker driver fiber missing") + assert.Equal(t, core.FiberSkipped, f.State()) + + f, ok = app.Fiber("driver_inproc_cron") + assert.True(t, ok, "inproc scheduler driver fiber missing") + assert.Equal(t, core.FiberActive, f.State()) + + f, ok = app.Fiber("driver_asynq_cron") + assert.True(t, ok, "asynq scheduler driver fiber missing") + assert.Equal(t, core.FiberSkipped, f.State()) // Verify domain plugins _, ok = app.Plugin("auth") @@ -83,19 +106,35 @@ func TestNewWaveletAppProfiles(t *testing.T) { } func TestNewWaveletAppWithRedisEnabled(t *testing.T) { - orig := config.Config.Redis.Enabled - config.Config.Redis.Enabled = true - defer func() { config.Config.Redis.Enabled = orig }() - - app := newWaveletApp(core.ProfileAll) + app := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{ + "redis": map[string]any{ + "enabled": true, + }, + })) require.NotNil(t, app) + require.NoError(t, app.Reconcile()) - _, ok := app.Plugin("cache") + f, ok := app.Fiber("cache") assert.True(t, ok, "cache plugin missing in Redis mode") + assert.Equal(t, core.FiberActive, f.State()) - _, ok = app.Plugin("driver_asynq_worker") + f, ok = app.Fiber("driver_asynq_worker") assert.True(t, ok, "asynq worker driver missing in Redis mode") + assert.Equal(t, core.FiberActive, f.State()) - _, ok = app.Plugin("driver_asynq_cron") + f, ok = app.Fiber("driver_asynq_cron") assert.True(t, ok, "asynq scheduler driver missing in Redis mode") + assert.Equal(t, core.FiberActive, f.State()) + + f, ok = app.Fiber("cache_memory") + assert.True(t, ok) + assert.Equal(t, core.FiberSkipped, f.State()) + + f, ok = app.Fiber("driver_inproc_worker") + assert.True(t, ok) + assert.Equal(t, core.FiberSkipped, f.State()) + + f, ok = app.Fiber("driver_inproc_cron") + assert.True(t, ok) + assert.Equal(t, core.FiberSkipped, f.State()) } diff --git a/backend/cmd/banner.go b/backend/cmd/banner.go index 21bde4ec..882d2a06 100644 --- a/backend/cmd/banner.go +++ b/backend/cmd/banner.go @@ -1,23 +1,20 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -// Package cmd provides CLI command entry points. -// -//nolint:unused package cmd import ( "Wavelet/pkg/buildinfo" - "Wavelet/pkg/config" "fmt" "runtime" "strings" ) -//nolint:unused // startup banner formatting utilities type startupState struct { mode string listensForHTTP bool + env string + addr string } func printStartupBanner(state startupState) { @@ -25,6 +22,15 @@ func printStartupBanner(state startupState) { } func formatStartupBanner(state startupState) string { + env := state.env + if env == "" { + env = "production" + } + addr := state.addr + if addr == "" { + addr = "127.0.0.1:3000" + } + lines := []string{ "", "__ __ _ _ ", @@ -34,12 +40,12 @@ func formatStartupBanner(state startupState) string { " \\_/\\_/ \\__,_| \\_/ \\___|_|\\___|\\__|", fmt.Sprintf(" Wavelet %s", buildinfo.Version), "", - fmt.Sprintf(" Environment: %s", config.Config.App.Env), + fmt.Sprintf(" Environment: %s", env), fmt.Sprintf(" Runtime: %s/%s (%s)", runtime.GOOS, runtime.GOARCH, runtime.Version()), fmt.Sprintf(" Build time: %s", buildTime()), } if state.listensForHTTP { - lines = append(lines, fmt.Sprintf(" Listening: http://%s", config.Config.App.Addr)) + lines = append(lines, fmt.Sprintf(" Listening: http://%s", addr)) } lines = append(lines, fmt.Sprintf(" Mode: %s", state.mode), "") return strings.Join(lines, "\n") diff --git a/backend/cmd/banner_test.go b/backend/cmd/banner_test.go index 0faf4774..75f5ea9e 100644 --- a/backend/cmd/banner_test.go +++ b/backend/cmd/banner_test.go @@ -5,7 +5,6 @@ package cmd import ( "Wavelet/pkg/buildinfo" - "Wavelet/pkg/config" "strings" "testing" ) @@ -13,23 +12,19 @@ import ( 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", listensForHTTP: true, + env: "production", + addr: ":3000", }) for _, want := range []string{ diff --git a/backend/cmd/redis_plug_test.go b/backend/cmd/redis_plug_test.go index 7c085866..a4fb8e0f 100644 --- a/backend/cmd/redis_plug_test.go +++ b/backend/cmd/redis_plug_test.go @@ -7,49 +7,56 @@ import ( "Wavelet/core" "Wavelet/core/contracts" "Wavelet/core/extpoints" - "Wavelet/pkg/config" + "Wavelet/pkg/idgen" "context" "fmt" "sync/atomic" "testing" "time" + "github.com/alicebob/miniredis/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRedisPluggability_Simulation(t *testing.T) { - origRedisEnabled := config.Config.Redis.Enabled - origAddr := config.Config.App.Addr - config.Config.App.Addr = "127.0.0.1:0" - defer func() { - config.Config.Redis.Enabled = origRedisEnabled - config.Config.App.Addr = origAddr - }() + _ = idgen.Init(1) // ══════════════════════════════════════════════════════════════════════════ // 场景 1: 拔出 Redis (Zero-Redis Monolith 模式) // ══════════════════════════════════════════════════════════════════════════ t.Run("Scenario_Unplugged_ZeroRedis_Mode", func(t *testing.T) { - config.Config.Redis.Enabled = false - - app := newWaveletApp(core.ProfileAll) + app := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{ + "app": map[string]any{ + "addr": "127.0.0.1:0", + }, + "redis": map[string]any{ + "enabled": false, + }, + })) require.NotNil(t, app) + require.NoError(t, app.Reconcile()) // 1. 验证插件挂载形态 - _, ok := app.Plugin("cache_memory") + f, ok := app.Fiber("cache_memory") assert.True(t, ok, "cache_memory 必须挂载") - _, ok = app.Plugin("driver_inproc_worker") + assert.Equal(t, core.FiberActive, f.State()) + f, ok = app.Fiber("driver_inproc_worker") assert.True(t, ok, "driver_inproc_worker 必须挂载") - _, ok = app.Plugin("driver_inproc_cron") + assert.Equal(t, core.FiberActive, f.State()) + f, ok = app.Fiber("driver_inproc_cron") assert.True(t, ok, "driver_inproc_cron 必须挂载") + assert.Equal(t, core.FiberActive, f.State()) - _, ok = app.Plugin("cache") - assert.False(t, ok, "分布式 cache 不得挂载") - _, ok = app.Plugin("driver_asynq_worker") - assert.False(t, ok, "asynq_worker 不得挂载") - _, ok = app.Plugin("driver_asynq_cron") - assert.False(t, ok, "asynq_cron 不得挂载") + f, ok = app.Fiber("cache") + assert.True(t, ok) + assert.Equal(t, core.FiberSkipped, f.State(), "分布式 cache 不得挂载") + f, ok = app.Fiber("driver_asynq_worker") + assert.True(t, ok) + assert.Equal(t, core.FiberSkipped, f.State(), "asynq_worker 不得挂载") + f, ok = app.Fiber("driver_asynq_cron") + assert.True(t, ok) + assert.Equal(t, core.FiberSkipped, f.State(), "asynq_cron 不得挂载") // 2. 注册测试任务与 Cron 定时 var taskExecuted atomic.Int32 @@ -114,25 +121,42 @@ func TestRedisPluggability_Simulation(t *testing.T) { // 场景 2: 插入 Redis (Distributed Cluster 模式) // ══════════════════════════════════════════════════════════════════════════ t.Run("Scenario_Plugged_Redis_Mode", func(t *testing.T) { - config.Config.Redis.Enabled = true + mr, err := miniredis.Run() + require.NoError(t, err) + defer mr.Close() - app := newWaveletApp(core.ProfileAll) + app := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{ + "app": map[string]any{ + "addr": "127.0.0.1:0", + }, + "redis": map[string]any{ + "enabled": true, + "addrs": []string{mr.Addr()}, + }, + })) require.NotNil(t, app) + require.NoError(t, app.Reconcile()) // 1. 验证插件挂载形态 - _, ok := app.Plugin("cache") + f, ok := app.Fiber("cache") assert.True(t, ok, "分布式 cache 必须挂载") - _, ok = app.Plugin("driver_asynq_worker") + assert.Equal(t, core.FiberActive, f.State()) + f, ok = app.Fiber("driver_asynq_worker") assert.True(t, ok, "driver_asynq_worker 必须挂载") - _, ok = app.Plugin("driver_asynq_cron") + assert.Equal(t, core.FiberActive, f.State()) + f, ok = app.Fiber("driver_asynq_cron") assert.True(t, ok, "driver_asynq_cron 必须挂载") + assert.Equal(t, core.FiberActive, f.State()) - _, ok = app.Plugin("cache_memory") - assert.False(t, ok, "纯内存 cache 不得挂载") - _, ok = app.Plugin("driver_inproc_worker") - assert.False(t, ok, "inproc_worker 不得挂载") - _, ok = app.Plugin("driver_inproc_cron") - assert.False(t, ok, "inproc_cron 不得挂载") + f, ok = app.Fiber("cache_memory") + assert.True(t, ok) + assert.Equal(t, core.FiberSkipped, f.State(), "纯内存 cache 不得挂载") + f, ok = app.Fiber("driver_inproc_worker") + assert.True(t, ok) + assert.Equal(t, core.FiberSkipped, f.State(), "inproc_worker 不得挂载") + f, ok = app.Fiber("driver_inproc_cron") + assert.True(t, ok) + assert.Equal(t, core.FiberSkipped, f.State(), "inproc_cron 不得挂载") // 2. 注册测试任务 var asynqTaskExecuted atomic.Int32 @@ -143,7 +167,7 @@ func TestRedisPluggability_Simulation(t *testing.T) { return nil }, extpoints.WithTaskTimeout(3*time.Second)) - // 3. 启动应用 (连接真实运行中的 Redis 6379) + // 3. 启动应用 bootCtx, bootCancel := context.WithTimeout(context.Background(), 5*time.Second) defer bootCancel() require.NoError(t, app.Start(bootCtx)) @@ -172,7 +196,7 @@ func TestRedisPluggability_Simulation(t *testing.T) { require.NoError(t, err) require.NotNil(t, taskSvc) - taskID, err := taskSvc.Dispatch(reqCtx, "test:asynq_task", []byte("payload_plugged"), "unit_test") + taskID, err := taskSvc.Dispatch(reqCtx, "test:asynq_task", []byte("payload_plugged"), "default") require.NoError(t, err) assert.NotEmpty(t, taskID) @@ -190,10 +214,20 @@ func TestRedisPluggability_Simulation(t *testing.T) { // 场景 3: 往复插拔连续切换 (拔出 → 插入 → 再拔出,验证时空可组合性与零残留) // ══════════════════════════════════════════════════════════════════════════ t.Run("Scenario_Dynamic_Plug_Unplug_Sequence", func(t *testing.T) { + mr, err := miniredis.Run() + require.NoError(t, err) + defer mr.Close() + for i := 1; i <= 2; i++ { // 1. 拔出 Redis 运行 - config.Config.Redis.Enabled = false - appUnplugged := newWaveletApp(core.ProfileAll) + appUnplugged := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{ + "app": map[string]any{ + "addr": "127.0.0.1:0", + }, + "redis": map[string]any{ + "enabled": false, + }, + })) require.NoError(t, appUnplugged.Start(context.Background())) cacheSvc1, err := core.Inject[contracts.CacheService](appUnplugged.Context()) @@ -203,8 +237,15 @@ func TestRedisPluggability_Simulation(t *testing.T) { require.NoError(t, appUnplugged.Stop(context.Background())) // 2. 插入 Redis 运行 - config.Config.Redis.Enabled = true - appPlugged := newWaveletApp(core.ProfileAll) + appPlugged := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{ + "app": map[string]any{ + "addr": "127.0.0.1:0", + }, + "redis": map[string]any{ + "enabled": true, + "addrs": []string{mr.Addr()}, + }, + })) require.NoError(t, appPlugged.Start(context.Background())) cacheSvc2, err := core.Inject[contracts.CacheService](appPlugged.Context()) diff --git a/backend/cmd/root.go b/backend/cmd/root.go index cdc7dfd0..4049fcb7 100644 --- a/backend/cmd/root.go +++ b/backend/cmd/root.go @@ -4,10 +4,12 @@ package cmd import ( + "Wavelet/core/extpoints" "Wavelet/pkg/buildinfo" - "Wavelet/pkg/config" + "Wavelet/pkg/idgen" "Wavelet/pkg/logger" "Wavelet/pkg/trace" + "Wavelet/plugins/infra/config" "context" "log" "time" @@ -17,23 +19,63 @@ import ( const traceShutdownTimeout = 10 * time.Second +type hostConfig struct { + App struct { + AppName string `config:"app_name" env:"APP_NAME" default:"Wavelet"` + Env string `config:"env" env:"APP_ENV" default:"production"` + NodeID int64 `config:"node_id" env:"APP_NODE_ID" default:"1"` + Addr string `config:"addr" env:"APP_ADDR" default:"127.0.0.1:3000"` + } `config:"app"` + Log struct { + Level string `config:"level" env:"LOG_LEVEL" default:"info"` + Format string `config:"format" env:"LOG_FORMAT" default:"json"` + Output string `config:"output" env:"LOG_OUTPUT" default:"stdout"` + FilePath string `config:"file_path" env:"LOG_FILE_PATH" default:"./logs/app.log"` + MaxSize int `config:"max_size" env:"LOG_MAX_SIZE" default:"100"` + MaxAge int `config:"max_age" env:"LOG_MAX_AGE" default:"30"` + MaxBackups int `config:"max_backups" env:"LOG_MAX_BACKUPS" default:"10"` + Compress bool `config:"compress" env:"LOG_COMPRESS" default:"true"` + } `config:"log"` + OTel struct { + SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE" default:"1.0"` + TracerName string `config:"tracer_name" env:"OTEL_TRACER_NAME" default:"github.com/Rain-kl/Wavelet"` + } `config:"otel"` +} + var rootCmd = &cobra.Command{ Use: "wavelet", PersistentPreRun: func(_ *cobra.Command, _ []string) { + src, err := config.NewSource() + if err != nil { + log.Fatalf("[CMD] load config source failed: %v", err) + } + var cfg hostConfig + reg := extpoints.NewConfigRegistry(src) + _ = reg.Declare("host", extpoints.ConfigBinding{Target: &cfg}) + if err := reg.Resolve(); err != nil { + log.Fatalf("[CMD] resolve host config failed: %v", err) + } + _ = reg.Bind("", &cfg) + + // Initialize idgen snowflake generator + if err := idgen.Init(cfg.App.NodeID); err != nil { + log.Fatalf("[CMD] init idgen failed: %v", err) + } + logger.Init(logger.Config{ - Level: config.Config.Log.Level, - Format: config.Config.Log.Format, - Output: config.Config.Log.Output, - FilePath: config.Config.Log.FilePath, - MaxSize: config.Config.Log.MaxSize, - MaxAge: config.Config.Log.MaxAge, - MaxBackups: config.Config.Log.MaxBackups, - Compress: config.Config.Log.Compress, + Level: cfg.Log.Level, + Format: cfg.Log.Format, + Output: cfg.Log.Output, + FilePath: cfg.Log.FilePath, + MaxSize: cfg.Log.MaxSize, + MaxAge: cfg.Log.MaxAge, + MaxBackups: cfg.Log.MaxBackups, + Compress: cfg.Log.Compress, }) trace.Init(trace.Config{ - AppName: config.Config.App.AppName, - SamplingRate: config.Config.Otel.SamplingRate, - TracerName: config.Config.Otel.TracerName, + AppName: cfg.App.AppName, + SamplingRate: cfg.OTel.SamplingRate, + TracerName: cfg.OTel.TracerName, }) }, PersistentPostRun: func(_ *cobra.Command, _ []string) { diff --git a/backend/cmd/scheduler.go b/backend/cmd/scheduler.go index c3a499ad..3afdb9ce 100644 --- a/backend/cmd/scheduler.go +++ b/backend/cmd/scheduler.go @@ -5,7 +5,6 @@ package cmd import ( "Wavelet/core" - "log" "github.com/spf13/cobra" ) @@ -14,13 +13,6 @@ var schedulerCmd = &cobra.Command{ Use: "scheduler", Short: "wavelet Scheduler", Run: func(_ *cobra.Command, _ []string) { - printStartupBanner(startupState{ - mode: "scheduler", - listensForHTTP: false, - }) - app := newWaveletApp(core.ProfileSchedule) - if err := app.Run(); err != nil { - log.Fatalf("[Scheduler] run failed: %v\n", err) - } + runProfileApp(core.ProfileSchedule, "scheduler", false) }, } diff --git a/backend/cmd/worker.go b/backend/cmd/worker.go index 9c954a58..5011341b 100644 --- a/backend/cmd/worker.go +++ b/backend/cmd/worker.go @@ -5,7 +5,6 @@ package cmd import ( "Wavelet/core" - "log" "github.com/spf13/cobra" ) @@ -14,13 +13,6 @@ var workerCmd = &cobra.Command{ Use: "worker", Short: "wavelet Worker", Run: func(_ *cobra.Command, _ []string) { - printStartupBanner(startupState{ - mode: "worker", - listensForHTTP: false, - }) - app := newWaveletApp(core.ProfileWorker) - if err := app.Run(); err != nil { - log.Fatalf("[Worker] run failed: %v\n", err) - } + runProfileApp(core.ProfileWorker, "worker", false) }, } diff --git a/backend/core/config.go b/backend/core/config.go index 5d8b1080..a52bde7a 100644 --- a/backend/core/config.go +++ b/backend/core/config.go @@ -5,6 +5,7 @@ package core import ( "fmt" + "strings" "Wavelet/core/extpoints" ) @@ -29,3 +30,71 @@ func ConfigGet[T any](view extpoints.ConfigView, key string) (T, error) { } return value, nil } + +// MapSource implements ConfigSource backed by an in-memory map, ideal for unit tests. +type MapSource struct { + values map[string]any + env map[string]string +} + +// NewMapSource creates a new MapSource with the provided key-value mappings. +func NewMapSource(values map[string]any) *MapSource { + vals := make(map[string]any, len(values)) + for k, v := range values { + vals[k] = v + } + return &MapSource{ + values: vals, + env: make(map[string]string), + } +} + +// Lookup returns the value at the given path, supporting both flat keys and nested maps. +func (m *MapSource) Lookup(path string) (any, bool) { + if m == nil || m.values == nil { + return nil, false + } + if v, ok := m.values[path]; ok { + return v, true + } + parts := strings.Split(path, ".") + var cur any = m.values + for _, part := range parts { + mCur, ok := cur.(map[string]any) + if !ok { + return nil, false + } + cur, ok = mCur[part] + if !ok { + return nil, false + } + } + return cur, true +} + +// LookupEnv returns the environment variable value. +func (m *MapSource) LookupEnv(name string) (string, bool) { + if m == nil || m.env == nil { + return "", false + } + v, ok := m.env[name] + return v, ok +} + +// SetEnv sets an environment variable for testing. +func (m *MapSource) SetEnv(name, value string) { + if m.env == nil { + m.env = make(map[string]string) + } + m.env[name] = value +} + +// Describe describes the MapSource. +func (m *MapSource) Describe() string { + return "" +} + +// WithConfigValues returns an AppOption that installs a MapSource with the given key-value mappings. +func WithConfigValues(values map[string]any) AppOption { + return WithConfigSource(NewMapSource(values)) +} diff --git a/backend/downstream/README.md b/backend/downstream/README.md index 2deb1c5e..2c6a0925 100644 --- a/backend/downstream/README.md +++ b/backend/downstream/README.md @@ -35,7 +35,7 @@ type Plugin interface { storage.New(), // ... platform domain plugins ... custom_hello.New(), // your downstream plugin - driver_http.New(driver_http.WithAddr(config.Config.App.Addr)), + driver_http.New(), driver_asynq_worker.New(), driver_asynq_cron.New(), ) diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go deleted file mode 100644 index 0f0eb949..00000000 --- a/backend/pkg/config/config.go +++ /dev/null @@ -1,259 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package config 负责应用配置的加载、解析与环境变量覆盖。 -package config - -import ( - "encoding/json" - "errors" - "flag" - "log" - "os" - "strconv" - "strings" - - "github.com/spf13/viper" -) - -// 默认队列优先级 -const ( - webhookQueuePriority = 10 - whitelistQueuePriority = 5 - defaultQueuePriority = 3 -) - -// Config 全局配置单例,初始化后不可变 -var Config *configModel - -// findConfigPath searches upward for the config file to handle tests running in subdirectories. -func findConfigPath(configPath string) string { - if _, err := os.Stat(configPath); err == nil { - return configPath - } - dir := "." - for i := 0; i < 5; i++ { - dir += "/.." - path := dir + "/" + configPath - if _, err := os.Stat(path); err == nil { - return path - } - } - return configPath -} - -// isTest checks if the current execution context is within 'go test'. -func isTest() bool { - if flag.Lookup("test.v") != nil { - return true - } - for _, arg := range os.Args { - if strings.HasPrefix(arg, "-test.") || strings.HasSuffix(arg, ".test") { - return true - } - } - return false -} - -// load reads configuration from configPath, applies defaults and environment overrides, -// and optionally disables external services for in-test runs. It uses a private viper -// instance so a caller can resolve the same inputs repeatedly, which the parity test -// against the kernel configuration engine requires. -func load(configPath string, testMode bool) *configModel { - v := viper.New() - v.SetConfigFile(configPath) - - if err := v.ReadInConfig(); err != nil { - var notFound viper.ConfigFileNotFoundError - if !errors.As(err, ¬Found) { - // 文件存在但读取/解析失败 - if _, statErr := os.Stat(configPath); statErr == nil { //nolint:gosec // configPath is loaded from CONFIG_PATH environment variable - log.Fatalf("[Config] read config failed: %v\n", err) - } - } - log.Println("[Config] no config file found, using environment variables only") - v.SetConfigType("yaml") - if err := v.ReadConfig(strings.NewReader("")); err != nil { - log.Fatalf("[Config] failed to init empty config: %v\n", err) - } - } - - // 解析配置到结构体 - var c configModel - if err := v.Unmarshal(&c); err != nil { - log.Fatalf("[Config] parse config failed: %v\n", err) - } - - applyDefaults(&c) - - // 环境变量覆盖(优先级高于 config.yaml) - applyEnvOverrides(&c) - applyDefaults(&c) - - // Disable standard DB/Redis initializations during tests to prevent connection attempts. - if testMode { - c.Database.Enabled = false - c.Database.SQLitePath = ":memory:" - c.Redis.Enabled = false - c.ClickHouse.Enabled = false - } - - return &c -} - -func init() { - // 加载配置文件路径 - configPath := os.Getenv("CONFIG_PATH") - if configPath == "" { - configPath = findConfigPath("config.yaml") - } - - // 设置全局配置并打印 - Config = load(configPath, isTest()) - printConfig(Config) -} - -func applyDefaults(c *configModel) { - if c.App.SessionAge <= 0 { - c.App.SessionAge = 86400 - } - if c.Otel.TracerName == "" { - c.Otel.TracerName = "github.com/Rain-kl/Wavelet" - } -} - -// ─── 环境变量覆盖层 ──────────────────────────────────────────────────────────── -// 环境变量优先级高于 config.yaml,未设置则保留 yaml 中的值。 - -func envStr(key, fallback string) string { - if v, ok := os.LookupEnv(key); ok { - return v - } - return fallback -} - -func envInt(key string, fallback int) int { - if v, ok := os.LookupEnv(key); ok { - if n, err := strconv.Atoi(v); err == nil { - return n - } - } - return fallback -} - -func envInt64(key string, fallback int64) int64 { - if v, ok := os.LookupEnv(key); ok { - if n, err := strconv.ParseInt(v, 10, 64); err == nil { - return n - } - } - return fallback -} - -func envFloat64(key string, fallback float64) float64 { - if v, ok := os.LookupEnv(key); ok { - if n, err := strconv.ParseFloat(v, 64); err == nil { - return n - } - } - return fallback -} - -func envBool(key string, fallback bool) bool { - if v, ok := os.LookupEnv(key); ok { - if b, err := strconv.ParseBool(v); err == nil { - return b - } - } - return fallback -} - -// applyEnvOverrides 将环境变量值覆盖到配置结构体上(仅当环境变量已设置时生效) -func applyEnvOverrides(c *configModel) { - // ─── App ─── - c.App.AppName = envStr("APP_NAME", c.App.AppName) - c.App.Env = envStr("APP_ENV", c.App.Env) - c.App.Addr = envStr("APP_ADDR", c.App.Addr) - c.App.NodeID = envInt64("APP_NODE_ID", c.App.NodeID) - c.App.APIPrefix = envStr("APP_API_PREFIX", c.App.APIPrefix) - c.App.GracefulShutdownTimeout = envInt("APP_GRACEFUL_SHUTDOWN_TIMEOUT", c.App.GracefulShutdownTimeout) - c.App.SessionCookieName = envStr("APP_SESSION_COOKIE_NAME", c.App.SessionCookieName) - c.App.SessionSecret = envStr("APP_SESSION_SECRET", c.App.SessionSecret) - c.App.SessionDomain = envStr("APP_SESSION_DOMAIN", c.App.SessionDomain) - c.App.SessionAge = envInt("APP_SESSION_AGE", c.App.SessionAge) - c.App.SessionHTTPOnly = envBool("APP_SESSION_HTTP_ONLY", c.App.SessionHTTPOnly) - c.App.SessionSecure = envBool("APP_SESSION_SECURE", c.App.SessionSecure) - - // ─── Database ─── - c.Database.Host = envStr("DB_HOST", c.Database.Host) - c.Database.Port = envInt("DB_PORT", c.Database.Port) - c.Database.Username = envStr("DB_USERNAME", c.Database.Username) - c.Database.Password = envStr("DB_PASSWORD", c.Database.Password) - c.Database.Database = envStr("DB_NAME", c.Database.Database) - c.Database.SSLMode = envStr("DB_SSL_MODE", c.Database.SSLMode) - c.Database.TimeZone = envStr("DB_TIMEZONE", c.Database.TimeZone) - c.Database.LogLevel = envStr("DB_LOG_LEVEL", c.Database.LogLevel) - c.Database.MaxIdleConn = envInt("DB_MAX_IDLE_CONN", c.Database.MaxIdleConn) - c.Database.MaxOpenConn = envInt("DB_MAX_OPEN_CONN", c.Database.MaxOpenConn) - // 当 DB_HOST 环境变量已设置时自动启用数据库 - if _, ok := os.LookupEnv("DB_HOST"); ok { - c.Database.Enabled = true - } - c.Database.Enabled = envBool("DB_ENABLED", c.Database.Enabled) - c.Database.SQLitePath = envStr("SQLITE_PATH", c.Database.SQLitePath) - - // ─── Redis ─── - if v, ok := os.LookupEnv("REDIS_ADDR"); ok { - c.Redis.Addrs = []string{v} - c.Redis.Enabled = true // 当 REDIS_ADDR 已设置时自动启用 - } - c.Redis.Enabled = envBool("REDIS_ENABLED", c.Redis.Enabled) - c.Redis.Username = envStr("REDIS_USERNAME", c.Redis.Username) - c.Redis.Password = envStr("REDIS_PASSWORD", c.Redis.Password) - c.Redis.DB = envInt("REDIS_DB", c.Redis.DB) - c.Redis.KeyPrefix = envStr("REDIS_KEY_PREFIX", c.Redis.KeyPrefix) - c.Redis.PoolSize = envInt("REDIS_POOL_SIZE", c.Redis.PoolSize) - c.Redis.MaintNotifications = envBool("REDIS_MAINT_NOTIFICATIONS", c.Redis.MaintNotifications) - - // ─── ClickHouse ─── - if v, ok := os.LookupEnv("CLICKHOUSE_HOST"); ok { - c.ClickHouse.Hosts = []string{v} - c.ClickHouse.Enabled = true - } - c.ClickHouse.Enabled = envBool("CLICKHOUSE_ENABLED", c.ClickHouse.Enabled) - c.ClickHouse.Username = envStr("CLICKHOUSE_USERNAME", c.ClickHouse.Username) - c.ClickHouse.Password = envStr("CLICKHOUSE_PASSWORD", c.ClickHouse.Password) - c.ClickHouse.Database = envStr("CLICKHOUSE_NAME", c.ClickHouse.Database) - - // ─── Log ─── - c.Log.Level = envStr("LOG_LEVEL", c.Log.Level) - c.Log.Format = envStr("LOG_FORMAT", c.Log.Format) - c.Log.Output = envStr("LOG_OUTPUT", c.Log.Output) - - // ─── OTel ─── - c.Otel.SamplingRate = envFloat64("OTEL_SAMPLING_RATE", c.Otel.SamplingRate) - c.Otel.TracerName = envStr("OTEL_TRACER_NAME", c.Otel.TracerName) - - // ─── Worker ─── - c.Worker.Concurrency = envInt("WORKER_CONCURRENCY", c.Worker.Concurrency) - c.Worker.StrictPriority = envBool("WORKER_STRICT_PRIORITY", c.Worker.StrictPriority) - - // 无 yaml 且无环境变量时,使用代码级默认队列 - if len(c.Worker.Queues) == 0 { - c.Worker.Queues = []QueueConfig{ - {Name: "webhook", Priority: webhookQueuePriority}, - {Name: "whitelist_only", Priority: whitelistQueuePriority}, - {Name: "default", Priority: defaultQueuePriority}, - } - } -} - -// printConfig 打印配置内容 -func printConfig(c *configModel) { - configJSON, err := json.MarshalIndent(c, "", " ") - if err != nil { - log.Printf("[Config] failed to marshal config: %v\n", err) - return - } - log.Printf("[Config] loaded configuration:\n%s\n", string(configJSON)) -} diff --git a/backend/pkg/config/config_test.go b/backend/pkg/config/config_test.go deleted file mode 100644 index b79e48ee..00000000 --- a/backend/pkg/config/config_test.go +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package config - -import "testing" - -func TestApplyEnvOverridesRedisMaintNotifications(t *testing.T) { - t.Setenv("REDIS_MAINT_NOTIFICATIONS", "true") - - cfg := &configModel{} - applyEnvOverrides(cfg) - - if !cfg.Redis.MaintNotifications { - t.Fatal("REDIS_MAINT_NOTIFICATIONS=true was not applied") - } -} diff --git a/backend/pkg/config/model.go b/backend/pkg/config/model.go deleted file mode 100644 index 91321943..00000000 --- a/backend/pkg/config/model.go +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package config - -import "time" - -type configModel struct { - App appConfig `mapstructure:"app"` - Database databaseConfig `mapstructure:"database"` - Redis redisConfig `mapstructure:"redis"` - Log logConfig `mapstructure:"log"` - Scheduler schedulerConfig `mapstructure:"scheduler"` - Worker workerConfig `mapstructure:"worker"` - ClickHouse clickHouseConfig `mapstructure:"clickhouse"` - Otel otelConfig `mapstructure:"otel"` -} - -// appConfig 应用基本配置 -type appConfig struct { - AppName string `mapstructure:"app_name"` - Env string `mapstructure:"env"` - Addr string `mapstructure:"addr"` - NodeID int64 `mapstructure:"node_id"` - APIPrefix string `mapstructure:"api_prefix"` - GracefulShutdownTimeout int `mapstructure:"graceful_shutdown_timeout"` - SessionCookieName string `mapstructure:"session_cookie_name"` - SessionSecret string `mapstructure:"session_secret"` - SessionDomain string `mapstructure:"session_domain"` - SessionAge int `mapstructure:"session_age"` - SessionHTTPOnly bool `mapstructure:"session_http_only"` - SessionSecure bool `mapstructure:"session_secure"` -} - -// IsProduction 检查当前环境是否为生产环境 -func (a *appConfig) IsProduction() bool { - return a.Env == "production" -} - -// databaseConfig 数据库配置 -type databaseConfig struct { - Enabled bool `mapstructure:"enabled"` - SQLitePath string `mapstructure:"sqlite_path"` // PostgreSQL 禁用时的 SQLite 文件路径 - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - Username string `mapstructure:"username"` - Password string `mapstructure:"password"` - Database string `mapstructure:"database"` - MaxIdleConn int `mapstructure:"max_idle_conn"` - MaxOpenConn int `mapstructure:"max_open_conn"` - ConnMaxLifetime int `mapstructure:"conn_max_lifetime"` - ConnMaxIdleTime int `mapstructure:"conn_max_idle_time"` - LogLevel string `mapstructure:"log_level"` - SSLMode string `mapstructure:"ssl_mode"` - TimeZone string `mapstructure:"time_zone"` - ApplicationName string `mapstructure:"application_name"` - SearchPath string `mapstructure:"search_path"` - PreferSimpleProtocol bool `mapstructure:"prefer_simple_protocol"` - StatementCacheCapacity int `mapstructure:"statement_cache_capacity"` - DefaultQueryExecMode string `mapstructure:"default_query_exec_mode"` - Replicas []databaseReplicaConfig `mapstructure:"replicas"` - SlowThreshold time.Duration `mapstructure:"slow_threshold"` -} - -// databaseReplicaConfig 只读副本配置 -type databaseReplicaConfig struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - Username string `mapstructure:"username"` - Password string `mapstructure:"password"` -} - -// clickhouse 配置 -type clickHouseConfig struct { - Enabled bool `mapstructure:"enabled"` - Hosts []string `mapstructure:"hosts"` - Username string `mapstructure:"username"` - Password string `mapstructure:"password"` - Database string `mapstructure:"database"` - MaxIdleConn int `mapstructure:"max_idle_conn"` - MaxOpenConn int `mapstructure:"max_open_conn"` - ConnMaxLifetime int `mapstructure:"conn_max_lifetime"` - DialTimeout int `mapstructure:"dial_timeout"` - BlockBufferSize uint8 `mapstructure:"block_buffer_size"` -} - -// redisConfig Redis配置 -type redisConfig struct { - Enabled bool `mapstructure:"enabled"` - Addrs []string `mapstructure:"addrs"` - Username string `mapstructure:"username"` - Password string `mapstructure:"password"` - DB int `mapstructure:"db"` - ClusterMode bool `mapstructure:"cluster_mode"` - MasterName string `mapstructure:"master_name"` - KeyPrefix string `mapstructure:"key_prefix"` - PoolSize int `mapstructure:"pool_size"` - MinIdleConn int `mapstructure:"min_idle_conn"` - DialTimeout int `mapstructure:"dial_timeout"` - ReadTimeout int `mapstructure:"read_timeout"` - WriteTimeout int `mapstructure:"write_timeout"` - MaxRetries int `mapstructure:"max_retries"` - PoolTimeout int `mapstructure:"pool_timeout"` - ConnMaxIdleTime int `mapstructure:"conn_max_idle_time"` - MaintNotifications bool `mapstructure:"maint_notifications"` -} - -// logConfig 日志配置 -type logConfig struct { - Level string `mapstructure:"level"` - Format string `mapstructure:"format"` - Output string `mapstructure:"output"` - FilePath string `mapstructure:"file_path"` - MaxSize int `mapstructure:"max_size"` - MaxAge int `mapstructure:"max_age"` - MaxBackups int `mapstructure:"max_backups"` - Compress bool `mapstructure:"compress"` -} - -// schedulerConfig 定时任务配置 -type schedulerConfig struct{} - -// workerConfig 工作配置 -type workerConfig struct { - Concurrency int `mapstructure:"concurrency"` - StrictPriority bool `mapstructure:"strict_priority"` - Queues []QueueConfig `mapstructure:"queues"` -} - -// QueueConfig 队列配置 -type QueueConfig struct { - Name string `mapstructure:"name"` - Priority int `mapstructure:"priority"` -} - -// otelConfig OpenTelemetry 配置 -type otelConfig struct { - SamplingRate float64 `mapstructure:"sampling_rate"` - TracerName string `mapstructure:"tracer_name"` -} diff --git a/backend/pkg/config/parity_test.go b/backend/pkg/config/parity_test.go deleted file mode 100644 index 02918aa0..00000000 --- a/backend/pkg/config/parity_test.go +++ /dev/null @@ -1,327 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Temporary migration harness: proves the kernel configuration engine resolves every -// key identically to pkg/config. Delete this file together with backend/pkg/config in P4. -package config - -import ( - "fmt" - "os" - "path/filepath" - "reflect" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "Wavelet/core/extpoints" -) - -// yamlSource is a test-local ConfigSource over the repository configuration file. -// It deliberately does not import plugins/infra/config: backend/pkg must not depend on -// upper layers even in tests, and the adapter has its own coverage in its package tests. -type yamlSource struct { - v *viper.Viper -} - -func newYAMLSource(t *testing.T, path string) *yamlSource { - t.Helper() - - v := viper.New() - v.SetConfigFile(path) - require.NoError(t, v.ReadInConfig()) - return &yamlSource{v: v} -} - -func (s *yamlSource) Lookup(path string) (any, bool) { - if !s.v.IsSet(path) { - return nil, false - } - return s.v.Get(path), true -} - -func (s *yamlSource) LookupEnv(name string) (string, bool) { return os.LookupEnv(name) } - -func (s *yamlSource) Describe() string { return s.v.ConfigFileUsed() } - -// The mirror declarations below reproduce the legacy loader's coverage exactly, which is -// why several keys declare no env: the legacy applyEnvOverrides only honoured an -// environment variable for a subset of fields. Widening that set is a declaration-time -// choice available to each plugin in P3, not an engine behaviour change. -type engineAppConfig struct { - AppName string `config:"app_name" env:"APP_NAME"` - Env string `config:"env" env:"APP_ENV"` - Addr string `config:"addr" env:"APP_ADDR"` - NodeID int64 `config:"node_id" env:"APP_NODE_ID"` - APIPrefix string `config:"api_prefix" env:"APP_API_PREFIX"` - GracefulShutdownTimeout int `config:"graceful_shutdown_timeout" env:"APP_GRACEFUL_SHUTDOWN_TIMEOUT"` - SessionCookieName string `config:"session_cookie_name" env:"APP_SESSION_COOKIE_NAME"` - SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"` - SessionDomain string `config:"session_domain" env:"APP_SESSION_DOMAIN"` - SessionAge int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"` - SessionHTTPOnly bool `config:"session_http_only" env:"APP_SESSION_HTTP_ONLY"` - SessionSecure bool `config:"session_secure" env:"APP_SESSION_SECURE"` -} - -type engineReplicaConfig struct { - Host string `config:"host"` - Port int `config:"port"` - Username string `config:"username"` - Password string `config:"password"` -} - -type engineDatabaseConfig struct { - Enabled bool `config:"enabled" env:"DB_ENABLED" default:"false" autoEnable:"DB_HOST"` - SQLitePath string `config:"sqlite_path" env:"SQLITE_PATH"` - Host string `config:"host" env:"DB_HOST"` - Port int `config:"port" env:"DB_PORT"` - Username string `config:"username" env:"DB_USERNAME"` - Password string `config:"password" env:"DB_PASSWORD" secret:"true"` - Database string `config:"database" env:"DB_NAME"` - MaxIdleConn int `config:"max_idle_conn" env:"DB_MAX_IDLE_CONN"` - MaxOpenConn int `config:"max_open_conn" env:"DB_MAX_OPEN_CONN"` - ConnMaxLifetime int `config:"conn_max_lifetime"` - ConnMaxIdleTime int `config:"conn_max_idle_time"` - LogLevel string `config:"log_level" env:"DB_LOG_LEVEL"` - SSLMode string `config:"ssl_mode" env:"DB_SSL_MODE"` - TimeZone string `config:"time_zone" env:"DB_TIMEZONE"` - ApplicationName string `config:"application_name"` - SearchPath string `config:"search_path"` - PreferSimpleProtocol bool `config:"prefer_simple_protocol"` - StatementCacheCapacity int `config:"statement_cache_capacity"` - DefaultQueryExecMode string `config:"default_query_exec_mode"` - Replicas []engineReplicaConfig `config:"replicas"` - SlowThreshold time.Duration `config:"slow_threshold"` -} - -type engineRedisConfig struct { - Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"` - Addrs []string `config:"addrs" env:"REDIS_ADDR"` - Username string `config:"username" env:"REDIS_USERNAME"` - Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"` - DB int `config:"db" env:"REDIS_DB"` - ClusterMode bool `config:"cluster_mode"` - MasterName string `config:"master_name"` - KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"` - PoolSize int `config:"pool_size" env:"REDIS_POOL_SIZE"` - MinIdleConn int `config:"min_idle_conn"` - DialTimeout int `config:"dial_timeout"` - ReadTimeout int `config:"read_timeout"` - WriteTimeout int `config:"write_timeout"` - MaxRetries int `config:"max_retries"` - PoolTimeout int `config:"pool_timeout"` - ConnMaxIdleTime int `config:"conn_max_idle_time"` - MaintNotifications bool `config:"maint_notifications" env:"REDIS_MAINT_NOTIFICATIONS"` -} - -type engineClickHouseConfig struct { - Enabled bool `config:"enabled" env:"CLICKHOUSE_ENABLED" default:"false" autoEnable:"CLICKHOUSE_HOST"` - Hosts []string `config:"hosts" env:"CLICKHOUSE_HOST"` - Username string `config:"username" env:"CLICKHOUSE_USERNAME"` - Password string `config:"password" env:"CLICKHOUSE_PASSWORD" secret:"true"` - Database string `config:"database" env:"CLICKHOUSE_NAME"` - MaxIdleConn int `config:"max_idle_conn"` - MaxOpenConn int `config:"max_open_conn"` - ConnMaxLifetime int `config:"conn_max_lifetime"` - DialTimeout int `config:"dial_timeout"` - BlockBufferSize uint8 `config:"block_buffer_size"` -} - -type engineLogConfig struct { - Level string `config:"level" env:"LOG_LEVEL"` - Format string `config:"format" env:"LOG_FORMAT"` - Output string `config:"output" env:"LOG_OUTPUT"` - FilePath string `config:"file_path"` - MaxSize int `config:"max_size"` - MaxAge int `config:"max_age"` - MaxBackups int `config:"max_backups"` - Compress bool `config:"compress"` -} - -type engineOtelConfig struct { - SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE"` - TracerName string `config:"tracer_name" env:"OTEL_TRACER_NAME" default:"github.com/Rain-kl/Wavelet"` -} - -type engineQueueConfig struct { - Name string `config:"name"` - Priority int `config:"priority"` -} - -type engineWorkerConfig struct { - Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY"` - StrictPriority bool `config:"strict_priority" env:"WORKER_STRICT_PRIORITY"` - Queues []engineQueueConfig `config:"queues"` -} - -// durationType mirrors the engine's own notion of a scalar duration field. -var durationType = reflect.TypeFor[time.Duration]() - -// flatten exports a struct into dotted leaf paths rendered as text. Both sides of the -// parity assertion use distinct Go types for the same shape, so values are compared -// textually instead of handing cmp a cross-type diff. -func flatten(prefix string, v reflect.Value, out map[string]string) { - t := v.Type() - - for i := 0; i < t.NumField(); i++ { - field := t.Field(i) - if field.PkgPath != "" { - continue - } - - fv := v.Field(i) - path := prefix + "." + field.Name - if fv.Kind() == reflect.Struct && fv.Type() != durationType { - flatten(path, fv, out) - continue - } - out[path] = fmt.Sprint(fv.Interface()) - } -} - -// configCandidates returns the configuration files to run parity against. The tracked -// example file is mandatory so the proof never skips silently in a fresh clone. A local -// gitignored config.yaml is compared as an extra scenario only when it sits next to the -// example file: the upward search used to locate it can otherwise leave the current -// checkout and silently read a sibling worktree's configuration instead. -func configCandidates(t *testing.T) []string { - t.Helper() - - example := findConfigPath("config.example.yaml") - info, err := os.Stat(example) - if err != nil || info.IsDir() { - t.Fatalf("tracked config.example.yaml is unreachable from the test working directory: %v", err) - } - - candidates := []string{example} - local := filepath.Join(filepath.Dir(example), "config.yaml") - if localInfo, localErr := os.Stat(local); localErr == nil && !localInfo.IsDir() { - candidates = append(candidates, local) - } - return candidates -} - -// bindSections declares every legacy section against the engine and binds them out. -func bindSections(t *testing.T, path string) map[string]string { - t.Helper() - - engine := extpoints.NewConfigRegistry(newYAMLSource(t, path)) - require.NoError(t, engine.Declare("parity", - extpoints.ConfigBinding{Prefix: "app", Target: &engineAppConfig{}}, - extpoints.ConfigBinding{Prefix: "database", Target: &engineDatabaseConfig{}}, - extpoints.ConfigBinding{Prefix: "redis", Target: &engineRedisConfig{}}, - extpoints.ConfigBinding{Prefix: "clickhouse", Target: &engineClickHouseConfig{}}, - extpoints.ConfigBinding{Prefix: "log", Target: &engineLogConfig{}}, - extpoints.ConfigBinding{Prefix: "otel", Target: &engineOtelConfig{}}, - extpoints.ConfigBinding{Prefix: "worker", Target: &engineWorkerConfig{}}, - )) - require.NoError(t, engine.Resolve()) - - var ( - app engineAppConfig - database engineDatabaseConfig - redis engineRedisConfig - clickhouse engineClickHouseConfig - log engineLogConfig - otel engineOtelConfig - worker engineWorkerConfig - ) - targets := []struct { - prefix string - target any - }{ - {"app", &app}, {"database", &database}, {"redis", &redis}, {"clickhouse", &clickhouse}, - {"log", &log}, {"otel", &otel}, {"worker", &worker}, - } - for _, item := range targets { - require.NoError(t, engine.Bind(item.prefix, item.target)) - } - - flat := map[string]string{} - flatten("app", reflect.ValueOf(app), flat) - flatten("database", reflect.ValueOf(database), flat) - flatten("redis", reflect.ValueOf(redis), flat) - flatten("clickhouse", reflect.ValueOf(clickhouse), flat) - flatten("log", reflect.ValueOf(log), flat) - flatten("otel", reflect.ValueOf(otel), flat) - flatten("worker", reflect.ValueOf(worker), flat) - return flat -} - -// legacySections resolves the same inputs with the legacy loader and flattens them. -func legacySections(t *testing.T, path string) map[string]string { - t.Helper() - - legacy := load(path, false) - - flat := map[string]string{} - flatten("app", reflect.ValueOf(legacy.App), flat) - flatten("database", reflect.ValueOf(legacy.Database), flat) - flatten("redis", reflect.ValueOf(legacy.Redis), flat) - flatten("clickhouse", reflect.ValueOf(legacy.ClickHouse), flat) - flatten("log", reflect.ValueOf(legacy.Log), flat) - flatten("otel", reflect.ValueOf(legacy.Otel), flat) - flatten("worker", reflect.ValueOf(legacy.Worker), flat) - return flat -} - -func TestEngineParityWithLegacyLoader(t *testing.T) { - paths := configCandidates(t) - - scenarios := []struct { - name string - env map[string]string - }{ - {name: "file only", env: nil}, - { - name: "implicit enable from hosts", - env: map[string]string{ - "DB_HOST": "postgres", "REDIS_ADDR": "redis:6379", "CLICKHOUSE_HOST": "ch:9000", - }, - }, - { - name: "explicit flags win over implicit enable", - env: map[string]string{ - "DB_HOST": "postgres", "DB_ENABLED": "false", - "REDIS_ADDR": "redis:6379", "REDIS_ENABLED": "false", - "CLICKHOUSE_HOST": "ch:9000", "CLICKHOUSE_ENABLED": "false", - }, - }, - { - name: "scalar overrides", - env: map[string]string{ - "LOG_LEVEL": "debug", "APP_ADDR": ":9999", "DB_PORT": "6543", - "SQLITE_PATH": "./data/parity.db", "REDIS_KEY_PREFIX": "parity:", - "REDIS_MAINT_NOTIFICATIONS": "true", "OTEL_SAMPLING_RATE": "0.5", - "WORKER_CONCURRENCY": "7", "APP_NODE_ID": "42", - }, - }, - } - - for _, path := range paths { - for _, scenario := range scenarios { - t.Run(filepath.Base(path)+" "+scenario.name, func(t *testing.T) { - for name, value := range scenario.env { - t.Setenv(name, value) - } - - assert.Empty(t, cmp.Diff(legacySections(t, path), bindSections(t, path)), - "engine resolution drifted from the legacy loader") - }) - } - } -} - -func TestEngineHonoursEnvOnlyForDeclaredKeys(t *testing.T) { - // slow_threshold has no environment counterpart in either the legacy loader or this - // mirror declaration, so setting one must leave the file value untouched. - t.Setenv("DB_SLOW_THRESHOLD", "9s") - - for _, path := range configCandidates(t) { - assert.Empty(t, cmp.Diff(legacySections(t, path), bindSections(t, path))) - } -} diff --git a/backend/pkg/idgen/snowflake.go b/backend/pkg/idgen/snowflake.go index 8e01c11c..3081178b 100644 --- a/backend/pkg/idgen/snowflake.go +++ b/backend/pkg/idgen/snowflake.go @@ -5,9 +5,10 @@ package idgen import ( - "Wavelet/pkg/config" + "errors" "fmt" "log" + "sync" "github.com/bwmarrin/snowflake" ) @@ -17,25 +18,44 @@ const epoch int64 = 1764547200000 const maxNegativeIDRetries = 3 -var node *snowflake.Node +var ( + mu sync.RWMutex + node *snowflake.Node +) -func init() { +// Init initializes the snowflake ID generator with the given node ID. +func Init(nodeID int64) error { snowflake.Epoch = epoch - nodeID := config.Config.App.NodeID - var err error - node, err = snowflake.NewNode(nodeID) + n, err := snowflake.NewNode(nodeID) if err != nil { - log.Fatalf("[Snowflake] init failed: %v\n", err) + return fmt.Errorf("idgen: init node %d failed: %w", nodeID, err) } + + mu.Lock() + node = n + mu.Unlock() + log.Printf("[Snowflake] initialized with node ID: %d, epoch: 2025-12-01\n", nodeID) + return nil } +// ErrNotInitialized indicates NextUint64ID was called before Init. +var ErrNotInitialized = errors.New("idgen: Init must be called before generating IDs") + // NextUint64ID 生成下一个分布式唯一 ID。 // 理论上不应出现负值;若出现则最多重试 maxNegativeIDRetries 次,仍失败则 panic。 func NextUint64ID() uint64 { + mu.RLock() + n := node + mu.RUnlock() + + if n == nil { + panic(ErrNotInitialized) + } + for attempt := 1; attempt <= maxNegativeIDRetries; attempt++ { - id := node.Generate().Int64() + id := n.Generate().Int64() if id >= 0 { return uint64(id) } diff --git a/backend/pkg/idgen/snowflake_test.go b/backend/pkg/idgen/snowflake_test.go index d0e5cdfa..82ef3407 100644 --- a/backend/pkg/idgen/snowflake_test.go +++ b/backend/pkg/idgen/snowflake_test.go @@ -7,9 +7,24 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNextUint64ID(t *testing.T) { + require.NoError(t, Init(1)) id := NextUint64ID() assert.NotZero(t, id) } + +func TestNextUint64ID_PanicsWhenNotInitialized(t *testing.T) { + mu.Lock() + node = nil + mu.Unlock() + + assert.PanicsWithError(t, ErrNotInitialized.Error(), func() { + NextUint64ID() + }) + + // Restore initialization for other tests + require.NoError(t, Init(1)) +} diff --git a/backend/plugins/domain/admin/config.go b/backend/plugins/domain/admin/config.go new file mode 100644 index 00000000..12763bcb --- /dev/null +++ b/backend/plugins/domain/admin/config.go @@ -0,0 +1,12 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package admin + +import "Wavelet/plugins/domain/admin/model" + +// DatabaseConfig aliases model.DatabaseConfig. +type DatabaseConfig = model.DatabaseConfig + +// ClickHouseConfig aliases model.ClickHouseConfig. +type ClickHouseConfig = model.ClickHouseConfig diff --git a/backend/plugins/domain/admin/handler/db.go b/backend/plugins/domain/admin/handler/db.go index 3d585616..e6d986f8 100644 --- a/backend/plugins/domain/admin/handler/db.go +++ b/backend/plugins/domain/admin/handler/db.go @@ -4,7 +4,6 @@ package handler import ( - "Wavelet/pkg/config" "Wavelet/pkg/logger" "Wavelet/pkg/response" "Wavelet/plugins/domain/admin/errs" @@ -147,7 +146,7 @@ func GetDatabaseInfo(c *gin.Context) { // @Failure 500 {object} response.Any "导出失败" // @Router /api/v1/admin/db-export [get] func ExportDatabase(c *gin.Context) { - if !config.Config.Database.Enabled { + if !service.GetDBConfig().Enabled { exportSQLite(c) } else { exportPostgres(c) diff --git a/backend/plugins/domain/admin/model/config.go b/backend/plugins/domain/admin/model/config.go new file mode 100644 index 00000000..355b6b71 --- /dev/null +++ b/backend/plugins/domain/admin/model/config.go @@ -0,0 +1,20 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package model + +// DatabaseConfig holds database configuration needed by the admin plugin. +type DatabaseConfig struct { + Enabled bool `config:"enabled" env:"DB_ENABLED" default:"false" autoEnable:"DB_HOST"` + Host string `config:"host" env:"DB_HOST"` + Port int `config:"port" env:"DB_PORT" default:"5432"` + Database string `config:"database" env:"DB_DATABASE"` + Username string `config:"username" env:"DB_USERNAME"` + Password string `config:"password" env:"DB_PASSWORD" secret:"true"` + SQLitePath string `config:"sqlite_path" env:"DB_SQLITE_PATH" default:"./data/wavelet.db"` +} + +// ClickHouseConfig holds clickhouse enablement status needed by admin log queries/switching. +type ClickHouseConfig struct { + Enabled bool `config:"enabled" env:"CLICKHOUSE_ENABLED" default:"false" autoEnable:"CLICKHOUSE_HOST"` +} diff --git a/backend/plugins/domain/admin/plugin.go b/backend/plugins/domain/admin/plugin.go index 25b15110..1772823c 100644 --- a/backend/plugins/domain/admin/plugin.go +++ b/backend/plugins/domain/admin/plugin.go @@ -66,8 +66,24 @@ func (p *Plugin) Manifest() core.Manifest { } } +// DeclareConfig declares configuration bindings consumed by the admin plugin. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "database", Target: &model.DatabaseConfig{}}, + {Prefix: "clickhouse", Target: &model.ClickHouseConfig{}}, + } +} + // Apply registers admin routes, tasks, schedules, and settings into the Context. func (p *Plugin) Apply(ctx *core.Context) error { + var dbCfg model.DatabaseConfig + _ = ctx.Config().Bind("database", &dbCfg) + service.SetDBConfig(dbCfg) + + var chCfg model.ClickHouseConfig + _ = ctx.Config().Bind("clickhouse", &chCfg) + service.SetClickHouseConfig(chCfg) + // 0. Bind Services reactively if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { service.SetDBService(db) diff --git a/backend/plugins/domain/admin/repository/db.go b/backend/plugins/domain/admin/repository/db.go index bd83c9e5..e721dffd 100644 --- a/backend/plugins/domain/admin/repository/db.go +++ b/backend/plugins/domain/admin/repository/db.go @@ -4,7 +4,6 @@ package repository import ( - "Wavelet/pkg/config" "Wavelet/plugins/domain/admin/errs" "Wavelet/plugins/domain/admin/model" "context" @@ -14,6 +13,7 @@ import ( "os" "os/exec" "strings" + "sync" "time" ) @@ -22,9 +22,30 @@ const ( logDBNameSQLite = "sqlite" ) +var ( + dbConfigMu sync.RWMutex + dbConfig = model.DatabaseConfig{ + SQLitePath: defaultSQLiteDBPath, + } +) + +// SetDBConfig sets the database configuration. +func SetDBConfig(cfg model.DatabaseConfig) { + dbConfigMu.Lock() + defer dbConfigMu.Unlock() + dbConfig = cfg +} + +// GetDBConfig gets the database configuration. +func GetDBConfig() model.DatabaseConfig { + dbConfigMu.RLock() + defer dbConfigMu.RUnlock() + return dbConfig +} + // sqliteDatabasePath resolves the effective SQLite file path from configuration. func sqliteDatabasePath() string { - name := config.Config.Database.SQLitePath + name := GetDBConfig().SQLitePath if name == "" { name = defaultSQLiteDBPath } @@ -93,7 +114,7 @@ func GetPostgresOverview(ctx context.Context) (model.DBOverviewResponse, error) return model.DBOverviewResponse{}, errs.ErrDatabaseUninitialized } - name := config.Config.Database.Database + name := GetDBConfig().Database var version string var ver string @@ -152,7 +173,7 @@ func ListDatabaseTableNames(ctx context.Context) ([]string, error) { var tables []string var err error - if !config.Config.Database.Enabled { + if !GetDBConfig().Enabled { err = gormDB.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").Scan(&tables).Error } else { err = gormDB.Raw("SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() ORDER BY table_name").Scan(&tables).Error @@ -288,9 +309,10 @@ func scanRowAsMap(rows *sql.Rows, cols []string) (map[string]any, error) { // GetSQLiteInfo collects the SQLite type/name/version triple. func GetSQLiteInfo(ctx context.Context) model.DatabaseInfoResponse { + cfg := GetDBConfig() info := model.DatabaseInfoResponse{ Type: logDBNameSQLite, - Name: config.Config.Database.SQLitePath, + Name: cfg.SQLitePath, Version: "SQLite", } if info.Name == "" { @@ -309,9 +331,10 @@ func GetSQLiteInfo(ctx context.Context) model.DatabaseInfoResponse { // GetPostgresInfo collects the PostgreSQL type/name/version triple. func GetPostgresInfo(ctx context.Context) model.DatabaseInfoResponse { + cfg := GetDBConfig() info := model.DatabaseInfoResponse{ Type: "postgres", - Name: config.Config.Database.Database, + Name: cfg.Database, Version: "PostgreSQL", } gormDB := GetDB(ctx) @@ -343,7 +366,7 @@ func OpenSQLiteExportFile() (*os.File, os.FileInfo, error) { // NewPgDumpCommand builds the streaming pg_dump command for the active database. func NewPgDumpCommand(ctx context.Context) (*exec.Cmd, string, error) { - dbCfg := config.Config.Database + dbCfg := GetDBConfig() pgDumpPath, err := exec.LookPath("pg_dump") if err != nil { diff --git a/backend/plugins/domain/admin/repository/flush_log_test.go b/backend/plugins/domain/admin/repository/flush_log_test.go index 11004154..09125581 100644 --- a/backend/plugins/domain/admin/repository/flush_log_test.go +++ b/backend/plugins/domain/admin/repository/flush_log_test.go @@ -44,6 +44,13 @@ func newFlushLogTestCache(t *testing.T) (contracts.CacheService, *miniredis.Mini p := cacheplugin.New(cacheplugin.WithRedis(rdb), cacheplugin.WithRAMCapacity(64)) ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(map[string]any{ + "redis": map[string]any{ + "enabled": true, + "addrs": []string{mr.Addr()}, + }, + })) + require.NoError(t, ctx.Config().Resolve()) require.NoError(t, p.Apply(ctx)) svc, err := core.Inject[contracts.CacheService](ctx) require.NoError(t, err) diff --git a/backend/plugins/domain/admin/service/db.go b/backend/plugins/domain/admin/service/db.go index 5b7be170..c1d2ec95 100644 --- a/backend/plugins/domain/admin/service/db.go +++ b/backend/plugins/domain/admin/service/db.go @@ -4,22 +4,57 @@ package service import ( - "Wavelet/pkg/config" "Wavelet/plugins/domain/admin/model" "Wavelet/plugins/domain/admin/repository" "context" "os" "os/exec" "strings" + "sync" "time" ) +var ( + dbConfigMu sync.RWMutex + dbConfig model.DatabaseConfig + chConfig model.ClickHouseConfig +) + +// SetDBConfig sets the database configuration in service and repository. +func SetDBConfig(cfg model.DatabaseConfig) { + dbConfigMu.Lock() + defer dbConfigMu.Unlock() + dbConfig = cfg + repository.SetDBConfig(cfg) +} + +// GetDBConfig returns the database configuration. +func GetDBConfig() model.DatabaseConfig { + dbConfigMu.RLock() + defer dbConfigMu.RUnlock() + return dbConfig +} + +// SetClickHouseConfig sets the clickhouse configuration. +func SetClickHouseConfig(cfg model.ClickHouseConfig) { + dbConfigMu.Lock() + defer dbConfigMu.Unlock() + chConfig = cfg +} + +// GetClickHouseConfig returns the clickhouse configuration. +func GetClickHouseConfig() model.ClickHouseConfig { + dbConfigMu.RLock() + defer dbConfigMu.RUnlock() + return chConfig +} + // selectSQLKeywords marks statements that return a result set instead of a row count. var selectSQLKeywords = []string{"select", "show", "explain", "describe", "pragma"} // DatabaseOverview collects the runtime overview of the active database. func DatabaseOverview(ctx context.Context) (model.DBOverviewResponse, error) { - if !config.Config.Database.Enabled { + if !GetDBConfig().Enabled { return repository.GetSQLiteOverview(ctx) } return repository.GetPostgresOverview(ctx) @@ -114,7 +149,7 @@ func isSelectStatement(trimmedSQL string) bool { // DatabaseInfo returns the active database type, name and version. func DatabaseInfo(ctx context.Context) model.DatabaseInfoResponse { - if !config.Config.Database.Enabled { + if !GetDBConfig().Enabled { return repository.GetSQLiteInfo(ctx) } return repository.GetPostgresInfo(ctx) diff --git a/backend/plugins/domain/admin/service/log_switch.go b/backend/plugins/domain/admin/service/log_switch.go index 652d079d..6145191b 100644 --- a/backend/plugins/domain/admin/service/log_switch.go +++ b/backend/plugins/domain/admin/service/log_switch.go @@ -5,7 +5,6 @@ package service import ( "Wavelet/core/contracts" - "Wavelet/pkg/config" "Wavelet/pkg/logger" "Wavelet/plugins/domain/admin/errs" "Wavelet/plugins/domain/admin/model" @@ -139,15 +138,15 @@ func validateSwitch(ctx context.Context, target string) error { } switch target { case targetClickHouse: - if !config.Config.ClickHouse.Enabled { + if !GetClickHouseConfig().Enabled { return errors.New(errs.ErrClickHouseNotEnabled) } case targetPostgres: - if !config.Config.Database.Enabled { + if !GetDBConfig().Enabled { return errors.New(errs.ErrPostgresNotEnabled) } case targetSQLite: - if config.Config.Database.Enabled { + if GetDBConfig().Enabled { return errors.New(errs.ErrSQLiteNotAllowedAsLogDB) } } diff --git a/backend/plugins/domain/admin/service/status.go b/backend/plugins/domain/admin/service/status.go index 75f72a76..dd3ef455 100644 --- a/backend/plugins/domain/admin/service/status.go +++ b/backend/plugins/domain/admin/service/status.go @@ -4,7 +4,6 @@ package service import ( - "Wavelet/pkg/config" "Wavelet/pkg/logger" "Wavelet/plugins/domain/admin/model" "Wavelet/plugins/domain/admin/repository" @@ -152,12 +151,12 @@ func retentionOr(ctx context.Context, key string) int { func availableLogTargets(active string) []string { if active == logDBNameClickHouse { - if config.Config.Database.Enabled { + if GetDBConfig().Enabled { return []string{logDBNamePostgres} } return []string{logDBNameSQLite} } - if config.Config.ClickHouse.Enabled { + if GetClickHouseConfig().Enabled { return []string{logDBNameClickHouse} } return []string{} diff --git a/backend/plugins/domain/auth/config.go b/backend/plugins/domain/auth/config.go new file mode 100644 index 00000000..c01b416e --- /dev/null +++ b/backend/plugins/domain/auth/config.go @@ -0,0 +1,14 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth + +// SessionConfig defines the session configuration declared by the auth plugin. +type SessionConfig struct { + SessionCookieName string `config:"session_cookie_name" env:"APP_SESSION_COOKIE_NAME" default:"wavelet_session"` + SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"` + SessionDomain string `config:"session_domain" env:"APP_SESSION_DOMAIN"` + SessionAge int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"` + SessionHTTPOnly bool `config:"session_http_only" env:"APP_SESSION_HTTP_ONLY" default:"true"` + SessionSecure bool `config:"session_secure" env:"APP_SESSION_SECURE"` +} diff --git a/backend/plugins/domain/auth/plugin.go b/backend/plugins/domain/auth/plugin.go index 639f0ccd..fdc35ab2 100644 --- a/backend/plugins/domain/auth/plugin.go +++ b/backend/plugins/domain/auth/plugin.go @@ -73,8 +73,20 @@ func (p *Plugin) Manifest() core.Manifest { } } +// DeclareConfig declares configuration bindings for the auth plugin. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "app", Target: &SessionConfig{}}, + } +} + // Apply registers the auth migrations, services, routes, and settings into the Context. func (p *Plugin) Apply(ctx *core.Context) error { + var cfg SessionConfig + if err := ctx.Config().Bind("app", &cfg); err == nil { + SetSessionConfig(cfg) + } + // 0. Bind DBService & CacheService from Context if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { setDBService(db) diff --git a/backend/plugins/domain/auth/session.go b/backend/plugins/domain/auth/session.go index 4adb7417..97141413 100644 --- a/backend/plugins/domain/auth/session.go +++ b/backend/plugins/domain/auth/session.go @@ -5,13 +5,13 @@ package auth import ( "Wavelet/core/contracts" - "Wavelet/pkg/config" "context" "crypto/sha256" "encoding/hex" "net/http" "strconv" "strings" + "sync" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" @@ -19,14 +19,38 @@ import ( gsessions "github.com/gorilla/sessions" ) +var ( + sessConfigMu sync.RWMutex + sessConfig = SessionConfig{ + SessionCookieName: "wavelet_session", + SessionAge: 86400, + SessionHTTPOnly: true, + } +) + +// SetSessionConfig updates the active session configuration. +func SetSessionConfig(cfg SessionConfig) { + sessConfigMu.Lock() + defer sessConfigMu.Unlock() + sessConfig = cfg +} + +// GetSessionConfig returns the active session configuration. +func GetSessionConfig() SessionConfig { + sessConfigMu.RLock() + defer sessConfigMu.RUnlock() + return sessConfig +} + // GetSessionOptions 根据配置构建 Session 选项 func GetSessionOptions(maxAge int) sessions.Options { + cfg := GetSessionConfig() return sessions.Options{ Path: "/", - Domain: config.Config.App.SessionDomain, + Domain: cfg.SessionDomain, MaxAge: maxAge, - HttpOnly: config.Config.App.SessionHTTPOnly, - Secure: config.Config.App.SessionSecure, + HttpOnly: cfg.SessionHTTPOnly, + Secure: cfg.SessionSecure, SameSite: http.SameSiteLaxMode, } } @@ -113,7 +137,8 @@ func SetLoginSession(ctx context.Context, c *gin.Context, user *contracts.UserDT } // 根据系统配置动态设置 Session 过期时间 - maxAge := config.Config.App.SessionAge + cfg := GetSessionConfig() + maxAge := cfg.SessionAge isSessionCookie := false val, err := GetSystemConfigValue(ctx, "login_session_ttl_hours") @@ -137,7 +162,7 @@ func SetLoginSession(ctx context.Context, c *gin.Context, user *contracts.UserDT } if isSessionCookie { - StripCookieMaxAgeAndExpires(c.Writer.Header(), config.Config.App.SessionCookieName) + StripCookieMaxAgeAndExpires(c.Writer.Header(), cfg.SessionCookieName) } return nil diff --git a/backend/plugins/domain/cap/plugin.go b/backend/plugins/domain/cap/plugin.go index 20e21b4f..ae047a78 100644 --- a/backend/plugins/domain/cap/plugin.go +++ b/backend/plugins/domain/cap/plugin.go @@ -41,8 +41,24 @@ func (p *Plugin) Manifest() core.Manifest { } } +type capAppConfig struct { + SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"` +} + +// DeclareConfig declares configuration bindings for the cap plugin. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "app", Target: &capAppConfig{}}, + } +} + // Apply registers the cap routes and settings into the Context. func (p *Plugin) Apply(ctx *core.Context) error { + var cfg capAppConfig + if err := ctx.Config().Bind("app", &cfg); err == nil && cfg.SessionSecret != "" { + SetSecret([]byte(cfg.SessionSecret)) + } + // 0. Bind DBService from Context if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { setDBService(db) diff --git a/backend/plugins/domain/cap/service.go b/backend/plugins/domain/cap/service.go index 6cd13689..b062fffe 100644 --- a/backend/plugins/domain/cap/service.go +++ b/backend/plugins/domain/cap/service.go @@ -5,7 +5,6 @@ package cap import ( - "Wavelet/pkg/config" "Wavelet/plugins/domain/cap/pow" "context" "crypto/sha256" @@ -161,23 +160,23 @@ func sGetAndDelete(ctx context.Context, store pow.Store, key string) (string, bo } var ( - defaultManager *Manager - once sync.Once + defaultManagerMu sync.RWMutex + defaultManager *Manager ) +// SetSecret sets the shared secret used by the default manager. +func SetSecret(secret []byte) { + defaultManagerMu.Lock() + defer defaultManagerMu.Unlock() + if len(secret) > 0 { + store := pow.NewMemoryStore(1 * time.Minute) + defaultManager = NewManager(secret, store) + } +} + // GetDefaultManager yields the global singleton CAPTCHA manager. func GetDefaultManager() *Manager { - once.Do(func() { - var secret []byte - if config.Config != nil && strings.TrimSpace(config.Config.App.SessionSecret) != "" { - secret = []byte(config.Config.App.SessionSecret) - } - if len(secret) == 0 { - return - } - - store := pow.NewMemoryStore(1 * time.Minute) - defaultManager = NewManager(secret, store) - }) + defaultManagerMu.RLock() + defer defaultManagerMu.RUnlock() return defaultManager } diff --git a/backend/plugins/domain/domain_test.go b/backend/plugins/domain/domain_test.go index 606af4de..d32c538b 100644 --- a/backend/plugins/domain/domain_test.go +++ b/backend/plugins/domain/domain_test.go @@ -6,6 +6,7 @@ package domain_test import ( "Wavelet/core" "Wavelet/core/contracts" + "Wavelet/pkg/idgen" "Wavelet/plugins/domain/admin" "Wavelet/plugins/domain/auth" "Wavelet/plugins/domain/message_gateway" @@ -31,6 +32,7 @@ import ( func setupTestDB(t *testing.T) *gorm.DB { t.Helper() + _ = idgen.Init(1) dbPath := filepath.Join(t.TempDir(), "domain_test.db") testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) require.NoError(t, err) @@ -76,6 +78,8 @@ func (m *mockOAuthProvider) ExchangeCode(ctx context.Context, code string) (*con func TestAuthPlugin(t *testing.T) { ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) testDB := setupTestDB(t) require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx)) @@ -140,6 +144,8 @@ func TestAuthPlugin(t *testing.T) { func TestUserPlugin(t *testing.T) { ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) testDB := setupTestDB(t) require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx)) @@ -242,6 +248,8 @@ func TestUserPlugin(t *testing.T) { func TestMessageGatewayPlugin(t *testing.T) { ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) testDB := setupTestDB(t) require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx)) @@ -310,6 +318,8 @@ func TestMessageGatewayPlugin(t *testing.T) { func TestRiskControlPlugin(t *testing.T) { ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) p := risk_control.New() assert.Equal(t, "risk_control", p.Name()) assert.Equal(t, "risk_control", p.Manifest().Name) @@ -330,6 +340,8 @@ func TestRiskControlPlugin(t *testing.T) { func TestAdminPlugin(t *testing.T) { ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) testDB := setupTestDB(t) require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx)) @@ -389,6 +401,13 @@ func TestAllDomainPluginsCombined(t *testing.T) { defer func() { _ = rdb.Close() }() ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(map[string]any{ + "redis": map[string]any{ + "enabled": true, + "addrs": []string{mr.Addr()}, + }, + })) + require.NoError(t, ctx.Config().Resolve()) testDB := setupTestDB(t) // Apply Infra plugins diff --git a/backend/plugins/domain/message_gateway/plugin.go b/backend/plugins/domain/message_gateway/plugin.go index e697c351..af8edef0 100644 --- a/backend/plugins/domain/message_gateway/plugin.go +++ b/backend/plugins/domain/message_gateway/plugin.go @@ -72,8 +72,23 @@ func (p *Plugin) Manifest() core.Manifest { } } +type mgAppConfig struct { + SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"` +} + +// DeclareConfig declares configuration bindings for the message_gateway plugin. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "app", Target: &mgAppConfig{}}, + } +} + // Apply registers message_gateway migrations, routes, tasks, schedules, events, and settings into the Context. func (p *Plugin) Apply(ctx *core.Context) error { + var cfg mgAppConfig + if err := ctx.Config().Bind("app", &cfg); err == nil && cfg.SessionSecret != "" { + service.SetCredentialSecret(cfg.SessionSecret) + } // 0. Bind DBService, CacheService, TaskService, UserService if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { repository.SetDBService(db) diff --git a/backend/plugins/domain/message_gateway/service/service.go b/backend/plugins/domain/message_gateway/service/service.go index 3890f14c..dfd33db5 100644 --- a/backend/plugins/domain/message_gateway/service/service.go +++ b/backend/plugins/domain/message_gateway/service/service.go @@ -7,7 +7,6 @@ package service import ( "Wavelet/core" "Wavelet/core/contracts" - "Wavelet/pkg/config" "Wavelet/pkg/logger" "Wavelet/pkg/util" "Wavelet/plugins/domain/message_gateway/errs" @@ -101,12 +100,23 @@ func FormatCode(s string) string { return s[:4] + "-" + s[4:] } +var ( + credentialSecretMu sync.RWMutex + credentialSecret string +) + +// SetCredentialSecret sets the secret used to derive CredentialKey. +func SetCredentialSecret(secret string) { + credentialSecretMu.Lock() + defer credentialSecretMu.Unlock() + credentialSecret = secret +} + // CredentialKey is AES-256 hex derived from the session secret. func CredentialKey() string { - secret := "" - if config.Config != nil { - secret = config.Config.App.SessionSecret - } + credentialSecretMu.RLock() + secret := credentialSecret + credentialSecretMu.RUnlock() sum := sha256.Sum256([]byte(secret)) return hex.EncodeToString(sum[:]) } diff --git a/backend/plugins/domain/risk_control/logstore/gorm_test.go b/backend/plugins/domain/risk_control/logstore/gorm_test.go index 725d2a6f..e2eaf6a9 100644 --- a/backend/plugins/domain/risk_control/logstore/gorm_test.go +++ b/backend/plugins/domain/risk_control/logstore/gorm_test.go @@ -4,6 +4,7 @@ package logstore import ( + "Wavelet/pkg/idgen" "context" "testing" "time" @@ -15,6 +16,7 @@ import ( func newTestUserAccessStore(t *testing.T) *userAccessLogGormStore { t.Helper() + _ = idgen.Init(1) gdb, err := gorm.Open(sqlite.Open("file:logstore-"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) require.NoError(t, err) require.NoError(t, gdb.AutoMigrate(&UserAccessLog{})) diff --git a/backend/plugins/domain/risk_control/logstore/provider.go b/backend/plugins/domain/risk_control/logstore/provider.go index 83b6abd5..ed16fc43 100644 --- a/backend/plugins/domain/risk_control/logstore/provider.go +++ b/backend/plugins/domain/risk_control/logstore/provider.go @@ -4,7 +4,6 @@ package logstore import ( - "Wavelet/pkg/config" "Wavelet/pkg/logger" "context" "errors" @@ -34,6 +33,9 @@ const resolveCacheTTL = 1 * time.Second var ( configReader ConfigReader + defaultDBMu sync.RWMutex + defaultDB = dbNameSQLite + storeMu sync.RWMutex active *Store activeDB string @@ -41,6 +43,25 @@ var ( lastResolveTime time.Time ) +// SetDefaultDatabases configures the fallback database based on database and clickhouse enablement. +func SetDefaultDatabases(dbEnabled, chEnabled bool) { + defaultDBMu.Lock() + defer defaultDBMu.Unlock() + defaultDB = dbNameSQLite + if dbEnabled { + defaultDB = dbNamePostgres + } + if chEnabled { + defaultDB = dbNameClickHouse + } +} + +func getDefaultDatabase() string { + defaultDBMu.RLock() + defer defaultDBMu.RUnlock() + return defaultDB +} + // SetConfigReader 注入系统配置读取函数(bootstrap 调用,测试可注入内存实现)。 func SetConfigReader(fn ConfigReader) { configReader = fn } @@ -169,13 +190,7 @@ func resolveDatabase(ctx context.Context) (string, error) { resolved := v if resolved == "" { - resolved = dbNameSQLite - if config.Config.Database.Enabled { - resolved = dbNamePostgres - } - if config.Config.ClickHouse.Enabled { - resolved = dbNameClickHouse - } + resolved = getDefaultDatabase() } storeMu.Lock() diff --git a/backend/plugins/domain/risk_control/middleware.go b/backend/plugins/domain/risk_control/middleware.go index 8d65c6d9..7bf9c2fa 100644 --- a/backend/plugins/domain/risk_control/middleware.go +++ b/backend/plugins/domain/risk_control/middleware.go @@ -6,26 +6,38 @@ package risk_control import ( "Wavelet/core/contracts" - "Wavelet/pkg/config" "Wavelet/pkg/ginutil" "Wavelet/pkg/idgen" "Wavelet/pkg/response" "Wavelet/plugins/domain/risk_control/logstore" "encoding/json" "net/http" + "sync/atomic" "time" "github.com/gin-gonic/gin" ) +var accessLogEnabled atomic.Bool + +// SetAccessLogEnabled enables or disables access log collection. +func SetAccessLogEnabled(enabled bool) { + accessLogEnabled.Store(enabled) +} + +// IsAccessLogEnabled reports whether access log collection is enabled. +func IsAccessLogEnabled() bool { + return accessLogEnabled.Load() +} + // Middleware is an alias for RiskControlMiddleware. var Middleware = RiskControlMiddleware // RiskControlMiddleware 全局日志采集中间件 func RiskControlMiddleware() gin.HandlerFunc { return func(c *gin.Context) { - // 如果未启用 ClickHouse,直接放行 - if config.Config == nil || !config.Config.ClickHouse.Enabled { + // 如果未启用日志采集,直接放行 + if !IsAccessLogEnabled() { c.Next() return } diff --git a/backend/plugins/domain/risk_control/middleware_test.go b/backend/plugins/domain/risk_control/middleware_test.go index f1bd7bb6..e9319004 100644 --- a/backend/plugins/domain/risk_control/middleware_test.go +++ b/backend/plugins/domain/risk_control/middleware_test.go @@ -6,8 +6,8 @@ package risk_control_test import ( "Wavelet/core/contracts" "Wavelet/pkg/batchwriter" - "Wavelet/pkg/config" "Wavelet/pkg/ginutil" + "Wavelet/pkg/idgen" "Wavelet/pkg/testhelper" "Wavelet/plugins/domain/risk_control" "Wavelet/plugins/domain/risk_control/logstore" @@ -23,6 +23,10 @@ import ( "github.com/stretchr/testify/assert" ) +func init() { + _ = idgen.Init(1) +} + func newTestAccessLogWriter(t *testing.T, cfg batchwriter.Config) (*batchwriter.Writer[*logstore.UserAccessLog], func() []*logstore.UserAccessLog) { t.Helper() @@ -70,8 +74,8 @@ func TestRiskControlMiddleware(t *testing.T) { gin.SetMode(gin.TestMode) t.Run("ClickHouse disabled", func(t *testing.T) { - config.Config.ClickHouse.Enabled = false - defer func() { config.Config.ClickHouse.Enabled = false }() + risk_control.SetAccessLogEnabled(false) + defer risk_control.SetAccessLogEnabled(false) r := testhelper.NewTestGinEngine(risk_control.RiskControlMiddleware()) r.GET("/test", func(c *gin.Context) { @@ -87,8 +91,8 @@ func TestRiskControlMiddleware(t *testing.T) { }) t.Run("ClickHouse enabled - Normal Authenticated Request", func(t *testing.T) { - config.Config.ClickHouse.Enabled = true - defer func() { config.Config.ClickHouse.Enabled = false }() + risk_control.SetAccessLogEnabled(true) + defer risk_control.SetAccessLogEnabled(false) cfg := batchwriter.DefaultConfig() cfg.MaxBatchSize = 100 @@ -133,8 +137,8 @@ func TestRiskControlMiddleware(t *testing.T) { }) t.Run("ClickHouse enabled - Unauthenticated Request", func(t *testing.T) { - config.Config.ClickHouse.Enabled = true - defer func() { config.Config.ClickHouse.Enabled = false }() + risk_control.SetAccessLogEnabled(true) + defer risk_control.SetAccessLogEnabled(false) cfg := batchwriter.DefaultConfig() cfg.MaxBatchSize = 100 @@ -162,8 +166,8 @@ func TestRiskControlMiddleware(t *testing.T) { }) t.Run("ClickHouse enabled - Buffer Full Rate Limiting", func(t *testing.T) { - config.Config.ClickHouse.Enabled = true - defer func() { config.Config.ClickHouse.Enabled = false }() + risk_control.SetAccessLogEnabled(true) + defer risk_control.SetAccessLogEnabled(false) cfg := batchwriter.DefaultConfig() cfg.QueueSize = 2 diff --git a/backend/plugins/domain/risk_control/plugin.go b/backend/plugins/domain/risk_control/plugin.go index 83c0c699..3976fc3a 100644 --- a/backend/plugins/domain/risk_control/plugin.go +++ b/backend/plugins/domain/risk_control/plugin.go @@ -68,8 +68,32 @@ func (p *Plugin) Manifest() core.Manifest { } } +type rcClickHouseConfig struct { + Enabled bool `config:"enabled" env:"CLICKHOUSE_ENABLED" default:"false" autoEnable:"CLICKHOUSE_HOST"` +} + +type rcDBConfig struct { + Enabled bool `config:"enabled" env:"DB_ENABLED" default:"false" autoEnable:"DB_HOST"` +} + +// DeclareConfig declares configuration bindings for the risk_control plugin. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "clickhouse", Target: &rcClickHouseConfig{}}, + {Prefix: "database", Target: &rcDBConfig{}}, + } +} + // Apply registers risk control middlewares, settings, and cleanup hooks into the Context. func (p *Plugin) Apply(ctx *core.Context) error { + var chCfg rcClickHouseConfig + _ = ctx.Config().Bind("clickhouse", &chCfg) + var dbCfg rcDBConfig + _ = ctx.Config().Bind("database", &dbCfg) + + SetAccessLogEnabled(chCfg.Enabled) + logstore.SetDefaultDatabases(dbCfg.Enabled, chCfg.Enabled) + // 0. Bind DBService if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { logstore.SetDBService(db) diff --git a/backend/plugins/domain/system/plugin.go b/backend/plugins/domain/system/plugin.go index fd82f9ac..97a3b35d 100644 --- a/backend/plugins/domain/system/plugin.go +++ b/backend/plugins/domain/system/plugin.go @@ -7,7 +7,6 @@ package system import ( "Wavelet/core" "Wavelet/core/contracts" - "Wavelet/pkg/config" "Wavelet/pkg/logger" "Wavelet/pkg/response" "net/http" @@ -48,6 +47,8 @@ func (p *Plugin) Manifest() core.Manifest { // Apply registers system routes. func (p *Plugin) Apply(ctx *core.Context) error { + appName := ctx.Config().String("app.app_name", "Wavelet") + // 1. Health check ctx.Router().GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) @@ -65,7 +66,7 @@ func (p *Plugin) Apply(ctx *core.Context) error { c.JSON(http.StatusOK, response.OK(gin.H{ "configs": configs, "app": gin.H{ - "name": config.Config.App.AppName, + "name": appName, }, })) }) diff --git a/backend/plugins/domain/upload/handler/routers_test.go b/backend/plugins/domain/upload/handler/routers_test.go index 36ab068c..aef62038 100644 --- a/backend/plugins/domain/upload/handler/routers_test.go +++ b/backend/plugins/domain/upload/handler/routers_test.go @@ -27,6 +27,7 @@ import ( "github.com/gin-gonic/gin" + "Wavelet/pkg/idgen" uploadstats "Wavelet/plugins/domain/upload/stats" ) @@ -36,6 +37,7 @@ type testResponse struct { } func setupTestRouter(authUser *contracts.UserDTO) *gin.Engine { + _ = idgen.Init(1) gin.SetMode(gin.TestMode) r := gin.New() r.Use(response.ErrorHandlerMiddleware()) diff --git a/backend/plugins/domain/upload/ingest/ingest_test.go b/backend/plugins/domain/upload/ingest/ingest_test.go index 7dbf92a2..de3cefc2 100644 --- a/backend/plugins/domain/upload/ingest/ingest_test.go +++ b/backend/plugins/domain/upload/ingest/ingest_test.go @@ -5,6 +5,7 @@ package ingest import ( "Wavelet/core/contracts" + "Wavelet/pkg/idgen" "Wavelet/plugins/domain/upload/models" "Wavelet/plugins/domain/upload/shared" "bytes" @@ -17,6 +18,10 @@ import ( "testing" ) +func init() { + _ = idgen.Init(1) +} + type testStorageService struct { mu sync.RWMutex mockFiles map[string][]byte diff --git a/backend/plugins/domain/user/plugin_test.go b/backend/plugins/domain/user/plugin_test.go index 9bab4c53..387d2757 100644 --- a/backend/plugins/domain/user/plugin_test.go +++ b/backend/plugins/domain/user/plugin_test.go @@ -6,6 +6,7 @@ package user_test import ( "Wavelet/core" "Wavelet/core/contracts" + "Wavelet/pkg/idgen" "Wavelet/plugins/domain/user" "context" "path/filepath" @@ -21,6 +22,7 @@ import ( func setupTestDB(t *testing.T) *gorm.DB { t.Helper() + _ = idgen.Init(1) dbPath := filepath.Join(t.TempDir(), "user_test.db") testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) require.NoError(t, err) @@ -36,6 +38,8 @@ func setupTestDB(t *testing.T) *gorm.DB { func TestUserPluginUnit(t *testing.T) { ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) testDB := setupTestDB(t) dbPlugin := database.New(database.WithDB(testDB)) diff --git a/backend/plugins/domain/user/users_by_ids_test.go b/backend/plugins/domain/user/users_by_ids_test.go index 9a96e1fa..2c71ce14 100644 --- a/backend/plugins/domain/user/users_by_ids_test.go +++ b/backend/plugins/domain/user/users_by_ids_test.go @@ -21,6 +21,8 @@ import ( // 否则调用方(如访问日志按用户补全)会按 ID 逐条打库。 func TestGetUsersByIDsUsesSingleQuery(t *testing.T) { ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) testDB := setupTestDB(t) require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx)) require.NoError(t, user.New().Apply(ctx)) diff --git a/backend/plugins/drivers/driver_asynq_cron/plugin.go b/backend/plugins/drivers/driver_asynq_cron/plugin.go index a57e2105..5e620bdf 100644 --- a/backend/plugins/drivers/driver_asynq_cron/plugin.go +++ b/backend/plugins/drivers/driver_asynq_cron/plugin.go @@ -7,7 +7,6 @@ package driver_asynq_cron import ( "Wavelet/core" "Wavelet/core/contracts" - "Wavelet/pkg/config" "context" "embed" "encoding/json" @@ -84,10 +83,45 @@ func (p *Plugin) Name() string { return "driver_asynq_cron" } +type redisCronConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"` + Addrs []string `config:"addrs" env:"REDIS_ADDR"` + Username string `config:"username" env:"REDIS_USERNAME"` + Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"` + DB int `config:"db" env:"REDIS_DB"` +} + +// DeclareConfig declares configuration bindings for driver_asynq_cron. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "redis", Target: &redisCronConfig{}}, + } +} + +// ConfigEnabled gates plugin activation when Redis is enabled. +func (p *Plugin) ConfigEnabled(view core.ConfigView) bool { + return view.Bool("redis.enabled", false) +} + // Apply mounts the Asynq Cron Scheduler driver into the micro-kernel Context. func (p *Plugin) Apply(ctx *core.Context) error { + var rCfg redisCronConfig + _ = ctx.Config().Bind("redis", &rCfg) + p.mu.Lock() p.coreCtx = ctx + if p.redisOpt == nil { + addr := "127.0.0.1:6379" + if len(rCfg.Addrs) > 0 && rCfg.Addrs[0] != "" { + addr = rCfg.Addrs[0] + } + p.redisOpt = asynq.RedisClientOpt{ + Addr: addr, + Username: rCfg.Username, + Password: rCfg.Password, + DB: rCfg.DB, + } + } p.mu.Unlock() // Bind DBService @@ -263,15 +297,7 @@ func (p *Plugin) resolveRedisOpt() asynq.RedisConnOpt { if RedisOpt != nil { return RedisOpt } - redisCfg := config.Config.Redis - addr := "127.0.0.1:6379" - if len(redisCfg.Addrs) > 0 && redisCfg.Addrs[0] != "" { - addr = redisCfg.Addrs[0] - } return asynq.RedisClientOpt{ - Addr: addr, - Username: redisCfg.Username, - Password: redisCfg.Password, - DB: redisCfg.DB, + Addr: "127.0.0.1:6379", } } diff --git a/backend/plugins/drivers/driver_asynq_worker/config.go b/backend/plugins/drivers/driver_asynq_worker/config.go new file mode 100644 index 00000000..efaf01cc --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_worker/config.go @@ -0,0 +1,28 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_asynq_worker + +type queueConfig struct { + Name string `config:"name"` + Priority int `config:"priority"` +} + +type workerConfig struct { + Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY" default:"10"` + StrictPriority bool `config:"strict_priority" env:"WORKER_STRICT_PRIORITY" default:"false"` + Queues []queueConfig `config:"queues"` +} + +type redisWorkerConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"` + Addrs []string `config:"addrs" env:"REDIS_ADDR"` + Username string `config:"username" env:"REDIS_USERNAME"` + Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"` + DB int `config:"db" env:"REDIS_DB"` + ClusterMode bool `config:"cluster_mode" env:"REDIS_CLUSTER_MODE"` + MasterName string `config:"master_name" env:"REDIS_MASTER_NAME"` + KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"` + PoolSize int `config:"pool_size" env:"REDIS_POOL_SIZE"` + MaintNotifications bool `config:"maint_notifications" env:"REDIS_MAINT_NOTIFICATIONS" default:"false"` +} diff --git a/backend/plugins/drivers/driver_asynq_worker/executor_test.go b/backend/plugins/drivers/driver_asynq_worker/executor_test.go index 23db8f0a..25774a19 100644 --- a/backend/plugins/drivers/driver_asynq_worker/executor_test.go +++ b/backend/plugins/drivers/driver_asynq_worker/executor_test.go @@ -4,6 +4,7 @@ package driver_asynq_worker import ( + "Wavelet/pkg/idgen" "Wavelet/pkg/testhelper" "context" "errors" @@ -22,6 +23,10 @@ import ( "gorm.io/gorm" ) +func init() { + _ = idgen.Init(1) +} + type mockDBService struct { db *gorm.DB } diff --git a/backend/plugins/drivers/driver_asynq_worker/plugin.go b/backend/plugins/drivers/driver_asynq_worker/plugin.go index 5fd31bc0..cd29567e 100644 --- a/backend/plugins/drivers/driver_asynq_worker/plugin.go +++ b/backend/plugins/drivers/driver_asynq_worker/plugin.go @@ -108,10 +108,44 @@ func (p *Plugin) Name() string { return "driver_asynq_worker" } +// DeclareConfig declares configuration bindings consumed by the Asynq worker driver. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "worker", Target: &workerConfig{}}, + {Prefix: "redis", Target: &redisWorkerConfig{}}, + } +} + +// ConfigEnabled gates plugin activation when Redis is enabled. +func (p *Plugin) ConfigEnabled(view core.ConfigView) bool { + return view.Bool("redis.enabled", false) +} + // Apply mounts the Asynq Worker driver into the micro-kernel Context. func (p *Plugin) Apply(ctx *core.Context) error { + var wCfg workerConfig + _ = ctx.Config().Bind("worker", &wCfg) + var rCfg redisWorkerConfig + _ = ctx.Config().Bind("redis", &rCfg) + p.mu.Lock() p.coreCtx = ctx + if p.concurrency == defaultConcurrency && wCfg.Concurrency > 0 { + p.concurrency = wCfg.Concurrency + } + p.strictPriority = wCfg.StrictPriority + if len(p.queues) == 1 && p.queues["default"] == 1 && len(wCfg.Queues) > 0 { + qMap := make(map[string]int, len(wCfg.Queues)) + for _, q := range wCfg.Queues { + qMap[q.Name] = q.Priority + } + p.queues = qMap + } + if p.redisOpt == nil { + p.redisOpt = NewRedisConnOptWithConfig(rCfg) + } + RedisOpt = p.redisOpt + ResetAsynqClient() p.mu.Unlock() // 0. Bind DBService diff --git a/backend/plugins/drivers/driver_asynq_worker/utils.go b/backend/plugins/drivers/driver_asynq_worker/utils.go index 9e00beeb..2b7d2e02 100644 --- a/backend/plugins/drivers/driver_asynq_worker/utils.go +++ b/backend/plugins/drivers/driver_asynq_worker/utils.go @@ -4,7 +4,6 @@ package driver_asynq_worker import ( - "Wavelet/pkg/config" "sync" "github.com/hibiken/asynq" @@ -72,8 +71,11 @@ func GetAsynqClient() *asynq.Client { return AsynqClient } - opt := NewRedisConnOpt() - RedisOpt = opt + opt := RedisOpt + if opt == nil { + opt = NewRedisConnOpt() + RedisOpt = opt + } AsynqClient = asynq.NewClient(opt) return AsynqClient } @@ -88,9 +90,33 @@ func ResetAsynqClient() { } } +var ( + keyPrefixMu sync.RWMutex + keyPrefix string +) + +// SetKeyPrefix sets the redis key prefix for queue names. +func SetKeyPrefix(prefix string) { + keyPrefixMu.Lock() + defer keyPrefixMu.Unlock() + keyPrefix = prefix +} + +// GetKeyPrefix returns the redis key prefix for queue names. +func GetKeyPrefix() string { + keyPrefixMu.RLock() + defer keyPrefixMu.RUnlock() + return keyPrefix +} + // NewRedisConnOpt 根据配置返回对应的 asynq Redis 连接选项 func NewRedisConnOpt() asynq.RedisConnOpt { - cfg := config.Config.Redis + return NewRedisConnOptWithConfig(redisWorkerConfig{}) +} + +// NewRedisConnOptWithConfig returns the asynq RedisConnOpt based on the provided configuration. +func NewRedisConnOptWithConfig(cfg redisWorkerConfig) asynq.RedisConnOpt { + SetKeyPrefix(cfg.KeyPrefix) addrs := cfg.Addrs if cfg.ClusterMode { @@ -135,7 +161,7 @@ func NewRedisConnOpt() asynq.RedisConnOpt { // PrefixedQueue 返回带前缀的队列名,用于 Cluster 模式隔离 func PrefixedQueue(queue string) string { - prefix := config.Config.Redis.KeyPrefix + prefix := GetKeyPrefix() if prefix == "" { return queue } diff --git a/backend/plugins/drivers/driver_asynq_worker/utils_test.go b/backend/plugins/drivers/driver_asynq_worker/utils_test.go index b68a0672..d4b6947c 100644 --- a/backend/plugins/drivers/driver_asynq_worker/utils_test.go +++ b/backend/plugins/drivers/driver_asynq_worker/utils_test.go @@ -4,7 +4,6 @@ package driver_asynq_worker import ( - "Wavelet/pkg/config" "testing" "github.com/redis/go-redis/v9/maintnotifications" @@ -42,17 +41,17 @@ func TestMaintNotificationsConfig(t *testing.T) { } func TestPrefixedQueue(t *testing.T) { - oldPrefix := config.Config.Redis.KeyPrefix + oldPrefix := GetKeyPrefix() defer func() { - config.Config.Redis.KeyPrefix = oldPrefix + SetKeyPrefix(oldPrefix) }() - config.Config.Redis.KeyPrefix = "test:" + SetKeyPrefix("test:") if got := PrefixedQueue("default"); got != "test:default" { t.Fatalf("PrefixedQueue() = %q, want %q", got, "test:default") } - config.Config.Redis.KeyPrefix = "" + SetKeyPrefix("") if got := PrefixedQueue("default"); got != "default" { t.Fatalf("PrefixedQueue() = %q, want %q", got, "default") } diff --git a/backend/plugins/drivers/driver_http/config.go b/backend/plugins/drivers/driver_http/config.go new file mode 100644 index 00000000..dea5144f --- /dev/null +++ b/backend/plugins/drivers/driver_http/config.go @@ -0,0 +1,27 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_http + +type httpAppConfig struct { + Addr string `config:"addr" env:"APP_ADDR" default:":8000"` + AppName string `config:"app_name" env:"APP_NAME" default:"Wavelet"` + APIPrefix string `config:"api_prefix" env:"APP_API_PREFIX" default:"/api/v1"` + Env string `config:"env" env:"APP_ENV" default:"development"` + GracefulShutdownTimeout int `config:"graceful_shutdown_timeout" env:"APP_GRACEFUL_SHUTDOWN_TIMEOUT" default:"30"` + SessionCookieName string `config:"session_cookie_name" env:"APP_SESSION_COOKIE_NAME" default:"wavelet_session"` + SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"` + SessionDomain string `config:"session_domain" env:"APP_SESSION_DOMAIN"` + SessionAge int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"` + SessionHTTPOnly bool `config:"session_http_only" env:"APP_SESSION_HTTP_ONLY" default:"true"` + SessionSecure bool `config:"session_secure" env:"APP_SESSION_SECURE"` +} + +type httpRedisConfig struct { + Addrs []string `config:"addrs" env:"REDIS_ADDR"` + Username string `config:"username" env:"REDIS_USERNAME"` + Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"` + DB int `config:"db" env:"REDIS_DB"` + KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"` + MinIdleConn int `config:"min_idle_conn" env:"REDIS_MIN_IDLE_CONN"` +} diff --git a/backend/plugins/drivers/driver_http/engine.go b/backend/plugins/drivers/driver_http/engine.go index 101de55e..cc80a8c5 100644 --- a/backend/plugins/drivers/driver_http/engine.go +++ b/backend/plugins/drivers/driver_http/engine.go @@ -4,19 +4,9 @@ package driver_http import ( - "Wavelet/pkg/config" - "Wavelet/pkg/trace" - "Wavelet/pkg/util" - "context" - "errors" "log" - "net" "net/http" - "os" - "os/signal" "strconv" - "syscall" - "time" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/redis" @@ -26,102 +16,82 @@ import ( // BuildEngine 构建并初始化 Gin 路由引擎及全部中间件和路由 func BuildEngine() (*gin.Engine, error) { + return BuildEngineWithConfig(httpAppConfig{}, httpRedisConfig{}) +} + +// BuildEngineWithConfig constructs the Gin engine with explicitly injected configuration. +func BuildEngineWithConfig(appCfg httpAppConfig, redisCfg httpRedisConfig) (*gin.Engine, error) { // 运行模式 - if config.Config.App.IsProduction() { + if appCfg.Env == "production" || appCfg.Env == "prod" { gin.SetMode(gin.ReleaseMode) } + setAPIPrefix(appCfg.APIPrefix) + // 初始化路由 r := gin.New() r.Use(gin.Recovery()) r.Use(corsMiddleware()) - cfg := config.Config.Redis - addrs := cfg.Addrs + addrs := redisCfg.Addrs sessionAddr := "localhost:6379" if len(addrs) > 0 { sessionAddr = addrs[0] } + sessionSecret := appCfg.SessionSecret + if sessionSecret == "" { + sessionSecret = "wavelet-default-session-secret" + } + sessionStore, err := redis.NewStoreWithDB( - cfg.MinIdleConn, + redisCfg.MinIdleConn, "tcp", sessionAddr, - cfg.Username, - cfg.Password, - strconv.Itoa(cfg.DB), - []byte(config.Config.App.SessionSecret), + redisCfg.Username, + redisCfg.Password, + strconv.Itoa(redisCfg.DB), + []byte(sessionSecret), ) if err != nil { return nil, err } // 设置 Session Redis Key 前缀 - if cfg.KeyPrefix != "" { - if err := redis.SetKeyPrefix(sessionStore, cfg.KeyPrefix+"session:"); err != nil { + if redisCfg.KeyPrefix != "" { + if err := redis.SetKeyPrefix(sessionStore, redisCfg.KeyPrefix+"session:"); err != nil { log.Printf("[API] set session key prefix failed: %v\n", err) } } + sessionCookieName := appCfg.SessionCookieName + if sessionCookieName == "" { + sessionCookieName = "wavelet_session" + } + + sessionAge := appCfg.SessionAge + if sessionAge <= 0 { + sessionAge = 86400 + } + sessionStore.Options(sessions.Options{ Path: "/", - Domain: config.Config.App.SessionDomain, - MaxAge: config.Config.App.SessionAge, - HttpOnly: config.Config.App.SessionHTTPOnly, - Secure: config.Config.App.SessionSecure, + Domain: appCfg.SessionDomain, + MaxAge: sessionAge, + HttpOnly: appCfg.SessionHTTPOnly, + Secure: appCfg.SessionSecure, SameSite: http.SameSiteLaxMode, }) - r.Use(sessions.Sessions(config.Config.App.SessionCookieName, sessionStore)) + r.Use(sessions.Sessions(sessionCookieName, sessionStore)) + + appName := appCfg.AppName + if appName == "" { + appName = "Wavelet" + } // 补充中间件 - r.Use(otelgin.Middleware(config.Config.App.AppName), errorHandlerMiddleware(), loggerMiddleware()) + r.Use(otelgin.Middleware(appName), errorHandlerMiddleware(), loggerMiddleware()) return r, nil } - -// Serve 启动 HTTP API 服务。onStarted 仅会在 HTTP 地址成功绑定后调用。 -func Serve(onStarted func()) { - r, err := BuildEngine() - if err != nil { - log.Fatalf("[API] init session store failed: %v\n", err) - } - - srv := &http.Server{ - Addr: config.Config.App.Addr, - Handler: r, - 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() - } - - util.Go(func() { - 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) - } - }) - - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - <-quit - - shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Config.App.GracefulShutdownTimeout)*time.Second) - - trace.Shutdown(shutdownCtx) - - if err := srv.Shutdown(shutdownCtx); err != nil { - log.Printf("[API] server forced to shutdown: %v\n", err) - cancel() - os.Exit(1) - } - cancel() - - log.Println("[API] server exited") -} diff --git a/backend/plugins/drivers/driver_http/middlewares.go b/backend/plugins/drivers/driver_http/middlewares.go index e07d995a..f323ce60 100644 --- a/backend/plugins/drivers/driver_http/middlewares.go +++ b/backend/plugins/drivers/driver_http/middlewares.go @@ -5,13 +5,13 @@ package driver_http import ( - "Wavelet/pkg/config" "Wavelet/pkg/logger" "Wavelet/pkg/response" "context" "net/http" "strconv" "strings" + "sync" "time" "github.com/gin-gonic/gin" @@ -21,6 +21,26 @@ import ( otel_trace "Wavelet/pkg/trace" ) +var ( + apiPrefixMu sync.RWMutex + apiPrefix = "/api/v1" +) + +func setAPIPrefix(prefix string) { + if prefix == "" { + return + } + apiPrefixMu.Lock() + defer apiPrefixMu.Unlock() + apiPrefix = prefix +} + +func getAPIPrefix() string { + apiPrefixMu.RLock() + defer apiPrefixMu.RUnlock() + return apiPrefix +} + func loggerMiddleware() gin.HandlerFunc { return func(c *gin.Context) { // 初始化 Trace @@ -46,7 +66,7 @@ func loggerMiddleware() gin.HandlerFunc { // 打印日志 // 排除健康检查接口 - healthPath := config.Config.App.APIPrefix + "/health" + healthPath := getAPIPrefix() + "/health" if c.Request.URL.Path != healthPath { logger.InfoF( ctx, diff --git a/backend/plugins/drivers/driver_http/plugin.go b/backend/plugins/drivers/driver_http/plugin.go index fe543458..4fcaf4ac 100644 --- a/backend/plugins/drivers/driver_http/plugin.go +++ b/backend/plugins/drivers/driver_http/plugin.go @@ -91,10 +91,31 @@ func (p *Plugin) Name() string { return "driver_http" } +// DeclareConfig declares configuration bindings for driver_http. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "app", Target: &httpAppConfig{}}, + {Prefix: "redis", Target: &httpRedisConfig{}}, + } +} + // Apply mounts the HTTP driver plugin into the micro-kernel Context. func (p *Plugin) Apply(ctx *core.Context) error { + var appCfg httpAppConfig + if err := ctx.Config().Bind("app", &appCfg); err != nil { + return err + } + var redisCfg httpRedisConfig + _ = ctx.Config().Bind("redis", &redisCfg) + p.mu.Lock() p.coreCtx = ctx + if p.addr == defaultAddr && appCfg.Addr != "" { + p.addr = appCfg.Addr + } + if appCfg.GracefulShutdownTimeout > 0 { + p.shutdownTimeout = time.Duration(appCfg.GracefulShutdownTimeout) * time.Second + } p.mu.Unlock() // Bind DBService from Context @@ -147,7 +168,17 @@ func (p *Plugin) Start(ctx context.Context) error { } if p.engine == nil { - p.engine = gin.New() + var appCfg httpAppConfig + var redisCfg httpRedisConfig + if p.coreCtx != nil { + _ = p.coreCtx.Config().Bind("app", &appCfg) + _ = p.coreCtx.Config().Bind("redis", &redisCfg) + } + var err error + p.engine, err = BuildEngineWithConfig(appCfg, redisCfg) + if err != nil { + p.engine = gin.New() + } } // Mount routes collected in Context RouterExtension diff --git a/backend/plugins/drivers/driver_inproc_cron/plugin.go b/backend/plugins/drivers/driver_inproc_cron/plugin.go index c70dde53..49fbc3fe 100644 --- a/backend/plugins/drivers/driver_inproc_cron/plugin.go +++ b/backend/plugins/drivers/driver_inproc_cron/plugin.go @@ -38,6 +38,22 @@ func (p *Plugin) Manifest() core.Manifest { } } +type redisGateConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"` +} + +// DeclareConfig declares configuration bindings for driver_inproc_cron. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "redis", Target: &redisGateConfig{}}, + } +} + +// ConfigEnabled gates plugin activation when Redis is disabled. +func (p *Plugin) ConfigEnabled(view core.ConfigView) bool { + return !view.Bool("redis.enabled", false) +} + // Apply registers the scheduler driver into the Context. func (p *Plugin) Apply(ctx *core.Context) error { p.mu.Lock() diff --git a/backend/plugins/drivers/driver_inproc_worker/plugin.go b/backend/plugins/drivers/driver_inproc_worker/plugin.go index 0d9ad818..cd004818 100644 --- a/backend/plugins/drivers/driver_inproc_worker/plugin.go +++ b/backend/plugins/drivers/driver_inproc_worker/plugin.go @@ -98,6 +98,22 @@ func (p *Plugin) Manifest() core.Manifest { } } +type redisGateConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"` +} + +// DeclareConfig declares the configuration bindings consumed by this plugin. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "redis", Target: &redisGateConfig{}}, + } +} + +// ConfigEnabled gates plugin activation when Redis is disabled. +func (p *Plugin) ConfigEnabled(view core.ConfigView) bool { + return !view.Bool("redis.enabled", false) +} + // Apply registers the worker driver and provides contracts.TaskService. func (p *Plugin) Apply(ctx *core.Context) error { p.coreCtx = ctx diff --git a/backend/plugins/drivers/driver_inproc_worker/plugin_test.go b/backend/plugins/drivers/driver_inproc_worker/plugin_test.go index 42417d75..1107e433 100644 --- a/backend/plugins/drivers/driver_inproc_worker/plugin_test.go +++ b/backend/plugins/drivers/driver_inproc_worker/plugin_test.go @@ -6,6 +6,7 @@ package driver_inproc_worker_test import ( "Wavelet/core" "Wavelet/core/extpoints" + "Wavelet/pkg/idgen" "Wavelet/plugins/drivers/driver_inproc_worker" "context" "sync/atomic" @@ -17,6 +18,7 @@ import ( ) func TestInprocWorkerPlugin(t *testing.T) { + require.NoError(t, idgen.Init(1)) ctx := core.NewContext(context.Background()) p := driver_inproc_worker.New( driver_inproc_worker.WithConcurrency(2), diff --git a/backend/plugins/drivers/drivers_test.go b/backend/plugins/drivers/drivers_test.go index 340e2d2e..2fc891d2 100644 --- a/backend/plugins/drivers/drivers_test.go +++ b/backend/plugins/drivers/drivers_test.go @@ -32,6 +32,8 @@ func init() { func TestHTTPDriverLifecycle(t *testing.T) { ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) var globalMiddlewareCalled atomic.Bool var groupMiddlewareCalled atomic.Bool @@ -264,6 +266,8 @@ func TestMultipleDriversInContext(t *testing.T) { defer mr.Close() ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) httpPlugin := driver_http.New(driver_http.WithAddr("127.0.0.1:0")) workerPlugin := driver_asynq_worker.New(driver_asynq_worker.WithRedisOpt(asynq.RedisClientOpt{Addr: mr.Addr()})) diff --git a/backend/plugins/infra/cache/config.go b/backend/plugins/infra/cache/config.go new file mode 100644 index 00000000..ba0d3dd1 --- /dev/null +++ b/backend/plugins/infra/cache/config.go @@ -0,0 +1,25 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cache + +// RedisConfig declares the configuration read by the Redis cache provider. +type RedisConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"` + Addrs []string `config:"addrs" env:"REDIS_ADDR"` + Username string `config:"username" env:"REDIS_USERNAME"` + Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"` + DB int `config:"db" env:"REDIS_DB"` + ClusterMode bool `config:"cluster_mode" env:"REDIS_CLUSTER_MODE"` + MasterName string `config:"master_name" env:"REDIS_MASTER_NAME"` + KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"` + PoolSize int `config:"pool_size" env:"REDIS_POOL_SIZE"` + MinIdleConn int `config:"min_idle_conn" env:"REDIS_MIN_IDLE_CONN"` + DialTimeout int `config:"dial_timeout" env:"REDIS_DIAL_TIMEOUT"` + ReadTimeout int `config:"read_timeout" env:"REDIS_READ_TIMEOUT"` + WriteTimeout int `config:"write_timeout" env:"REDIS_WRITE_TIMEOUT"` + MaxRetries int `config:"max_retries" env:"REDIS_MAX_RETRIES"` + PoolTimeout int `config:"pool_timeout" env:"REDIS_POOL_TIMEOUT"` + ConnMaxIdleTime int `config:"conn_max_idle_time" env:"REDIS_CONN_MAX_IDLE_TIME"` + MaintNotifications bool `config:"maint_notifications" env:"REDIS_MAINT_NOTIFICATIONS" default:"false"` +} diff --git a/backend/plugins/infra/cache/plugin.go b/backend/plugins/infra/cache/plugin.go index 081011e9..0155da2d 100644 --- a/backend/plugins/infra/cache/plugin.go +++ b/backend/plugins/infra/cache/plugin.go @@ -77,13 +77,30 @@ func (p *Plugin) Name() string { return "cache" } +// DeclareConfig declares the configuration bindings consumed by this plugin. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "redis", Target: &RedisConfig{}}, + } +} + +// ConfigEnabled gates plugin activation based on whether Redis is enabled. +func (p *Plugin) ConfigEnabled(view core.ConfigView) bool { + return view.Bool("redis.enabled", false) +} + // Apply mounts the multi-layer cache service into the Context. func (p *Plugin) Apply(ctx *core.Context) error { + var cfg RedisConfig + if err := ctx.Config().Bind("redis", &cfg); err != nil { + return err + } + redisClient := p.redisClient if redisClient == nil { if Redis == nil { var err error - redisClient, err = InitRedis() + redisClient, err = InitRedisWithConfig(cfg) if err != nil { return err } diff --git a/backend/plugins/infra/cache/plugin_test.go b/backend/plugins/infra/cache/plugin_test.go index e1cf4c67..21d38698 100644 --- a/backend/plugins/infra/cache/plugin_test.go +++ b/backend/plugins/infra/cache/plugin_test.go @@ -32,9 +32,11 @@ func TestCachePluginOperations(t *testing.T) { cache.WithKeyPrefix("app:"), cache.WithRAMCapacity(500), ) - assert.Equal(t, "cache", p.Name()) - ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(map[string]any{ + "redis.enabled": true, + })) + require.NoError(t, ctx.Config().Resolve()) require.NoError(t, p.Apply(ctx)) svc, err := core.Inject[contracts.CacheService](ctx) diff --git a/backend/plugins/infra/cache/redis.go b/backend/plugins/infra/cache/redis.go index 56a5e1f9..f9ab5779 100644 --- a/backend/plugins/infra/cache/redis.go +++ b/backend/plugins/infra/cache/redis.go @@ -4,12 +4,12 @@ package cache import ( - "Wavelet/pkg/config" "context" "encoding/json" "fmt" "log" "strings" + "sync" "time" "github.com/redis/go-redis/extra/redisotel/v9" @@ -18,18 +18,37 @@ import ( "go.opentelemetry.io/otel/attribute" ) -// Redis 全局 Redis 客户端实例 -var Redis redis.UniversalClient +var ( + // Redis 全局 Redis 客户端实例 + Redis redis.UniversalClient -// InitRedis 初始化全局/默认 Redis 客户端实例 -func InitRedis() (redis.UniversalClient, error) { - cfg := config.Config.Redis + keyPrefixMu sync.RWMutex + keyPrefix string +) +// SetKeyPrefix sets the package-level key prefix. +func SetKeyPrefix(prefix string) { + keyPrefixMu.Lock() + defer keyPrefixMu.Unlock() + keyPrefix = prefix +} + +// GetKeyPrefix returns the package-level key prefix. +func GetKeyPrefix() string { + keyPrefixMu.RLock() + defer keyPrefixMu.RUnlock() + return keyPrefix +} + +// InitRedisWithConfig initializes the Redis client using the provided RedisConfig. +func InitRedisWithConfig(cfg RedisConfig) (redis.UniversalClient, error) { if !cfg.Enabled { log.Println("[Redis] is disabled, skipping Redis initialization") return nil, nil } + SetKeyPrefix(cfg.KeyPrefix) + var client redis.UniversalClient if cfg.ClusterMode { @@ -114,7 +133,7 @@ func redisMaintNotificationsConfig(enabled bool) *maintnotifications.Config { // PrefixedKey 返回带前缀的 Key func PrefixedKey(key string) string { - prefix := config.Config.Redis.KeyPrefix + prefix := GetKeyPrefix() if prefix == "" { return key } diff --git a/backend/plugins/infra/cache_memory/plugin.go b/backend/plugins/infra/cache_memory/plugin.go index 1242226c..74c815ef 100644 --- a/backend/plugins/infra/cache_memory/plugin.go +++ b/backend/plugins/infra/cache_memory/plugin.go @@ -54,6 +54,23 @@ func (p *Plugin) Manifest() core.Manifest { } } +// redisGateConfig declares the Redis gate configuration for cache_memory. +type redisGateConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"` +} + +// DeclareConfig declares the configuration bindings consumed by this plugin. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "redis", Target: &redisGateConfig{}}, + } +} + +// ConfigEnabled gates plugin activation when Redis is disabled. +func (p *Plugin) ConfigEnabled(view core.ConfigView) bool { + return !view.Bool("redis.enabled", false) +} + // Apply mounts the in-memory cache service into the Context. func (p *Plugin) Apply(ctx *core.Context) error { svc, err := newMemoryCacheService(p.capacity, ctx.Events()) diff --git a/backend/plugins/infra/database/clickhouse.go b/backend/plugins/infra/database/clickhouse.go index 883b43d2..1e38f176 100644 --- a/backend/plugins/infra/database/clickhouse.go +++ b/backend/plugins/infra/database/clickhouse.go @@ -5,7 +5,6 @@ package database import ( - "Wavelet/pkg/config" "context" "fmt" "log" @@ -34,35 +33,35 @@ var ( chDB *gorm.DB ) -func init() { - if !config.Config.ClickHouse.Enabled { - return +// InitClickHouseWithConfig initializes the ClickHouse connection using the provided configuration. +func InitClickHouseWithConfig(cfg ClickHouseConfig) error { + if !cfg.Enabled { + return nil } - cfg := config.Config.ClickHouse if cfg.Database == "" { - log.Fatalf("[ClickHouse] database name is required (expected: wavelet)\n") + return fmt.Errorf("[ClickHouse] database name is required (expected: wavelet)") } - opts := buildClickHouseOptions() + opts := buildClickHouseOptions(cfg) var err error ChConn, err = clickhouse.Open(opts) if err != nil { - log.Fatalf("[ClickHouse] init connection failed: %v\n", err) + return fmt.Errorf("[ClickHouse] init connection failed: %w", err) } if err = ChConn.Ping(context.Background()); err != nil { - log.Fatalf("[ClickHouse] ping failed: %v\n", err) + return fmt.Errorf("[ClickHouse] ping failed: %w", err) } chDB, err = gorm.Open(clickhouseDriver.New(clickhouseDriver.Config{ - DSN: buildClickHouseDSN(), + DSN: buildClickHouseDSN(cfg), }), &gorm.Config{ SkipDefaultTransaction: true, }) if err != nil { - log.Fatalf("[ClickHouse] init gorm connection failed: %v\n", err) + return fmt.Errorf("[ClickHouse] init gorm connection failed: %w", err) } if err = chDB.Use( @@ -74,12 +73,12 @@ func init() { ), ), ); err != nil { - log.Fatalf("[ClickHouse] init trace failed: %v\n", err) + return fmt.Errorf("[ClickHouse] init trace failed: %w", err) } sqlDB, err := chDB.DB() if err != nil { - log.Fatalf("[ClickHouse] load sql db failed: %v\n", err) + return fmt.Errorf("[ClickHouse] load sql db failed: %w", err) } sqlDB.SetMaxIdleConns(cfg.MaxIdleConn) @@ -87,11 +86,10 @@ func init() { sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second) log.Println("[ClickHouse] connection established successfully") + return nil } -func buildClickHouseOptions() *clickhouse.Options { - cfg := config.Config.ClickHouse - +func buildClickHouseOptions(cfg ClickHouseConfig) *clickhouse.Options { return &clickhouse.Options{ Addr: cfg.Hosts, Auth: clickhouse.Auth{ @@ -114,9 +112,7 @@ func buildClickHouseOptions() *clickhouse.Options { } } -func buildClickHouseDSN() string { - cfg := config.Config.ClickHouse - +func buildClickHouseDSN(cfg ClickHouseConfig) string { chURL := &url.URL{ Scheme: "clickhouse", Host: strings.Join(cfg.Hosts, ","), diff --git a/backend/plugins/infra/database/config.go b/backend/plugins/infra/database/config.go new file mode 100644 index 00000000..56f7a255 --- /dev/null +++ b/backend/plugins/infra/database/config.go @@ -0,0 +1,57 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package database + +import "time" + +// ReplicaConfig declares a single read-replica configuration for PostgreSQL. +type ReplicaConfig struct { + Host string `config:"host"` + Port int `config:"port"` + Username string `config:"username"` + Password string `config:"password" secret:"true"` +} + +// Config declares PostgreSQL and SQLite database configuration. +type Config struct { + Enabled bool `config:"enabled" env:"DB_ENABLED" default:"false" autoEnable:"DB_HOST"` + SQLitePath string `config:"sqlite_path" env:"SQLITE_PATH"` + Host string `config:"host" env:"DB_HOST"` + Port int `config:"port" env:"DB_PORT" default:"5432"` + Username string `config:"username" env:"DB_USERNAME"` + Password string `config:"password" env:"DB_PASSWORD" secret:"true"` + Database string `config:"database" env:"DB_NAME" default:"wavelet"` + MaxIdleConn int `config:"max_idle_conn" env:"DB_MAX_IDLE_CONN" default:"10"` + MaxOpenConn int `config:"max_open_conn" env:"DB_MAX_OPEN_CONN" default:"100"` + ConnMaxLifetime int `config:"conn_max_lifetime" env:"DB_CONN_MAX_LIFETIME" default:"3600"` + ConnMaxIdleTime int `config:"conn_max_idle_time" env:"DB_CONN_MAX_IDLE_TIME" default:"600"` + LogLevel string `config:"log_level" env:"DB_LOG_LEVEL" default:"warn"` + SSLMode string `config:"ssl_mode" env:"DB_SSL_MODE" default:"disable"` + TimeZone string `config:"time_zone" env:"DB_TIMEZONE" default:"UTC"` + ApplicationName string `config:"application_name" env:"DB_APPLICATION_NAME" default:"wavelet"` + SearchPath string `config:"search_path" env:"DB_SEARCH_PATH" default:"public"` + PreferSimpleProtocol bool `config:"prefer_simple_protocol" env:"DB_PREFER_SIMPLE_PROTOCOL"` + StatementCacheCapacity int `config:"statement_cache_capacity" env:"DB_STATEMENT_CACHE_CAPACITY"` + DefaultQueryExecMode string `config:"default_query_exec_mode" env:"DB_DEFAULT_QUERY_EXEC_MODE"` + Replicas []ReplicaConfig `config:"replicas"` + SlowThreshold time.Duration `config:"slow_threshold" env:"DB_SLOW_THRESHOLD" default:"200ms"` +} + +// ClickHouseConfig declares the configuration for ClickHouse analytical storage. +type ClickHouseConfig struct { + Enabled bool `config:"enabled" env:"CLICKHOUSE_ENABLED" default:"false" autoEnable:"CLICKHOUSE_HOST"` + Hosts []string `config:"hosts" env:"CLICKHOUSE_HOST"` + Username string `config:"username" env:"CLICKHOUSE_USERNAME"` + Password string `config:"password" env:"CLICKHOUSE_PASSWORD" secret:"true"` + Database string `config:"database" env:"CLICKHOUSE_NAME" default:"wavelet"` + MaxIdleConn int `config:"max_idle_conn" env:"CLICKHOUSE_MAX_IDLE_CONN" default:"10"` + MaxOpenConn int `config:"max_open_conn" env:"CLICKHOUSE_MAX_OPEN_CONN" default:"50"` + ConnMaxLifetime int `config:"conn_max_lifetime" env:"CLICKHOUSE_CONN_MAX_LIFETIME" default:"3600"` + DialTimeout int `config:"dial_timeout" env:"CLICKHOUSE_DIAL_TIMEOUT" default:"10"` + BlockBufferSize uint8 `config:"block_buffer_size" env:"CLICKHOUSE_BLOCK_BUFFER_SIZE" default:"10"` +} + +type appEnvConfig struct { + Env string `config:"env" env:"APP_ENV" default:"development"` +} diff --git a/backend/plugins/infra/database/plugin.go b/backend/plugins/infra/database/plugin.go index eca4c978..eb16349d 100644 --- a/backend/plugins/infra/database/plugin.go +++ b/backend/plugins/infra/database/plugin.go @@ -56,17 +56,45 @@ func (p *Plugin) Name() string { return "database" } +// DeclareConfig declares database configuration keys. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "database", Target: &Config{}}, + {Prefix: "clickhouse", Target: &ClickHouseConfig{}}, + {Prefix: "app", Target: &appEnvConfig{}}, + } +} + // Apply mounts the database service into the Context. func (p *Plugin) Apply(ctx *core.Context) error { + var dbCfg Config + if err := ctx.Config().Bind("database", &dbCfg); err != nil { + return err + } + + var chCfg ClickHouseConfig + if err := ctx.Config().Bind("clickhouse", &chCfg); err != nil { + return err + } + + var appCfg appEnvConfig + _ = ctx.Config().Bind("app", &appCfg) + targetDB := p.db if targetDB == nil { var err error - targetDB, err = InitDB() + targetDB, err = InitDBWithConfig(dbCfg, appCfg.Env == "production" || appCfg.Env == "prod") if err != nil { return err } } + if chCfg.Enabled { + if err := InitClickHouseWithConfig(chCfg); err != nil { + return err + } + } + svc := &dbServiceImpl{ primary: targetDB, namedDBs: p.namedDBs, diff --git a/backend/plugins/infra/database/plugin_test.go b/backend/plugins/infra/database/plugin_test.go index 8aa6861a..01d33c5d 100644 --- a/backend/plugins/infra/database/plugin_test.go +++ b/backend/plugins/infra/database/plugin_test.go @@ -37,9 +37,11 @@ func TestDatabasePlugin(t *testing.T) { database.WithDB(gdb), database.WithNamedDB("analytics", namedDB), ) - assert.Equal(t, "database", p.Name()) - ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(map[string]any{ + "database.enabled": false, + })) + require.NoError(t, ctx.Config().Resolve()) require.NoError(t, p.Apply(ctx)) svc, err := core.Inject[contracts.DBService](ctx) diff --git a/backend/plugins/infra/database/postgres.go b/backend/plugins/infra/database/postgres.go index 4e864fe4..f110ec27 100644 --- a/backend/plugins/infra/database/postgres.go +++ b/backend/plugins/infra/database/postgres.go @@ -4,7 +4,6 @@ package database import ( - "Wavelet/pkg/config" "context" "fmt" "log" @@ -30,15 +29,19 @@ const sqliteDirMode = 0o750 // InitDB 初始化主数据库实例(支持 PostgreSQL / SQLite) func InitDB() (*gorm.DB, error) { - if !config.Config.Database.Enabled { - return initSQLite() - } - return initPostgres() + return InitDBWithConfig(Config{}, false) } -// initSQLite 初始化 SQLite 数据库(PostgreSQL 禁用时的后备方案) -func initSQLite() (*gorm.DB, error) { - sqlitePath := config.Config.Database.SQLitePath +// InitDBWithConfig initializes the main database with the provided config. +func InitDBWithConfig(cfg Config, isProd bool) (*gorm.DB, error) { + if !cfg.Enabled { + return initSQLiteWithConfig(cfg, isProd) + } + return initPostgresWithConfig(cfg, isProd) +} + +func initSQLiteWithConfig(cfg Config, isProd bool) (*gorm.DB, error) { + sqlitePath := cfg.SQLitePath if sqlitePath == "" { sqlitePath = "./data/wavelet.db" } @@ -54,9 +57,9 @@ func initSQLite() (*gorm.DB, error) { targetDB, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{ DisableForeignKeyConstraintWhenMigrating: true, Logger: &gormZapLogger{ - logLevel: parseLogLevel(config.Config.Database.LogLevel), - slowThreshold: config.Config.Database.SlowThreshold, - ignoreRecordNotFoundError: config.Config.App.IsProduction(), + logLevel: parseLogLevel(cfg.LogLevel), + slowThreshold: cfg.SlowThreshold, + ignoreRecordNotFoundError: isProd, }, }) if err != nil { @@ -81,24 +84,21 @@ func initSQLite() (*gorm.DB, error) { return targetDB, nil } -// initPostgres 初始化 PostgreSQL 数据库 -func initPostgres() (*gorm.DB, error) { - dbConfig := config.Config.Database - +func initPostgresWithConfig(cfg Config, isProd bool) (*gorm.DB, error) { // 构建主库 DSN 并连接 - primaryDSN := buildDSN(dbConfig.Host, dbConfig.Port, dbConfig.Username, dbConfig.Password) + primaryDSN := buildDSN(cfg, cfg.Host, cfg.Port, cfg.Username, cfg.Password) pgConfig := postgres.Config{ DSN: primaryDSN, - PreferSimpleProtocol: dbConfig.PreferSimpleProtocol, + PreferSimpleProtocol: cfg.PreferSimpleProtocol, } targetDB, err := gorm.Open(postgres.New(pgConfig), &gorm.Config{ DisableForeignKeyConstraintWhenMigrating: true, Logger: &gormZapLogger{ - logLevel: parseLogLevel(config.Config.Database.LogLevel), - slowThreshold: config.Config.Database.SlowThreshold, - ignoreRecordNotFoundError: config.Config.App.IsProduction(), + logLevel: parseLogLevel(cfg.LogLevel), + slowThreshold: cfg.SlowThreshold, + ignoreRecordNotFoundError: isProd, }, }) if err != nil { @@ -110,9 +110,9 @@ func initPostgres() (*gorm.DB, error) { tracing.NewPlugin( tracing.WithoutMetrics(), tracing.WithAttributes( - attribute.String("db.instance", dbConfig.Database), - attribute.String("db.ip", dbConfig.Host), - attribute.String("server.address", net.JoinHostPort(dbConfig.Host, strconv.Itoa(dbConfig.Port))), + attribute.String("db.instance", cfg.Database), + attribute.String("db.ip", cfg.Host), + attribute.String("server.address", net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))), attribute.String("db.system", "PostgreSQL"), ), ), @@ -120,21 +120,21 @@ func initPostgres() (*gorm.DB, error) { return nil, err } - if len(dbConfig.Replicas) > 0 { + if len(cfg.Replicas) > 0 { var replicaDialectors []gorm.Dialector - for _, replica := range dbConfig.Replicas { + for _, replica := range cfg.Replicas { username := replica.Username if username == "" { - username = dbConfig.Username + username = cfg.Username } password := replica.Password if password == "" { - password = dbConfig.Password + password = cfg.Password } - replicaDSN := buildDSN(replica.Host, replica.Port, username, password) + replicaDSN := buildDSN(cfg, replica.Host, replica.Port, username, password) replicaDialectors = append(replicaDialectors, postgres.New(postgres.Config{ DSN: replicaDSN, - PreferSimpleProtocol: dbConfig.PreferSimpleProtocol, + PreferSimpleProtocol: cfg.PreferSimpleProtocol, })) } @@ -143,15 +143,15 @@ func initPostgres() (*gorm.DB, error) { Policy: dbresolver.RandomPolicy{}, }) - resolver.SetMaxIdleConns(dbConfig.MaxIdleConn). - SetMaxOpenConns(dbConfig.MaxOpenConn). - SetConnMaxLifetime(time.Duration(dbConfig.ConnMaxLifetime) * time.Second). - SetConnMaxIdleTime(time.Duration(dbConfig.ConnMaxIdleTime) * time.Second) + resolver.SetMaxIdleConns(cfg.MaxIdleConn). + SetMaxOpenConns(cfg.MaxOpenConn). + SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second). + SetConnMaxIdleTime(time.Duration(cfg.ConnMaxIdleTime) * time.Second) if err = targetDB.Use(resolver); err != nil { return nil, err } - log.Printf("[PostgreSQL] initialized in Primary-Replica mode (%d replicas)\n", len(dbConfig.Replicas)) + log.Printf("[PostgreSQL] initialized in Primary-Replica mode (%d replicas)\n", len(cfg.Replicas)) } else { log.Println("[PostgreSQL] initialized in Standalone mode") } @@ -162,18 +162,17 @@ func initPostgres() (*gorm.DB, error) { return nil, err } - sqlDB.SetMaxIdleConns(dbConfig.MaxIdleConn) - sqlDB.SetMaxOpenConns(dbConfig.MaxOpenConn) - sqlDB.SetConnMaxLifetime(time.Duration(dbConfig.ConnMaxLifetime) * time.Second) - sqlDB.SetConnMaxIdleTime(time.Duration(dbConfig.ConnMaxIdleTime) * time.Second) + sqlDB.SetMaxIdleConns(cfg.MaxIdleConn) + sqlDB.SetMaxOpenConns(cfg.MaxOpenConn) + sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second) + sqlDB.SetConnMaxIdleTime(time.Duration(cfg.ConnMaxIdleTime) * time.Second) db = targetDB return targetDB, nil } // buildDSN 构建 PostgreSQL DSN -func buildDSN(host string, port int, username, password string) string { - cfg := config.Config.Database +func buildDSN(cfg Config, host string, port int, username, password string) string { pqURL := &url.URL{ Scheme: "postgres", Host: net.JoinHostPort(host, strconv.Itoa(port)), diff --git a/backend/plugins/infra/infra_test.go b/backend/plugins/infra/infra_test.go index 568b10e6..1bd4e519 100644 --- a/backend/plugins/infra/infra_test.go +++ b/backend/plugins/infra/infra_test.go @@ -41,11 +41,25 @@ func setupTestDB(t *testing.T) *gorm.DB { return db } -func TestDatabasePlugin(t *testing.T) { +func prepareTestContext(values map[string]any, declarers ...core.Plugin) *core.Context { ctx := core.NewContext(context.Background()) - testDB := setupTestDB(t) + ctx.Config().SetSource(core.NewMapSource(values)) + for _, p := range declarers { + if d, ok := p.(interface{ DeclareConfig() []core.ConfigBinding }); ok { + for _, b := range d.DeclareConfig() { + _ = ctx.Config().Declare(p.Name(), b) + } + } + } + _ = ctx.Config().Resolve() + return ctx +} +func TestDatabasePlugin(t *testing.T) { + testDB := setupTestDB(t) p := database.New(database.WithDB(testDB)) + ctx := prepareTestContext(nil, p) + require.Equal(t, "database", p.Name()) require.NoError(t, p.Apply(ctx)) @@ -70,8 +84,9 @@ func TestDatabasePlugin(t *testing.T) { } func TestCachePluginRAMOnly(t *testing.T) { - ctx := core.NewContext(context.Background()) p := cache.New() + ctx := prepareTestContext(nil, p) + require.Equal(t, "cache", p.Name()) require.NoError(t, p.Apply(ctx)) @@ -142,12 +157,12 @@ func TestCachePluginWithRedisAndPubSub(t *testing.T) { }) defer func() { _ = rdb.Close() }() - ctx1 := core.NewContext(context.Background()) - ctx2 := core.NewContext(context.Background()) - p1 := cache.New(cache.WithRedis(rdb), cache.WithKeyPrefix("test:")) p2 := cache.New(cache.WithRedis(rdb), cache.WithKeyPrefix("test:")) + ctx1 := prepareTestContext(map[string]any{"redis.enabled": true}, p1) + ctx2 := prepareTestContext(map[string]any{"redis.enabled": true}, p2) + require.NoError(t, p1.Apply(ctx1)) require.NoError(t, p2.Apply(ctx2)) @@ -294,14 +309,20 @@ func TestStoragePlugin(t *testing.T) { } func TestAllInfraPluginsCombined(t *testing.T) { - ctx := core.NewContext(context.Background()) testDB := setupTestDB(t) memBackend := newMemoryBackend() - require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx)) - require.NoError(t, cache.New().Apply(ctx)) - require.NoError(t, logger.New().Apply(ctx)) - require.NoError(t, storage.New(storage.WithBackend(memBackend)).Apply(ctx)) + dbP := database.New(database.WithDB(testDB)) + cacheP := cache.New() + logP := logger.New() + storageP := storage.New(storage.WithBackend(memBackend)) + + ctx := prepareTestContext(nil, dbP, cacheP, logP, storageP) + + require.NoError(t, dbP.Apply(ctx)) + require.NoError(t, cacheP.Apply(ctx)) + require.NoError(t, logP.Apply(ctx)) + require.NoError(t, storageP.Apply(ctx)) // Using3 to resolve dependencies concurrently var resolved bool diff --git a/docs/WAVELET_WHITE_PAPER.md b/docs/WAVELET_WHITE_PAPER.md index e727a55a..2c66ad38 100644 --- a/docs/WAVELET_WHITE_PAPER.md +++ b/docs/WAVELET_WHITE_PAPER.md @@ -138,6 +138,8 @@ Wavelet 贯彻了 Cordis 核心范式,通过形式化保证解决组件系统 ### 5.1 彻底根除集中式包与建立 backend/ 顶级总包 在过去的传统单体架构中,集中式的 `internal/model/`、`internal/repository/` 以及 `internal/` 目录往往成为大杂烩,随着团队扩展导致模块边界失控与隐式耦合。在本次 Cordis 架构重构中,我们实施了彻底的物理清退与顶级前后端分包: - **`backend/` 顶级总包**:汇聚所有 Go 后端代码(`cmd/`、`core/`、`plugins/`、`pkg/`、`main.go`),根目录仅保留顶级功能域。 +- **配置读取框架归属内核**:`backend/core/extpoints/` 只承载与实现无关的配置声明与解析引擎(不 import viper),`backend/plugins/infra/config/` 承担文件与环境装载,读哪些字段由各插件自行声明;组合根不再跨插件判断配置选实现,改由 `ConfigGatedPlugin` 门禁 + `FiberSkipped` 决定激活方。 +- **`pkg/config/` 全局单例**:处于退场过渡期。配置声明与解析能力已上收内核,业务侧全量迁移与旧包物理清退由后续迁移计划落地。 - **`internal/` 目录**:**100% 物理清除**。通用的无状态基础库平移至 `backend/pkg/`,所有业务全部下沉至 `backend/plugins/domain/`。 - **`pkg/model/` 目录**:**100% 物理清除**。消灭集中式数据模型。 - **`pkg/repository/` 目录**:**100% 物理清除**。消灭集中式仓储。 @@ -162,4 +164,23 @@ Wavelet 贯彻了 Cordis 核心范式,通过形式化保证解决组件系统 2. **Pub/Sub 并发安全防线**:在启动 Redis Pub/Sub 监听协程前,严格捕获局部客户端实例,彻底消除测试或重启期间对可变全局客户端的数据竞争(Data Race Free)。 3. **零旁路读写 (No Bypass)**:严禁插件 A 跨界旁路直接操作属于插件 B 的数据表,跨域调用一律面向 `backend/core/contracts` 契约编程或发布事件。 +--- + +## 6. Cordis 配置扩展点与条件门禁机制 (Config Extension & Gated Activation) + +### 6.1 彻底清退全局配置单例 (Zero-Singleton Architecture) +在传统单体架构中,`pkg/config.Config` 全局静态变量充斥在各个业务与驱动模块中,导致隐式依赖、无法独立单测、无法多实例共存。Cordis 架构引入了基于微内核上下文的配置扩展点(`ctx.Config()`): +- **插件自包含声明**:每个插件实现 `DeclareConfig() []core.ConfigBinding`,声明自身所需的静态启动配置前缀、结构体与字段 tag(`config`、`env`、`default`、`autoEnable`、`secret`)。 +- **统一生命周期解析**:通过 `app.Prepare()` 建立配置解析屏障,统一绑定 YAML 文件与环境变量,支持前缀冲突检测与敏感字段脱敏导出。 +- **纯净依赖隔离**:插件在 `Apply(ctx)` 中通过 `ctx.Config().Bind("", &cfg)` 读取自身配置,微内核与 `pkg/` 工具包绝对不依赖任何配置具体实现。 + +### 6.2 基于配置的动态插件门禁 (Configuration-Gated Plugins) +为了原生支持**单机单体(Zero-Redis Monolith)**与**分布式集群(Distributed Cluster)**无缝切换,Cordis 提供了 `core.ConfigGatedPlugin` 扩展接口: +- **门禁契约**:实现 `ConfigEnabled(view core.ConfigView) bool` 方法。微内核在 `Reconcile` / `ApplyPlugins` 阶段依据解析后的配置动态求值。 +- **互斥挂载**: + - 当 `redis.enabled = false`(默认):`cache_memory`、`driver_inproc_worker` 与 `driver_inproc_cron` 自动进入 `ACTIVE` 状态;分布式插件进入 `SKIPPED` 状态,达成零外部中间件极简单体。 + - 当 `redis.enabled = true`:`cache`、`driver_asynq_worker` 与 `driver_asynq_cron` 自动激活,无缝升级为分布式高可用架构。 +- **动态拔插可组合性**:所有互斥插件可同时通过 `app.Use(...)` 注册,装配根无需编写侵入式的 `if-else` 条件分支,全面实现架构的时空可组合性与高内聚。 + + diff --git a/docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md b/docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md index 5efb9849..c2965834 100644 --- a/docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md +++ b/docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md @@ -344,3 +344,21 @@ app.Run() → Reconcile/Apply # 插件内 Bind/Get 读取已解析值 | domain/其他 | `plugins/domain/auth/session.go`、`plugins/domain/cap/service.go`、`plugins/domain/message_gateway/service/service.go`、`plugins/domain/system/plugin.go`、`plugins/domain/risk_control/middleware.go`、`plugins/domain/risk_control/middleware_test.go`、`plugins/domain/risk_control/logstore/provider.go` | > 注:`risk_control/middleware.go`、`cap/service.go`、`message_gateway/service/service.go` 等处以 `config.Config != nil` 做存在性判断的分支,在注入式配置模型下不再可能,迁移时一并消除。 + +--- + +## 8. 落地回写(P1 + P2 已实施) + +实施结果与本设计原述的差异,均已按下列口径落地: + +| # | 设计原述 | 落地结果 | 缘由 | +| :--- | :--- | :--- | :--- | +| R1 | §4.3 C1、§6 把 `app.session_age<=0` 列为内核解析错误 | 引擎不做值域校验,`ErrConfigInvalid` 保留但未在内核使用;值域由声明者在 `Bind` 之后校验(P3 由 auth 承担) | 引擎被设计成不认识任何业务 key 的语义,把业务规则塞进内核会破坏该不变式 | +| R2 | §3.2 `ConfigView.Source(key)` | 更名 `Origin(key)`;新增 `Value(key) (any, bool)`;`ConfigExtension` 增加 `SetSource`、`Resolved` | `Source` 与类型名 `ConfigSource` 同文件易混淆;`Value` 支撑 `core.ConfigGet[T]`(Go 方法不能带类型参数);`SetSource` 进接口以免运行时类型断言 | +| R3 | §3.5 仅有 `WithShutdownTimeout` | 新增 `App.ShutdownTimeout()` 与 `SetShutdownTimeout(d) *App` | 组合根需在 `Prepare()` 之后把已解析预算写回内核,构造期选项无法表达该顺序 | +| R4 | §4.2 时序图把门禁求值画在 `Prepare()` 内 | `Prepare()` 只建立解析屏障,门禁在 `reconcileLocked` 每轮调和中求值 | `App.Use` 可在 `Prepare()` 之后继续挂载插件;只在 `Prepare` 求值会留下一批永不判定的门禁 | +| R5 | 未涉及 | `App` 未注入 `ConfigSource` 时配置能力视为未启用,解析屏障直接放行;实现了 `ConfigGatedPlugin` 却无配置源的插件 fail fast 点名原因 | 内核存在大量不使用配置的装配路径(既有测试与嵌入式用法),不能强制要求配置源;但门禁无数据可依时必须报错,而非静默全激活 | +| R6 | §4.1 隐含"每个 key 都有 env 覆盖" | env 覆盖面完全由声明决定。旧装载器只对部分 key 提供 env(`slow_threshold`、`conn_max_lifetime` 等从未有 env 覆盖),对拍镜像必须精确复刻该覆盖面 | 否则对拍出现假漂移;放宽某 key 的 env 覆盖是 P3 的声明选择,不构成引擎行为变更 | +| R7 | §4.1 "向上最多 5 层查找 `config.yaml`" | 该向上查找会**越出 git worktree 边界**:从 `backend/pkg/config` 出发第 5 层可命中父级检出的 `config.yaml` | 属既有行为、非本次引入,但在 worktree 中开发会静默使用另一份检出的配置。对拍测试已改为以入库的 `config.example.yaml` 所在目录为锚;`config.yaml` 本身被 gitignore,干净克隆中不存在 | + +分期口径:本设计 §7.3 的 P1 + P2 已实施完成;P3(27 个消费文件迁移、`pkg/idgen` 解耦)与 P4(删除 `backend/pkg/config`、移除对拍夹具)由后续计划承接。