feat(core): implement cordis configuration extension and migrate all plugins

This commit is contained in:
ryan
2026-08-29 10:53:53 +08:00
parent b43c429544
commit ed8491addf
74 changed files with 1354 additions and 1145 deletions
+15 -5
View File
@@ -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
}
```
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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)
},
}
+54 -25
View File
@@ -6,7 +6,6 @@ package cmd
import (
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/pkg/config"
"Wavelet/plugins/domain/admin"
"Wavelet/plugins/domain/auth"
"Wavelet/plugins/domain/cap"
@@ -22,6 +21,7 @@ import (
"Wavelet/plugins/drivers/driver_inproc_worker"
"Wavelet/plugins/infra/cache"
"Wavelet/plugins/infra/cache_memory"
"Wavelet/plugins/infra/config"
"Wavelet/plugins/infra/logger"
"Wavelet/plugins/infra/storage"
"context"
@@ -38,14 +38,48 @@ import (
infradb "Wavelet/plugins/infra/database"
)
const (
defaultShutdownTimeout = 15 * time.Second
defaultHTTPAddr = "127.0.0.1:3000"
)
// runProfileApp prepares and runs the application for a given profile.
func runProfileApp(profile core.Profile, mode string, listensForHTTP bool) {
app := newWaveletApp(profile)
if err := app.Prepare(); err != nil {
log.Fatalf("[%s] prepare failed: %v\n", mode, err)
}
state := startupState{
mode: mode,
listensForHTTP: listensForHTTP,
env: app.Context().Config().String("app.env", "production"),
}
if listensForHTTP {
state.addr = app.Context().Config().String("app.addr", defaultHTTPAddr)
}
printStartupBanner(state)
if err := app.Run(); err != nil {
log.Fatalf("[%s] run failed: %v\n", mode, err)
}
}
// newWaveletApp creates a core.App wired with Wavelet platform infrastructure, domain plugins, and profile drivers.
//
//nolint:contextcheck
func newWaveletApp(profile core.Profile) *core.App {
app := core.NewApp(
func newWaveletApp(profile core.Profile, opts ...core.AppOption) *core.App {
src, err := config.NewSource()
if err != nil {
log.Fatalf("[App] load config source failed: %v\n", err)
}
appOpts := []core.AppOption{
core.WithProfile(profile),
core.WithShutdownTimeout(time.Duration(config.Config.App.GracefulShutdownTimeout)*time.Second),
)
core.WithConfigSource(src),
core.WithShutdownTimeout(defaultShutdownTimeout),
}
appOpts = append(appOpts, opts...)
app := core.NewApp(appOpts...)
// 1. Register standard infrastructure plugins
app.Use(
@@ -54,20 +88,15 @@ func newWaveletApp(profile core.Profile) *core.App {
storage.New(),
)
// 2. Register Cache and Async/Cron Drivers based on Redis configuration
if config.Config.Redis.Enabled {
app.Use(
cache.New(),
driver_asynq_worker.New(),
driver_asynq_cron.New(),
)
} else {
app.Use(
cache_memory.New(),
driver_inproc_worker.New(),
driver_inproc_cron.New(),
)
}
// 2. Register Cache and Async/Cron Drivers (both gated: cache vs cache_memory, asynq vs inproc)
app.Use(
cache.New(),
cache_memory.New(),
driver_asynq_worker.New(),
driver_inproc_worker.New(),
driver_asynq_cron.New(),
driver_inproc_cron.New(),
)
// 3. Register all 8 domain business plugins (admin first to ensure schema and base config tables exist)
app.Use(
@@ -86,7 +115,7 @@ func newWaveletApp(profile core.Profile) *core.App {
// 5. Mount HTTP runtime driver
app.Use(
driver_http.New(driver_http.WithAddr(config.Config.App.Addr)),
driver_http.New(),
)
return app
@@ -236,7 +265,7 @@ func (e *gooseEngine) Migrate(ctx *core.Context, entries []core.MigrationEntry)
return fmt.Errorf("migration: get underlying DB from GORM: %w", err)
}
dialect := gooseDialect()
dialect := gooseDialect(ctx)
dialectStr := string(dialect)
for _, entry := range entries {
@@ -267,11 +296,11 @@ func (e *gooseEngine) Migrate(ctx *core.Context, entries []core.MigrationEntry)
}
// gooseDialect returns the goose dialect based on the configured database engine.
func gooseDialect() goose.Dialect {
if !config.Config.Database.Enabled {
return goose.DialectSQLite3
func gooseDialect(ctx *core.Context) goose.Dialect {
if ctx != nil && ctx.Config() != nil && ctx.Config().Bool("database.enabled", false) {
return goose.DialectPostgres
}
return goose.DialectPostgres
return goose.DialectSQLite3
}
func findMigrationFS(rootFS fs.FS, dialect goose.Dialect) fs.FS {
+57 -18
View File
@@ -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 -7
View File
@@ -1,23 +1,20 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package cmd provides CLI command entry points.
//
//nolint:unused
package cmd
import (
"Wavelet/pkg/buildinfo"
"Wavelet/pkg/config"
"fmt"
"runtime"
"strings"
)
//nolint:unused // startup banner formatting utilities
type startupState struct {
mode string
listensForHTTP bool
env string
addr string
}
func printStartupBanner(state startupState) {
@@ -25,6 +22,15 @@ func printStartupBanner(state startupState) {
}
func formatStartupBanner(state startupState) string {
env := state.env
if env == "" {
env = "production"
}
addr := state.addr
if addr == "" {
addr = "127.0.0.1:3000"
}
lines := []string{
"",
"__ __ _ _ ",
@@ -34,12 +40,12 @@ func formatStartupBanner(state startupState) string {
" \\_/\\_/ \\__,_| \\_/ \\___|_|\\___|\\__|",
fmt.Sprintf(" Wavelet %s", buildinfo.Version),
"",
fmt.Sprintf(" Environment: %s", config.Config.App.Env),
fmt.Sprintf(" Environment: %s", env),
fmt.Sprintf(" Runtime: %s/%s (%s)", runtime.GOOS, runtime.GOARCH, runtime.Version()),
fmt.Sprintf(" Build time: %s", buildTime()),
}
if state.listensForHTTP {
lines = append(lines, fmt.Sprintf(" Listening: http://%s", config.Config.App.Addr))
lines = append(lines, fmt.Sprintf(" Listening: http://%s", addr))
}
lines = append(lines, fmt.Sprintf(" Mode: %s", state.mode), "")
return strings.Join(lines, "\n")
+2 -7
View File
@@ -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{
+78 -37
View File
@@ -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
View File
@@ -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) {
+1 -9
View File
@@ -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)
},
}
+1 -9
View File
@@ -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)
},
}
+69
View File
@@ -5,6 +5,7 @@ package core
import (
"fmt"
"strings"
"Wavelet/core/extpoints"
)
@@ -29,3 +30,71 @@ func ConfigGet[T any](view extpoints.ConfigView, key string) (T, error) {
}
return value, nil
}
// MapSource implements ConfigSource backed by an in-memory map, ideal for unit tests.
type MapSource struct {
values map[string]any
env map[string]string
}
// NewMapSource creates a new MapSource with the provided key-value mappings.
func NewMapSource(values map[string]any) *MapSource {
vals := make(map[string]any, len(values))
for k, v := range values {
vals[k] = v
}
return &MapSource{
values: vals,
env: make(map[string]string),
}
}
// Lookup returns the value at the given path, supporting both flat keys and nested maps.
func (m *MapSource) Lookup(path string) (any, bool) {
if m == nil || m.values == nil {
return nil, false
}
if v, ok := m.values[path]; ok {
return v, true
}
parts := strings.Split(path, ".")
var cur any = m.values
for _, part := range parts {
mCur, ok := cur.(map[string]any)
if !ok {
return nil, false
}
cur, ok = mCur[part]
if !ok {
return nil, false
}
}
return cur, true
}
// LookupEnv returns the environment variable value.
func (m *MapSource) LookupEnv(name string) (string, bool) {
if m == nil || m.env == nil {
return "", false
}
v, ok := m.env[name]
return v, ok
}
// SetEnv sets an environment variable for testing.
func (m *MapSource) SetEnv(name, value string) {
if m.env == nil {
m.env = make(map[string]string)
}
m.env[name] = value
}
// Describe describes the MapSource.
func (m *MapSource) Describe() string {
return "<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))
}
+1 -1
View File
@@ -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(),
)
-259
View File
@@ -1,259 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package config 负责应用配置的加载、解析与环境变量覆盖。
package config
import (
"encoding/json"
"errors"
"flag"
"log"
"os"
"strconv"
"strings"
"github.com/spf13/viper"
)
// 默认队列优先级
const (
webhookQueuePriority = 10
whitelistQueuePriority = 5
defaultQueuePriority = 3
)
// Config 全局配置单例,初始化后不可变
var Config *configModel
// findConfigPath searches upward for the config file to handle tests running in subdirectories.
func findConfigPath(configPath string) string {
if _, err := os.Stat(configPath); err == nil {
return configPath
}
dir := "."
for i := 0; i < 5; i++ {
dir += "/.."
path := dir + "/" + configPath
if _, err := os.Stat(path); err == nil {
return path
}
}
return configPath
}
// isTest checks if the current execution context is within 'go test'.
func isTest() bool {
if flag.Lookup("test.v") != nil {
return true
}
for _, arg := range os.Args {
if strings.HasPrefix(arg, "-test.") || strings.HasSuffix(arg, ".test") {
return true
}
}
return false
}
// load reads configuration from configPath, applies defaults and environment overrides,
// and optionally disables external services for in-test runs. It uses a private viper
// instance so a caller can resolve the same inputs repeatedly, which the parity test
// against the kernel configuration engine requires.
func load(configPath string, testMode bool) *configModel {
v := viper.New()
v.SetConfigFile(configPath)
if err := v.ReadInConfig(); err != nil {
var notFound viper.ConfigFileNotFoundError
if !errors.As(err, &notFound) {
// 文件存在但读取/解析失败
if _, statErr := os.Stat(configPath); statErr == nil { //nolint:gosec // configPath is loaded from CONFIG_PATH environment variable
log.Fatalf("[Config] read config failed: %v\n", err)
}
}
log.Println("[Config] no config file found, using environment variables only")
v.SetConfigType("yaml")
if err := v.ReadConfig(strings.NewReader("")); err != nil {
log.Fatalf("[Config] failed to init empty config: %v\n", err)
}
}
// 解析配置到结构体
var c configModel
if err := v.Unmarshal(&c); err != nil {
log.Fatalf("[Config] parse config failed: %v\n", err)
}
applyDefaults(&c)
// 环境变量覆盖(优先级高于 config.yaml)
applyEnvOverrides(&c)
applyDefaults(&c)
// Disable standard DB/Redis initializations during tests to prevent connection attempts.
if testMode {
c.Database.Enabled = false
c.Database.SQLitePath = ":memory:"
c.Redis.Enabled = false
c.ClickHouse.Enabled = false
}
return &c
}
func init() {
// 加载配置文件路径
configPath := os.Getenv("CONFIG_PATH")
if configPath == "" {
configPath = findConfigPath("config.yaml")
}
// 设置全局配置并打印
Config = load(configPath, isTest())
printConfig(Config)
}
func applyDefaults(c *configModel) {
if c.App.SessionAge <= 0 {
c.App.SessionAge = 86400
}
if c.Otel.TracerName == "" {
c.Otel.TracerName = "github.com/Rain-kl/Wavelet"
}
}
// ─── 环境变量覆盖层 ────────────────────────────────────────────────────────────
// 环境变量优先级高于 config.yaml,未设置则保留 yaml 中的值。
func envStr(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return fallback
}
func envInt(key string, fallback int) int {
if v, ok := os.LookupEnv(key); ok {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
func envInt64(key string, fallback int64) int64 {
if v, ok := os.LookupEnv(key); ok {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
return n
}
}
return fallback
}
func envFloat64(key string, fallback float64) float64 {
if v, ok := os.LookupEnv(key); ok {
if n, err := strconv.ParseFloat(v, 64); err == nil {
return n
}
}
return fallback
}
func envBool(key string, fallback bool) bool {
if v, ok := os.LookupEnv(key); ok {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return fallback
}
// applyEnvOverrides 将环境变量值覆盖到配置结构体上(仅当环境变量已设置时生效)
func applyEnvOverrides(c *configModel) {
// ─── App ───
c.App.AppName = envStr("APP_NAME", c.App.AppName)
c.App.Env = envStr("APP_ENV", c.App.Env)
c.App.Addr = envStr("APP_ADDR", c.App.Addr)
c.App.NodeID = envInt64("APP_NODE_ID", c.App.NodeID)
c.App.APIPrefix = envStr("APP_API_PREFIX", c.App.APIPrefix)
c.App.GracefulShutdownTimeout = envInt("APP_GRACEFUL_SHUTDOWN_TIMEOUT", c.App.GracefulShutdownTimeout)
c.App.SessionCookieName = envStr("APP_SESSION_COOKIE_NAME", c.App.SessionCookieName)
c.App.SessionSecret = envStr("APP_SESSION_SECRET", c.App.SessionSecret)
c.App.SessionDomain = envStr("APP_SESSION_DOMAIN", c.App.SessionDomain)
c.App.SessionAge = envInt("APP_SESSION_AGE", c.App.SessionAge)
c.App.SessionHTTPOnly = envBool("APP_SESSION_HTTP_ONLY", c.App.SessionHTTPOnly)
c.App.SessionSecure = envBool("APP_SESSION_SECURE", c.App.SessionSecure)
// ─── Database ───
c.Database.Host = envStr("DB_HOST", c.Database.Host)
c.Database.Port = envInt("DB_PORT", c.Database.Port)
c.Database.Username = envStr("DB_USERNAME", c.Database.Username)
c.Database.Password = envStr("DB_PASSWORD", c.Database.Password)
c.Database.Database = envStr("DB_NAME", c.Database.Database)
c.Database.SSLMode = envStr("DB_SSL_MODE", c.Database.SSLMode)
c.Database.TimeZone = envStr("DB_TIMEZONE", c.Database.TimeZone)
c.Database.LogLevel = envStr("DB_LOG_LEVEL", c.Database.LogLevel)
c.Database.MaxIdleConn = envInt("DB_MAX_IDLE_CONN", c.Database.MaxIdleConn)
c.Database.MaxOpenConn = envInt("DB_MAX_OPEN_CONN", c.Database.MaxOpenConn)
// 当 DB_HOST 环境变量已设置时自动启用数据库
if _, ok := os.LookupEnv("DB_HOST"); ok {
c.Database.Enabled = true
}
c.Database.Enabled = envBool("DB_ENABLED", c.Database.Enabled)
c.Database.SQLitePath = envStr("SQLITE_PATH", c.Database.SQLitePath)
// ─── Redis ───
if v, ok := os.LookupEnv("REDIS_ADDR"); ok {
c.Redis.Addrs = []string{v}
c.Redis.Enabled = true // 当 REDIS_ADDR 已设置时自动启用
}
c.Redis.Enabled = envBool("REDIS_ENABLED", c.Redis.Enabled)
c.Redis.Username = envStr("REDIS_USERNAME", c.Redis.Username)
c.Redis.Password = envStr("REDIS_PASSWORD", c.Redis.Password)
c.Redis.DB = envInt("REDIS_DB", c.Redis.DB)
c.Redis.KeyPrefix = envStr("REDIS_KEY_PREFIX", c.Redis.KeyPrefix)
c.Redis.PoolSize = envInt("REDIS_POOL_SIZE", c.Redis.PoolSize)
c.Redis.MaintNotifications = envBool("REDIS_MAINT_NOTIFICATIONS", c.Redis.MaintNotifications)
// ─── ClickHouse ───
if v, ok := os.LookupEnv("CLICKHOUSE_HOST"); ok {
c.ClickHouse.Hosts = []string{v}
c.ClickHouse.Enabled = true
}
c.ClickHouse.Enabled = envBool("CLICKHOUSE_ENABLED", c.ClickHouse.Enabled)
c.ClickHouse.Username = envStr("CLICKHOUSE_USERNAME", c.ClickHouse.Username)
c.ClickHouse.Password = envStr("CLICKHOUSE_PASSWORD", c.ClickHouse.Password)
c.ClickHouse.Database = envStr("CLICKHOUSE_NAME", c.ClickHouse.Database)
// ─── Log ───
c.Log.Level = envStr("LOG_LEVEL", c.Log.Level)
c.Log.Format = envStr("LOG_FORMAT", c.Log.Format)
c.Log.Output = envStr("LOG_OUTPUT", c.Log.Output)
// ─── OTel ───
c.Otel.SamplingRate = envFloat64("OTEL_SAMPLING_RATE", c.Otel.SamplingRate)
c.Otel.TracerName = envStr("OTEL_TRACER_NAME", c.Otel.TracerName)
// ─── Worker ───
c.Worker.Concurrency = envInt("WORKER_CONCURRENCY", c.Worker.Concurrency)
c.Worker.StrictPriority = envBool("WORKER_STRICT_PRIORITY", c.Worker.StrictPriority)
// 无 yaml 且无环境变量时,使用代码级默认队列
if len(c.Worker.Queues) == 0 {
c.Worker.Queues = []QueueConfig{
{Name: "webhook", Priority: webhookQueuePriority},
{Name: "whitelist_only", Priority: whitelistQueuePriority},
{Name: "default", Priority: defaultQueuePriority},
}
}
}
// printConfig 打印配置内容
func printConfig(c *configModel) {
configJSON, err := json.MarshalIndent(c, "", " ")
if err != nil {
log.Printf("[Config] failed to marshal config: %v\n", err)
return
}
log.Printf("[Config] loaded configuration:\n%s\n", string(configJSON))
}
-17
View File
@@ -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")
}
}
-140
View File
@@ -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"`
}
-327
View File
@@ -1,327 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Temporary migration harness: proves the kernel configuration engine resolves every
// key identically to pkg/config. Delete this file together with backend/pkg/config in P4.
package config
import (
"fmt"
"os"
"path/filepath"
"reflect"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"Wavelet/core/extpoints"
)
// yamlSource is a test-local ConfigSource over the repository configuration file.
// It deliberately does not import plugins/infra/config: backend/pkg must not depend on
// upper layers even in tests, and the adapter has its own coverage in its package tests.
type yamlSource struct {
v *viper.Viper
}
func newYAMLSource(t *testing.T, path string) *yamlSource {
t.Helper()
v := viper.New()
v.SetConfigFile(path)
require.NoError(t, v.ReadInConfig())
return &yamlSource{v: v}
}
func (s *yamlSource) Lookup(path string) (any, bool) {
if !s.v.IsSet(path) {
return nil, false
}
return s.v.Get(path), true
}
func (s *yamlSource) LookupEnv(name string) (string, bool) { return os.LookupEnv(name) }
func (s *yamlSource) Describe() string { return s.v.ConfigFileUsed() }
// The mirror declarations below reproduce the legacy loader's coverage exactly, which is
// why several keys declare no env: the legacy applyEnvOverrides only honoured an
// environment variable for a subset of fields. Widening that set is a declaration-time
// choice available to each plugin in P3, not an engine behaviour change.
type engineAppConfig struct {
AppName string `config:"app_name" env:"APP_NAME"`
Env string `config:"env" env:"APP_ENV"`
Addr string `config:"addr" env:"APP_ADDR"`
NodeID int64 `config:"node_id" env:"APP_NODE_ID"`
APIPrefix string `config:"api_prefix" env:"APP_API_PREFIX"`
GracefulShutdownTimeout int `config:"graceful_shutdown_timeout" env:"APP_GRACEFUL_SHUTDOWN_TIMEOUT"`
SessionCookieName string `config:"session_cookie_name" env:"APP_SESSION_COOKIE_NAME"`
SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
SessionDomain string `config:"session_domain" env:"APP_SESSION_DOMAIN"`
SessionAge int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"`
SessionHTTPOnly bool `config:"session_http_only" env:"APP_SESSION_HTTP_ONLY"`
SessionSecure bool `config:"session_secure" env:"APP_SESSION_SECURE"`
}
type engineReplicaConfig struct {
Host string `config:"host"`
Port int `config:"port"`
Username string `config:"username"`
Password string `config:"password"`
}
type engineDatabaseConfig struct {
Enabled bool `config:"enabled" env:"DB_ENABLED" default:"false" autoEnable:"DB_HOST"`
SQLitePath string `config:"sqlite_path" env:"SQLITE_PATH"`
Host string `config:"host" env:"DB_HOST"`
Port int `config:"port" env:"DB_PORT"`
Username string `config:"username" env:"DB_USERNAME"`
Password string `config:"password" env:"DB_PASSWORD" secret:"true"`
Database string `config:"database" env:"DB_NAME"`
MaxIdleConn int `config:"max_idle_conn" env:"DB_MAX_IDLE_CONN"`
MaxOpenConn int `config:"max_open_conn" env:"DB_MAX_OPEN_CONN"`
ConnMaxLifetime int `config:"conn_max_lifetime"`
ConnMaxIdleTime int `config:"conn_max_idle_time"`
LogLevel string `config:"log_level" env:"DB_LOG_LEVEL"`
SSLMode string `config:"ssl_mode" env:"DB_SSL_MODE"`
TimeZone string `config:"time_zone" env:"DB_TIMEZONE"`
ApplicationName string `config:"application_name"`
SearchPath string `config:"search_path"`
PreferSimpleProtocol bool `config:"prefer_simple_protocol"`
StatementCacheCapacity int `config:"statement_cache_capacity"`
DefaultQueryExecMode string `config:"default_query_exec_mode"`
Replicas []engineReplicaConfig `config:"replicas"`
SlowThreshold time.Duration `config:"slow_threshold"`
}
type engineRedisConfig struct {
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
Addrs []string `config:"addrs" env:"REDIS_ADDR"`
Username string `config:"username" env:"REDIS_USERNAME"`
Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"`
DB int `config:"db" env:"REDIS_DB"`
ClusterMode bool `config:"cluster_mode"`
MasterName string `config:"master_name"`
KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"`
PoolSize int `config:"pool_size" env:"REDIS_POOL_SIZE"`
MinIdleConn int `config:"min_idle_conn"`
DialTimeout int `config:"dial_timeout"`
ReadTimeout int `config:"read_timeout"`
WriteTimeout int `config:"write_timeout"`
MaxRetries int `config:"max_retries"`
PoolTimeout int `config:"pool_timeout"`
ConnMaxIdleTime int `config:"conn_max_idle_time"`
MaintNotifications bool `config:"maint_notifications" env:"REDIS_MAINT_NOTIFICATIONS"`
}
type engineClickHouseConfig struct {
Enabled bool `config:"enabled" env:"CLICKHOUSE_ENABLED" default:"false" autoEnable:"CLICKHOUSE_HOST"`
Hosts []string `config:"hosts" env:"CLICKHOUSE_HOST"`
Username string `config:"username" env:"CLICKHOUSE_USERNAME"`
Password string `config:"password" env:"CLICKHOUSE_PASSWORD" secret:"true"`
Database string `config:"database" env:"CLICKHOUSE_NAME"`
MaxIdleConn int `config:"max_idle_conn"`
MaxOpenConn int `config:"max_open_conn"`
ConnMaxLifetime int `config:"conn_max_lifetime"`
DialTimeout int `config:"dial_timeout"`
BlockBufferSize uint8 `config:"block_buffer_size"`
}
type engineLogConfig struct {
Level string `config:"level" env:"LOG_LEVEL"`
Format string `config:"format" env:"LOG_FORMAT"`
Output string `config:"output" env:"LOG_OUTPUT"`
FilePath string `config:"file_path"`
MaxSize int `config:"max_size"`
MaxAge int `config:"max_age"`
MaxBackups int `config:"max_backups"`
Compress bool `config:"compress"`
}
type engineOtelConfig struct {
SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE"`
TracerName string `config:"tracer_name" env:"OTEL_TRACER_NAME" default:"github.com/Rain-kl/Wavelet"`
}
type engineQueueConfig struct {
Name string `config:"name"`
Priority int `config:"priority"`
}
type engineWorkerConfig struct {
Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY"`
StrictPriority bool `config:"strict_priority" env:"WORKER_STRICT_PRIORITY"`
Queues []engineQueueConfig `config:"queues"`
}
// durationType mirrors the engine's own notion of a scalar duration field.
var durationType = reflect.TypeFor[time.Duration]()
// flatten exports a struct into dotted leaf paths rendered as text. Both sides of the
// parity assertion use distinct Go types for the same shape, so values are compared
// textually instead of handing cmp a cross-type diff.
func flatten(prefix string, v reflect.Value, out map[string]string) {
t := v.Type()
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.PkgPath != "" {
continue
}
fv := v.Field(i)
path := prefix + "." + field.Name
if fv.Kind() == reflect.Struct && fv.Type() != durationType {
flatten(path, fv, out)
continue
}
out[path] = fmt.Sprint(fv.Interface())
}
}
// configCandidates returns the configuration files to run parity against. The tracked
// example file is mandatory so the proof never skips silently in a fresh clone. A local
// gitignored config.yaml is compared as an extra scenario only when it sits next to the
// example file: the upward search used to locate it can otherwise leave the current
// checkout and silently read a sibling worktree's configuration instead.
func configCandidates(t *testing.T) []string {
t.Helper()
example := findConfigPath("config.example.yaml")
info, err := os.Stat(example)
if err != nil || info.IsDir() {
t.Fatalf("tracked config.example.yaml is unreachable from the test working directory: %v", err)
}
candidates := []string{example}
local := filepath.Join(filepath.Dir(example), "config.yaml")
if localInfo, localErr := os.Stat(local); localErr == nil && !localInfo.IsDir() {
candidates = append(candidates, local)
}
return candidates
}
// bindSections declares every legacy section against the engine and binds them out.
func bindSections(t *testing.T, path string) map[string]string {
t.Helper()
engine := extpoints.NewConfigRegistry(newYAMLSource(t, path))
require.NoError(t, engine.Declare("parity",
extpoints.ConfigBinding{Prefix: "app", Target: &engineAppConfig{}},
extpoints.ConfigBinding{Prefix: "database", Target: &engineDatabaseConfig{}},
extpoints.ConfigBinding{Prefix: "redis", Target: &engineRedisConfig{}},
extpoints.ConfigBinding{Prefix: "clickhouse", Target: &engineClickHouseConfig{}},
extpoints.ConfigBinding{Prefix: "log", Target: &engineLogConfig{}},
extpoints.ConfigBinding{Prefix: "otel", Target: &engineOtelConfig{}},
extpoints.ConfigBinding{Prefix: "worker", Target: &engineWorkerConfig{}},
))
require.NoError(t, engine.Resolve())
var (
app engineAppConfig
database engineDatabaseConfig
redis engineRedisConfig
clickhouse engineClickHouseConfig
log engineLogConfig
otel engineOtelConfig
worker engineWorkerConfig
)
targets := []struct {
prefix string
target any
}{
{"app", &app}, {"database", &database}, {"redis", &redis}, {"clickhouse", &clickhouse},
{"log", &log}, {"otel", &otel}, {"worker", &worker},
}
for _, item := range targets {
require.NoError(t, engine.Bind(item.prefix, item.target))
}
flat := map[string]string{}
flatten("app", reflect.ValueOf(app), flat)
flatten("database", reflect.ValueOf(database), flat)
flatten("redis", reflect.ValueOf(redis), flat)
flatten("clickhouse", reflect.ValueOf(clickhouse), flat)
flatten("log", reflect.ValueOf(log), flat)
flatten("otel", reflect.ValueOf(otel), flat)
flatten("worker", reflect.ValueOf(worker), flat)
return flat
}
// legacySections resolves the same inputs with the legacy loader and flattens them.
func legacySections(t *testing.T, path string) map[string]string {
t.Helper()
legacy := load(path, false)
flat := map[string]string{}
flatten("app", reflect.ValueOf(legacy.App), flat)
flatten("database", reflect.ValueOf(legacy.Database), flat)
flatten("redis", reflect.ValueOf(legacy.Redis), flat)
flatten("clickhouse", reflect.ValueOf(legacy.ClickHouse), flat)
flatten("log", reflect.ValueOf(legacy.Log), flat)
flatten("otel", reflect.ValueOf(legacy.Otel), flat)
flatten("worker", reflect.ValueOf(legacy.Worker), flat)
return flat
}
func TestEngineParityWithLegacyLoader(t *testing.T) {
paths := configCandidates(t)
scenarios := []struct {
name string
env map[string]string
}{
{name: "file only", env: nil},
{
name: "implicit enable from hosts",
env: map[string]string{
"DB_HOST": "postgres", "REDIS_ADDR": "redis:6379", "CLICKHOUSE_HOST": "ch:9000",
},
},
{
name: "explicit flags win over implicit enable",
env: map[string]string{
"DB_HOST": "postgres", "DB_ENABLED": "false",
"REDIS_ADDR": "redis:6379", "REDIS_ENABLED": "false",
"CLICKHOUSE_HOST": "ch:9000", "CLICKHOUSE_ENABLED": "false",
},
},
{
name: "scalar overrides",
env: map[string]string{
"LOG_LEVEL": "debug", "APP_ADDR": ":9999", "DB_PORT": "6543",
"SQLITE_PATH": "./data/parity.db", "REDIS_KEY_PREFIX": "parity:",
"REDIS_MAINT_NOTIFICATIONS": "true", "OTEL_SAMPLING_RATE": "0.5",
"WORKER_CONCURRENCY": "7", "APP_NODE_ID": "42",
},
},
}
for _, path := range paths {
for _, scenario := range scenarios {
t.Run(filepath.Base(path)+" "+scenario.name, func(t *testing.T) {
for name, value := range scenario.env {
t.Setenv(name, value)
}
assert.Empty(t, cmp.Diff(legacySections(t, path), bindSections(t, path)),
"engine resolution drifted from the legacy loader")
})
}
}
}
func TestEngineHonoursEnvOnlyForDeclaredKeys(t *testing.T) {
// slow_threshold has no environment counterpart in either the legacy loader or this
// mirror declaration, so setting one must leave the file value untouched.
t.Setenv("DB_SLOW_THRESHOLD", "9s")
for _, path := range configCandidates(t) {
assert.Empty(t, cmp.Diff(legacySections(t, path), bindSections(t, path)))
}
}
+28 -8
View File
@@ -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)
}
+15
View File
@@ -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))
}
+12
View File
@@ -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
+1 -2
View File
@@ -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"`
}
+16
View File
@@ -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)
+30 -7
View File
@@ -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)
+38 -3
View File
@@ -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{}
+14
View File
@@ -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"`
}
+12
View File
@@ -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)
+31 -6
View File
@@ -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
+16
View File
@@ -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)
+14 -15
View File
@@ -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
}
+19
View File
@@ -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)
+3 -2
View File
@@ -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"`
}
+42 -72
View File
@@ -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,
+32 -1
View File
@@ -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),
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+26 -7
View File
@@ -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())
+15 -19
View File
@@ -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, ","),
+57
View File
@@ -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"`
}
+29 -1
View File
@@ -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)
+39 -40
View File
@@ -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)),
+32 -11
View File
@@ -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
+21
View File
@@ -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`、移除对拍夹具)由后续计划承接。