mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
merge: feat(core): implement cordis configuration extension and migrate all plugins
This commit is contained in:
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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("<prefix>", &cfg)` 读取**自己声明**的配置,字段以 tag 表达来源:`config`(yaml 路径)、`env`(覆盖变量名)、`default`、`autoEnable`(该变量存在即置真)、`secret`(导出脱敏)。需要在 `Apply` 之前被门禁求值的键,必须在 `DeclareConfig()` 中提前声明并实现 `core.ConfigGatedPlugin`。新增基础设施 key 保持顶层命名(`redis.*`),插件私有配置归 `plugins.<name>.*`。**严禁**再造全局配置单例或在 `backend/pkg/` 读取配置。
|
||||
- **动态设置**:插件自包含在 `Apply` 中通过 `ctx.Settings().Register(core.SettingSchema{...})` 声明可热更新的管理台设置模式(与上面的静态启动配置分属两层)。
|
||||
- **数据迁移**:插件自包含在内部维护 `migrations/*.sql`,通过 `//go:embed` 打包并在 `Apply` 中通过 `ctx.Migrations().Register(pluginID, embedFS)` 注入。
|
||||
- **表单一所有者原则 (Single Owner Principle)**:
|
||||
- 每张数据表有且仅由一个所有者插件声明与维护(表名使用插件前缀如 `w_order_*`)。
|
||||
|
||||
+1
-9
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
+1
-9
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
+56
-25
@@ -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,12 +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.
|
||||
func newWaveletApp(profile core.Profile) *core.App {
|
||||
app := core.NewApp(
|
||||
//
|
||||
//nolint:contextcheck
|
||||
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(
|
||||
@@ -52,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(
|
||||
@@ -84,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
|
||||
@@ -234,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 {
|
||||
@@ -265,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 {
|
||||
|
||||
+57
-18
@@ -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())
|
||||
}
|
||||
|
||||
+13
-5
@@ -1,21 +1,20 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package cmd provides CLI command entry points.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/buildinfo"
|
||||
"Wavelet/pkg/config"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// startup banner formatting utilities
|
||||
type startupState struct {
|
||||
mode string
|
||||
listensForHTTP bool
|
||||
env string
|
||||
addr string
|
||||
}
|
||||
|
||||
func printStartupBanner(state startupState) {
|
||||
@@ -23,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{
|
||||
"",
|
||||
"__ __ _ _ ",
|
||||
@@ -32,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")
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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())
|
||||
|
||||
+54
-12
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
+154
-13
@@ -68,22 +68,54 @@ func WithShutdownTimeout(timeout time.Duration) AppOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithConfigSource installs the raw configuration source adapter, typically built by an
|
||||
// infrastructure package outside the kernel, before any plugin is applied.
|
||||
func WithConfigSource(src ConfigSource) AppOption {
|
||||
return func(a *App) {
|
||||
if src == nil {
|
||||
return
|
||||
}
|
||||
// Installed during Prepare so the option order, including WithContext, is irrelevant.
|
||||
a.configSource = src
|
||||
}
|
||||
}
|
||||
|
||||
// WithConfigDecl lets the composition root declare the configuration it reads itself,
|
||||
// so host-level values take part in conflict validation and the redacted report. The
|
||||
// bindings are registered during Prepare, so option order does not matter.
|
||||
func WithConfigDecl(pluginID string, bindings ...ConfigBinding) AppOption {
|
||||
return func(a *App) {
|
||||
if len(bindings) == 0 {
|
||||
return
|
||||
}
|
||||
if a.hostDeclOwner == "" {
|
||||
a.hostDeclOwner = pluginID
|
||||
}
|
||||
a.hostDeclBindings = append(a.hostDeclBindings, bindings...)
|
||||
}
|
||||
}
|
||||
|
||||
// App is the unified assembly entrypoint and runtime aspect dispatcher of the Cordis micro-kernel.
|
||||
// It manages plugin collection, dependency mounting, migration execution, profile-based driver startup,
|
||||
// and graceful signal-driven LIFO shutdown.
|
||||
type App struct {
|
||||
mu sync.RWMutex
|
||||
ctx *Context
|
||||
profile Profile
|
||||
plugins []Plugin
|
||||
pluginMap map[string]Plugin
|
||||
fibers []*Fiber
|
||||
fiberMap map[string]*Fiber
|
||||
applied bool
|
||||
running bool
|
||||
startedDrivers []Driver
|
||||
migrationEngine MigrationEngine
|
||||
shutdownTimeout time.Duration
|
||||
mu sync.RWMutex
|
||||
ctx *Context
|
||||
profile Profile
|
||||
plugins []Plugin
|
||||
pluginMap map[string]Plugin
|
||||
fibers []*Fiber
|
||||
fiberMap map[string]*Fiber
|
||||
applied bool
|
||||
running bool
|
||||
startedDrivers []Driver
|
||||
migrationEngine MigrationEngine
|
||||
shutdownTimeout time.Duration
|
||||
configSource ConfigSource
|
||||
hostDeclOwner string
|
||||
hostDeclBindings []ConfigBinding
|
||||
prepared bool
|
||||
applyErr error
|
||||
}
|
||||
|
||||
// NewApp creates a new Cordis application instance with default options.
|
||||
@@ -162,6 +194,11 @@ func (a *App) Use(plugins ...Plugin) *App {
|
||||
a.fiberMap[name] = f
|
||||
}
|
||||
a.pluginMap[name] = p
|
||||
|
||||
if gated, ok := p.(ConfigGatedPlugin); ok && a.applyErr == nil {
|
||||
// Gates are evaluated before Apply, so their keys must be declared at mount time.
|
||||
a.applyErr = a.ctx.Config().Declare(name, gated.DeclareConfig()...)
|
||||
}
|
||||
}
|
||||
|
||||
return a
|
||||
@@ -227,10 +264,29 @@ func (a *App) Reconcile() error {
|
||||
}
|
||||
|
||||
func (a *App) reconcileLocked() error {
|
||||
if err := a.prepareLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for {
|
||||
progress := false
|
||||
for _, f := range a.fibers {
|
||||
if f.State() == FiberPending && f.DependenciesSatisfied(a.ctx) {
|
||||
if f.State() != FiberPending {
|
||||
continue
|
||||
}
|
||||
|
||||
gated, skip, err := a.evaluateGateLocked(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if gated && skip {
|
||||
if err := f.Skip(); err != nil {
|
||||
return fmt.Errorf("core: skip gated fiber %q failed: %w", f.Name(), err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if f.DependenciesSatisfied(a.ctx) {
|
||||
if err := f.Load(); err != nil {
|
||||
return fmt.Errorf("core: load fiber %q failed: %w", f.Name(), err)
|
||||
}
|
||||
@@ -255,6 +311,24 @@ func (a *App) reconcileLocked() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// evaluateGateLocked reports whether a configuration-gated plugin is excluded by the
|
||||
// resolved values. Plugins that do not implement the gate interface are never skipped.
|
||||
func (a *App) evaluateGateLocked(f *Fiber) (gated bool, skip bool, err error) {
|
||||
gatedPlugin, ok := f.plugin.(ConfigGatedPlugin)
|
||||
if !ok {
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
view := a.ctx.Config()
|
||||
if !view.Resolved() {
|
||||
return true, false, fmt.Errorf(
|
||||
"core: plugin %q is configuration-gated but the App has no ConfigSource; "+
|
||||
"pass core.WithConfigSource or remove DeclareConfig", f.Name())
|
||||
}
|
||||
|
||||
return true, !gatedPlugin.ConfigEnabled(view), nil
|
||||
}
|
||||
|
||||
// ApplyPlugins applies all registered plugins on the application Context via reactive reconciliation.
|
||||
// It is idempotent and only applies plugins once per App instance.
|
||||
func (a *App) ApplyPlugins() error {
|
||||
@@ -264,11 +338,78 @@ func (a *App) ApplyPlugins() error {
|
||||
return nil
|
||||
}
|
||||
a.applied = true
|
||||
|
||||
declaredErr, prepareErr := a.applyErr, a.prepareLocked()
|
||||
a.mu.Unlock()
|
||||
|
||||
if declaredErr != nil {
|
||||
return declaredErr
|
||||
}
|
||||
if prepareErr != nil {
|
||||
return prepareErr
|
||||
}
|
||||
|
||||
return a.Reconcile()
|
||||
}
|
||||
|
||||
// Prepare resolves declared configuration and establishes the resolution barrier that
|
||||
// gates and plugin Bind calls depend on. It is idempotent and runs implicitly from
|
||||
// ApplyPlugins; callers that need resolved values earlier — for example to size a
|
||||
// shutdown budget — invoke it explicitly right after mounting plugins.
|
||||
func (a *App) Prepare() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if a.applyErr != nil {
|
||||
return a.applyErr
|
||||
}
|
||||
return a.prepareLocked()
|
||||
}
|
||||
|
||||
// prepareLocked installs the injected source, registers host declarations and resolves
|
||||
// every declared key once. An App without a ConfigSource leaves configuration unused,
|
||||
// so kernel-level usage stays opt-in for embedders that configure nothing.
|
||||
func (a *App) prepareLocked() error {
|
||||
if a.prepared {
|
||||
return nil
|
||||
}
|
||||
if a.configSource == nil {
|
||||
a.prepared = true
|
||||
return nil
|
||||
}
|
||||
|
||||
config := a.ctx.Config()
|
||||
config.SetSource(a.configSource)
|
||||
|
||||
if err := config.Declare(a.hostDeclOwner, a.hostDeclBindings...); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := config.Resolve(); err != nil {
|
||||
return err
|
||||
}
|
||||
a.prepared = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShutdownTimeout returns the graceful shutdown budget for the application.
|
||||
func (a *App) ShutdownTimeout() time.Duration {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.shutdownTimeout
|
||||
}
|
||||
|
||||
// SetShutdownTimeout replaces the graceful shutdown budget, ignoring non-positive
|
||||
// values so a missing configuration key can never shrink the kernel fallback to zero.
|
||||
func (a *App) SetShutdownTimeout(timeout time.Duration) *App {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if timeout > 0 {
|
||||
a.shutdownTimeout = timeout
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// RunMigrations dispatches migration execution across all registered plugin migration entries.
|
||||
func (a *App) RunMigrations() error {
|
||||
entries := a.ctx.Migrations().Entries()
|
||||
|
||||
@@ -504,3 +504,93 @@ func TestAppIdempotencyAndErrorStates(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "sql migrate error")
|
||||
assert.False(t, app3.IsRunning())
|
||||
}
|
||||
|
||||
// newGateSource builds a configuration source whose only key decides the test gates.
|
||||
func newGateSource(enabled bool) *mapSource {
|
||||
return &mapSource{
|
||||
values: map[string]any{"gate.enabled": enabled},
|
||||
env: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppPrepareResolvesThenGatesDuringReconcile(t *testing.T) {
|
||||
primary := &gatedPlugin{name: "cache", enabled: true}
|
||||
fallback := &gatedPlugin{name: "cache_memory", enabled: false}
|
||||
|
||||
app := core.NewApp(core.WithConfigSource(newGateSource(true)))
|
||||
app.Use(primary, fallback)
|
||||
require.NoError(t, app.Prepare())
|
||||
|
||||
cacheFiber, ok := app.Fiber("cache")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, core.FiberPending, cacheFiber.State(), "Prepare only builds the resolution barrier")
|
||||
assert.True(t, app.Context().Config().Resolved())
|
||||
|
||||
require.NoError(t, app.Reconcile())
|
||||
|
||||
assert.Equal(t, core.FiberActive, cacheFiber.State())
|
||||
|
||||
memoryFiber, ok := app.Fiber("cache_memory")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, memoryFiber.State())
|
||||
assert.False(t, fallback.applied, "the gated-out provider must never reach Apply")
|
||||
}
|
||||
|
||||
func TestAppGatesPluginsMountedAfterPrepare(t *testing.T) {
|
||||
app := core.NewApp(core.WithConfigSource(newGateSource(true)))
|
||||
require.NoError(t, app.Prepare())
|
||||
|
||||
late := &gatedPlugin{name: "cache_memory", enabled: false}
|
||||
app.Use(late)
|
||||
require.NoError(t, app.Reconcile())
|
||||
|
||||
fiber, ok := app.Fiber("cache_memory")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, fiber.State(),
|
||||
"plugins mounted after Prepare must still be gated")
|
||||
}
|
||||
|
||||
func TestAppApplyPluginsGatesImplicitly(t *testing.T) {
|
||||
app := core.NewApp(core.WithConfigSource(newGateSource(false)))
|
||||
app.Use(&gatedPlugin{name: "cache", enabled: true})
|
||||
|
||||
require.NoError(t, app.ApplyPlugins())
|
||||
|
||||
fiber, ok := app.Fiber("cache")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, fiber.State(),
|
||||
"ApplyPlugins must resolve and gate without an explicit Prepare call")
|
||||
}
|
||||
|
||||
func TestAppPrepareReportsConfigurationErrors(t *testing.T) {
|
||||
src := &mapSource{
|
||||
values: map[string]any{"gate.enabled": "yes"},
|
||||
env: map[string]string{},
|
||||
}
|
||||
app := core.NewApp(core.WithConfigSource(src))
|
||||
app.Use(&gatedPlugin{name: "cache", enabled: true})
|
||||
|
||||
err := app.Prepare()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "gate.enabled")
|
||||
}
|
||||
|
||||
func TestAppGatedPluginWithoutConfigSourceFailsFast(t *testing.T) {
|
||||
app := core.NewApp()
|
||||
app.Use(&gatedPlugin{name: "cache", enabled: true})
|
||||
|
||||
err := app.ApplyPlugins()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cache")
|
||||
assert.Contains(t, err.Error(), "ConfigSource")
|
||||
}
|
||||
|
||||
func TestAppSetShutdownTimeoutIgnoresNonPositive(t *testing.T) {
|
||||
app := core.NewApp()
|
||||
|
||||
app.SetShutdownTimeout(0)
|
||||
assert.Equal(t, 10*time.Second, app.ShutdownTimeout(), "zero must not shrink the kernel fallback")
|
||||
|
||||
app.SetShutdownTimeout(45 * time.Second)
|
||||
assert.Equal(t, 45*time.Second, app.ShutdownTimeout())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"Wavelet/core/extpoints"
|
||||
)
|
||||
|
||||
// ConfigGet reads one resolved configuration value with its declared type. It is the
|
||||
// generic counterpart of the fallback accessors on ConfigView, used when a caller must
|
||||
// distinguish "unset" from "set to the zero value".
|
||||
func ConfigGet[T any](view extpoints.ConfigView, key string) (T, error) {
|
||||
var zero T
|
||||
if view == nil {
|
||||
return zero, extpoints.ErrConfigNotResolved
|
||||
}
|
||||
|
||||
raw, ok := view.Value(key)
|
||||
if !ok {
|
||||
return zero, fmt.Errorf("%w: %s", extpoints.ErrConfigUnknownKey, key)
|
||||
}
|
||||
|
||||
value, ok := raw.(T)
|
||||
if !ok {
|
||||
return zero, fmt.Errorf("%w: key %q holds %T, want %T", extpoints.ErrConfigType, key, raw, zero)
|
||||
}
|
||||
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 "<map source>"
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/extpoints"
|
||||
)
|
||||
|
||||
// mapSource implements extpoints.ConfigSource over static maps.
|
||||
type mapSource struct {
|
||||
values map[string]any
|
||||
env map[string]string
|
||||
}
|
||||
|
||||
func (m *mapSource) Lookup(path string) (any, bool) {
|
||||
v, ok := m.values[path]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (m *mapSource) LookupEnv(name string) (string, bool) {
|
||||
v, ok := m.env[name]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (m *mapSource) Describe() string { return "map" }
|
||||
|
||||
type otelConfig struct {
|
||||
SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE"`
|
||||
}
|
||||
|
||||
// newOtelRegistry declares the otel section against a source carrying the given file values.
|
||||
func newOtelRegistry(t *testing.T, values map[string]any) extpoints.ConfigExtension {
|
||||
t.Helper()
|
||||
|
||||
r := extpoints.NewConfigRegistry(&mapSource{values: values, env: map[string]string{}})
|
||||
require.NoError(t, r.Declare("host", extpoints.ConfigBinding{Prefix: "otel", Target: &otelConfig{}}))
|
||||
require.NoError(t, r.Resolve())
|
||||
return r
|
||||
}
|
||||
|
||||
func TestConfigGetReturnsDeclaredType(t *testing.T) {
|
||||
view := newOtelRegistry(t, map[string]any{"otel.sampling_rate": 0.25})
|
||||
|
||||
rate, err := core.ConfigGet[float64](view, "otel.sampling_rate")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0.25, rate)
|
||||
}
|
||||
|
||||
func TestConfigGetRejectsTypeMismatch(t *testing.T) {
|
||||
view := newOtelRegistry(t, map[string]any{"otel.sampling_rate": 0.25})
|
||||
|
||||
text, err := core.ConfigGet[string](view, "otel.sampling_rate")
|
||||
require.ErrorIs(t, err, extpoints.ErrConfigType)
|
||||
assert.Empty(t, text)
|
||||
}
|
||||
|
||||
func TestConfigGetRejectsUndeclaredKey(t *testing.T) {
|
||||
view := newOtelRegistry(t, nil)
|
||||
|
||||
_, err := core.ConfigGet[float64](view, "otel.unregistered")
|
||||
require.ErrorIs(t, err, extpoints.ErrConfigUnknownKey)
|
||||
}
|
||||
|
||||
func TestConfigGetRejectsNilView(t *testing.T) {
|
||||
_, err := core.ConfigGet[float64](nil, "otel.sampling_rate")
|
||||
require.ErrorIs(t, err, extpoints.ErrConfigNotResolved)
|
||||
}
|
||||
|
||||
func TestContextConfigIsSharedAcrossForks(t *testing.T) {
|
||||
ctx := core.NewContext(nil)
|
||||
child := ctx.Fork()
|
||||
|
||||
require.NotNil(t, ctx.Config())
|
||||
assert.Same(t, ctx.Config(), child.Config(), "configuration declarations are process-wide facts")
|
||||
|
||||
require.NoError(t, child.Config().Declare("cache",
|
||||
extpoints.ConfigBinding{Prefix: "otel", Target: &otelConfig{}}))
|
||||
|
||||
declared := false
|
||||
for _, entry := range ctx.Config().Entries() {
|
||||
declared = declared || entry.Key == "otel.sampling_rate"
|
||||
}
|
||||
assert.True(t, declared, "a declaration made in a plugin scope must be visible to the root")
|
||||
assert.False(t, ctx.Config().Resolved())
|
||||
}
|
||||
@@ -27,6 +27,7 @@ type Context struct {
|
||||
tasks extpoints.TaskExtension
|
||||
schedules extpoints.ScheduleExtension
|
||||
settings extpoints.SettingExtension
|
||||
config extpoints.ConfigExtension
|
||||
|
||||
mu sync.RWMutex
|
||||
children []*Context
|
||||
@@ -56,6 +57,7 @@ func NewContext(base context.Context) *Context {
|
||||
tasks: extpoints.NewTaskRegistry(),
|
||||
schedules: extpoints.NewScheduleRegistry(),
|
||||
settings: extpoints.NewSettingRegistry(),
|
||||
config: extpoints.NewConfigRegistry(nil),
|
||||
values: make(map[any]any),
|
||||
}
|
||||
}
|
||||
@@ -167,6 +169,7 @@ func (c *Context) ForkWithContext(base context.Context) *Context {
|
||||
tasks: c.tasks,
|
||||
schedules: c.schedules,
|
||||
settings: c.settings,
|
||||
config: c.config,
|
||||
values: make(map[any]any),
|
||||
}
|
||||
|
||||
@@ -235,6 +238,13 @@ func (c *Context) Setting() extpoints.SettingExtension {
|
||||
return c.Settings()
|
||||
}
|
||||
|
||||
// Config returns the process-level configuration extension point. The registry is
|
||||
// shared by every fork because configuration declarations are global facts, and it
|
||||
// carries no per-scope disposers: values are resolved once before Apply runs.
|
||||
func (c *Context) Config() extpoints.ConfigExtension {
|
||||
return c.config
|
||||
}
|
||||
|
||||
// OnDispose registers a cleanup callback function to be executed when this Context is disposed.
|
||||
// It accepts func() error, func(), or Disposer.
|
||||
func (c *Context) OnDispose(fn any) {
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package extpoints
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by the configuration extension point.
|
||||
var (
|
||||
// ErrConfigConflict is returned when the same key is declared with disagreeing metadata.
|
||||
ErrConfigConflict = errors.New("extpoints: conflicting configuration declarations")
|
||||
|
||||
// ErrConfigType is returned when a value cannot be converted to the declared type.
|
||||
ErrConfigType = errors.New("extpoints: configuration value type mismatch")
|
||||
|
||||
// ErrConfigUnknownKey is returned when a configuration key was never declared.
|
||||
ErrConfigUnknownKey = errors.New("extpoints: unknown configuration key")
|
||||
|
||||
// ErrConfigNotResolved is returned when typed reads happen before resolution.
|
||||
ErrConfigNotResolved = errors.New("extpoints: configuration not resolved; run App.Prepare first")
|
||||
|
||||
// ErrConfigTarget is returned when a binding target is not an addressable struct pointer.
|
||||
ErrConfigTarget = errors.New("extpoints: configuration binding target must be a non-nil struct pointer")
|
||||
|
||||
// ErrConfigNoSource is returned when resolution is attempted without a registered source.
|
||||
ErrConfigNoSource = errors.New("extpoints: no configuration source registered")
|
||||
)
|
||||
|
||||
// Configuration origin labels reported by ConfigView.Origin and ConfigEntry.Origin.
|
||||
const (
|
||||
// OriginEnv marks a value that came from an environment variable.
|
||||
OriginEnv = "env"
|
||||
// OriginAutoEnable marks a boolean enabled by the presence of another environment variable.
|
||||
OriginAutoEnable = "auto-enable"
|
||||
// OriginFile marks a value that came from the configuration file.
|
||||
OriginFile = "file"
|
||||
// OriginDefault marks a value that came from a declaration default.
|
||||
OriginDefault = "default"
|
||||
)
|
||||
|
||||
// RedactedValue replaces the printed value of keys declared with secret:"true".
|
||||
const RedactedValue = "******"
|
||||
|
||||
// durationType distinguishes time.Duration from plain int64 during tag walking and decoding.
|
||||
var durationType = reflect.TypeFor[time.Duration]()
|
||||
|
||||
// ConfigRegistry must satisfy the full extension contract, so a missing accessor is a
|
||||
// compile error rather than a runtime surprise inside a plugin Apply.
|
||||
var _ ConfigExtension = (*ConfigRegistry)(nil)
|
||||
|
||||
// ConfigSource abstracts where raw configuration values come from, keeping the
|
||||
// micro-kernel free of concrete loaders such as viper.
|
||||
type ConfigSource interface {
|
||||
// Lookup returns the raw value stored at a dotted path in the configuration file.
|
||||
Lookup(path string) (any, bool)
|
||||
// LookupEnv returns the raw value of an environment variable.
|
||||
LookupEnv(name string) (string, bool)
|
||||
// Describe returns a human readable identity for the source, used in diagnostics.
|
||||
Describe() string
|
||||
}
|
||||
|
||||
// ConfigBinding declares that a plugin reads every `config` tagged field of Target
|
||||
// under a dotted configuration prefix.
|
||||
type ConfigBinding struct {
|
||||
// Prefix is the dotted configuration path, e.g. "redis". An empty prefix means
|
||||
// each field's `config` tag is already a full path.
|
||||
Prefix string
|
||||
// Target must be a non-nil pointer to a struct carrying `config` tags.
|
||||
Target any
|
||||
}
|
||||
|
||||
// configField is a single leaf discovered while walking a binding struct's tags.
|
||||
// key is the fully qualified dotted path used for resolution; path is the raw `config`
|
||||
// tag value used to locate the Go field again during Bind.
|
||||
type configField struct {
|
||||
key string
|
||||
path string
|
||||
env string
|
||||
autoEnable string
|
||||
def string
|
||||
secret bool
|
||||
typ reflect.Type
|
||||
}
|
||||
|
||||
// configDecl is the registered form of a configField, attributed to its declaring plugin.
|
||||
type configDecl struct {
|
||||
key string
|
||||
pluginID string
|
||||
env string
|
||||
autoEnable string
|
||||
def string
|
||||
secret bool
|
||||
typ reflect.Type
|
||||
}
|
||||
|
||||
// ConfigEntry is a redacted, self-describing view of one effective configuration key.
|
||||
type ConfigEntry struct {
|
||||
Key string
|
||||
PluginID string
|
||||
Env string
|
||||
Origin string
|
||||
Value string
|
||||
}
|
||||
|
||||
// ConfigView is the read-only surface over effective configuration values.
|
||||
// Keys are dotted paths such as "redis.enabled".
|
||||
type ConfigView interface {
|
||||
Value(key string) (any, bool)
|
||||
String(key, fallback string) string
|
||||
Bool(key string, fallback bool) bool
|
||||
Int(key string, fallback int) int
|
||||
Duration(key string, fallback time.Duration) time.Duration
|
||||
Strings(key string) []string
|
||||
WasSet(envName string) bool
|
||||
Origin(key string) string
|
||||
}
|
||||
|
||||
// ConfigExtension is the plugin-facing configuration extension point mounted on the
|
||||
// root Context and shared by every forked plugin scope.
|
||||
type ConfigExtension interface {
|
||||
ConfigView
|
||||
|
||||
// SetSource installs the raw value source after construction, letting the composition
|
||||
// root build the adapter once the kernel Context already exists.
|
||||
SetSource(src ConfigSource)
|
||||
// Declare registers plugin-owned configuration bindings before Apply runs.
|
||||
Declare(pluginID string, bindings ...ConfigBinding) error
|
||||
// Bind resolves and assigns the configuration values for a tagged struct.
|
||||
Bind(prefix string, target any) error
|
||||
// Resolve computes the effective value of every declared key once.
|
||||
Resolve() error
|
||||
// Resolved reports whether Resolve has already run.
|
||||
Resolved() bool
|
||||
// Entries returns the redacted effective configuration ordered by key.
|
||||
Entries() []ConfigEntry
|
||||
}
|
||||
|
||||
// ConfigRegistry implements ConfigExtension. Declarations are additive; values are
|
||||
// computed once by Resolve and reused by every later read.
|
||||
type ConfigRegistry struct {
|
||||
mu sync.RWMutex
|
||||
src ConfigSource
|
||||
decls map[string]*configDecl
|
||||
order []string
|
||||
values map[string]any
|
||||
origins map[string]string
|
||||
resolved bool
|
||||
}
|
||||
|
||||
// NewConfigRegistry creates an empty configuration registry. A nil src is allowed so
|
||||
// that the kernel can construct the registry before the composition root injects one.
|
||||
func NewConfigRegistry(src ConfigSource) *ConfigRegistry {
|
||||
return &ConfigRegistry{
|
||||
src: src,
|
||||
decls: make(map[string]*configDecl),
|
||||
values: make(map[string]any),
|
||||
origins: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// SetSource installs the raw value source. It is intended for the composition root,
|
||||
// which builds the adapter after the kernel Context already exists.
|
||||
func (r *ConfigRegistry) SetSource(src ConfigSource) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.src = src
|
||||
}
|
||||
|
||||
// Declare registers every `config` tagged leaf of each binding's target struct.
|
||||
// Repeated declarations of the same key are accepted only when their env, default,
|
||||
// auto-enable and secret metadata agree; disagreement is ErrConfigConflict.
|
||||
func (r *ConfigRegistry) Declare(pluginID string, bindings ...ConfigBinding) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
for _, b := range bindings {
|
||||
if err := r.declareBinding(pluginID, b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ConfigRegistry) declareBinding(pluginID string, b ConfigBinding) error {
|
||||
target, err := bindingStruct(b.Target, b.Prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fields, err := walkConfigFields(target.Type(), b.Prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range fields {
|
||||
if err := r.addDecl(pluginID, f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bindingStruct validates that a binding or bind target is a usable struct pointer.
|
||||
func bindingStruct(target any, prefix string) (reflect.Value, error) {
|
||||
rv := reflect.ValueOf(target)
|
||||
if !rv.IsValid() || rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct {
|
||||
return reflect.Value{}, fmt.Errorf("%w: prefix %q received %T", ErrConfigTarget, prefix, target)
|
||||
}
|
||||
return rv.Elem(), nil
|
||||
}
|
||||
|
||||
// walkConfigFields collects leaf configuration declarations from `config` tagged fields.
|
||||
// A field without a `config` tag is skipped, except for embedded structs which are
|
||||
// recursed into so their own tags resolve under the same prefix.
|
||||
func walkConfigFields(t reflect.Type, prefix string) ([]configField, error) {
|
||||
var out []configField
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
sf := t.Field(i)
|
||||
if sf.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
path := sf.Tag.Get("config")
|
||||
if path == "-" {
|
||||
continue
|
||||
}
|
||||
if path == "" {
|
||||
if sf.Type.Kind() == reflect.Struct && sf.Type != durationType {
|
||||
nested, err := walkConfigFields(sf.Type, prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, nested...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, configField{
|
||||
key: joinKey(prefix, path),
|
||||
path: path,
|
||||
env: sf.Tag.Get("env"),
|
||||
autoEnable: sf.Tag.Get("autoEnable"),
|
||||
def: sf.Tag.Get("default"),
|
||||
secret: strings.EqualFold(sf.Tag.Get("secret"), "true"),
|
||||
typ: sf.Type,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func joinKey(prefix, path string) string {
|
||||
if prefix == "" {
|
||||
return path
|
||||
}
|
||||
return prefix + "." + path
|
||||
}
|
||||
|
||||
// addDecl records one leaf, enforcing the shared-declaration consistency rule.
|
||||
func (r *ConfigRegistry) addDecl(pluginID string, f configField) error {
|
||||
if existing, ok := r.decls[f.key]; ok {
|
||||
if existing.env != f.env || existing.def != f.def ||
|
||||
existing.autoEnable != f.autoEnable || existing.secret != f.secret {
|
||||
return fmt.Errorf(
|
||||
"%w: key %q declared by plugin %q and plugin %q with disagreeing env/default/autoEnable/secret metadata",
|
||||
ErrConfigConflict, f.key, existing.pluginID, pluginID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
r.decls[f.key] = &configDecl{
|
||||
key: f.key, pluginID: pluginID, env: f.env,
|
||||
autoEnable: f.autoEnable, def: f.def, secret: f.secret, typ: f.typ,
|
||||
}
|
||||
r.order = append(r.order, f.key)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package extpoints
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Resolve computes the effective value of every declared key. Priority is, in order:
|
||||
// an explicit environment override, an auto-enable trigger, the configuration file,
|
||||
// then the declared default. Resolution is idempotent; later declarations resolve lazily.
|
||||
func (r *ConfigRegistry) Resolve() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.src == nil {
|
||||
return ErrConfigNoSource
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, key := range r.order {
|
||||
if _, done := r.values[key]; done {
|
||||
continue
|
||||
}
|
||||
if err := r.resolveLocked(key); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
r.resolved = true
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Resolved reports whether Resolve has already run.
|
||||
func (r *ConfigRegistry) Resolved() bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.resolved
|
||||
}
|
||||
|
||||
// resolveLocked computes the effective value of one key. The caller must hold r.mu.
|
||||
func (r *ConfigRegistry) resolveLocked(key string) error {
|
||||
d, ok := r.decls[key]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrConfigUnknownKey, key)
|
||||
}
|
||||
|
||||
if d.env != "" {
|
||||
if raw, found := r.src.LookupEnv(d.env); found {
|
||||
value, err := convertValue(raw, d.typ)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: key %q from environment %s: %w", ErrConfigType, key, d.env, err)
|
||||
}
|
||||
r.values[key], r.origins[key] = value, OriginEnv
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if d.autoEnable != "" && d.typ.Kind() == reflect.Bool {
|
||||
if _, found := r.src.LookupEnv(d.autoEnable); found {
|
||||
r.values[key], r.origins[key] = true, OriginAutoEnable
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if raw, found := r.src.Lookup(key); found {
|
||||
value, err := convertValue(raw, d.typ)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: key %q from %s: %w", ErrConfigType, key, r.src.Describe(), err)
|
||||
}
|
||||
r.values[key], r.origins[key] = value, OriginFile
|
||||
return nil
|
||||
}
|
||||
|
||||
if d.def != "" {
|
||||
value, err := convertValue(d.def, d.typ)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: default %q for key %q: %w", ErrConfigType, d.def, key, err)
|
||||
}
|
||||
r.values[key], r.origins[key] = value, OriginDefault
|
||||
return nil
|
||||
}
|
||||
|
||||
r.values[key] = reflect.New(d.typ).Elem().Interface()
|
||||
r.origins[key] = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bind resolves the tagged fields of target and assigns them in place. Prefixes that
|
||||
// were never declared self-register, so only gates need DeclareConfig.
|
||||
func (r *ConfigRegistry) Bind(prefix string, target any) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.src == nil {
|
||||
return ErrConfigNoSource
|
||||
}
|
||||
if !r.resolved {
|
||||
return fmt.Errorf("%w: Bind(%q, %T) ran before App.Prepare", ErrConfigNotResolved, prefix, target)
|
||||
}
|
||||
|
||||
elem, err := bindingStruct(target, prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields, err := walkConfigFields(elem.Type(), prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, f := range fields {
|
||||
if _, declared := r.decls[f.key]; !declared {
|
||||
if err := r.addDecl("bind:"+prefix, f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, done := r.values[f.key]; !done {
|
||||
if err := r.resolveLocked(f.key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return assignFields(elem, fields, r.values)
|
||||
}
|
||||
|
||||
// assignFields writes resolved values into a freshly walked target struct.
|
||||
func assignFields(elem reflect.Value, fields []configField, values map[string]any) error {
|
||||
for _, f := range fields {
|
||||
field := elem.FieldByName(fieldNameForPath(elem.Type(), f.path))
|
||||
if !field.IsValid() || !field.CanSet() {
|
||||
return fmt.Errorf("%w: field for key %q is not settable", ErrConfigTarget, f.key)
|
||||
}
|
||||
|
||||
value := reflect.ValueOf(values[f.key])
|
||||
if !value.Type().AssignableTo(field.Type()) {
|
||||
return fmt.Errorf("%w: key %q resolves to %s, field expects %s",
|
||||
ErrConfigType, f.key, value.Type(), field.Type())
|
||||
}
|
||||
field.Set(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Entries returns the effective configuration as redacted, key-sorted entries.
|
||||
func (r *ConfigRegistry) Entries() []ConfigEntry {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
keys := append([]string(nil), r.order...)
|
||||
sort.Strings(keys)
|
||||
|
||||
out := make([]ConfigEntry, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
d := r.decls[key]
|
||||
if _, done := r.values[key]; !done && r.src != nil {
|
||||
_ = r.resolveLocked(key)
|
||||
}
|
||||
out = append(out, ConfigEntry{
|
||||
Key: d.key,
|
||||
PluginID: d.pluginID,
|
||||
Env: d.env,
|
||||
Origin: r.origins[key],
|
||||
Value: formatEntryValue(r.values[key], d.secret),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formatEntryValue renders one effective value for diagnostics, masking secrets.
|
||||
func formatEntryValue(value any, secret bool) string {
|
||||
if secret {
|
||||
return RedactedValue
|
||||
}
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
|
||||
// Value returns the resolved value for key, lazily resolving a declared key that has
|
||||
// not been computed yet. Missing and unresolvable keys report false rather than an
|
||||
// error so gates and diagnostics can keep using the fallback accessors.
|
||||
func (r *ConfigRegistry) Value(key string) (any, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, done := r.values[key]; !done {
|
||||
if r.src == nil {
|
||||
return nil, false
|
||||
}
|
||||
if _, declared := r.decls[key]; !declared {
|
||||
return nil, false
|
||||
}
|
||||
if err := r.resolveLocked(key); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
value, ok := r.values[key]
|
||||
return value, ok
|
||||
}
|
||||
|
||||
// String returns the string value of key or fallback when absent or mismatched.
|
||||
func (r *ConfigRegistry) String(key, fallback string) string {
|
||||
if value, ok := r.Value(key); ok {
|
||||
if converted, err := convertString(value); err == nil {
|
||||
return converted.(string)
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Bool returns the boolean value of key or fallback when absent or mismatched.
|
||||
func (r *ConfigRegistry) Bool(key string, fallback bool) bool {
|
||||
if value, ok := r.Value(key); ok {
|
||||
if converted, err := convertBool(value); err == nil {
|
||||
return converted.(bool)
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Int returns the int value of key or fallback when absent or mismatched.
|
||||
func (r *ConfigRegistry) Int(key string, fallback int) int {
|
||||
if value, ok := r.Value(key); ok {
|
||||
converted, err := convertNumeric(value, reflect.TypeFor[int](), signedNumbers)
|
||||
if err == nil {
|
||||
return converted.(int)
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Duration returns the time.Duration value of key or fallback when absent or mismatched.
|
||||
func (r *ConfigRegistry) Duration(key string, fallback time.Duration) time.Duration {
|
||||
if value, ok := r.Value(key); ok {
|
||||
if converted, err := convertDuration(value); err == nil {
|
||||
return converted.(time.Duration)
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Strings returns the []string value of key, or nil when absent.
|
||||
func (r *ConfigRegistry) Strings(key string) []string {
|
||||
value, ok := r.Value(key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
converted, err := convertSlice(value, reflect.TypeFor[[]string]())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
list, _ := converted.([]string)
|
||||
return list
|
||||
}
|
||||
|
||||
// WasSet reports whether an environment variable is present, regardless of its value.
|
||||
func (r *ConfigRegistry) WasSet(envName string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
if r.src == nil {
|
||||
return false
|
||||
}
|
||||
_, found := r.src.LookupEnv(envName)
|
||||
return found
|
||||
}
|
||||
|
||||
// Origin reports where a key's effective value came from; "" means the zero value.
|
||||
func (r *ConfigRegistry) Origin(key string) string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.origins[key]
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package extpoints_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"Wavelet/core/extpoints"
|
||||
)
|
||||
|
||||
// fakeSource is an in-memory extpoints.ConfigSource used by configuration engine tests.
|
||||
type fakeSource struct {
|
||||
values map[string]any
|
||||
env map[string]string
|
||||
}
|
||||
|
||||
func newFakeSource() *fakeSource {
|
||||
return &fakeSource{values: map[string]any{}, env: map[string]string{}}
|
||||
}
|
||||
|
||||
func (f *fakeSource) Lookup(path string) (any, bool) {
|
||||
v, ok := f.values[path]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (f *fakeSource) LookupEnv(name string) (string, bool) {
|
||||
v, ok := f.env[name]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (f *fakeSource) Describe() string { return "fake" }
|
||||
|
||||
// redisConfig mirrors how a plugin declares the configuration it reads.
|
||||
type redisConfig struct {
|
||||
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
|
||||
Addrs []string `config:"addrs" env:"REDIS_ADDR"`
|
||||
DB int `config:"db" env:"REDIS_DB"`
|
||||
KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"`
|
||||
Dial time.Duration `config:"dial_timeout" env:"REDIS_DIAL_TIMEOUT"`
|
||||
Ignored string `config:"-"`
|
||||
private string
|
||||
}
|
||||
|
||||
func TestDeclareRegistersTaggedLeafKeys(t *testing.T) {
|
||||
r := extpoints.NewConfigRegistry(newFakeSource())
|
||||
|
||||
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
|
||||
|
||||
keys := make([]string, 0)
|
||||
for _, e := range r.Entries() {
|
||||
keys = append(keys, e.Key)
|
||||
}
|
||||
assert.Equal(t, []string{
|
||||
"redis.addrs", "redis.db", "redis.dial_timeout", "redis.enabled", "redis.key_prefix",
|
||||
}, keys)
|
||||
}
|
||||
|
||||
func TestDeclareRejectsNonStructPointerTarget(t *testing.T) {
|
||||
r := extpoints.NewConfigRegistry(newFakeSource())
|
||||
|
||||
assert.ErrorIs(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: redisConfig{}}),
|
||||
extpoints.ErrConfigTarget)
|
||||
assert.ErrorIs(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: (*redisConfig)(nil)}),
|
||||
extpoints.ErrConfigTarget)
|
||||
}
|
||||
|
||||
func TestDeclareAllowsIdenticalDuplicateAndRejectsConflictingMetadata(t *testing.T) {
|
||||
r := extpoints.NewConfigRegistry(newFakeSource())
|
||||
binding := extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}
|
||||
require.NoError(t, r.Declare("cache", binding))
|
||||
require.NoError(t, r.Declare("cache_memory", binding), "identical shared declarations must be allowed")
|
||||
|
||||
type conflictingConfig struct {
|
||||
Enabled bool `config:"enabled" env:"REDIS_ON" default:"true"`
|
||||
}
|
||||
err := r.Declare("driver_http", extpoints.ConfigBinding{Prefix: "redis", Target: &conflictingConfig{}})
|
||||
require.ErrorIs(t, err, extpoints.ErrConfigConflict)
|
||||
assert.Contains(t, err.Error(), "redis.enabled")
|
||||
assert.Contains(t, err.Error(), "cache")
|
||||
assert.Contains(t, err.Error(), "driver_http")
|
||||
}
|
||||
|
||||
// queueConfig is a composite element mirroring worker.queues in config.yaml.
|
||||
type queueConfig struct {
|
||||
Name string `config:"name"`
|
||||
Priority int `config:"priority"`
|
||||
}
|
||||
|
||||
type workerConfig struct {
|
||||
Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY"`
|
||||
Queues []queueConfig `config:"queues"`
|
||||
}
|
||||
|
||||
type sessionConfig struct {
|
||||
Secret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
|
||||
Age int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"`
|
||||
}
|
||||
|
||||
func TestResolveScalarDurationAndSlice(t *testing.T) {
|
||||
src := newFakeSource()
|
||||
src.values["redis.db"] = 1
|
||||
src.values["redis.dial_timeout"] = "5s"
|
||||
src.values["redis.addrs"] = []any{"127.0.0.1:6379"}
|
||||
src.env["REDIS_KEY_PREFIX"] = "refresh:"
|
||||
|
||||
r := extpoints.NewConfigRegistry(src)
|
||||
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
|
||||
require.NoError(t, r.Resolve())
|
||||
|
||||
var got redisConfig
|
||||
require.NoError(t, r.Bind("redis", &got))
|
||||
assert.Equal(t, redisConfig{
|
||||
Addrs: []string{"127.0.0.1:6379"}, DB: 1, KeyPrefix: "refresh:", Dial: 5 * time.Second,
|
||||
}, got)
|
||||
}
|
||||
|
||||
func TestResolveFillsSliceFromScalarEnvironmentValue(t *testing.T) {
|
||||
src := newFakeSource()
|
||||
src.env["REDIS_ADDR"] = "redis:6379"
|
||||
|
||||
r := extpoints.NewConfigRegistry(src)
|
||||
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
|
||||
require.NoError(t, r.Resolve())
|
||||
|
||||
var got redisConfig
|
||||
require.NoError(t, r.Bind("redis", &got))
|
||||
assert.Equal(t, []string{"redis:6379"}, got.Addrs)
|
||||
}
|
||||
|
||||
func TestResolveCompositeSliceOfStructs(t *testing.T) {
|
||||
src := newFakeSource()
|
||||
src.values["worker.concurrency"] = 20
|
||||
src.values["worker.queues"] = []any{
|
||||
map[string]any{"name": "webhook", "priority": 10},
|
||||
map[string]any{"name": "default", "priority": 3},
|
||||
}
|
||||
|
||||
r := extpoints.NewConfigRegistry(src)
|
||||
require.NoError(t, r.Declare("asynq_worker", extpoints.ConfigBinding{Prefix: "worker", Target: &workerConfig{}}))
|
||||
require.NoError(t, r.Resolve())
|
||||
|
||||
var got workerConfig
|
||||
require.NoError(t, r.Bind("worker", &got))
|
||||
assert.Equal(t, workerConfig{
|
||||
Concurrency: 20,
|
||||
Queues: []queueConfig{{Name: "webhook", Priority: 10}, {Name: "default", Priority: 3}},
|
||||
}, got)
|
||||
}
|
||||
|
||||
func TestResolveReportsTypeMismatchOnBadEnvironmentValue(t *testing.T) {
|
||||
src := newFakeSource()
|
||||
src.env["WORKER_CONCURRENCY"] = "many"
|
||||
|
||||
r := extpoints.NewConfigRegistry(src)
|
||||
require.NoError(t, r.Declare("asynq_worker", extpoints.ConfigBinding{Prefix: "worker", Target: &workerConfig{}}))
|
||||
|
||||
err := r.Resolve()
|
||||
require.ErrorIs(t, err, extpoints.ErrConfigType)
|
||||
assert.Contains(t, err.Error(), "worker.concurrency")
|
||||
assert.Contains(t, err.Error(), "WORKER_CONCURRENCY")
|
||||
}
|
||||
|
||||
func TestViewAccessorsAndOrigins(t *testing.T) {
|
||||
src := newFakeSource()
|
||||
src.values["redis.db"] = 1
|
||||
src.env["REDIS_ADDR"] = "redis:6379"
|
||||
src.env["REDIS_ENABLED"] = "false"
|
||||
|
||||
r := extpoints.NewConfigRegistry(src)
|
||||
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
|
||||
require.NoError(t, r.Declare("auth", extpoints.ConfigBinding{Prefix: "app", Target: &sessionConfig{}}))
|
||||
require.NoError(t, r.Resolve())
|
||||
|
||||
assert.Equal(t, extpoints.OriginEnv, r.Origin("redis.addrs"))
|
||||
assert.Equal(t, []string{"redis:6379"}, r.Strings("redis.addrs"))
|
||||
assert.False(t, r.Bool("redis.enabled", true))
|
||||
assert.Equal(t, 1, r.Int("redis.db", 0))
|
||||
assert.Equal(t, "86400", r.String("app.session_age", "0"))
|
||||
assert.Equal(t, "fallback", r.String("redis.missing", "fallback"))
|
||||
assert.Zero(t, r.Duration("redis.dial_timeout", 0))
|
||||
assert.True(t, r.WasSet("REDIS_ADDR"))
|
||||
assert.False(t, r.WasSet("REDIS_NOPE"))
|
||||
}
|
||||
|
||||
func TestAutoEnableBeatsFileValueButLosesToExplicitEnv(t *testing.T) {
|
||||
src := newFakeSource()
|
||||
src.env["REDIS_ADDR"] = "redis:6379"
|
||||
src.values["redis.enabled"] = false
|
||||
|
||||
r := extpoints.NewConfigRegistry(src)
|
||||
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
|
||||
require.NoError(t, r.Resolve())
|
||||
assert.True(t, r.Bool("redis.enabled", false), "REDIS_ADDR presence implies enabled")
|
||||
assert.Equal(t, extpoints.OriginAutoEnable, r.Origin("redis.enabled"))
|
||||
|
||||
explicit := newFakeSource()
|
||||
explicit.env["REDIS_ADDR"] = "redis:6379"
|
||||
explicit.env["REDIS_ENABLED"] = "false"
|
||||
|
||||
r2 := extpoints.NewConfigRegistry(explicit)
|
||||
require.NoError(t, r2.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
|
||||
require.NoError(t, r2.Resolve())
|
||||
assert.False(t, r2.Bool("redis.enabled", true), "explicit REDIS_ENABLED must win over auto-enable")
|
||||
assert.Equal(t, extpoints.OriginEnv, r2.Origin("redis.enabled"))
|
||||
}
|
||||
|
||||
func TestEntriesRedactSecretsAndReportDefaults(t *testing.T) {
|
||||
r := extpoints.NewConfigRegistry(newFakeSource())
|
||||
require.NoError(t, r.Declare("auth", extpoints.ConfigBinding{Prefix: "app", Target: &sessionConfig{}}))
|
||||
require.NoError(t, r.Resolve())
|
||||
|
||||
entries := map[string]extpoints.ConfigEntry{}
|
||||
for _, e := range r.Entries() {
|
||||
entries[e.Key] = e
|
||||
}
|
||||
|
||||
assert.Equal(t, extpoints.RedactedValue, entries["app.session_secret"].Value)
|
||||
assert.Equal(t, extpoints.OriginDefault, entries["app.session_age"].Origin)
|
||||
assert.Equal(t, "86400", entries["app.session_age"].Value)
|
||||
}
|
||||
|
||||
func TestBindRejectsReadsBeforeSourceIsRegistered(t *testing.T) {
|
||||
r := extpoints.NewConfigRegistry(nil)
|
||||
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
|
||||
|
||||
assert.ErrorIs(t, r.Resolve(), extpoints.ErrConfigNoSource)
|
||||
|
||||
var cfg redisConfig
|
||||
assert.ErrorIs(t, r.Bind("redis", &cfg), extpoints.ErrConfigNoSource)
|
||||
}
|
||||
|
||||
func TestBindRejectsReadsBeforeResolution(t *testing.T) {
|
||||
r := extpoints.NewConfigRegistry(newFakeSource())
|
||||
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
|
||||
|
||||
var cfg redisConfig
|
||||
err := r.Bind("redis", &cfg)
|
||||
require.ErrorIs(t, err, extpoints.ErrConfigNotResolved)
|
||||
assert.Contains(t, err.Error(), "App.Prepare")
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package extpoints
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// convertValue coerces a raw value coming from the configuration file or an
|
||||
// environment variable into the declared Go type.
|
||||
func convertValue(raw any, typ reflect.Type) (any, error) {
|
||||
if typ == durationType {
|
||||
return convertDuration(raw)
|
||||
}
|
||||
|
||||
switch typ.Kind() {
|
||||
case reflect.Bool:
|
||||
return convertBool(raw)
|
||||
case reflect.String:
|
||||
return convertString(raw)
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return convertNumeric(raw, typ, signedNumbers)
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return convertNumeric(raw, typ, unsignedNumbers)
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return convertNumeric(raw, typ, floatingNumbers)
|
||||
case reflect.Slice:
|
||||
return convertSlice(raw, typ)
|
||||
case reflect.Struct:
|
||||
return convertStruct(raw, typ)
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %s is not a supported configuration type", ErrConfigType, typ)
|
||||
}
|
||||
}
|
||||
|
||||
func convertBool(raw any) (any, error) {
|
||||
switch v := raw.(type) {
|
||||
case bool:
|
||||
return v, nil
|
||||
case string:
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %q is not a boolean", ErrConfigType, v)
|
||||
}
|
||||
return parsed, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %v is not a boolean", ErrConfigType, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func convertString(raw any) (any, error) {
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
return v, nil
|
||||
case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||
return fmt.Sprint(v), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %v is not a string", ErrConfigType, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// numericString extracts the textual form of a value so environment overrides,
|
||||
// which always arrive as strings, share one parsing path with file values.
|
||||
func numericString(raw any) (string, bool) {
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(v), true
|
||||
case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||
return fmt.Sprint(v), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// numericKind selects which strconv family converts a raw value.
|
||||
type numericKind int
|
||||
|
||||
const (
|
||||
signedNumbers numericKind = iota
|
||||
unsignedNumbers
|
||||
floatingNumbers
|
||||
)
|
||||
|
||||
// convertNumeric parses a raw value into the numeric type declared by typ. The three
|
||||
// numeric families share one implementation because they differ only in the strconv
|
||||
// call and the reflect setter.
|
||||
func convertNumeric(raw any, typ reflect.Type, family numericKind) (any, error) {
|
||||
text, ok := numericString(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %v is not a %s", ErrConfigType, raw, typ)
|
||||
}
|
||||
|
||||
out := reflect.New(typ).Elem()
|
||||
var err error
|
||||
|
||||
switch family {
|
||||
case signedNumbers:
|
||||
parsed, parseErr := strconv.ParseInt(text, 10, typ.Bits())
|
||||
out.SetInt(parsed)
|
||||
err = parseErr
|
||||
case unsignedNumbers:
|
||||
parsed, parseErr := strconv.ParseUint(text, 10, typ.Bits())
|
||||
out.SetUint(parsed)
|
||||
err = parseErr
|
||||
default:
|
||||
parsed, parseErr := strconv.ParseFloat(text, typ.Bits())
|
||||
out.SetFloat(parsed)
|
||||
err = parseErr
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %q is not a valid %s", ErrConfigType, text, typ)
|
||||
}
|
||||
return out.Interface(), nil
|
||||
}
|
||||
|
||||
// convertDuration accepts both Go duration strings such as "200ms" and integer
|
||||
// nanoseconds, mirroring what the previous viper based decoding supported.
|
||||
func convertDuration(raw any) (any, error) {
|
||||
if v, ok := raw.(time.Duration); ok {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
text, ok := numericString(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %v is not a duration", ErrConfigType, raw)
|
||||
}
|
||||
|
||||
if parsed, err := time.ParseDuration(text); err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
nanos, err := strconv.ParseInt(text, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %q is not a valid duration", ErrConfigType, text)
|
||||
}
|
||||
return time.Duration(nanos), nil
|
||||
}
|
||||
|
||||
// convertSlice promotes a scalar into a single-element slice so that a value such as
|
||||
// REDIS_ADDR=redis:6379 can populate the redis.addrs list.
|
||||
func convertSlice(raw any, typ reflect.Type) (any, error) {
|
||||
items, ok := sliceItems(raw)
|
||||
if !ok {
|
||||
items = []any{raw}
|
||||
}
|
||||
|
||||
out := reflect.MakeSlice(typ, 0, len(items))
|
||||
for _, item := range items {
|
||||
converted, err := convertValue(item, typ.Elem())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = reflect.Append(out, reflect.ValueOf(converted))
|
||||
}
|
||||
return out.Interface(), nil
|
||||
}
|
||||
|
||||
// sliceItems normalises the several slice shapes a loader may produce.
|
||||
func sliceItems(raw any) ([]any, bool) {
|
||||
switch v := raw.(type) {
|
||||
case []any:
|
||||
return v, true
|
||||
case []string:
|
||||
items := make([]any, len(v))
|
||||
for i, s := range v {
|
||||
items[i] = s
|
||||
}
|
||||
return items, true
|
||||
}
|
||||
|
||||
rv := reflect.ValueOf(raw)
|
||||
if rv.IsValid() && rv.Kind() == reflect.Slice {
|
||||
items := make([]any, rv.Len())
|
||||
for i := range items {
|
||||
items[i] = rv.Index(i).Interface()
|
||||
}
|
||||
return items, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func convertStruct(raw any, typ reflect.Type) (any, error) {
|
||||
table, ok := asStringMap(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %v is not a mapping, cannot decode into %s", ErrConfigType, raw, typ)
|
||||
}
|
||||
|
||||
fields, err := walkConfigFields(typ, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := reflect.New(typ).Elem()
|
||||
for _, f := range fields {
|
||||
item, present := table[f.path]
|
||||
if !present || item == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
converted, err := convertValue(item, f.typ)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s.%s: %w", ErrConfigType, typ.Name(), f.key, err)
|
||||
}
|
||||
out.FieldByName(fieldNameForPath(typ, f.path)).Set(reflect.ValueOf(converted))
|
||||
}
|
||||
return out.Interface(), nil
|
||||
}
|
||||
|
||||
// asStringMap normalises the two map shapes produced by YAML decoders.
|
||||
func asStringMap(raw any) (map[string]any, bool) {
|
||||
switch v := raw.(type) {
|
||||
case map[string]any:
|
||||
return v, true
|
||||
case map[any]any:
|
||||
out := make(map[string]any, len(v))
|
||||
for key, val := range v {
|
||||
name, ok := key.(string)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out[name] = val
|
||||
}
|
||||
return out, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// fieldNameForPath maps a declared config path back to the Go struct field carrying it.
|
||||
func fieldNameForPath(t reflect.Type, path string) string {
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
if t.Field(i).Tag.Get("config") == path {
|
||||
return t.Field(i).Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -27,6 +27,10 @@ const (
|
||||
|
||||
// FiberDisposed indicates the plugin has been completely unmounted and its context disposed.
|
||||
FiberDisposed FiberState = "DISPOSED"
|
||||
|
||||
// FiberSkipped indicates the plugin never activated because its configuration gate
|
||||
// evaluated to false, so an alternative provider took over.
|
||||
FiberSkipped FiberState = "SKIPPED"
|
||||
)
|
||||
|
||||
// Fiber wraps a Plugin instance with a dedicated scoped Context and manages its
|
||||
@@ -134,6 +138,26 @@ func (f *Fiber) Load() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip transitions a pending plugin to FiberSkipped and releases its scoped Context.
|
||||
// Active plugins are left untouched, which makes the call safe to replay on every
|
||||
// reconciliation pass, including for plugins mounted after the first gate evaluation.
|
||||
func (f *Fiber) Skip() error {
|
||||
f.mu.Lock()
|
||||
if f.state != FiberPending {
|
||||
f.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
f.state = FiberSkipped
|
||||
f.mu.Unlock()
|
||||
|
||||
return f.ctx.Dispose()
|
||||
}
|
||||
|
||||
// Skipped reports whether the plugin was excluded by its configuration gate.
|
||||
func (f *Fiber) Skipped() bool {
|
||||
return f.State() == FiberSkipped
|
||||
}
|
||||
|
||||
// Unload tears down the plugin: ACTIVE -> UNLOADING -> DISPOSED.
|
||||
func (f *Fiber) Unload() error {
|
||||
f.mu.Lock()
|
||||
|
||||
@@ -5,6 +5,7 @@ package core_test
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/extpoints"
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
@@ -108,3 +109,56 @@ func TestFiber_UnsatisfiedDependencyReturnsError(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsatisfied dependencies")
|
||||
}
|
||||
|
||||
// gateConfig is the configuration section consumed by gatedPlugin below.
|
||||
type gateConfig struct {
|
||||
Enabled bool `config:"enabled" env:"GATE_ENABLED"`
|
||||
}
|
||||
|
||||
// gatedPlugin implements core.ConfigGatedPlugin: two instances with opposite
|
||||
// expectations model the mutually exclusive cache / cache_memory driver pair.
|
||||
type gatedPlugin struct {
|
||||
name string
|
||||
enabled bool
|
||||
applied bool
|
||||
}
|
||||
|
||||
func (g *gatedPlugin) Name() string { return g.name }
|
||||
|
||||
func (g *gatedPlugin) Apply(_ *core.Context) error {
|
||||
g.applied = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *gatedPlugin) DeclareConfig() []extpoints.ConfigBinding {
|
||||
return []extpoints.ConfigBinding{{Prefix: "gate", Target: &gateConfig{}}}
|
||||
}
|
||||
|
||||
func (g *gatedPlugin) ConfigEnabled(view extpoints.ConfigView) bool {
|
||||
return view.Bool("gate.enabled", false) == g.enabled
|
||||
}
|
||||
|
||||
func TestFiberSkipMovesToSkippedStateAndDisposesScope(t *testing.T) {
|
||||
root := core.NewContext(nil)
|
||||
plugin := &gatedPlugin{name: "cache", enabled: true}
|
||||
f := core.NewFiber(root, plugin)
|
||||
require.Equal(t, core.FiberPending, f.State())
|
||||
|
||||
require.NoError(t, f.Skip())
|
||||
|
||||
assert.Equal(t, core.FiberSkipped, f.State())
|
||||
assert.True(t, f.Skipped())
|
||||
assert.False(t, plugin.applied, "a skipped plugin must never reach Apply")
|
||||
assert.NoError(t, f.Unload(), "unloading a skipped fiber is a no-op")
|
||||
}
|
||||
|
||||
func TestFiberSkipIsInertForActiveFibers(t *testing.T) {
|
||||
root := core.NewContext(nil)
|
||||
f := core.NewFiber(root, &gatedPlugin{name: "cache", enabled: true})
|
||||
require.NoError(t, f.Load())
|
||||
|
||||
require.NoError(t, f.Skip())
|
||||
|
||||
assert.Equal(t, core.FiberActive, f.State(), "Skip only applies to pending fibers")
|
||||
assert.False(t, f.Skipped())
|
||||
}
|
||||
|
||||
@@ -154,3 +154,32 @@ type SettingExtension = extpoints.SettingExtension
|
||||
|
||||
// SettingSchema re-exports extpoints.SettingSchema.
|
||||
type SettingSchema = extpoints.SettingSchema
|
||||
|
||||
// ConfigExtension re-exports extpoints.ConfigExtension.
|
||||
type ConfigExtension = extpoints.ConfigExtension
|
||||
|
||||
// ConfigSource re-exports extpoints.ConfigSource.
|
||||
type ConfigSource = extpoints.ConfigSource
|
||||
|
||||
// ConfigBinding re-exports extpoints.ConfigBinding.
|
||||
type ConfigBinding = extpoints.ConfigBinding
|
||||
|
||||
// ConfigView re-exports extpoints.ConfigView.
|
||||
type ConfigView = extpoints.ConfigView
|
||||
|
||||
// ConfigEntry re-exports extpoints.ConfigEntry.
|
||||
type ConfigEntry = extpoints.ConfigEntry
|
||||
|
||||
// ConfigGatedPlugin is an optional interface for plugins whose activation depends on
|
||||
// configuration. The kernel evaluates the gate during reconciliation, before the
|
||||
// plugin's Apply runs, so every key read by ConfigEnabled must be published through
|
||||
// DeclareConfig.
|
||||
type ConfigGatedPlugin interface {
|
||||
Plugin
|
||||
|
||||
// DeclareConfig publishes the configuration bindings consumed by ConfigEnabled.
|
||||
DeclareConfig() []extpoints.ConfigBinding
|
||||
|
||||
// ConfigEnabled reports whether this plugin should activate for the resolved values.
|
||||
ConfigEnabled(view extpoints.ConfigView) bool
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -1,255 +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
|
||||
}
|
||||
|
||||
func init() {
|
||||
// 加载配置文件路径
|
||||
configPath := os.Getenv("CONFIG_PATH")
|
||||
if configPath == "" {
|
||||
configPath = findConfigPath("config.yaml")
|
||||
}
|
||||
|
||||
// 设置配置文件
|
||||
viper.SetConfigFile(configPath)
|
||||
viper.AutomaticEnv()
|
||||
|
||||
// 读取配置文件(可选:找不到文件时使用空默认值 + 环境变量)
|
||||
if err := viper.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")
|
||||
viper.SetConfigType("yaml")
|
||||
if err := viper.ReadConfig(strings.NewReader("")); err != nil {
|
||||
log.Fatalf("[Config] failed to init empty config: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 解析配置到结构体
|
||||
var c configModel
|
||||
if err := viper.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 isTest() {
|
||||
c.Database.Enabled = false
|
||||
c.Database.SQLitePath = ":memory:"
|
||||
c.Redis.Enabled = false
|
||||
c.ClickHouse.Enabled = false
|
||||
}
|
||||
|
||||
// 设置全局配置
|
||||
Config = &c
|
||||
|
||||
// 打印配置
|
||||
printConfig(&c)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
|
||||
@@ -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{}))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()}))
|
||||
|
||||
+25
@@ -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"`
|
||||
}
|
||||
+18
-1
@@ -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
|
||||
}
|
||||
|
||||
+4
-2
@@ -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)
|
||||
|
||||
Vendored
+26
-7
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package config adapts viper to the kernel configuration source contract. It is a
|
||||
// runtime adapter rather than a core.Plugin: it owns no routes, services or tasks and
|
||||
// therefore never appears in app.Use. Keeping viper here preserves the micro-kernel
|
||||
// rule against importing concrete runtime dependencies.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// DefaultFileName is the configuration file looked up when CONFIG_PATH is unset.
|
||||
const DefaultFileName = "config.yaml"
|
||||
|
||||
// EnvOnlyOrigin is reported by Describe when no configuration file was loaded.
|
||||
const EnvOnlyOrigin = "<env only>"
|
||||
|
||||
// maxSearchDepth bounds the upward directory walk so a misconfigured working directory
|
||||
// cannot make the loader scan the whole filesystem.
|
||||
const maxSearchDepth = 5
|
||||
|
||||
// Option configures a Source.
|
||||
type Option func(*Source)
|
||||
|
||||
// WithPath pins the configuration file, bypassing CONFIG_PATH and the upward search.
|
||||
func WithPath(path string) Option {
|
||||
return func(s *Source) {
|
||||
s.path = path
|
||||
}
|
||||
}
|
||||
|
||||
// Source implements core.ConfigSource over a configuration file plus the process environment.
|
||||
type Source struct {
|
||||
v *viper.Viper
|
||||
path string
|
||||
found bool
|
||||
}
|
||||
|
||||
// NewSource loads the configuration file. A missing file is not an error: the source
|
||||
// then serves environment values only, matching the behaviour the previous pkg/config
|
||||
// loader had for deployments that configure everything through the environment.
|
||||
func NewSource(opts ...Option) (*Source, error) {
|
||||
s := &Source{}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
|
||||
if s.path == "" {
|
||||
s.path = os.Getenv("CONFIG_PATH")
|
||||
}
|
||||
if s.path == "" {
|
||||
s.path = findConfigPath(DefaultFileName)
|
||||
}
|
||||
|
||||
v := viper.New()
|
||||
v.SetConfigFile(s.path)
|
||||
|
||||
err := v.ReadInConfig()
|
||||
switch {
|
||||
case err == nil:
|
||||
s.found = true
|
||||
case isNotFound(err):
|
||||
// No file: fall through to environment-only lookups.
|
||||
default:
|
||||
if _, statErr := os.Stat(s.path); statErr == nil { //nolint:gosec // s.path comes from CONFIG_PATH or a bounded upward search
|
||||
return nil, fmt.Errorf("infra/config: read %s: %w", s.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
s.v = v
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// isNotFound reports whether the loader failed only because the file is absent.
|
||||
func isNotFound(err error) bool {
|
||||
var notFound viper.ConfigFileNotFoundError
|
||||
return errors.As(err, ¬Found) || errors.Is(err, fs.ErrNotExist)
|
||||
}
|
||||
|
||||
// Lookup returns the raw value stored at a dotted path, or false when the file was not
|
||||
// loaded or the path is absent. Declared defaults therefore stay distinguishable from
|
||||
// values explicitly set to a zero.
|
||||
func (s *Source) Lookup(path string) (any, bool) {
|
||||
if !s.found || !s.v.IsSet(path) {
|
||||
return nil, false
|
||||
}
|
||||
return s.v.Get(path), true
|
||||
}
|
||||
|
||||
// LookupEnv reads a process environment variable.
|
||||
func (s *Source) LookupEnv(name string) (string, bool) {
|
||||
return os.LookupEnv(name)
|
||||
}
|
||||
|
||||
// Describe returns the loaded file path, or EnvOnlyOrigin when running on environment values.
|
||||
func (s *Source) Describe() string {
|
||||
if !s.found {
|
||||
return EnvOnlyOrigin
|
||||
}
|
||||
return s.path
|
||||
}
|
||||
|
||||
// findConfigPath searches upward from the working directory so tests and binaries run
|
||||
// from backend/ still find the repository-root configuration file.
|
||||
func findConfigPath(configPath string) string {
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
return configPath
|
||||
}
|
||||
|
||||
dir := "."
|
||||
for range maxSearchDepth {
|
||||
dir += "/.."
|
||||
path := dir + "/" + configPath
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return configPath
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"Wavelet/plugins/infra/config"
|
||||
)
|
||||
|
||||
const sampleYAML = "" +
|
||||
"app:\n addr: \":8000\"\n node_id: 1\n" +
|
||||
"database:\n enabled: false\n port: 5432\n slow_threshold: 200ms\n" +
|
||||
"redis:\n addrs:\n - \"127.0.0.1:6379\"\n"
|
||||
|
||||
func writeConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(body), 0o600))
|
||||
return path
|
||||
}
|
||||
|
||||
func TestSourceLooksUpNestedPaths(t *testing.T) {
|
||||
src, err := config.NewSource(config.WithPath(writeConfig(t, sampleYAML)))
|
||||
require.NoError(t, err)
|
||||
|
||||
value, ok := src.Lookup("database.port")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 5432, value)
|
||||
|
||||
_, ok = src.Lookup("database.missing")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestSourceKeepsZeroValuedKeysDistinctFromMissing(t *testing.T) {
|
||||
src, err := config.NewSource(config.WithPath(writeConfig(t, sampleYAML)))
|
||||
require.NoError(t, err)
|
||||
|
||||
value, ok := src.Lookup("database.enabled")
|
||||
require.True(t, ok, "an explicitly set false must not look like a missing key")
|
||||
assert.Equal(t, false, value)
|
||||
}
|
||||
|
||||
func TestSourceTreatsUnsetFileAsEnvOnly(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "absent.yaml")
|
||||
|
||||
src, err := config.NewSource(config.WithPath(missing))
|
||||
require.NoError(t, err, "a missing configuration file must fall back to environment values")
|
||||
|
||||
_, ok := src.Lookup("app.addr")
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, config.EnvOnlyOrigin, src.Describe())
|
||||
}
|
||||
|
||||
func TestSourceRejectsMalformedFile(t *testing.T) {
|
||||
src, err := config.NewSource(config.WithPath(writeConfig(t, "app: [unclosed\n")))
|
||||
|
||||
assert.Nil(t, src)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "infra/config")
|
||||
}
|
||||
|
||||
func TestSourceLookupEnvReadsProcessEnvironment(t *testing.T) {
|
||||
t.Setenv("WAVELET_SOURCE_PROBE", "present")
|
||||
|
||||
src, err := config.NewSource(config.WithPath(writeConfig(t, sampleYAML)))
|
||||
require.NoError(t, err)
|
||||
|
||||
value, ok := src.LookupEnv("WAVELET_SOURCE_PROBE")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "present", value)
|
||||
|
||||
_, ok = src.LookupEnv("WAVELET_SOURCE_ABSENT")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestSourcePrefersConfigPathEnvironmentVariable(t *testing.T) {
|
||||
t.Setenv("CONFIG_PATH", writeConfig(t, "app:\n addr: \":9100\"\n"))
|
||||
|
||||
src, err := config.NewSource()
|
||||
require.NoError(t, err)
|
||||
|
||||
value, ok := src.Lookup("app.addr")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, ":9100", value)
|
||||
}
|
||||
@@ -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, ","),
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("<prefix>", &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` 条件分支,全面实现架构的时空可组合性与高内聚。
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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`、移除对拍夹具)由后续计划承接。
|
||||
|
||||
@@ -53,12 +53,12 @@ echo -e "${BOLD}═════════════════════
|
||||
# ==============================================================================
|
||||
log_check "1. 检查微内核 (backend/core/) 纯洁度..."
|
||||
|
||||
# 1.1 禁止直接依赖重型 Web/ORM/Worker 框架
|
||||
CORE_FRAMEWORK_IMPORTS=$(rg -n '"github.com/gin-gonic/gin"|"gorm.io/gorm"|"github.com/hibiken/asynq"|"github.com/robfig/cron' \
|
||||
# 1.1 禁止直接依赖重型 Web/ORM/Worker/Config 框架
|
||||
CORE_FRAMEWORK_IMPORTS=$(rg -n '"github.com/gin-gonic/gin"|"gorm.io/gorm"|"github.com/hibiken/asynq"|"github.com/robfig/cron|"github.com/spf13/viper"|"github.com/mitchellh/mapstructure"' \
|
||||
"${BACKEND_DIR}/core/" --glob '*.go' -g '!*contracts*' -g '!*_test.go' || true)
|
||||
|
||||
if [ -n "${CORE_FRAMEWORK_IMPORTS}" ]; then
|
||||
log_fail "backend/core/ 严禁导入具体 Web/ORM/Worker 运行时框架 (gin, gorm, asynq, cron):"
|
||||
log_fail "backend/core/ 严禁导入具体 Web/ORM/Worker/Config 运行时框架 (gin, gorm, asynq, cron, viper, mapstructure):"
|
||||
echo "${CORE_FRAMEWORK_IMPORTS}" >&2
|
||||
else
|
||||
log_pass "backend/core/ 无重型框架依赖"
|
||||
|
||||
Reference in New Issue
Block a user