mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
feat(core): implement cordis configuration extension and migrate all plugins
This commit is contained in:
@@ -22,18 +22,28 @@ Wavelet 提供两种维度的配置能力:
|
||||
|
||||
## 2. 插件内配置声明与绑定
|
||||
|
||||
### 2.1 静态配置绑定 (`ctx.Config().Bind`)
|
||||
### 2.1 静态配置声明与绑定 (`DeclareConfig` 与 `ctx.Config().Bind`)
|
||||
|
||||
静态启动配置遵循插件自包含声明与解耦规范:
|
||||
|
||||
```go
|
||||
type OrderStaticConfig struct {
|
||||
PaymentGatewayURL string `yaml:"payment_gateway_url" json:"payment_gateway_url"`
|
||||
TimeoutSeconds int `yaml:"timeout_seconds" json:"timeout_seconds"`
|
||||
PaymentGatewayURL string `config:"payment_gateway_url" env:"ORDER_PAYMENT_URL" default:"https://pay.example.com"`
|
||||
TimeoutSeconds int `config:"timeout_seconds" env:"ORDER_TIMEOUT" default:"30"`
|
||||
ApiKey string `config:"api_key" env:"ORDER_API_KEY" secret:"true"`
|
||||
}
|
||||
|
||||
// 可选:实现 DeclareConfig 声明配置模式(若需门禁求值则实现 core.ConfigGatedPlugin)
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "plugins.order", Target: &OrderStaticConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var cfg OrderStaticConfig
|
||||
// 从 config.yaml 中的 plugins.order 节点绑定配置
|
||||
ctx.Config().Bind("plugins.order", &cfg)
|
||||
// 从统一配置源绑定 plugins.order 节点配置(支持 YAML 与环境变量覆盖)
|
||||
_ = ctx.Config().Bind("plugins.order", &cfg)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
@@ -111,7 +111,8 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
|
||||
- **扩展点自包含注册**:
|
||||
- **HTTP 路由**:插件自包含在 `Apply` 中通过 `ctx.Router().Group(...)` 挂载路由与中间件,禁止跨插件散落注册。
|
||||
- **异步与定时任务**:插件自包含在 `Apply` 中通过 `ctx.Task().Register(...)` 与 `ctx.Schedule().RegisterCron(...)` 声明。
|
||||
- **动态配置**:插件自包含在 `Apply` 中通过 `ctx.Settings().Register(core.SettingSchema{...})` 声明配置模式,通过 `ctx.Config().Bind(...)` 绑定 YAML 配置。
|
||||
- **静态启动配置**:插件自包含在 `Apply` 中通过 `ctx.Config().Bind("<prefix>", &cfg)` 读取**自己声明**的配置,字段以 tag 表达来源:`config`(yaml 路径)、`env`(覆盖变量名)、`default`、`autoEnable`(该变量存在即置真)、`secret`(导出脱敏)。需要在 `Apply` 之前被门禁求值的键,必须在 `DeclareConfig()` 中提前声明并实现 `core.ConfigGatedPlugin`。新增基础设施 key 保持顶层命名(`redis.*`),插件私有配置归 `plugins.<name>.*`。**严禁**再造全局配置单例或在 `backend/pkg/` 读取配置。
|
||||
- **动态设置**:插件自包含在 `Apply` 中通过 `ctx.Settings().Register(core.SettingSchema{...})` 声明可热更新的管理台设置模式(与上面的静态启动配置分属两层)。
|
||||
- **数据迁移**:插件自包含在内部维护 `migrations/*.sql`,通过 `//go:embed` 打包并在 `Apply` 中通过 `ctx.Migrations().Register(pluginID, embedFS)` 注入。
|
||||
- **表单一所有者原则 (Single Owner Principle)**:
|
||||
- 每张数据表有且仅由一个所有者插件声明与维护(表名使用插件前缀如 `w_order_*`)。
|
||||
|
||||
+1
-9
@@ -6,7 +6,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"log"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -15,13 +14,6 @@ var allCmd = &cobra.Command{
|
||||
Use: "all",
|
||||
Short: "以融合模式同时启动 API、Worker 和 Scheduler",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
printStartupBanner(startupState{
|
||||
mode: "all (API + Worker + Scheduler)",
|
||||
listensForHTTP: true,
|
||||
})
|
||||
app := newWaveletApp(core.ProfileAll)
|
||||
if err := app.Run(); err != nil {
|
||||
log.Fatalf("[All] run failed: %v\n", err)
|
||||
}
|
||||
runProfileApp(core.ProfileAll, "all (API + Worker + Scheduler)", true)
|
||||
},
|
||||
}
|
||||
|
||||
+1
-9
@@ -5,7 +5,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"log"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -14,13 +13,6 @@ var apiCmd = &cobra.Command{
|
||||
Use: "api",
|
||||
Short: "wavelet API",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
printStartupBanner(startupState{
|
||||
mode: "api",
|
||||
listensForHTTP: true,
|
||||
})
|
||||
app := newWaveletApp(core.ProfileAPI)
|
||||
if err := app.Run(); err != nil {
|
||||
log.Fatalf("[API] run failed: %v\n", err)
|
||||
}
|
||||
runProfileApp(core.ProfileAPI, "api", true)
|
||||
},
|
||||
}
|
||||
|
||||
+54
-25
@@ -6,7 +6,6 @@ package cmd
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/plugins/domain/admin"
|
||||
"Wavelet/plugins/domain/auth"
|
||||
"Wavelet/plugins/domain/cap"
|
||||
@@ -22,6 +21,7 @@ import (
|
||||
"Wavelet/plugins/drivers/driver_inproc_worker"
|
||||
"Wavelet/plugins/infra/cache"
|
||||
"Wavelet/plugins/infra/cache_memory"
|
||||
"Wavelet/plugins/infra/config"
|
||||
"Wavelet/plugins/infra/logger"
|
||||
"Wavelet/plugins/infra/storage"
|
||||
"context"
|
||||
@@ -38,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
@@ -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
@@ -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")
|
||||
|
||||
@@ -5,7 +5,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/buildinfo"
|
||||
"Wavelet/pkg/config"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -13,23 +12,19 @@ import (
|
||||
func TestFormatStartupBanner(t *testing.T) {
|
||||
previousVersion := buildinfo.Version
|
||||
previousBuildTime := buildinfo.BuildTime
|
||||
previousEnv := config.Config.App.Env
|
||||
previousAddr := config.Config.App.Addr
|
||||
t.Cleanup(func() {
|
||||
buildinfo.Version = previousVersion
|
||||
buildinfo.BuildTime = previousBuildTime
|
||||
config.Config.App.Env = previousEnv
|
||||
config.Config.App.Addr = previousAddr
|
||||
})
|
||||
|
||||
buildinfo.Version = "v3.2.1"
|
||||
buildinfo.BuildTime = "2026-07-13T08:00:00Z"
|
||||
config.Config.App.Env = "production"
|
||||
config.Config.App.Addr = ":3000"
|
||||
|
||||
banner := formatStartupBanner(startupState{
|
||||
mode: "API",
|
||||
listensForHTTP: true,
|
||||
env: "production",
|
||||
addr: ":3000",
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
|
||||
@@ -7,49 +7,56 @@ import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/core/extpoints"
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/idgen"
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRedisPluggability_Simulation(t *testing.T) {
|
||||
origRedisEnabled := config.Config.Redis.Enabled
|
||||
origAddr := config.Config.App.Addr
|
||||
config.Config.App.Addr = "127.0.0.1:0"
|
||||
defer func() {
|
||||
config.Config.Redis.Enabled = origRedisEnabled
|
||||
config.Config.App.Addr = origAddr
|
||||
}()
|
||||
_ = idgen.Init(1)
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 场景 1: 拔出 Redis (Zero-Redis Monolith 模式)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
t.Run("Scenario_Unplugged_ZeroRedis_Mode", func(t *testing.T) {
|
||||
config.Config.Redis.Enabled = false
|
||||
|
||||
app := newWaveletApp(core.ProfileAll)
|
||||
app := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{
|
||||
"app": map[string]any{
|
||||
"addr": "127.0.0.1:0",
|
||||
},
|
||||
"redis": map[string]any{
|
||||
"enabled": false,
|
||||
},
|
||||
}))
|
||||
require.NotNil(t, app)
|
||||
require.NoError(t, app.Reconcile())
|
||||
|
||||
// 1. 验证插件挂载形态
|
||||
_, ok := app.Plugin("cache_memory")
|
||||
f, ok := app.Fiber("cache_memory")
|
||||
assert.True(t, ok, "cache_memory 必须挂载")
|
||||
_, ok = app.Plugin("driver_inproc_worker")
|
||||
assert.Equal(t, core.FiberActive, f.State())
|
||||
f, ok = app.Fiber("driver_inproc_worker")
|
||||
assert.True(t, ok, "driver_inproc_worker 必须挂载")
|
||||
_, ok = app.Plugin("driver_inproc_cron")
|
||||
assert.Equal(t, core.FiberActive, f.State())
|
||||
f, ok = app.Fiber("driver_inproc_cron")
|
||||
assert.True(t, ok, "driver_inproc_cron 必须挂载")
|
||||
assert.Equal(t, core.FiberActive, f.State())
|
||||
|
||||
_, ok = app.Plugin("cache")
|
||||
assert.False(t, ok, "分布式 cache 不得挂载")
|
||||
_, ok = app.Plugin("driver_asynq_worker")
|
||||
assert.False(t, ok, "asynq_worker 不得挂载")
|
||||
_, ok = app.Plugin("driver_asynq_cron")
|
||||
assert.False(t, ok, "asynq_cron 不得挂载")
|
||||
f, ok = app.Fiber("cache")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, f.State(), "分布式 cache 不得挂载")
|
||||
f, ok = app.Fiber("driver_asynq_worker")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, f.State(), "asynq_worker 不得挂载")
|
||||
f, ok = app.Fiber("driver_asynq_cron")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, f.State(), "asynq_cron 不得挂载")
|
||||
|
||||
// 2. 注册测试任务与 Cron 定时
|
||||
var taskExecuted atomic.Int32
|
||||
@@ -114,25 +121,42 @@ func TestRedisPluggability_Simulation(t *testing.T) {
|
||||
// 场景 2: 插入 Redis (Distributed Cluster 模式)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
t.Run("Scenario_Plugged_Redis_Mode", func(t *testing.T) {
|
||||
config.Config.Redis.Enabled = true
|
||||
mr, err := miniredis.Run()
|
||||
require.NoError(t, err)
|
||||
defer mr.Close()
|
||||
|
||||
app := newWaveletApp(core.ProfileAll)
|
||||
app := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{
|
||||
"app": map[string]any{
|
||||
"addr": "127.0.0.1:0",
|
||||
},
|
||||
"redis": map[string]any{
|
||||
"enabled": true,
|
||||
"addrs": []string{mr.Addr()},
|
||||
},
|
||||
}))
|
||||
require.NotNil(t, app)
|
||||
require.NoError(t, app.Reconcile())
|
||||
|
||||
// 1. 验证插件挂载形态
|
||||
_, ok := app.Plugin("cache")
|
||||
f, ok := app.Fiber("cache")
|
||||
assert.True(t, ok, "分布式 cache 必须挂载")
|
||||
_, ok = app.Plugin("driver_asynq_worker")
|
||||
assert.Equal(t, core.FiberActive, f.State())
|
||||
f, ok = app.Fiber("driver_asynq_worker")
|
||||
assert.True(t, ok, "driver_asynq_worker 必须挂载")
|
||||
_, ok = app.Plugin("driver_asynq_cron")
|
||||
assert.Equal(t, core.FiberActive, f.State())
|
||||
f, ok = app.Fiber("driver_asynq_cron")
|
||||
assert.True(t, ok, "driver_asynq_cron 必须挂载")
|
||||
assert.Equal(t, core.FiberActive, f.State())
|
||||
|
||||
_, ok = app.Plugin("cache_memory")
|
||||
assert.False(t, ok, "纯内存 cache 不得挂载")
|
||||
_, ok = app.Plugin("driver_inproc_worker")
|
||||
assert.False(t, ok, "inproc_worker 不得挂载")
|
||||
_, ok = app.Plugin("driver_inproc_cron")
|
||||
assert.False(t, ok, "inproc_cron 不得挂载")
|
||||
f, ok = app.Fiber("cache_memory")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, f.State(), "纯内存 cache 不得挂载")
|
||||
f, ok = app.Fiber("driver_inproc_worker")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, f.State(), "inproc_worker 不得挂载")
|
||||
f, ok = app.Fiber("driver_inproc_cron")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, f.State(), "inproc_cron 不得挂载")
|
||||
|
||||
// 2. 注册测试任务
|
||||
var asynqTaskExecuted atomic.Int32
|
||||
@@ -143,7 +167,7 @@ func TestRedisPluggability_Simulation(t *testing.T) {
|
||||
return nil
|
||||
}, extpoints.WithTaskTimeout(3*time.Second))
|
||||
|
||||
// 3. 启动应用 (连接真实运行中的 Redis 6379)
|
||||
// 3. 启动应用
|
||||
bootCtx, bootCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer bootCancel()
|
||||
require.NoError(t, app.Start(bootCtx))
|
||||
@@ -172,7 +196,7 @@ func TestRedisPluggability_Simulation(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, taskSvc)
|
||||
|
||||
taskID, err := taskSvc.Dispatch(reqCtx, "test:asynq_task", []byte("payload_plugged"), "unit_test")
|
||||
taskID, err := taskSvc.Dispatch(reqCtx, "test:asynq_task", []byte("payload_plugged"), "default")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, taskID)
|
||||
|
||||
@@ -190,10 +214,20 @@ func TestRedisPluggability_Simulation(t *testing.T) {
|
||||
// 场景 3: 往复插拔连续切换 (拔出 → 插入 → 再拔出,验证时空可组合性与零残留)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
t.Run("Scenario_Dynamic_Plug_Unplug_Sequence", func(t *testing.T) {
|
||||
mr, err := miniredis.Run()
|
||||
require.NoError(t, err)
|
||||
defer mr.Close()
|
||||
|
||||
for i := 1; i <= 2; i++ {
|
||||
// 1. 拔出 Redis 运行
|
||||
config.Config.Redis.Enabled = false
|
||||
appUnplugged := newWaveletApp(core.ProfileAll)
|
||||
appUnplugged := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{
|
||||
"app": map[string]any{
|
||||
"addr": "127.0.0.1:0",
|
||||
},
|
||||
"redis": map[string]any{
|
||||
"enabled": false,
|
||||
},
|
||||
}))
|
||||
require.NoError(t, appUnplugged.Start(context.Background()))
|
||||
|
||||
cacheSvc1, err := core.Inject[contracts.CacheService](appUnplugged.Context())
|
||||
@@ -203,8 +237,15 @@ func TestRedisPluggability_Simulation(t *testing.T) {
|
||||
require.NoError(t, appUnplugged.Stop(context.Background()))
|
||||
|
||||
// 2. 插入 Redis 运行
|
||||
config.Config.Redis.Enabled = true
|
||||
appPlugged := newWaveletApp(core.ProfileAll)
|
||||
appPlugged := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{
|
||||
"app": map[string]any{
|
||||
"addr": "127.0.0.1:0",
|
||||
},
|
||||
"redis": map[string]any{
|
||||
"enabled": true,
|
||||
"addrs": []string{mr.Addr()},
|
||||
},
|
||||
}))
|
||||
require.NoError(t, appPlugged.Start(context.Background()))
|
||||
|
||||
cacheSvc2, err := core.Inject[contracts.CacheService](appPlugged.Context())
|
||||
|
||||
+54
-12
@@ -4,10 +4,12 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/core/extpoints"
|
||||
"Wavelet/pkg/buildinfo"
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/idgen"
|
||||
"Wavelet/pkg/logger"
|
||||
"Wavelet/pkg/trace"
|
||||
"Wavelet/plugins/infra/config"
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
@@ -17,23 +19,63 @@ import (
|
||||
|
||||
const traceShutdownTimeout = 10 * time.Second
|
||||
|
||||
type hostConfig struct {
|
||||
App struct {
|
||||
AppName string `config:"app_name" env:"APP_NAME" default:"Wavelet"`
|
||||
Env string `config:"env" env:"APP_ENV" default:"production"`
|
||||
NodeID int64 `config:"node_id" env:"APP_NODE_ID" default:"1"`
|
||||
Addr string `config:"addr" env:"APP_ADDR" default:"127.0.0.1:3000"`
|
||||
} `config:"app"`
|
||||
Log struct {
|
||||
Level string `config:"level" env:"LOG_LEVEL" default:"info"`
|
||||
Format string `config:"format" env:"LOG_FORMAT" default:"json"`
|
||||
Output string `config:"output" env:"LOG_OUTPUT" default:"stdout"`
|
||||
FilePath string `config:"file_path" env:"LOG_FILE_PATH" default:"./logs/app.log"`
|
||||
MaxSize int `config:"max_size" env:"LOG_MAX_SIZE" default:"100"`
|
||||
MaxAge int `config:"max_age" env:"LOG_MAX_AGE" default:"30"`
|
||||
MaxBackups int `config:"max_backups" env:"LOG_MAX_BACKUPS" default:"10"`
|
||||
Compress bool `config:"compress" env:"LOG_COMPRESS" default:"true"`
|
||||
} `config:"log"`
|
||||
OTel struct {
|
||||
SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE" default:"1.0"`
|
||||
TracerName string `config:"tracer_name" env:"OTEL_TRACER_NAME" default:"github.com/Rain-kl/Wavelet"`
|
||||
} `config:"otel"`
|
||||
}
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "wavelet",
|
||||
PersistentPreRun: func(_ *cobra.Command, _ []string) {
|
||||
src, err := config.NewSource()
|
||||
if err != nil {
|
||||
log.Fatalf("[CMD] load config source failed: %v", err)
|
||||
}
|
||||
var cfg hostConfig
|
||||
reg := extpoints.NewConfigRegistry(src)
|
||||
_ = reg.Declare("host", extpoints.ConfigBinding{Target: &cfg})
|
||||
if err := reg.Resolve(); err != nil {
|
||||
log.Fatalf("[CMD] resolve host config failed: %v", err)
|
||||
}
|
||||
_ = reg.Bind("", &cfg)
|
||||
|
||||
// Initialize idgen snowflake generator
|
||||
if err := idgen.Init(cfg.App.NodeID); err != nil {
|
||||
log.Fatalf("[CMD] init idgen failed: %v", err)
|
||||
}
|
||||
|
||||
logger.Init(logger.Config{
|
||||
Level: config.Config.Log.Level,
|
||||
Format: config.Config.Log.Format,
|
||||
Output: config.Config.Log.Output,
|
||||
FilePath: config.Config.Log.FilePath,
|
||||
MaxSize: config.Config.Log.MaxSize,
|
||||
MaxAge: config.Config.Log.MaxAge,
|
||||
MaxBackups: config.Config.Log.MaxBackups,
|
||||
Compress: config.Config.Log.Compress,
|
||||
Level: cfg.Log.Level,
|
||||
Format: cfg.Log.Format,
|
||||
Output: cfg.Log.Output,
|
||||
FilePath: cfg.Log.FilePath,
|
||||
MaxSize: cfg.Log.MaxSize,
|
||||
MaxAge: cfg.Log.MaxAge,
|
||||
MaxBackups: cfg.Log.MaxBackups,
|
||||
Compress: cfg.Log.Compress,
|
||||
})
|
||||
trace.Init(trace.Config{
|
||||
AppName: config.Config.App.AppName,
|
||||
SamplingRate: config.Config.Otel.SamplingRate,
|
||||
TracerName: config.Config.Otel.TracerName,
|
||||
AppName: cfg.App.AppName,
|
||||
SamplingRate: cfg.OTel.SamplingRate,
|
||||
TracerName: cfg.OTel.TracerName,
|
||||
})
|
||||
},
|
||||
PersistentPostRun: func(_ *cobra.Command, _ []string) {
|
||||
|
||||
@@ -5,7 +5,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"log"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -14,13 +13,6 @@ var schedulerCmd = &cobra.Command{
|
||||
Use: "scheduler",
|
||||
Short: "wavelet Scheduler",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
printStartupBanner(startupState{
|
||||
mode: "scheduler",
|
||||
listensForHTTP: false,
|
||||
})
|
||||
app := newWaveletApp(core.ProfileSchedule)
|
||||
if err := app.Run(); err != nil {
|
||||
log.Fatalf("[Scheduler] run failed: %v\n", err)
|
||||
}
|
||||
runProfileApp(core.ProfileSchedule, "scheduler", false)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"log"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -14,13 +13,6 @@ var workerCmd = &cobra.Command{
|
||||
Use: "worker",
|
||||
Short: "wavelet Worker",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
printStartupBanner(startupState{
|
||||
mode: "worker",
|
||||
listensForHTTP: false,
|
||||
})
|
||||
app := newWaveletApp(core.ProfileWorker)
|
||||
if err := app.Run(); err != nil {
|
||||
log.Fatalf("[Worker] run failed: %v\n", err)
|
||||
}
|
||||
runProfileApp(core.ProfileWorker, "worker", false)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ type Plugin interface {
|
||||
storage.New(),
|
||||
// ... platform domain plugins ...
|
||||
custom_hello.New(), // your downstream plugin
|
||||
driver_http.New(driver_http.WithAddr(config.Config.App.Addr)),
|
||||
driver_http.New(),
|
||||
driver_asynq_worker.New(),
|
||||
driver_asynq_cron.New(),
|
||||
)
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package config 负责应用配置的加载、解析与环境变量覆盖。
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// 默认队列优先级
|
||||
const (
|
||||
webhookQueuePriority = 10
|
||||
whitelistQueuePriority = 5
|
||||
defaultQueuePriority = 3
|
||||
)
|
||||
|
||||
// Config 全局配置单例,初始化后不可变
|
||||
var Config *configModel
|
||||
|
||||
// findConfigPath searches upward for the config file to handle tests running in subdirectories.
|
||||
func findConfigPath(configPath string) string {
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
return configPath
|
||||
}
|
||||
dir := "."
|
||||
for i := 0; i < 5; i++ {
|
||||
dir += "/.."
|
||||
path := dir + "/" + configPath
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return configPath
|
||||
}
|
||||
|
||||
// isTest checks if the current execution context is within 'go test'.
|
||||
func isTest() bool {
|
||||
if flag.Lookup("test.v") != nil {
|
||||
return true
|
||||
}
|
||||
for _, arg := range os.Args {
|
||||
if strings.HasPrefix(arg, "-test.") || strings.HasSuffix(arg, ".test") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// load reads configuration from configPath, applies defaults and environment overrides,
|
||||
// and optionally disables external services for in-test runs. It uses a private viper
|
||||
// instance so a caller can resolve the same inputs repeatedly, which the parity test
|
||||
// against the kernel configuration engine requires.
|
||||
func load(configPath string, testMode bool) *configModel {
|
||||
v := viper.New()
|
||||
v.SetConfigFile(configPath)
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
var notFound viper.ConfigFileNotFoundError
|
||||
if !errors.As(err, ¬Found) {
|
||||
// 文件存在但读取/解析失败
|
||||
if _, statErr := os.Stat(configPath); statErr == nil { //nolint:gosec // configPath is loaded from CONFIG_PATH environment variable
|
||||
log.Fatalf("[Config] read config failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
log.Println("[Config] no config file found, using environment variables only")
|
||||
v.SetConfigType("yaml")
|
||||
if err := v.ReadConfig(strings.NewReader("")); err != nil {
|
||||
log.Fatalf("[Config] failed to init empty config: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 解析配置到结构体
|
||||
var c configModel
|
||||
if err := v.Unmarshal(&c); err != nil {
|
||||
log.Fatalf("[Config] parse config failed: %v\n", err)
|
||||
}
|
||||
|
||||
applyDefaults(&c)
|
||||
|
||||
// 环境变量覆盖(优先级高于 config.yaml)
|
||||
applyEnvOverrides(&c)
|
||||
applyDefaults(&c)
|
||||
|
||||
// Disable standard DB/Redis initializations during tests to prevent connection attempts.
|
||||
if testMode {
|
||||
c.Database.Enabled = false
|
||||
c.Database.SQLitePath = ":memory:"
|
||||
c.Redis.Enabled = false
|
||||
c.ClickHouse.Enabled = false
|
||||
}
|
||||
|
||||
return &c
|
||||
}
|
||||
|
||||
func init() {
|
||||
// 加载配置文件路径
|
||||
configPath := os.Getenv("CONFIG_PATH")
|
||||
if configPath == "" {
|
||||
configPath = findConfigPath("config.yaml")
|
||||
}
|
||||
|
||||
// 设置全局配置并打印
|
||||
Config = load(configPath, isTest())
|
||||
printConfig(Config)
|
||||
}
|
||||
|
||||
func applyDefaults(c *configModel) {
|
||||
if c.App.SessionAge <= 0 {
|
||||
c.App.SessionAge = 86400
|
||||
}
|
||||
if c.Otel.TracerName == "" {
|
||||
c.Otel.TracerName = "github.com/Rain-kl/Wavelet"
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 环境变量覆盖层 ────────────────────────────────────────────────────────────
|
||||
// 环境变量优先级高于 config.yaml,未设置则保留 yaml 中的值。
|
||||
|
||||
func envStr(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envInt64(key string, fallback int64) int64 {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envFloat64(key string, fallback float64) float64 {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
if n, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envBool(key string, fallback bool) bool {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// applyEnvOverrides 将环境变量值覆盖到配置结构体上(仅当环境变量已设置时生效)
|
||||
func applyEnvOverrides(c *configModel) {
|
||||
// ─── App ───
|
||||
c.App.AppName = envStr("APP_NAME", c.App.AppName)
|
||||
c.App.Env = envStr("APP_ENV", c.App.Env)
|
||||
c.App.Addr = envStr("APP_ADDR", c.App.Addr)
|
||||
c.App.NodeID = envInt64("APP_NODE_ID", c.App.NodeID)
|
||||
c.App.APIPrefix = envStr("APP_API_PREFIX", c.App.APIPrefix)
|
||||
c.App.GracefulShutdownTimeout = envInt("APP_GRACEFUL_SHUTDOWN_TIMEOUT", c.App.GracefulShutdownTimeout)
|
||||
c.App.SessionCookieName = envStr("APP_SESSION_COOKIE_NAME", c.App.SessionCookieName)
|
||||
c.App.SessionSecret = envStr("APP_SESSION_SECRET", c.App.SessionSecret)
|
||||
c.App.SessionDomain = envStr("APP_SESSION_DOMAIN", c.App.SessionDomain)
|
||||
c.App.SessionAge = envInt("APP_SESSION_AGE", c.App.SessionAge)
|
||||
c.App.SessionHTTPOnly = envBool("APP_SESSION_HTTP_ONLY", c.App.SessionHTTPOnly)
|
||||
c.App.SessionSecure = envBool("APP_SESSION_SECURE", c.App.SessionSecure)
|
||||
|
||||
// ─── Database ───
|
||||
c.Database.Host = envStr("DB_HOST", c.Database.Host)
|
||||
c.Database.Port = envInt("DB_PORT", c.Database.Port)
|
||||
c.Database.Username = envStr("DB_USERNAME", c.Database.Username)
|
||||
c.Database.Password = envStr("DB_PASSWORD", c.Database.Password)
|
||||
c.Database.Database = envStr("DB_NAME", c.Database.Database)
|
||||
c.Database.SSLMode = envStr("DB_SSL_MODE", c.Database.SSLMode)
|
||||
c.Database.TimeZone = envStr("DB_TIMEZONE", c.Database.TimeZone)
|
||||
c.Database.LogLevel = envStr("DB_LOG_LEVEL", c.Database.LogLevel)
|
||||
c.Database.MaxIdleConn = envInt("DB_MAX_IDLE_CONN", c.Database.MaxIdleConn)
|
||||
c.Database.MaxOpenConn = envInt("DB_MAX_OPEN_CONN", c.Database.MaxOpenConn)
|
||||
// 当 DB_HOST 环境变量已设置时自动启用数据库
|
||||
if _, ok := os.LookupEnv("DB_HOST"); ok {
|
||||
c.Database.Enabled = true
|
||||
}
|
||||
c.Database.Enabled = envBool("DB_ENABLED", c.Database.Enabled)
|
||||
c.Database.SQLitePath = envStr("SQLITE_PATH", c.Database.SQLitePath)
|
||||
|
||||
// ─── Redis ───
|
||||
if v, ok := os.LookupEnv("REDIS_ADDR"); ok {
|
||||
c.Redis.Addrs = []string{v}
|
||||
c.Redis.Enabled = true // 当 REDIS_ADDR 已设置时自动启用
|
||||
}
|
||||
c.Redis.Enabled = envBool("REDIS_ENABLED", c.Redis.Enabled)
|
||||
c.Redis.Username = envStr("REDIS_USERNAME", c.Redis.Username)
|
||||
c.Redis.Password = envStr("REDIS_PASSWORD", c.Redis.Password)
|
||||
c.Redis.DB = envInt("REDIS_DB", c.Redis.DB)
|
||||
c.Redis.KeyPrefix = envStr("REDIS_KEY_PREFIX", c.Redis.KeyPrefix)
|
||||
c.Redis.PoolSize = envInt("REDIS_POOL_SIZE", c.Redis.PoolSize)
|
||||
c.Redis.MaintNotifications = envBool("REDIS_MAINT_NOTIFICATIONS", c.Redis.MaintNotifications)
|
||||
|
||||
// ─── ClickHouse ───
|
||||
if v, ok := os.LookupEnv("CLICKHOUSE_HOST"); ok {
|
||||
c.ClickHouse.Hosts = []string{v}
|
||||
c.ClickHouse.Enabled = true
|
||||
}
|
||||
c.ClickHouse.Enabled = envBool("CLICKHOUSE_ENABLED", c.ClickHouse.Enabled)
|
||||
c.ClickHouse.Username = envStr("CLICKHOUSE_USERNAME", c.ClickHouse.Username)
|
||||
c.ClickHouse.Password = envStr("CLICKHOUSE_PASSWORD", c.ClickHouse.Password)
|
||||
c.ClickHouse.Database = envStr("CLICKHOUSE_NAME", c.ClickHouse.Database)
|
||||
|
||||
// ─── Log ───
|
||||
c.Log.Level = envStr("LOG_LEVEL", c.Log.Level)
|
||||
c.Log.Format = envStr("LOG_FORMAT", c.Log.Format)
|
||||
c.Log.Output = envStr("LOG_OUTPUT", c.Log.Output)
|
||||
|
||||
// ─── OTel ───
|
||||
c.Otel.SamplingRate = envFloat64("OTEL_SAMPLING_RATE", c.Otel.SamplingRate)
|
||||
c.Otel.TracerName = envStr("OTEL_TRACER_NAME", c.Otel.TracerName)
|
||||
|
||||
// ─── Worker ───
|
||||
c.Worker.Concurrency = envInt("WORKER_CONCURRENCY", c.Worker.Concurrency)
|
||||
c.Worker.StrictPriority = envBool("WORKER_STRICT_PRIORITY", c.Worker.StrictPriority)
|
||||
|
||||
// 无 yaml 且无环境变量时,使用代码级默认队列
|
||||
if len(c.Worker.Queues) == 0 {
|
||||
c.Worker.Queues = []QueueConfig{
|
||||
{Name: "webhook", Priority: webhookQueuePriority},
|
||||
{Name: "whitelist_only", Priority: whitelistQueuePriority},
|
||||
{Name: "default", Priority: defaultQueuePriority},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// printConfig 打印配置内容
|
||||
func printConfig(c *configModel) {
|
||||
configJSON, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("[Config] failed to marshal config: %v\n", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[Config] loaded configuration:\n%s\n", string(configJSON))
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestApplyEnvOverridesRedisMaintNotifications(t *testing.T) {
|
||||
t.Setenv("REDIS_MAINT_NOTIFICATIONS", "true")
|
||||
|
||||
cfg := &configModel{}
|
||||
applyEnvOverrides(cfg)
|
||||
|
||||
if !cfg.Redis.MaintNotifications {
|
||||
t.Fatal("REDIS_MAINT_NOTIFICATIONS=true was not applied")
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
type configModel struct {
|
||||
App appConfig `mapstructure:"app"`
|
||||
Database databaseConfig `mapstructure:"database"`
|
||||
Redis redisConfig `mapstructure:"redis"`
|
||||
Log logConfig `mapstructure:"log"`
|
||||
Scheduler schedulerConfig `mapstructure:"scheduler"`
|
||||
Worker workerConfig `mapstructure:"worker"`
|
||||
ClickHouse clickHouseConfig `mapstructure:"clickhouse"`
|
||||
Otel otelConfig `mapstructure:"otel"`
|
||||
}
|
||||
|
||||
// appConfig 应用基本配置
|
||||
type appConfig struct {
|
||||
AppName string `mapstructure:"app_name"`
|
||||
Env string `mapstructure:"env"`
|
||||
Addr string `mapstructure:"addr"`
|
||||
NodeID int64 `mapstructure:"node_id"`
|
||||
APIPrefix string `mapstructure:"api_prefix"`
|
||||
GracefulShutdownTimeout int `mapstructure:"graceful_shutdown_timeout"`
|
||||
SessionCookieName string `mapstructure:"session_cookie_name"`
|
||||
SessionSecret string `mapstructure:"session_secret"`
|
||||
SessionDomain string `mapstructure:"session_domain"`
|
||||
SessionAge int `mapstructure:"session_age"`
|
||||
SessionHTTPOnly bool `mapstructure:"session_http_only"`
|
||||
SessionSecure bool `mapstructure:"session_secure"`
|
||||
}
|
||||
|
||||
// IsProduction 检查当前环境是否为生产环境
|
||||
func (a *appConfig) IsProduction() bool {
|
||||
return a.Env == "production"
|
||||
}
|
||||
|
||||
// databaseConfig 数据库配置
|
||||
type databaseConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
SQLitePath string `mapstructure:"sqlite_path"` // PostgreSQL 禁用时的 SQLite 文件路径
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
Database string `mapstructure:"database"`
|
||||
MaxIdleConn int `mapstructure:"max_idle_conn"`
|
||||
MaxOpenConn int `mapstructure:"max_open_conn"`
|
||||
ConnMaxLifetime int `mapstructure:"conn_max_lifetime"`
|
||||
ConnMaxIdleTime int `mapstructure:"conn_max_idle_time"`
|
||||
LogLevel string `mapstructure:"log_level"`
|
||||
SSLMode string `mapstructure:"ssl_mode"`
|
||||
TimeZone string `mapstructure:"time_zone"`
|
||||
ApplicationName string `mapstructure:"application_name"`
|
||||
SearchPath string `mapstructure:"search_path"`
|
||||
PreferSimpleProtocol bool `mapstructure:"prefer_simple_protocol"`
|
||||
StatementCacheCapacity int `mapstructure:"statement_cache_capacity"`
|
||||
DefaultQueryExecMode string `mapstructure:"default_query_exec_mode"`
|
||||
Replicas []databaseReplicaConfig `mapstructure:"replicas"`
|
||||
SlowThreshold time.Duration `mapstructure:"slow_threshold"`
|
||||
}
|
||||
|
||||
// databaseReplicaConfig 只读副本配置
|
||||
type databaseReplicaConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
}
|
||||
|
||||
// clickhouse 配置
|
||||
type clickHouseConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Hosts []string `mapstructure:"hosts"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
Database string `mapstructure:"database"`
|
||||
MaxIdleConn int `mapstructure:"max_idle_conn"`
|
||||
MaxOpenConn int `mapstructure:"max_open_conn"`
|
||||
ConnMaxLifetime int `mapstructure:"conn_max_lifetime"`
|
||||
DialTimeout int `mapstructure:"dial_timeout"`
|
||||
BlockBufferSize uint8 `mapstructure:"block_buffer_size"`
|
||||
}
|
||||
|
||||
// redisConfig Redis配置
|
||||
type redisConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Addrs []string `mapstructure:"addrs"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
DB int `mapstructure:"db"`
|
||||
ClusterMode bool `mapstructure:"cluster_mode"`
|
||||
MasterName string `mapstructure:"master_name"`
|
||||
KeyPrefix string `mapstructure:"key_prefix"`
|
||||
PoolSize int `mapstructure:"pool_size"`
|
||||
MinIdleConn int `mapstructure:"min_idle_conn"`
|
||||
DialTimeout int `mapstructure:"dial_timeout"`
|
||||
ReadTimeout int `mapstructure:"read_timeout"`
|
||||
WriteTimeout int `mapstructure:"write_timeout"`
|
||||
MaxRetries int `mapstructure:"max_retries"`
|
||||
PoolTimeout int `mapstructure:"pool_timeout"`
|
||||
ConnMaxIdleTime int `mapstructure:"conn_max_idle_time"`
|
||||
MaintNotifications bool `mapstructure:"maint_notifications"`
|
||||
}
|
||||
|
||||
// logConfig 日志配置
|
||||
type logConfig struct {
|
||||
Level string `mapstructure:"level"`
|
||||
Format string `mapstructure:"format"`
|
||||
Output string `mapstructure:"output"`
|
||||
FilePath string `mapstructure:"file_path"`
|
||||
MaxSize int `mapstructure:"max_size"`
|
||||
MaxAge int `mapstructure:"max_age"`
|
||||
MaxBackups int `mapstructure:"max_backups"`
|
||||
Compress bool `mapstructure:"compress"`
|
||||
}
|
||||
|
||||
// schedulerConfig 定时任务配置
|
||||
type schedulerConfig struct{}
|
||||
|
||||
// workerConfig 工作配置
|
||||
type workerConfig struct {
|
||||
Concurrency int `mapstructure:"concurrency"`
|
||||
StrictPriority bool `mapstructure:"strict_priority"`
|
||||
Queues []QueueConfig `mapstructure:"queues"`
|
||||
}
|
||||
|
||||
// QueueConfig 队列配置
|
||||
type QueueConfig struct {
|
||||
Name string `mapstructure:"name"`
|
||||
Priority int `mapstructure:"priority"`
|
||||
}
|
||||
|
||||
// otelConfig OpenTelemetry 配置
|
||||
type otelConfig struct {
|
||||
SamplingRate float64 `mapstructure:"sampling_rate"`
|
||||
TracerName string `mapstructure:"tracer_name"`
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,10 @@
|
||||
package idgen
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/bwmarrin/snowflake"
|
||||
)
|
||||
@@ -17,25 +18,44 @@ const epoch int64 = 1764547200000
|
||||
|
||||
const maxNegativeIDRetries = 3
|
||||
|
||||
var node *snowflake.Node
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
node *snowflake.Node
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Init initializes the snowflake ID generator with the given node ID.
|
||||
func Init(nodeID int64) error {
|
||||
snowflake.Epoch = epoch
|
||||
|
||||
nodeID := config.Config.App.NodeID
|
||||
var err error
|
||||
node, err = snowflake.NewNode(nodeID)
|
||||
n, err := snowflake.NewNode(nodeID)
|
||||
if err != nil {
|
||||
log.Fatalf("[Snowflake] init failed: %v\n", err)
|
||||
return fmt.Errorf("idgen: init node %d failed: %w", nodeID, err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
node = n
|
||||
mu.Unlock()
|
||||
|
||||
log.Printf("[Snowflake] initialized with node ID: %d, epoch: 2025-12-01\n", nodeID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrNotInitialized indicates NextUint64ID was called before Init.
|
||||
var ErrNotInitialized = errors.New("idgen: Init must be called before generating IDs")
|
||||
|
||||
// NextUint64ID 生成下一个分布式唯一 ID。
|
||||
// 理论上不应出现负值;若出现则最多重试 maxNegativeIDRetries 次,仍失败则 panic。
|
||||
func NextUint64ID() uint64 {
|
||||
mu.RLock()
|
||||
n := node
|
||||
mu.RUnlock()
|
||||
|
||||
if n == nil {
|
||||
panic(ErrNotInitialized)
|
||||
}
|
||||
|
||||
for attempt := 1; attempt <= maxNegativeIDRetries; attempt++ {
|
||||
id := node.Generate().Int64()
|
||||
id := n.Generate().Int64()
|
||||
if id >= 0 {
|
||||
return uint64(id)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,24 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNextUint64ID(t *testing.T) {
|
||||
require.NoError(t, Init(1))
|
||||
id := NextUint64ID()
|
||||
assert.NotZero(t, id)
|
||||
}
|
||||
|
||||
func TestNextUint64ID_PanicsWhenNotInitialized(t *testing.T) {
|
||||
mu.Lock()
|
||||
node = nil
|
||||
mu.Unlock()
|
||||
|
||||
assert.PanicsWithError(t, ErrNotInitialized.Error(), func() {
|
||||
NextUint64ID()
|
||||
})
|
||||
|
||||
// Restore initialization for other tests
|
||||
require.NoError(t, Init(1))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package admin
|
||||
|
||||
import "Wavelet/plugins/domain/admin/model"
|
||||
|
||||
// DatabaseConfig aliases model.DatabaseConfig.
|
||||
type DatabaseConfig = model.DatabaseConfig
|
||||
|
||||
// ClickHouseConfig aliases model.ClickHouseConfig.
|
||||
type ClickHouseConfig = model.ClickHouseConfig
|
||||
@@ -4,7 +4,6 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/logger"
|
||||
"Wavelet/pkg/response"
|
||||
"Wavelet/plugins/domain/admin/errs"
|
||||
@@ -147,7 +146,7 @@ func GetDatabaseInfo(c *gin.Context) {
|
||||
// @Failure 500 {object} response.Any "导出失败"
|
||||
// @Router /api/v1/admin/db-export [get]
|
||||
func ExportDatabase(c *gin.Context) {
|
||||
if !config.Config.Database.Enabled {
|
||||
if !service.GetDBConfig().Enabled {
|
||||
exportSQLite(c)
|
||||
} else {
|
||||
exportPostgres(c)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package model
|
||||
|
||||
// DatabaseConfig holds database configuration needed by the admin plugin.
|
||||
type DatabaseConfig struct {
|
||||
Enabled bool `config:"enabled" env:"DB_ENABLED" default:"false" autoEnable:"DB_HOST"`
|
||||
Host string `config:"host" env:"DB_HOST"`
|
||||
Port int `config:"port" env:"DB_PORT" default:"5432"`
|
||||
Database string `config:"database" env:"DB_DATABASE"`
|
||||
Username string `config:"username" env:"DB_USERNAME"`
|
||||
Password string `config:"password" env:"DB_PASSWORD" secret:"true"`
|
||||
SQLitePath string `config:"sqlite_path" env:"DB_SQLITE_PATH" default:"./data/wavelet.db"`
|
||||
}
|
||||
|
||||
// ClickHouseConfig holds clickhouse enablement status needed by admin log queries/switching.
|
||||
type ClickHouseConfig struct {
|
||||
Enabled bool `config:"enabled" env:"CLICKHOUSE_ENABLED" default:"false" autoEnable:"CLICKHOUSE_HOST"`
|
||||
}
|
||||
@@ -66,8 +66,24 @@ func (p *Plugin) Manifest() core.Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
// DeclareConfig declares configuration bindings consumed by the admin plugin.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "database", Target: &model.DatabaseConfig{}},
|
||||
{Prefix: "clickhouse", Target: &model.ClickHouseConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// Apply registers admin routes, tasks, schedules, and settings into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var dbCfg model.DatabaseConfig
|
||||
_ = ctx.Config().Bind("database", &dbCfg)
|
||||
service.SetDBConfig(dbCfg)
|
||||
|
||||
var chCfg model.ClickHouseConfig
|
||||
_ = ctx.Config().Bind("clickhouse", &chCfg)
|
||||
service.SetClickHouseConfig(chCfg)
|
||||
|
||||
// 0. Bind Services reactively
|
||||
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
||||
service.SetDBService(db)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/plugins/domain/admin/errs"
|
||||
"Wavelet/plugins/domain/admin/model"
|
||||
"context"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -22,9 +22,30 @@ const (
|
||||
logDBNameSQLite = "sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
dbConfigMu sync.RWMutex
|
||||
dbConfig = model.DatabaseConfig{
|
||||
SQLitePath: defaultSQLiteDBPath,
|
||||
}
|
||||
)
|
||||
|
||||
// SetDBConfig sets the database configuration.
|
||||
func SetDBConfig(cfg model.DatabaseConfig) {
|
||||
dbConfigMu.Lock()
|
||||
defer dbConfigMu.Unlock()
|
||||
dbConfig = cfg
|
||||
}
|
||||
|
||||
// GetDBConfig gets the database configuration.
|
||||
func GetDBConfig() model.DatabaseConfig {
|
||||
dbConfigMu.RLock()
|
||||
defer dbConfigMu.RUnlock()
|
||||
return dbConfig
|
||||
}
|
||||
|
||||
// sqliteDatabasePath resolves the effective SQLite file path from configuration.
|
||||
func sqliteDatabasePath() string {
|
||||
name := config.Config.Database.SQLitePath
|
||||
name := GetDBConfig().SQLitePath
|
||||
if name == "" {
|
||||
name = defaultSQLiteDBPath
|
||||
}
|
||||
@@ -93,7 +114,7 @@ func GetPostgresOverview(ctx context.Context) (model.DBOverviewResponse, error)
|
||||
return model.DBOverviewResponse{}, errs.ErrDatabaseUninitialized
|
||||
}
|
||||
|
||||
name := config.Config.Database.Database
|
||||
name := GetDBConfig().Database
|
||||
|
||||
var version string
|
||||
var ver string
|
||||
@@ -152,7 +173,7 @@ func ListDatabaseTableNames(ctx context.Context) ([]string, error) {
|
||||
var tables []string
|
||||
var err error
|
||||
|
||||
if !config.Config.Database.Enabled {
|
||||
if !GetDBConfig().Enabled {
|
||||
err = gormDB.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").Scan(&tables).Error
|
||||
} else {
|
||||
err = gormDB.Raw("SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() ORDER BY table_name").Scan(&tables).Error
|
||||
@@ -288,9 +309,10 @@ func scanRowAsMap(rows *sql.Rows, cols []string) (map[string]any, error) {
|
||||
|
||||
// GetSQLiteInfo collects the SQLite type/name/version triple.
|
||||
func GetSQLiteInfo(ctx context.Context) model.DatabaseInfoResponse {
|
||||
cfg := GetDBConfig()
|
||||
info := model.DatabaseInfoResponse{
|
||||
Type: logDBNameSQLite,
|
||||
Name: config.Config.Database.SQLitePath,
|
||||
Name: cfg.SQLitePath,
|
||||
Version: "SQLite",
|
||||
}
|
||||
if info.Name == "" {
|
||||
@@ -309,9 +331,10 @@ func GetSQLiteInfo(ctx context.Context) model.DatabaseInfoResponse {
|
||||
|
||||
// GetPostgresInfo collects the PostgreSQL type/name/version triple.
|
||||
func GetPostgresInfo(ctx context.Context) model.DatabaseInfoResponse {
|
||||
cfg := GetDBConfig()
|
||||
info := model.DatabaseInfoResponse{
|
||||
Type: "postgres",
|
||||
Name: config.Config.Database.Database,
|
||||
Name: cfg.Database,
|
||||
Version: "PostgreSQL",
|
||||
}
|
||||
gormDB := GetDB(ctx)
|
||||
@@ -343,7 +366,7 @@ func OpenSQLiteExportFile() (*os.File, os.FileInfo, error) {
|
||||
|
||||
// NewPgDumpCommand builds the streaming pg_dump command for the active database.
|
||||
func NewPgDumpCommand(ctx context.Context) (*exec.Cmd, string, error) {
|
||||
dbCfg := config.Config.Database
|
||||
dbCfg := GetDBConfig()
|
||||
|
||||
pgDumpPath, err := exec.LookPath("pg_dump")
|
||||
if err != nil {
|
||||
|
||||
@@ -44,6 +44,13 @@ func newFlushLogTestCache(t *testing.T) (contracts.CacheService, *miniredis.Mini
|
||||
|
||||
p := cacheplugin.New(cacheplugin.WithRedis(rdb), cacheplugin.WithRAMCapacity(64))
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(map[string]any{
|
||||
"redis": map[string]any{
|
||||
"enabled": true,
|
||||
"addrs": []string{mr.Addr()},
|
||||
},
|
||||
}))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
require.NoError(t, p.Apply(ctx))
|
||||
svc, err := core.Inject[contracts.CacheService](ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -4,22 +4,57 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/plugins/domain/admin/model"
|
||||
"Wavelet/plugins/domain/admin/repository"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
dbConfigMu sync.RWMutex
|
||||
dbConfig model.DatabaseConfig
|
||||
chConfig model.ClickHouseConfig
|
||||
)
|
||||
|
||||
// SetDBConfig sets the database configuration in service and repository.
|
||||
func SetDBConfig(cfg model.DatabaseConfig) {
|
||||
dbConfigMu.Lock()
|
||||
defer dbConfigMu.Unlock()
|
||||
dbConfig = cfg
|
||||
repository.SetDBConfig(cfg)
|
||||
}
|
||||
|
||||
// GetDBConfig returns the database configuration.
|
||||
func GetDBConfig() model.DatabaseConfig {
|
||||
dbConfigMu.RLock()
|
||||
defer dbConfigMu.RUnlock()
|
||||
return dbConfig
|
||||
}
|
||||
|
||||
// SetClickHouseConfig sets the clickhouse configuration.
|
||||
func SetClickHouseConfig(cfg model.ClickHouseConfig) {
|
||||
dbConfigMu.Lock()
|
||||
defer dbConfigMu.Unlock()
|
||||
chConfig = cfg
|
||||
}
|
||||
|
||||
// GetClickHouseConfig returns the clickhouse configuration.
|
||||
func GetClickHouseConfig() model.ClickHouseConfig {
|
||||
dbConfigMu.RLock()
|
||||
defer dbConfigMu.RUnlock()
|
||||
return chConfig
|
||||
}
|
||||
|
||||
// selectSQLKeywords marks statements that return a result set instead of a row count.
|
||||
var selectSQLKeywords = []string{"select", "show", "explain", "describe", "pragma"}
|
||||
|
||||
// DatabaseOverview collects the runtime overview of the active database.
|
||||
func DatabaseOverview(ctx context.Context) (model.DBOverviewResponse, error) {
|
||||
if !config.Config.Database.Enabled {
|
||||
if !GetDBConfig().Enabled {
|
||||
return repository.GetSQLiteOverview(ctx)
|
||||
}
|
||||
return repository.GetPostgresOverview(ctx)
|
||||
@@ -114,7 +149,7 @@ func isSelectStatement(trimmedSQL string) bool {
|
||||
|
||||
// DatabaseInfo returns the active database type, name and version.
|
||||
func DatabaseInfo(ctx context.Context) model.DatabaseInfoResponse {
|
||||
if !config.Config.Database.Enabled {
|
||||
if !GetDBConfig().Enabled {
|
||||
return repository.GetSQLiteInfo(ctx)
|
||||
}
|
||||
return repository.GetPostgresInfo(ctx)
|
||||
|
||||
@@ -5,7 +5,6 @@ package service
|
||||
|
||||
import (
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/logger"
|
||||
"Wavelet/plugins/domain/admin/errs"
|
||||
"Wavelet/plugins/domain/admin/model"
|
||||
@@ -139,15 +138,15 @@ func validateSwitch(ctx context.Context, target string) error {
|
||||
}
|
||||
switch target {
|
||||
case targetClickHouse:
|
||||
if !config.Config.ClickHouse.Enabled {
|
||||
if !GetClickHouseConfig().Enabled {
|
||||
return errors.New(errs.ErrClickHouseNotEnabled)
|
||||
}
|
||||
case targetPostgres:
|
||||
if !config.Config.Database.Enabled {
|
||||
if !GetDBConfig().Enabled {
|
||||
return errors.New(errs.ErrPostgresNotEnabled)
|
||||
}
|
||||
case targetSQLite:
|
||||
if config.Config.Database.Enabled {
|
||||
if GetDBConfig().Enabled {
|
||||
return errors.New(errs.ErrSQLiteNotAllowedAsLogDB)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/logger"
|
||||
"Wavelet/plugins/domain/admin/model"
|
||||
"Wavelet/plugins/domain/admin/repository"
|
||||
@@ -152,12 +151,12 @@ func retentionOr(ctx context.Context, key string) int {
|
||||
|
||||
func availableLogTargets(active string) []string {
|
||||
if active == logDBNameClickHouse {
|
||||
if config.Config.Database.Enabled {
|
||||
if GetDBConfig().Enabled {
|
||||
return []string{logDBNamePostgres}
|
||||
}
|
||||
return []string{logDBNameSQLite}
|
||||
}
|
||||
if config.Config.ClickHouse.Enabled {
|
||||
if GetClickHouseConfig().Enabled {
|
||||
return []string{logDBNameClickHouse}
|
||||
}
|
||||
return []string{}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package auth
|
||||
|
||||
// SessionConfig defines the session configuration declared by the auth plugin.
|
||||
type SessionConfig struct {
|
||||
SessionCookieName string `config:"session_cookie_name" env:"APP_SESSION_COOKIE_NAME" default:"wavelet_session"`
|
||||
SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
|
||||
SessionDomain string `config:"session_domain" env:"APP_SESSION_DOMAIN"`
|
||||
SessionAge int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"`
|
||||
SessionHTTPOnly bool `config:"session_http_only" env:"APP_SESSION_HTTP_ONLY" default:"true"`
|
||||
SessionSecure bool `config:"session_secure" env:"APP_SESSION_SECURE"`
|
||||
}
|
||||
@@ -73,8 +73,20 @@ func (p *Plugin) Manifest() core.Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
// DeclareConfig declares configuration bindings for the auth plugin.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "app", Target: &SessionConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// Apply registers the auth migrations, services, routes, and settings into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var cfg SessionConfig
|
||||
if err := ctx.Config().Bind("app", &cfg); err == nil {
|
||||
SetSessionConfig(cfg)
|
||||
}
|
||||
|
||||
// 0. Bind DBService & CacheService from Context
|
||||
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
||||
setDBService(db)
|
||||
|
||||
@@ -5,13 +5,13 @@ package auth
|
||||
|
||||
import (
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/config"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -19,14 +19,38 @@ import (
|
||||
gsessions "github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
var (
|
||||
sessConfigMu sync.RWMutex
|
||||
sessConfig = SessionConfig{
|
||||
SessionCookieName: "wavelet_session",
|
||||
SessionAge: 86400,
|
||||
SessionHTTPOnly: true,
|
||||
}
|
||||
)
|
||||
|
||||
// SetSessionConfig updates the active session configuration.
|
||||
func SetSessionConfig(cfg SessionConfig) {
|
||||
sessConfigMu.Lock()
|
||||
defer sessConfigMu.Unlock()
|
||||
sessConfig = cfg
|
||||
}
|
||||
|
||||
// GetSessionConfig returns the active session configuration.
|
||||
func GetSessionConfig() SessionConfig {
|
||||
sessConfigMu.RLock()
|
||||
defer sessConfigMu.RUnlock()
|
||||
return sessConfig
|
||||
}
|
||||
|
||||
// GetSessionOptions 根据配置构建 Session 选项
|
||||
func GetSessionOptions(maxAge int) sessions.Options {
|
||||
cfg := GetSessionConfig()
|
||||
return sessions.Options{
|
||||
Path: "/",
|
||||
Domain: config.Config.App.SessionDomain,
|
||||
Domain: cfg.SessionDomain,
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: config.Config.App.SessionHTTPOnly,
|
||||
Secure: config.Config.App.SessionSecure,
|
||||
HttpOnly: cfg.SessionHTTPOnly,
|
||||
Secure: cfg.SessionSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
@@ -113,7 +137,8 @@ func SetLoginSession(ctx context.Context, c *gin.Context, user *contracts.UserDT
|
||||
}
|
||||
|
||||
// 根据系统配置动态设置 Session 过期时间
|
||||
maxAge := config.Config.App.SessionAge
|
||||
cfg := GetSessionConfig()
|
||||
maxAge := cfg.SessionAge
|
||||
isSessionCookie := false
|
||||
|
||||
val, err := GetSystemConfigValue(ctx, "login_session_ttl_hours")
|
||||
@@ -137,7 +162,7 @@ func SetLoginSession(ctx context.Context, c *gin.Context, user *contracts.UserDT
|
||||
}
|
||||
|
||||
if isSessionCookie {
|
||||
StripCookieMaxAgeAndExpires(c.Writer.Header(), config.Config.App.SessionCookieName)
|
||||
StripCookieMaxAgeAndExpires(c.Writer.Header(), cfg.SessionCookieName)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -41,8 +41,24 @@ func (p *Plugin) Manifest() core.Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
type capAppConfig struct {
|
||||
SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
|
||||
}
|
||||
|
||||
// DeclareConfig declares configuration bindings for the cap plugin.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "app", Target: &capAppConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// Apply registers the cap routes and settings into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var cfg capAppConfig
|
||||
if err := ctx.Config().Bind("app", &cfg); err == nil && cfg.SessionSecret != "" {
|
||||
SetSecret([]byte(cfg.SessionSecret))
|
||||
}
|
||||
|
||||
// 0. Bind DBService from Context
|
||||
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
||||
setDBService(db)
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
package cap
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/plugins/domain/cap/pow"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
@@ -161,23 +160,23 @@ func sGetAndDelete(ctx context.Context, store pow.Store, key string) (string, bo
|
||||
}
|
||||
|
||||
var (
|
||||
defaultManager *Manager
|
||||
once sync.Once
|
||||
defaultManagerMu sync.RWMutex
|
||||
defaultManager *Manager
|
||||
)
|
||||
|
||||
// SetSecret sets the shared secret used by the default manager.
|
||||
func SetSecret(secret []byte) {
|
||||
defaultManagerMu.Lock()
|
||||
defer defaultManagerMu.Unlock()
|
||||
if len(secret) > 0 {
|
||||
store := pow.NewMemoryStore(1 * time.Minute)
|
||||
defaultManager = NewManager(secret, store)
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultManager yields the global singleton CAPTCHA manager.
|
||||
func GetDefaultManager() *Manager {
|
||||
once.Do(func() {
|
||||
var secret []byte
|
||||
if config.Config != nil && strings.TrimSpace(config.Config.App.SessionSecret) != "" {
|
||||
secret = []byte(config.Config.App.SessionSecret)
|
||||
}
|
||||
if len(secret) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
store := pow.NewMemoryStore(1 * time.Minute)
|
||||
defaultManager = NewManager(secret, store)
|
||||
})
|
||||
defaultManagerMu.RLock()
|
||||
defer defaultManagerMu.RUnlock()
|
||||
return defaultManager
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package domain_test
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/idgen"
|
||||
"Wavelet/plugins/domain/admin"
|
||||
"Wavelet/plugins/domain/auth"
|
||||
"Wavelet/plugins/domain/message_gateway"
|
||||
@@ -31,6 +32,7 @@ import (
|
||||
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
_ = idgen.Init(1)
|
||||
dbPath := filepath.Join(t.TempDir(), "domain_test.db")
|
||||
testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
@@ -76,6 +78,8 @@ func (m *mockOAuthProvider) ExchangeCode(ctx context.Context, code string) (*con
|
||||
|
||||
func TestAuthPlugin(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
testDB := setupTestDB(t)
|
||||
|
||||
require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx))
|
||||
@@ -140,6 +144,8 @@ func TestAuthPlugin(t *testing.T) {
|
||||
|
||||
func TestUserPlugin(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
testDB := setupTestDB(t)
|
||||
|
||||
require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx))
|
||||
@@ -242,6 +248,8 @@ func TestUserPlugin(t *testing.T) {
|
||||
|
||||
func TestMessageGatewayPlugin(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
testDB := setupTestDB(t)
|
||||
|
||||
require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx))
|
||||
@@ -310,6 +318,8 @@ func TestMessageGatewayPlugin(t *testing.T) {
|
||||
|
||||
func TestRiskControlPlugin(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
p := risk_control.New()
|
||||
assert.Equal(t, "risk_control", p.Name())
|
||||
assert.Equal(t, "risk_control", p.Manifest().Name)
|
||||
@@ -330,6 +340,8 @@ func TestRiskControlPlugin(t *testing.T) {
|
||||
|
||||
func TestAdminPlugin(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
testDB := setupTestDB(t)
|
||||
|
||||
require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx))
|
||||
@@ -389,6 +401,13 @@ func TestAllDomainPluginsCombined(t *testing.T) {
|
||||
defer func() { _ = rdb.Close() }()
|
||||
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(map[string]any{
|
||||
"redis": map[string]any{
|
||||
"enabled": true,
|
||||
"addrs": []string{mr.Addr()},
|
||||
},
|
||||
}))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
testDB := setupTestDB(t)
|
||||
|
||||
// Apply Infra plugins
|
||||
|
||||
@@ -72,8 +72,23 @@ func (p *Plugin) Manifest() core.Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
type mgAppConfig struct {
|
||||
SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
|
||||
}
|
||||
|
||||
// DeclareConfig declares configuration bindings for the message_gateway plugin.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "app", Target: &mgAppConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// Apply registers message_gateway migrations, routes, tasks, schedules, events, and settings into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var cfg mgAppConfig
|
||||
if err := ctx.Config().Bind("app", &cfg); err == nil && cfg.SessionSecret != "" {
|
||||
service.SetCredentialSecret(cfg.SessionSecret)
|
||||
}
|
||||
// 0. Bind DBService, CacheService, TaskService, UserService
|
||||
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
||||
repository.SetDBService(db)
|
||||
|
||||
@@ -7,7 +7,6 @@ package service
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/logger"
|
||||
"Wavelet/pkg/util"
|
||||
"Wavelet/plugins/domain/message_gateway/errs"
|
||||
@@ -101,12 +100,23 @@ func FormatCode(s string) string {
|
||||
return s[:4] + "-" + s[4:]
|
||||
}
|
||||
|
||||
var (
|
||||
credentialSecretMu sync.RWMutex
|
||||
credentialSecret string
|
||||
)
|
||||
|
||||
// SetCredentialSecret sets the secret used to derive CredentialKey.
|
||||
func SetCredentialSecret(secret string) {
|
||||
credentialSecretMu.Lock()
|
||||
defer credentialSecretMu.Unlock()
|
||||
credentialSecret = secret
|
||||
}
|
||||
|
||||
// CredentialKey is AES-256 hex derived from the session secret.
|
||||
func CredentialKey() string {
|
||||
secret := ""
|
||||
if config.Config != nil {
|
||||
secret = config.Config.App.SessionSecret
|
||||
}
|
||||
credentialSecretMu.RLock()
|
||||
secret := credentialSecret
|
||||
credentialSecretMu.RUnlock()
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package logstore
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/idgen"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
|
||||
func newTestUserAccessStore(t *testing.T) *userAccessLogGormStore {
|
||||
t.Helper()
|
||||
_ = idgen.Init(1)
|
||||
gdb, err := gorm.Open(sqlite.Open("file:logstore-"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, gdb.AutoMigrate(&UserAccessLog{}))
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package logstore
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/logger"
|
||||
"context"
|
||||
"errors"
|
||||
@@ -34,6 +33,9 @@ const resolveCacheTTL = 1 * time.Second
|
||||
var (
|
||||
configReader ConfigReader
|
||||
|
||||
defaultDBMu sync.RWMutex
|
||||
defaultDB = dbNameSQLite
|
||||
|
||||
storeMu sync.RWMutex
|
||||
active *Store
|
||||
activeDB string
|
||||
@@ -41,6 +43,25 @@ var (
|
||||
lastResolveTime time.Time
|
||||
)
|
||||
|
||||
// SetDefaultDatabases configures the fallback database based on database and clickhouse enablement.
|
||||
func SetDefaultDatabases(dbEnabled, chEnabled bool) {
|
||||
defaultDBMu.Lock()
|
||||
defer defaultDBMu.Unlock()
|
||||
defaultDB = dbNameSQLite
|
||||
if dbEnabled {
|
||||
defaultDB = dbNamePostgres
|
||||
}
|
||||
if chEnabled {
|
||||
defaultDB = dbNameClickHouse
|
||||
}
|
||||
}
|
||||
|
||||
func getDefaultDatabase() string {
|
||||
defaultDBMu.RLock()
|
||||
defer defaultDBMu.RUnlock()
|
||||
return defaultDB
|
||||
}
|
||||
|
||||
// SetConfigReader 注入系统配置读取函数(bootstrap 调用,测试可注入内存实现)。
|
||||
func SetConfigReader(fn ConfigReader) { configReader = fn }
|
||||
|
||||
@@ -169,13 +190,7 @@ func resolveDatabase(ctx context.Context) (string, error) {
|
||||
|
||||
resolved := v
|
||||
if resolved == "" {
|
||||
resolved = dbNameSQLite
|
||||
if config.Config.Database.Enabled {
|
||||
resolved = dbNamePostgres
|
||||
}
|
||||
if config.Config.ClickHouse.Enabled {
|
||||
resolved = dbNameClickHouse
|
||||
}
|
||||
resolved = getDefaultDatabase()
|
||||
}
|
||||
|
||||
storeMu.Lock()
|
||||
|
||||
@@ -6,26 +6,38 @@ package risk_control
|
||||
|
||||
import (
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/ginutil"
|
||||
"Wavelet/pkg/idgen"
|
||||
"Wavelet/pkg/response"
|
||||
"Wavelet/plugins/domain/risk_control/logstore"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var accessLogEnabled atomic.Bool
|
||||
|
||||
// SetAccessLogEnabled enables or disables access log collection.
|
||||
func SetAccessLogEnabled(enabled bool) {
|
||||
accessLogEnabled.Store(enabled)
|
||||
}
|
||||
|
||||
// IsAccessLogEnabled reports whether access log collection is enabled.
|
||||
func IsAccessLogEnabled() bool {
|
||||
return accessLogEnabled.Load()
|
||||
}
|
||||
|
||||
// Middleware is an alias for RiskControlMiddleware.
|
||||
var Middleware = RiskControlMiddleware
|
||||
|
||||
// RiskControlMiddleware 全局日志采集中间件
|
||||
func RiskControlMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 如果未启用 ClickHouse,直接放行
|
||||
if config.Config == nil || !config.Config.ClickHouse.Enabled {
|
||||
// 如果未启用日志采集,直接放行
|
||||
if !IsAccessLogEnabled() {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ package risk_control_test
|
||||
import (
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/batchwriter"
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/ginutil"
|
||||
"Wavelet/pkg/idgen"
|
||||
"Wavelet/pkg/testhelper"
|
||||
"Wavelet/plugins/domain/risk_control"
|
||||
"Wavelet/plugins/domain/risk_control/logstore"
|
||||
@@ -23,6 +23,10 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_ = idgen.Init(1)
|
||||
}
|
||||
|
||||
func newTestAccessLogWriter(t *testing.T, cfg batchwriter.Config) (*batchwriter.Writer[*logstore.UserAccessLog], func() []*logstore.UserAccessLog) {
|
||||
t.Helper()
|
||||
|
||||
@@ -70,8 +74,8 @@ func TestRiskControlMiddleware(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("ClickHouse disabled", func(t *testing.T) {
|
||||
config.Config.ClickHouse.Enabled = false
|
||||
defer func() { config.Config.ClickHouse.Enabled = false }()
|
||||
risk_control.SetAccessLogEnabled(false)
|
||||
defer risk_control.SetAccessLogEnabled(false)
|
||||
|
||||
r := testhelper.NewTestGinEngine(risk_control.RiskControlMiddleware())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
@@ -87,8 +91,8 @@ func TestRiskControlMiddleware(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("ClickHouse enabled - Normal Authenticated Request", func(t *testing.T) {
|
||||
config.Config.ClickHouse.Enabled = true
|
||||
defer func() { config.Config.ClickHouse.Enabled = false }()
|
||||
risk_control.SetAccessLogEnabled(true)
|
||||
defer risk_control.SetAccessLogEnabled(false)
|
||||
|
||||
cfg := batchwriter.DefaultConfig()
|
||||
cfg.MaxBatchSize = 100
|
||||
@@ -133,8 +137,8 @@ func TestRiskControlMiddleware(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("ClickHouse enabled - Unauthenticated Request", func(t *testing.T) {
|
||||
config.Config.ClickHouse.Enabled = true
|
||||
defer func() { config.Config.ClickHouse.Enabled = false }()
|
||||
risk_control.SetAccessLogEnabled(true)
|
||||
defer risk_control.SetAccessLogEnabled(false)
|
||||
|
||||
cfg := batchwriter.DefaultConfig()
|
||||
cfg.MaxBatchSize = 100
|
||||
@@ -162,8 +166,8 @@ func TestRiskControlMiddleware(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("ClickHouse enabled - Buffer Full Rate Limiting", func(t *testing.T) {
|
||||
config.Config.ClickHouse.Enabled = true
|
||||
defer func() { config.Config.ClickHouse.Enabled = false }()
|
||||
risk_control.SetAccessLogEnabled(true)
|
||||
defer risk_control.SetAccessLogEnabled(false)
|
||||
|
||||
cfg := batchwriter.DefaultConfig()
|
||||
cfg.QueueSize = 2
|
||||
|
||||
@@ -68,8 +68,32 @@ func (p *Plugin) Manifest() core.Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
type rcClickHouseConfig struct {
|
||||
Enabled bool `config:"enabled" env:"CLICKHOUSE_ENABLED" default:"false" autoEnable:"CLICKHOUSE_HOST"`
|
||||
}
|
||||
|
||||
type rcDBConfig struct {
|
||||
Enabled bool `config:"enabled" env:"DB_ENABLED" default:"false" autoEnable:"DB_HOST"`
|
||||
}
|
||||
|
||||
// DeclareConfig declares configuration bindings for the risk_control plugin.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "clickhouse", Target: &rcClickHouseConfig{}},
|
||||
{Prefix: "database", Target: &rcDBConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// Apply registers risk control middlewares, settings, and cleanup hooks into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var chCfg rcClickHouseConfig
|
||||
_ = ctx.Config().Bind("clickhouse", &chCfg)
|
||||
var dbCfg rcDBConfig
|
||||
_ = ctx.Config().Bind("database", &dbCfg)
|
||||
|
||||
SetAccessLogEnabled(chCfg.Enabled)
|
||||
logstore.SetDefaultDatabases(dbCfg.Enabled, chCfg.Enabled)
|
||||
|
||||
// 0. Bind DBService
|
||||
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
||||
logstore.SetDBService(db)
|
||||
|
||||
@@ -7,7 +7,6 @@ package system
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/logger"
|
||||
"Wavelet/pkg/response"
|
||||
"net/http"
|
||||
@@ -48,6 +47,8 @@ func (p *Plugin) Manifest() core.Manifest {
|
||||
|
||||
// Apply registers system routes.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
appName := ctx.Config().String("app.app_name", "Wavelet")
|
||||
|
||||
// 1. Health check
|
||||
ctx.Router().GET("/healthz", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
@@ -65,7 +66,7 @@ func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
c.JSON(http.StatusOK, response.OK(gin.H{
|
||||
"configs": configs,
|
||||
"app": gin.H{
|
||||
"name": config.Config.App.AppName,
|
||||
"name": appName,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"Wavelet/pkg/idgen"
|
||||
uploadstats "Wavelet/plugins/domain/upload/stats"
|
||||
)
|
||||
|
||||
@@ -36,6 +37,7 @@ type testResponse struct {
|
||||
}
|
||||
|
||||
func setupTestRouter(authUser *contracts.UserDTO) *gin.Engine {
|
||||
_ = idgen.Init(1)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(response.ErrorHandlerMiddleware())
|
||||
|
||||
@@ -5,6 +5,7 @@ package ingest
|
||||
|
||||
import (
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/idgen"
|
||||
"Wavelet/plugins/domain/upload/models"
|
||||
"Wavelet/plugins/domain/upload/shared"
|
||||
"bytes"
|
||||
@@ -17,6 +18,10 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_ = idgen.Init(1)
|
||||
}
|
||||
|
||||
type testStorageService struct {
|
||||
mu sync.RWMutex
|
||||
mockFiles map[string][]byte
|
||||
|
||||
@@ -6,6 +6,7 @@ package user_test
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/idgen"
|
||||
"Wavelet/plugins/domain/user"
|
||||
"context"
|
||||
"path/filepath"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
_ = idgen.Init(1)
|
||||
dbPath := filepath.Join(t.TempDir(), "user_test.db")
|
||||
testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
@@ -36,6 +38,8 @@ func setupTestDB(t *testing.T) *gorm.DB {
|
||||
|
||||
func TestUserPluginUnit(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
testDB := setupTestDB(t)
|
||||
|
||||
dbPlugin := database.New(database.WithDB(testDB))
|
||||
|
||||
@@ -21,6 +21,8 @@ import (
|
||||
// 否则调用方(如访问日志按用户补全)会按 ID 逐条打库。
|
||||
func TestGetUsersByIDsUsesSingleQuery(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
testDB := setupTestDB(t)
|
||||
require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx))
|
||||
require.NoError(t, user.New().Apply(ctx))
|
||||
|
||||
@@ -7,7 +7,6 @@ package driver_asynq_cron
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/config"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
@@ -84,10 +83,45 @@ func (p *Plugin) Name() string {
|
||||
return "driver_asynq_cron"
|
||||
}
|
||||
|
||||
type redisCronConfig struct {
|
||||
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
|
||||
Addrs []string `config:"addrs" env:"REDIS_ADDR"`
|
||||
Username string `config:"username" env:"REDIS_USERNAME"`
|
||||
Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"`
|
||||
DB int `config:"db" env:"REDIS_DB"`
|
||||
}
|
||||
|
||||
// DeclareConfig declares configuration bindings for driver_asynq_cron.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "redis", Target: &redisCronConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigEnabled gates plugin activation when Redis is enabled.
|
||||
func (p *Plugin) ConfigEnabled(view core.ConfigView) bool {
|
||||
return view.Bool("redis.enabled", false)
|
||||
}
|
||||
|
||||
// Apply mounts the Asynq Cron Scheduler driver into the micro-kernel Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var rCfg redisCronConfig
|
||||
_ = ctx.Config().Bind("redis", &rCfg)
|
||||
|
||||
p.mu.Lock()
|
||||
p.coreCtx = ctx
|
||||
if p.redisOpt == nil {
|
||||
addr := "127.0.0.1:6379"
|
||||
if len(rCfg.Addrs) > 0 && rCfg.Addrs[0] != "" {
|
||||
addr = rCfg.Addrs[0]
|
||||
}
|
||||
p.redisOpt = asynq.RedisClientOpt{
|
||||
Addr: addr,
|
||||
Username: rCfg.Username,
|
||||
Password: rCfg.Password,
|
||||
DB: rCfg.DB,
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
// Bind DBService
|
||||
@@ -263,15 +297,7 @@ func (p *Plugin) resolveRedisOpt() asynq.RedisConnOpt {
|
||||
if RedisOpt != nil {
|
||||
return RedisOpt
|
||||
}
|
||||
redisCfg := config.Config.Redis
|
||||
addr := "127.0.0.1:6379"
|
||||
if len(redisCfg.Addrs) > 0 && redisCfg.Addrs[0] != "" {
|
||||
addr = redisCfg.Addrs[0]
|
||||
}
|
||||
return asynq.RedisClientOpt{
|
||||
Addr: addr,
|
||||
Username: redisCfg.Username,
|
||||
Password: redisCfg.Password,
|
||||
DB: redisCfg.DB,
|
||||
Addr: "127.0.0.1:6379",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package driver_asynq_worker
|
||||
|
||||
type queueConfig struct {
|
||||
Name string `config:"name"`
|
||||
Priority int `config:"priority"`
|
||||
}
|
||||
|
||||
type workerConfig struct {
|
||||
Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY" default:"10"`
|
||||
StrictPriority bool `config:"strict_priority" env:"WORKER_STRICT_PRIORITY" default:"false"`
|
||||
Queues []queueConfig `config:"queues"`
|
||||
}
|
||||
|
||||
type redisWorkerConfig struct {
|
||||
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
|
||||
Addrs []string `config:"addrs" env:"REDIS_ADDR"`
|
||||
Username string `config:"username" env:"REDIS_USERNAME"`
|
||||
Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"`
|
||||
DB int `config:"db" env:"REDIS_DB"`
|
||||
ClusterMode bool `config:"cluster_mode" env:"REDIS_CLUSTER_MODE"`
|
||||
MasterName string `config:"master_name" env:"REDIS_MASTER_NAME"`
|
||||
KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"`
|
||||
PoolSize int `config:"pool_size" env:"REDIS_POOL_SIZE"`
|
||||
MaintNotifications bool `config:"maint_notifications" env:"REDIS_MAINT_NOTIFICATIONS" default:"false"`
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package driver_asynq_worker
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/idgen"
|
||||
"Wavelet/pkg/testhelper"
|
||||
"context"
|
||||
"errors"
|
||||
@@ -22,6 +23,10 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_ = idgen.Init(1)
|
||||
}
|
||||
|
||||
type mockDBService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
@@ -108,10 +108,44 @@ func (p *Plugin) Name() string {
|
||||
return "driver_asynq_worker"
|
||||
}
|
||||
|
||||
// DeclareConfig declares configuration bindings consumed by the Asynq worker driver.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "worker", Target: &workerConfig{}},
|
||||
{Prefix: "redis", Target: &redisWorkerConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigEnabled gates plugin activation when Redis is enabled.
|
||||
func (p *Plugin) ConfigEnabled(view core.ConfigView) bool {
|
||||
return view.Bool("redis.enabled", false)
|
||||
}
|
||||
|
||||
// Apply mounts the Asynq Worker driver into the micro-kernel Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var wCfg workerConfig
|
||||
_ = ctx.Config().Bind("worker", &wCfg)
|
||||
var rCfg redisWorkerConfig
|
||||
_ = ctx.Config().Bind("redis", &rCfg)
|
||||
|
||||
p.mu.Lock()
|
||||
p.coreCtx = ctx
|
||||
if p.concurrency == defaultConcurrency && wCfg.Concurrency > 0 {
|
||||
p.concurrency = wCfg.Concurrency
|
||||
}
|
||||
p.strictPriority = wCfg.StrictPriority
|
||||
if len(p.queues) == 1 && p.queues["default"] == 1 && len(wCfg.Queues) > 0 {
|
||||
qMap := make(map[string]int, len(wCfg.Queues))
|
||||
for _, q := range wCfg.Queues {
|
||||
qMap[q.Name] = q.Priority
|
||||
}
|
||||
p.queues = qMap
|
||||
}
|
||||
if p.redisOpt == nil {
|
||||
p.redisOpt = NewRedisConnOptWithConfig(rCfg)
|
||||
}
|
||||
RedisOpt = p.redisOpt
|
||||
ResetAsynqClient()
|
||||
p.mu.Unlock()
|
||||
|
||||
// 0. Bind DBService
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package driver_asynq_worker
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"sync"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
@@ -72,8 +71,11 @@ func GetAsynqClient() *asynq.Client {
|
||||
return AsynqClient
|
||||
}
|
||||
|
||||
opt := NewRedisConnOpt()
|
||||
RedisOpt = opt
|
||||
opt := RedisOpt
|
||||
if opt == nil {
|
||||
opt = NewRedisConnOpt()
|
||||
RedisOpt = opt
|
||||
}
|
||||
AsynqClient = asynq.NewClient(opt)
|
||||
return AsynqClient
|
||||
}
|
||||
@@ -88,9 +90,33 @@ func ResetAsynqClient() {
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
keyPrefixMu sync.RWMutex
|
||||
keyPrefix string
|
||||
)
|
||||
|
||||
// SetKeyPrefix sets the redis key prefix for queue names.
|
||||
func SetKeyPrefix(prefix string) {
|
||||
keyPrefixMu.Lock()
|
||||
defer keyPrefixMu.Unlock()
|
||||
keyPrefix = prefix
|
||||
}
|
||||
|
||||
// GetKeyPrefix returns the redis key prefix for queue names.
|
||||
func GetKeyPrefix() string {
|
||||
keyPrefixMu.RLock()
|
||||
defer keyPrefixMu.RUnlock()
|
||||
return keyPrefix
|
||||
}
|
||||
|
||||
// NewRedisConnOpt 根据配置返回对应的 asynq Redis 连接选项
|
||||
func NewRedisConnOpt() asynq.RedisConnOpt {
|
||||
cfg := config.Config.Redis
|
||||
return NewRedisConnOptWithConfig(redisWorkerConfig{})
|
||||
}
|
||||
|
||||
// NewRedisConnOptWithConfig returns the asynq RedisConnOpt based on the provided configuration.
|
||||
func NewRedisConnOptWithConfig(cfg redisWorkerConfig) asynq.RedisConnOpt {
|
||||
SetKeyPrefix(cfg.KeyPrefix)
|
||||
addrs := cfg.Addrs
|
||||
|
||||
if cfg.ClusterMode {
|
||||
@@ -135,7 +161,7 @@ func NewRedisConnOpt() asynq.RedisConnOpt {
|
||||
|
||||
// PrefixedQueue 返回带前缀的队列名,用于 Cluster 模式隔离
|
||||
func PrefixedQueue(queue string) string {
|
||||
prefix := config.Config.Redis.KeyPrefix
|
||||
prefix := GetKeyPrefix()
|
||||
if prefix == "" {
|
||||
return queue
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package driver_asynq_worker
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"testing"
|
||||
|
||||
"github.com/redis/go-redis/v9/maintnotifications"
|
||||
@@ -42,17 +41,17 @@ func TestMaintNotificationsConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPrefixedQueue(t *testing.T) {
|
||||
oldPrefix := config.Config.Redis.KeyPrefix
|
||||
oldPrefix := GetKeyPrefix()
|
||||
defer func() {
|
||||
config.Config.Redis.KeyPrefix = oldPrefix
|
||||
SetKeyPrefix(oldPrefix)
|
||||
}()
|
||||
|
||||
config.Config.Redis.KeyPrefix = "test:"
|
||||
SetKeyPrefix("test:")
|
||||
if got := PrefixedQueue("default"); got != "test:default" {
|
||||
t.Fatalf("PrefixedQueue() = %q, want %q", got, "test:default")
|
||||
}
|
||||
|
||||
config.Config.Redis.KeyPrefix = ""
|
||||
SetKeyPrefix("")
|
||||
if got := PrefixedQueue("default"); got != "default" {
|
||||
t.Fatalf("PrefixedQueue() = %q, want %q", got, "default")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package driver_http
|
||||
|
||||
type httpAppConfig struct {
|
||||
Addr string `config:"addr" env:"APP_ADDR" default:":8000"`
|
||||
AppName string `config:"app_name" env:"APP_NAME" default:"Wavelet"`
|
||||
APIPrefix string `config:"api_prefix" env:"APP_API_PREFIX" default:"/api/v1"`
|
||||
Env string `config:"env" env:"APP_ENV" default:"development"`
|
||||
GracefulShutdownTimeout int `config:"graceful_shutdown_timeout" env:"APP_GRACEFUL_SHUTDOWN_TIMEOUT" default:"30"`
|
||||
SessionCookieName string `config:"session_cookie_name" env:"APP_SESSION_COOKIE_NAME" default:"wavelet_session"`
|
||||
SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
|
||||
SessionDomain string `config:"session_domain" env:"APP_SESSION_DOMAIN"`
|
||||
SessionAge int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"`
|
||||
SessionHTTPOnly bool `config:"session_http_only" env:"APP_SESSION_HTTP_ONLY" default:"true"`
|
||||
SessionSecure bool `config:"session_secure" env:"APP_SESSION_SECURE"`
|
||||
}
|
||||
|
||||
type httpRedisConfig struct {
|
||||
Addrs []string `config:"addrs" env:"REDIS_ADDR"`
|
||||
Username string `config:"username" env:"REDIS_USERNAME"`
|
||||
Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"`
|
||||
DB int `config:"db" env:"REDIS_DB"`
|
||||
KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"`
|
||||
MinIdleConn int `config:"min_idle_conn" env:"REDIS_MIN_IDLE_CONN"`
|
||||
}
|
||||
@@ -4,19 +4,9 @@
|
||||
package driver_http
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/trace"
|
||||
"Wavelet/pkg/util"
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/redis"
|
||||
@@ -26,102 +16,82 @@ import (
|
||||
|
||||
// BuildEngine 构建并初始化 Gin 路由引擎及全部中间件和路由
|
||||
func BuildEngine() (*gin.Engine, error) {
|
||||
return BuildEngineWithConfig(httpAppConfig{}, httpRedisConfig{})
|
||||
}
|
||||
|
||||
// BuildEngineWithConfig constructs the Gin engine with explicitly injected configuration.
|
||||
func BuildEngineWithConfig(appCfg httpAppConfig, redisCfg httpRedisConfig) (*gin.Engine, error) {
|
||||
// 运行模式
|
||||
if config.Config.App.IsProduction() {
|
||||
if appCfg.Env == "production" || appCfg.Env == "prod" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
setAPIPrefix(appCfg.APIPrefix)
|
||||
|
||||
// 初始化路由
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(corsMiddleware())
|
||||
|
||||
cfg := config.Config.Redis
|
||||
addrs := cfg.Addrs
|
||||
addrs := redisCfg.Addrs
|
||||
sessionAddr := "localhost:6379"
|
||||
if len(addrs) > 0 {
|
||||
sessionAddr = addrs[0]
|
||||
}
|
||||
|
||||
sessionSecret := appCfg.SessionSecret
|
||||
if sessionSecret == "" {
|
||||
sessionSecret = "wavelet-default-session-secret"
|
||||
}
|
||||
|
||||
sessionStore, err := redis.NewStoreWithDB(
|
||||
cfg.MinIdleConn,
|
||||
redisCfg.MinIdleConn,
|
||||
"tcp",
|
||||
sessionAddr,
|
||||
cfg.Username,
|
||||
cfg.Password,
|
||||
strconv.Itoa(cfg.DB),
|
||||
[]byte(config.Config.App.SessionSecret),
|
||||
redisCfg.Username,
|
||||
redisCfg.Password,
|
||||
strconv.Itoa(redisCfg.DB),
|
||||
[]byte(sessionSecret),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 设置 Session Redis Key 前缀
|
||||
if cfg.KeyPrefix != "" {
|
||||
if err := redis.SetKeyPrefix(sessionStore, cfg.KeyPrefix+"session:"); err != nil {
|
||||
if redisCfg.KeyPrefix != "" {
|
||||
if err := redis.SetKeyPrefix(sessionStore, redisCfg.KeyPrefix+"session:"); err != nil {
|
||||
log.Printf("[API] set session key prefix failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
sessionCookieName := appCfg.SessionCookieName
|
||||
if sessionCookieName == "" {
|
||||
sessionCookieName = "wavelet_session"
|
||||
}
|
||||
|
||||
sessionAge := appCfg.SessionAge
|
||||
if sessionAge <= 0 {
|
||||
sessionAge = 86400
|
||||
}
|
||||
|
||||
sessionStore.Options(sessions.Options{
|
||||
Path: "/",
|
||||
Domain: config.Config.App.SessionDomain,
|
||||
MaxAge: config.Config.App.SessionAge,
|
||||
HttpOnly: config.Config.App.SessionHTTPOnly,
|
||||
Secure: config.Config.App.SessionSecure,
|
||||
Domain: appCfg.SessionDomain,
|
||||
MaxAge: sessionAge,
|
||||
HttpOnly: appCfg.SessionHTTPOnly,
|
||||
Secure: appCfg.SessionSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
r.Use(sessions.Sessions(config.Config.App.SessionCookieName, sessionStore))
|
||||
r.Use(sessions.Sessions(sessionCookieName, sessionStore))
|
||||
|
||||
appName := appCfg.AppName
|
||||
if appName == "" {
|
||||
appName = "Wavelet"
|
||||
}
|
||||
|
||||
// 补充中间件
|
||||
r.Use(otelgin.Middleware(config.Config.App.AppName), errorHandlerMiddleware(), loggerMiddleware())
|
||||
r.Use(otelgin.Middleware(appName), errorHandlerMiddleware(), loggerMiddleware())
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Serve 启动 HTTP API 服务。onStarted 仅会在 HTTP 地址成功绑定后调用。
|
||||
func Serve(onStarted func()) {
|
||||
r, err := BuildEngine()
|
||||
if err != nil {
|
||||
log.Fatalf("[API] init session store failed: %v\n", err)
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: config.Config.App.Addr,
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", config.Config.App.Addr)
|
||||
if err != nil {
|
||||
log.Fatalf("[API] server failed to listen on %s: %v\n", config.Config.App.Addr, err)
|
||||
}
|
||||
if onStarted != nil {
|
||||
onStarted()
|
||||
}
|
||||
|
||||
util.Go(func() {
|
||||
log.Printf("[API] server listening on %s\n", config.Config.App.Addr)
|
||||
if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("[API] server failed: %v\n", err)
|
||||
}
|
||||
})
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Config.App.GracefulShutdownTimeout)*time.Second)
|
||||
|
||||
trace.Shutdown(shutdownCtx)
|
||||
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("[API] server forced to shutdown: %v\n", err)
|
||||
cancel()
|
||||
os.Exit(1)
|
||||
}
|
||||
cancel()
|
||||
|
||||
log.Println("[API] server exited")
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
package driver_http
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/logger"
|
||||
"Wavelet/pkg/response"
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -21,6 +21,26 @@ import (
|
||||
otel_trace "Wavelet/pkg/trace"
|
||||
)
|
||||
|
||||
var (
|
||||
apiPrefixMu sync.RWMutex
|
||||
apiPrefix = "/api/v1"
|
||||
)
|
||||
|
||||
func setAPIPrefix(prefix string) {
|
||||
if prefix == "" {
|
||||
return
|
||||
}
|
||||
apiPrefixMu.Lock()
|
||||
defer apiPrefixMu.Unlock()
|
||||
apiPrefix = prefix
|
||||
}
|
||||
|
||||
func getAPIPrefix() string {
|
||||
apiPrefixMu.RLock()
|
||||
defer apiPrefixMu.RUnlock()
|
||||
return apiPrefix
|
||||
}
|
||||
|
||||
func loggerMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 初始化 Trace
|
||||
@@ -46,7 +66,7 @@ func loggerMiddleware() gin.HandlerFunc {
|
||||
|
||||
// 打印日志
|
||||
// 排除健康检查接口
|
||||
healthPath := config.Config.App.APIPrefix + "/health"
|
||||
healthPath := getAPIPrefix() + "/health"
|
||||
if c.Request.URL.Path != healthPath {
|
||||
logger.InfoF(
|
||||
ctx,
|
||||
|
||||
@@ -91,10 +91,31 @@ func (p *Plugin) Name() string {
|
||||
return "driver_http"
|
||||
}
|
||||
|
||||
// DeclareConfig declares configuration bindings for driver_http.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "app", Target: &httpAppConfig{}},
|
||||
{Prefix: "redis", Target: &httpRedisConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// Apply mounts the HTTP driver plugin into the micro-kernel Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var appCfg httpAppConfig
|
||||
if err := ctx.Config().Bind("app", &appCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
var redisCfg httpRedisConfig
|
||||
_ = ctx.Config().Bind("redis", &redisCfg)
|
||||
|
||||
p.mu.Lock()
|
||||
p.coreCtx = ctx
|
||||
if p.addr == defaultAddr && appCfg.Addr != "" {
|
||||
p.addr = appCfg.Addr
|
||||
}
|
||||
if appCfg.GracefulShutdownTimeout > 0 {
|
||||
p.shutdownTimeout = time.Duration(appCfg.GracefulShutdownTimeout) * time.Second
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
// Bind DBService from Context
|
||||
@@ -147,7 +168,17 @@ func (p *Plugin) Start(ctx context.Context) error {
|
||||
}
|
||||
|
||||
if p.engine == nil {
|
||||
p.engine = gin.New()
|
||||
var appCfg httpAppConfig
|
||||
var redisCfg httpRedisConfig
|
||||
if p.coreCtx != nil {
|
||||
_ = p.coreCtx.Config().Bind("app", &appCfg)
|
||||
_ = p.coreCtx.Config().Bind("redis", &redisCfg)
|
||||
}
|
||||
var err error
|
||||
p.engine, err = BuildEngineWithConfig(appCfg, redisCfg)
|
||||
if err != nil {
|
||||
p.engine = gin.New()
|
||||
}
|
||||
}
|
||||
|
||||
// Mount routes collected in Context RouterExtension
|
||||
|
||||
@@ -38,6 +38,22 @@ func (p *Plugin) Manifest() core.Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
type redisGateConfig struct {
|
||||
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
|
||||
}
|
||||
|
||||
// DeclareConfig declares configuration bindings for driver_inproc_cron.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "redis", Target: &redisGateConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigEnabled gates plugin activation when Redis is disabled.
|
||||
func (p *Plugin) ConfigEnabled(view core.ConfigView) bool {
|
||||
return !view.Bool("redis.enabled", false)
|
||||
}
|
||||
|
||||
// Apply registers the scheduler driver into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
p.mu.Lock()
|
||||
|
||||
@@ -98,6 +98,22 @@ func (p *Plugin) Manifest() core.Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
type redisGateConfig struct {
|
||||
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
|
||||
}
|
||||
|
||||
// DeclareConfig declares the configuration bindings consumed by this plugin.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "redis", Target: &redisGateConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigEnabled gates plugin activation when Redis is disabled.
|
||||
func (p *Plugin) ConfigEnabled(view core.ConfigView) bool {
|
||||
return !view.Bool("redis.enabled", false)
|
||||
}
|
||||
|
||||
// Apply registers the worker driver and provides contracts.TaskService.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
p.coreCtx = ctx
|
||||
|
||||
@@ -6,6 +6,7 @@ package driver_inproc_worker_test
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/extpoints"
|
||||
"Wavelet/pkg/idgen"
|
||||
"Wavelet/plugins/drivers/driver_inproc_worker"
|
||||
"context"
|
||||
"sync/atomic"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
)
|
||||
|
||||
func TestInprocWorkerPlugin(t *testing.T) {
|
||||
require.NoError(t, idgen.Init(1))
|
||||
ctx := core.NewContext(context.Background())
|
||||
p := driver_inproc_worker.New(
|
||||
driver_inproc_worker.WithConcurrency(2),
|
||||
|
||||
@@ -32,6 +32,8 @@ func init() {
|
||||
|
||||
func TestHTTPDriverLifecycle(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
|
||||
var globalMiddlewareCalled atomic.Bool
|
||||
var groupMiddlewareCalled atomic.Bool
|
||||
@@ -264,6 +266,8 @@ func TestMultipleDriversInContext(t *testing.T) {
|
||||
defer mr.Close()
|
||||
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
|
||||
httpPlugin := driver_http.New(driver_http.WithAddr("127.0.0.1:0"))
|
||||
workerPlugin := driver_asynq_worker.New(driver_asynq_worker.WithRedisOpt(asynq.RedisClientOpt{Addr: mr.Addr()}))
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cache
|
||||
|
||||
// RedisConfig declares the configuration read by the Redis cache provider.
|
||||
type RedisConfig struct {
|
||||
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
|
||||
Addrs []string `config:"addrs" env:"REDIS_ADDR"`
|
||||
Username string `config:"username" env:"REDIS_USERNAME"`
|
||||
Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"`
|
||||
DB int `config:"db" env:"REDIS_DB"`
|
||||
ClusterMode bool `config:"cluster_mode" env:"REDIS_CLUSTER_MODE"`
|
||||
MasterName string `config:"master_name" env:"REDIS_MASTER_NAME"`
|
||||
KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"`
|
||||
PoolSize int `config:"pool_size" env:"REDIS_POOL_SIZE"`
|
||||
MinIdleConn int `config:"min_idle_conn" env:"REDIS_MIN_IDLE_CONN"`
|
||||
DialTimeout int `config:"dial_timeout" env:"REDIS_DIAL_TIMEOUT"`
|
||||
ReadTimeout int `config:"read_timeout" env:"REDIS_READ_TIMEOUT"`
|
||||
WriteTimeout int `config:"write_timeout" env:"REDIS_WRITE_TIMEOUT"`
|
||||
MaxRetries int `config:"max_retries" env:"REDIS_MAX_RETRIES"`
|
||||
PoolTimeout int `config:"pool_timeout" env:"REDIS_POOL_TIMEOUT"`
|
||||
ConnMaxIdleTime int `config:"conn_max_idle_time" env:"REDIS_CONN_MAX_IDLE_TIME"`
|
||||
MaintNotifications bool `config:"maint_notifications" env:"REDIS_MAINT_NOTIFICATIONS" default:"false"`
|
||||
}
|
||||
+18
-1
@@ -77,13 +77,30 @@ func (p *Plugin) Name() string {
|
||||
return "cache"
|
||||
}
|
||||
|
||||
// DeclareConfig declares the configuration bindings consumed by this plugin.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "redis", Target: &RedisConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigEnabled gates plugin activation based on whether Redis is enabled.
|
||||
func (p *Plugin) ConfigEnabled(view core.ConfigView) bool {
|
||||
return view.Bool("redis.enabled", false)
|
||||
}
|
||||
|
||||
// Apply mounts the multi-layer cache service into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var cfg RedisConfig
|
||||
if err := ctx.Config().Bind("redis", &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
redisClient := p.redisClient
|
||||
if redisClient == nil {
|
||||
if Redis == nil {
|
||||
var err error
|
||||
redisClient, err = InitRedis()
|
||||
redisClient, err = InitRedisWithConfig(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+4
-2
@@ -32,9 +32,11 @@ func TestCachePluginOperations(t *testing.T) {
|
||||
cache.WithKeyPrefix("app:"),
|
||||
cache.WithRAMCapacity(500),
|
||||
)
|
||||
assert.Equal(t, "cache", p.Name())
|
||||
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(map[string]any{
|
||||
"redis.enabled": true,
|
||||
}))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
require.NoError(t, p.Apply(ctx))
|
||||
|
||||
svc, err := core.Inject[contracts.CacheService](ctx)
|
||||
|
||||
Vendored
+26
-7
@@ -4,12 +4,12 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/extra/redisotel/v9"
|
||||
@@ -18,18 +18,37 @@ import (
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
)
|
||||
|
||||
// Redis 全局 Redis 客户端实例
|
||||
var Redis redis.UniversalClient
|
||||
var (
|
||||
// Redis 全局 Redis 客户端实例
|
||||
Redis redis.UniversalClient
|
||||
|
||||
// InitRedis 初始化全局/默认 Redis 客户端实例
|
||||
func InitRedis() (redis.UniversalClient, error) {
|
||||
cfg := config.Config.Redis
|
||||
keyPrefixMu sync.RWMutex
|
||||
keyPrefix string
|
||||
)
|
||||
|
||||
// SetKeyPrefix sets the package-level key prefix.
|
||||
func SetKeyPrefix(prefix string) {
|
||||
keyPrefixMu.Lock()
|
||||
defer keyPrefixMu.Unlock()
|
||||
keyPrefix = prefix
|
||||
}
|
||||
|
||||
// GetKeyPrefix returns the package-level key prefix.
|
||||
func GetKeyPrefix() string {
|
||||
keyPrefixMu.RLock()
|
||||
defer keyPrefixMu.RUnlock()
|
||||
return keyPrefix
|
||||
}
|
||||
|
||||
// InitRedisWithConfig initializes the Redis client using the provided RedisConfig.
|
||||
func InitRedisWithConfig(cfg RedisConfig) (redis.UniversalClient, error) {
|
||||
if !cfg.Enabled {
|
||||
log.Println("[Redis] is disabled, skipping Redis initialization")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
SetKeyPrefix(cfg.KeyPrefix)
|
||||
|
||||
var client redis.UniversalClient
|
||||
|
||||
if cfg.ClusterMode {
|
||||
@@ -114,7 +133,7 @@ func redisMaintNotificationsConfig(enabled bool) *maintnotifications.Config {
|
||||
|
||||
// PrefixedKey 返回带前缀的 Key
|
||||
func PrefixedKey(key string) string {
|
||||
prefix := config.Config.Redis.KeyPrefix
|
||||
prefix := GetKeyPrefix()
|
||||
if prefix == "" {
|
||||
return key
|
||||
}
|
||||
|
||||
@@ -54,6 +54,23 @@ func (p *Plugin) Manifest() core.Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
// redisGateConfig declares the Redis gate configuration for cache_memory.
|
||||
type redisGateConfig struct {
|
||||
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
|
||||
}
|
||||
|
||||
// DeclareConfig declares the configuration bindings consumed by this plugin.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "redis", Target: &redisGateConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigEnabled gates plugin activation when Redis is disabled.
|
||||
func (p *Plugin) ConfigEnabled(view core.ConfigView) bool {
|
||||
return !view.Bool("redis.enabled", false)
|
||||
}
|
||||
|
||||
// Apply mounts the in-memory cache service into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
svc, err := newMemoryCacheService(p.capacity, ctx.Events())
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -34,35 +33,35 @@ var (
|
||||
chDB *gorm.DB
|
||||
)
|
||||
|
||||
func init() {
|
||||
if !config.Config.ClickHouse.Enabled {
|
||||
return
|
||||
// InitClickHouseWithConfig initializes the ClickHouse connection using the provided configuration.
|
||||
func InitClickHouseWithConfig(cfg ClickHouseConfig) error {
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := config.Config.ClickHouse
|
||||
if cfg.Database == "" {
|
||||
log.Fatalf("[ClickHouse] database name is required (expected: wavelet)\n")
|
||||
return fmt.Errorf("[ClickHouse] database name is required (expected: wavelet)")
|
||||
}
|
||||
|
||||
opts := buildClickHouseOptions()
|
||||
opts := buildClickHouseOptions(cfg)
|
||||
|
||||
var err error
|
||||
ChConn, err = clickhouse.Open(opts)
|
||||
if err != nil {
|
||||
log.Fatalf("[ClickHouse] init connection failed: %v\n", err)
|
||||
return fmt.Errorf("[ClickHouse] init connection failed: %w", err)
|
||||
}
|
||||
|
||||
if err = ChConn.Ping(context.Background()); err != nil {
|
||||
log.Fatalf("[ClickHouse] ping failed: %v\n", err)
|
||||
return fmt.Errorf("[ClickHouse] ping failed: %w", err)
|
||||
}
|
||||
|
||||
chDB, err = gorm.Open(clickhouseDriver.New(clickhouseDriver.Config{
|
||||
DSN: buildClickHouseDSN(),
|
||||
DSN: buildClickHouseDSN(cfg),
|
||||
}), &gorm.Config{
|
||||
SkipDefaultTransaction: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("[ClickHouse] init gorm connection failed: %v\n", err)
|
||||
return fmt.Errorf("[ClickHouse] init gorm connection failed: %w", err)
|
||||
}
|
||||
|
||||
if err = chDB.Use(
|
||||
@@ -74,12 +73,12 @@ func init() {
|
||||
),
|
||||
),
|
||||
); err != nil {
|
||||
log.Fatalf("[ClickHouse] init trace failed: %v\n", err)
|
||||
return fmt.Errorf("[ClickHouse] init trace failed: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := chDB.DB()
|
||||
if err != nil {
|
||||
log.Fatalf("[ClickHouse] load sql db failed: %v\n", err)
|
||||
return fmt.Errorf("[ClickHouse] load sql db failed: %w", err)
|
||||
}
|
||||
|
||||
sqlDB.SetMaxIdleConns(cfg.MaxIdleConn)
|
||||
@@ -87,11 +86,10 @@ func init() {
|
||||
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second)
|
||||
|
||||
log.Println("[ClickHouse] connection established successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildClickHouseOptions() *clickhouse.Options {
|
||||
cfg := config.Config.ClickHouse
|
||||
|
||||
func buildClickHouseOptions(cfg ClickHouseConfig) *clickhouse.Options {
|
||||
return &clickhouse.Options{
|
||||
Addr: cfg.Hosts,
|
||||
Auth: clickhouse.Auth{
|
||||
@@ -114,9 +112,7 @@ func buildClickHouseOptions() *clickhouse.Options {
|
||||
}
|
||||
}
|
||||
|
||||
func buildClickHouseDSN() string {
|
||||
cfg := config.Config.ClickHouse
|
||||
|
||||
func buildClickHouseDSN(cfg ClickHouseConfig) string {
|
||||
chURL := &url.URL{
|
||||
Scheme: "clickhouse",
|
||||
Host: strings.Join(cfg.Hosts, ","),
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package database
|
||||
|
||||
import "time"
|
||||
|
||||
// ReplicaConfig declares a single read-replica configuration for PostgreSQL.
|
||||
type ReplicaConfig struct {
|
||||
Host string `config:"host"`
|
||||
Port int `config:"port"`
|
||||
Username string `config:"username"`
|
||||
Password string `config:"password" secret:"true"`
|
||||
}
|
||||
|
||||
// Config declares PostgreSQL and SQLite database configuration.
|
||||
type Config struct {
|
||||
Enabled bool `config:"enabled" env:"DB_ENABLED" default:"false" autoEnable:"DB_HOST"`
|
||||
SQLitePath string `config:"sqlite_path" env:"SQLITE_PATH"`
|
||||
Host string `config:"host" env:"DB_HOST"`
|
||||
Port int `config:"port" env:"DB_PORT" default:"5432"`
|
||||
Username string `config:"username" env:"DB_USERNAME"`
|
||||
Password string `config:"password" env:"DB_PASSWORD" secret:"true"`
|
||||
Database string `config:"database" env:"DB_NAME" default:"wavelet"`
|
||||
MaxIdleConn int `config:"max_idle_conn" env:"DB_MAX_IDLE_CONN" default:"10"`
|
||||
MaxOpenConn int `config:"max_open_conn" env:"DB_MAX_OPEN_CONN" default:"100"`
|
||||
ConnMaxLifetime int `config:"conn_max_lifetime" env:"DB_CONN_MAX_LIFETIME" default:"3600"`
|
||||
ConnMaxIdleTime int `config:"conn_max_idle_time" env:"DB_CONN_MAX_IDLE_TIME" default:"600"`
|
||||
LogLevel string `config:"log_level" env:"DB_LOG_LEVEL" default:"warn"`
|
||||
SSLMode string `config:"ssl_mode" env:"DB_SSL_MODE" default:"disable"`
|
||||
TimeZone string `config:"time_zone" env:"DB_TIMEZONE" default:"UTC"`
|
||||
ApplicationName string `config:"application_name" env:"DB_APPLICATION_NAME" default:"wavelet"`
|
||||
SearchPath string `config:"search_path" env:"DB_SEARCH_PATH" default:"public"`
|
||||
PreferSimpleProtocol bool `config:"prefer_simple_protocol" env:"DB_PREFER_SIMPLE_PROTOCOL"`
|
||||
StatementCacheCapacity int `config:"statement_cache_capacity" env:"DB_STATEMENT_CACHE_CAPACITY"`
|
||||
DefaultQueryExecMode string `config:"default_query_exec_mode" env:"DB_DEFAULT_QUERY_EXEC_MODE"`
|
||||
Replicas []ReplicaConfig `config:"replicas"`
|
||||
SlowThreshold time.Duration `config:"slow_threshold" env:"DB_SLOW_THRESHOLD" default:"200ms"`
|
||||
}
|
||||
|
||||
// ClickHouseConfig declares the configuration for ClickHouse analytical storage.
|
||||
type ClickHouseConfig struct {
|
||||
Enabled bool `config:"enabled" env:"CLICKHOUSE_ENABLED" default:"false" autoEnable:"CLICKHOUSE_HOST"`
|
||||
Hosts []string `config:"hosts" env:"CLICKHOUSE_HOST"`
|
||||
Username string `config:"username" env:"CLICKHOUSE_USERNAME"`
|
||||
Password string `config:"password" env:"CLICKHOUSE_PASSWORD" secret:"true"`
|
||||
Database string `config:"database" env:"CLICKHOUSE_NAME" default:"wavelet"`
|
||||
MaxIdleConn int `config:"max_idle_conn" env:"CLICKHOUSE_MAX_IDLE_CONN" default:"10"`
|
||||
MaxOpenConn int `config:"max_open_conn" env:"CLICKHOUSE_MAX_OPEN_CONN" default:"50"`
|
||||
ConnMaxLifetime int `config:"conn_max_lifetime" env:"CLICKHOUSE_CONN_MAX_LIFETIME" default:"3600"`
|
||||
DialTimeout int `config:"dial_timeout" env:"CLICKHOUSE_DIAL_TIMEOUT" default:"10"`
|
||||
BlockBufferSize uint8 `config:"block_buffer_size" env:"CLICKHOUSE_BLOCK_BUFFER_SIZE" default:"10"`
|
||||
}
|
||||
|
||||
type appEnvConfig struct {
|
||||
Env string `config:"env" env:"APP_ENV" default:"development"`
|
||||
}
|
||||
@@ -56,17 +56,45 @@ func (p *Plugin) Name() string {
|
||||
return "database"
|
||||
}
|
||||
|
||||
// DeclareConfig declares database configuration keys.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "database", Target: &Config{}},
|
||||
{Prefix: "clickhouse", Target: &ClickHouseConfig{}},
|
||||
{Prefix: "app", Target: &appEnvConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// Apply mounts the database service into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var dbCfg Config
|
||||
if err := ctx.Config().Bind("database", &dbCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var chCfg ClickHouseConfig
|
||||
if err := ctx.Config().Bind("clickhouse", &chCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var appCfg appEnvConfig
|
||||
_ = ctx.Config().Bind("app", &appCfg)
|
||||
|
||||
targetDB := p.db
|
||||
if targetDB == nil {
|
||||
var err error
|
||||
targetDB, err = InitDB()
|
||||
targetDB, err = InitDBWithConfig(dbCfg, appCfg.Env == "production" || appCfg.Env == "prod")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if chCfg.Enabled {
|
||||
if err := InitClickHouseWithConfig(chCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
svc := &dbServiceImpl{
|
||||
primary: targetDB,
|
||||
namedDBs: p.namedDBs,
|
||||
|
||||
@@ -37,9 +37,11 @@ func TestDatabasePlugin(t *testing.T) {
|
||||
database.WithDB(gdb),
|
||||
database.WithNamedDB("analytics", namedDB),
|
||||
)
|
||||
assert.Equal(t, "database", p.Name())
|
||||
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(map[string]any{
|
||||
"database.enabled": false,
|
||||
}))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
require.NoError(t, p.Apply(ctx))
|
||||
|
||||
svc, err := core.Inject[contracts.DBService](ctx)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -30,15 +29,19 @@ const sqliteDirMode = 0o750
|
||||
|
||||
// InitDB 初始化主数据库实例(支持 PostgreSQL / SQLite)
|
||||
func InitDB() (*gorm.DB, error) {
|
||||
if !config.Config.Database.Enabled {
|
||||
return initSQLite()
|
||||
}
|
||||
return initPostgres()
|
||||
return InitDBWithConfig(Config{}, false)
|
||||
}
|
||||
|
||||
// initSQLite 初始化 SQLite 数据库(PostgreSQL 禁用时的后备方案)
|
||||
func initSQLite() (*gorm.DB, error) {
|
||||
sqlitePath := config.Config.Database.SQLitePath
|
||||
// InitDBWithConfig initializes the main database with the provided config.
|
||||
func InitDBWithConfig(cfg Config, isProd bool) (*gorm.DB, error) {
|
||||
if !cfg.Enabled {
|
||||
return initSQLiteWithConfig(cfg, isProd)
|
||||
}
|
||||
return initPostgresWithConfig(cfg, isProd)
|
||||
}
|
||||
|
||||
func initSQLiteWithConfig(cfg Config, isProd bool) (*gorm.DB, error) {
|
||||
sqlitePath := cfg.SQLitePath
|
||||
if sqlitePath == "" {
|
||||
sqlitePath = "./data/wavelet.db"
|
||||
}
|
||||
@@ -54,9 +57,9 @@ func initSQLite() (*gorm.DB, error) {
|
||||
targetDB, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
Logger: &gormZapLogger{
|
||||
logLevel: parseLogLevel(config.Config.Database.LogLevel),
|
||||
slowThreshold: config.Config.Database.SlowThreshold,
|
||||
ignoreRecordNotFoundError: config.Config.App.IsProduction(),
|
||||
logLevel: parseLogLevel(cfg.LogLevel),
|
||||
slowThreshold: cfg.SlowThreshold,
|
||||
ignoreRecordNotFoundError: isProd,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -81,24 +84,21 @@ func initSQLite() (*gorm.DB, error) {
|
||||
return targetDB, nil
|
||||
}
|
||||
|
||||
// initPostgres 初始化 PostgreSQL 数据库
|
||||
func initPostgres() (*gorm.DB, error) {
|
||||
dbConfig := config.Config.Database
|
||||
|
||||
func initPostgresWithConfig(cfg Config, isProd bool) (*gorm.DB, error) {
|
||||
// 构建主库 DSN 并连接
|
||||
primaryDSN := buildDSN(dbConfig.Host, dbConfig.Port, dbConfig.Username, dbConfig.Password)
|
||||
primaryDSN := buildDSN(cfg, cfg.Host, cfg.Port, cfg.Username, cfg.Password)
|
||||
|
||||
pgConfig := postgres.Config{
|
||||
DSN: primaryDSN,
|
||||
PreferSimpleProtocol: dbConfig.PreferSimpleProtocol,
|
||||
PreferSimpleProtocol: cfg.PreferSimpleProtocol,
|
||||
}
|
||||
|
||||
targetDB, err := gorm.Open(postgres.New(pgConfig), &gorm.Config{
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
Logger: &gormZapLogger{
|
||||
logLevel: parseLogLevel(config.Config.Database.LogLevel),
|
||||
slowThreshold: config.Config.Database.SlowThreshold,
|
||||
ignoreRecordNotFoundError: config.Config.App.IsProduction(),
|
||||
logLevel: parseLogLevel(cfg.LogLevel),
|
||||
slowThreshold: cfg.SlowThreshold,
|
||||
ignoreRecordNotFoundError: isProd,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -110,9 +110,9 @@ func initPostgres() (*gorm.DB, error) {
|
||||
tracing.NewPlugin(
|
||||
tracing.WithoutMetrics(),
|
||||
tracing.WithAttributes(
|
||||
attribute.String("db.instance", dbConfig.Database),
|
||||
attribute.String("db.ip", dbConfig.Host),
|
||||
attribute.String("server.address", net.JoinHostPort(dbConfig.Host, strconv.Itoa(dbConfig.Port))),
|
||||
attribute.String("db.instance", cfg.Database),
|
||||
attribute.String("db.ip", cfg.Host),
|
||||
attribute.String("server.address", net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))),
|
||||
attribute.String("db.system", "PostgreSQL"),
|
||||
),
|
||||
),
|
||||
@@ -120,21 +120,21 @@ func initPostgres() (*gorm.DB, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(dbConfig.Replicas) > 0 {
|
||||
if len(cfg.Replicas) > 0 {
|
||||
var replicaDialectors []gorm.Dialector
|
||||
for _, replica := range dbConfig.Replicas {
|
||||
for _, replica := range cfg.Replicas {
|
||||
username := replica.Username
|
||||
if username == "" {
|
||||
username = dbConfig.Username
|
||||
username = cfg.Username
|
||||
}
|
||||
password := replica.Password
|
||||
if password == "" {
|
||||
password = dbConfig.Password
|
||||
password = cfg.Password
|
||||
}
|
||||
replicaDSN := buildDSN(replica.Host, replica.Port, username, password)
|
||||
replicaDSN := buildDSN(cfg, replica.Host, replica.Port, username, password)
|
||||
replicaDialectors = append(replicaDialectors, postgres.New(postgres.Config{
|
||||
DSN: replicaDSN,
|
||||
PreferSimpleProtocol: dbConfig.PreferSimpleProtocol,
|
||||
PreferSimpleProtocol: cfg.PreferSimpleProtocol,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -143,15 +143,15 @@ func initPostgres() (*gorm.DB, error) {
|
||||
Policy: dbresolver.RandomPolicy{},
|
||||
})
|
||||
|
||||
resolver.SetMaxIdleConns(dbConfig.MaxIdleConn).
|
||||
SetMaxOpenConns(dbConfig.MaxOpenConn).
|
||||
SetConnMaxLifetime(time.Duration(dbConfig.ConnMaxLifetime) * time.Second).
|
||||
SetConnMaxIdleTime(time.Duration(dbConfig.ConnMaxIdleTime) * time.Second)
|
||||
resolver.SetMaxIdleConns(cfg.MaxIdleConn).
|
||||
SetMaxOpenConns(cfg.MaxOpenConn).
|
||||
SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second).
|
||||
SetConnMaxIdleTime(time.Duration(cfg.ConnMaxIdleTime) * time.Second)
|
||||
|
||||
if err = targetDB.Use(resolver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("[PostgreSQL] initialized in Primary-Replica mode (%d replicas)\n", len(dbConfig.Replicas))
|
||||
log.Printf("[PostgreSQL] initialized in Primary-Replica mode (%d replicas)\n", len(cfg.Replicas))
|
||||
} else {
|
||||
log.Println("[PostgreSQL] initialized in Standalone mode")
|
||||
}
|
||||
@@ -162,18 +162,17 @@ func initPostgres() (*gorm.DB, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sqlDB.SetMaxIdleConns(dbConfig.MaxIdleConn)
|
||||
sqlDB.SetMaxOpenConns(dbConfig.MaxOpenConn)
|
||||
sqlDB.SetConnMaxLifetime(time.Duration(dbConfig.ConnMaxLifetime) * time.Second)
|
||||
sqlDB.SetConnMaxIdleTime(time.Duration(dbConfig.ConnMaxIdleTime) * time.Second)
|
||||
sqlDB.SetMaxIdleConns(cfg.MaxIdleConn)
|
||||
sqlDB.SetMaxOpenConns(cfg.MaxOpenConn)
|
||||
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second)
|
||||
sqlDB.SetConnMaxIdleTime(time.Duration(cfg.ConnMaxIdleTime) * time.Second)
|
||||
|
||||
db = targetDB
|
||||
return targetDB, nil
|
||||
}
|
||||
|
||||
// buildDSN 构建 PostgreSQL DSN
|
||||
func buildDSN(host string, port int, username, password string) string {
|
||||
cfg := config.Config.Database
|
||||
func buildDSN(cfg Config, host string, port int, username, password string) string {
|
||||
pqURL := &url.URL{
|
||||
Scheme: "postgres",
|
||||
Host: net.JoinHostPort(host, strconv.Itoa(port)),
|
||||
|
||||
@@ -41,11 +41,25 @@ func setupTestDB(t *testing.T) *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func TestDatabasePlugin(t *testing.T) {
|
||||
func prepareTestContext(values map[string]any, declarers ...core.Plugin) *core.Context {
|
||||
ctx := core.NewContext(context.Background())
|
||||
testDB := setupTestDB(t)
|
||||
ctx.Config().SetSource(core.NewMapSource(values))
|
||||
for _, p := range declarers {
|
||||
if d, ok := p.(interface{ DeclareConfig() []core.ConfigBinding }); ok {
|
||||
for _, b := range d.DeclareConfig() {
|
||||
_ = ctx.Config().Declare(p.Name(), b)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = ctx.Config().Resolve()
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestDatabasePlugin(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
p := database.New(database.WithDB(testDB))
|
||||
ctx := prepareTestContext(nil, p)
|
||||
|
||||
require.Equal(t, "database", p.Name())
|
||||
require.NoError(t, p.Apply(ctx))
|
||||
|
||||
@@ -70,8 +84,9 @@ func TestDatabasePlugin(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCachePluginRAMOnly(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
p := cache.New()
|
||||
ctx := prepareTestContext(nil, p)
|
||||
|
||||
require.Equal(t, "cache", p.Name())
|
||||
require.NoError(t, p.Apply(ctx))
|
||||
|
||||
@@ -142,12 +157,12 @@ func TestCachePluginWithRedisAndPubSub(t *testing.T) {
|
||||
})
|
||||
defer func() { _ = rdb.Close() }()
|
||||
|
||||
ctx1 := core.NewContext(context.Background())
|
||||
ctx2 := core.NewContext(context.Background())
|
||||
|
||||
p1 := cache.New(cache.WithRedis(rdb), cache.WithKeyPrefix("test:"))
|
||||
p2 := cache.New(cache.WithRedis(rdb), cache.WithKeyPrefix("test:"))
|
||||
|
||||
ctx1 := prepareTestContext(map[string]any{"redis.enabled": true}, p1)
|
||||
ctx2 := prepareTestContext(map[string]any{"redis.enabled": true}, p2)
|
||||
|
||||
require.NoError(t, p1.Apply(ctx1))
|
||||
require.NoError(t, p2.Apply(ctx2))
|
||||
|
||||
@@ -294,14 +309,20 @@ func TestStoragePlugin(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAllInfraPluginsCombined(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
testDB := setupTestDB(t)
|
||||
memBackend := newMemoryBackend()
|
||||
|
||||
require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx))
|
||||
require.NoError(t, cache.New().Apply(ctx))
|
||||
require.NoError(t, logger.New().Apply(ctx))
|
||||
require.NoError(t, storage.New(storage.WithBackend(memBackend)).Apply(ctx))
|
||||
dbP := database.New(database.WithDB(testDB))
|
||||
cacheP := cache.New()
|
||||
logP := logger.New()
|
||||
storageP := storage.New(storage.WithBackend(memBackend))
|
||||
|
||||
ctx := prepareTestContext(nil, dbP, cacheP, logP, storageP)
|
||||
|
||||
require.NoError(t, dbP.Apply(ctx))
|
||||
require.NoError(t, cacheP.Apply(ctx))
|
||||
require.NoError(t, logP.Apply(ctx))
|
||||
require.NoError(t, storageP.Apply(ctx))
|
||||
|
||||
// Using3 to resolve dependencies concurrently
|
||||
var resolved bool
|
||||
|
||||
@@ -138,6 +138,8 @@ Wavelet 贯彻了 Cordis 核心范式,通过形式化保证解决组件系统
|
||||
### 5.1 彻底根除集中式包与建立 backend/ 顶级总包
|
||||
在过去的传统单体架构中,集中式的 `internal/model/`、`internal/repository/` 以及 `internal/` 目录往往成为大杂烩,随着团队扩展导致模块边界失控与隐式耦合。在本次 Cordis 架构重构中,我们实施了彻底的物理清退与顶级前后端分包:
|
||||
- **`backend/` 顶级总包**:汇聚所有 Go 后端代码(`cmd/`、`core/`、`plugins/`、`pkg/`、`main.go`),根目录仅保留顶级功能域。
|
||||
- **配置读取框架归属内核**:`backend/core/extpoints/` 只承载与实现无关的配置声明与解析引擎(不 import viper),`backend/plugins/infra/config/` 承担文件与环境装载,读哪些字段由各插件自行声明;组合根不再跨插件判断配置选实现,改由 `ConfigGatedPlugin` 门禁 + `FiberSkipped` 决定激活方。
|
||||
- **`pkg/config/` 全局单例**:处于退场过渡期。配置声明与解析能力已上收内核,业务侧全量迁移与旧包物理清退由后续迁移计划落地。
|
||||
- **`internal/` 目录**:**100% 物理清除**。通用的无状态基础库平移至 `backend/pkg/`,所有业务全部下沉至 `backend/plugins/domain/`。
|
||||
- **`pkg/model/` 目录**:**100% 物理清除**。消灭集中式数据模型。
|
||||
- **`pkg/repository/` 目录**:**100% 物理清除**。消灭集中式仓储。
|
||||
@@ -162,4 +164,23 @@ Wavelet 贯彻了 Cordis 核心范式,通过形式化保证解决组件系统
|
||||
2. **Pub/Sub 并发安全防线**:在启动 Redis Pub/Sub 监听协程前,严格捕获局部客户端实例,彻底消除测试或重启期间对可变全局客户端的数据竞争(Data Race Free)。
|
||||
3. **零旁路读写 (No Bypass)**:严禁插件 A 跨界旁路直接操作属于插件 B 的数据表,跨域调用一律面向 `backend/core/contracts` 契约编程或发布事件。
|
||||
|
||||
---
|
||||
|
||||
## 6. Cordis 配置扩展点与条件门禁机制 (Config Extension & Gated Activation)
|
||||
|
||||
### 6.1 彻底清退全局配置单例 (Zero-Singleton Architecture)
|
||||
在传统单体架构中,`pkg/config.Config` 全局静态变量充斥在各个业务与驱动模块中,导致隐式依赖、无法独立单测、无法多实例共存。Cordis 架构引入了基于微内核上下文的配置扩展点(`ctx.Config()`):
|
||||
- **插件自包含声明**:每个插件实现 `DeclareConfig() []core.ConfigBinding`,声明自身所需的静态启动配置前缀、结构体与字段 tag(`config`、`env`、`default`、`autoEnable`、`secret`)。
|
||||
- **统一生命周期解析**:通过 `app.Prepare()` 建立配置解析屏障,统一绑定 YAML 文件与环境变量,支持前缀冲突检测与敏感字段脱敏导出。
|
||||
- **纯净依赖隔离**:插件在 `Apply(ctx)` 中通过 `ctx.Config().Bind("<prefix>", &cfg)` 读取自身配置,微内核与 `pkg/` 工具包绝对不依赖任何配置具体实现。
|
||||
|
||||
### 6.2 基于配置的动态插件门禁 (Configuration-Gated Plugins)
|
||||
为了原生支持**单机单体(Zero-Redis Monolith)**与**分布式集群(Distributed Cluster)**无缝切换,Cordis 提供了 `core.ConfigGatedPlugin` 扩展接口:
|
||||
- **门禁契约**:实现 `ConfigEnabled(view core.ConfigView) bool` 方法。微内核在 `Reconcile` / `ApplyPlugins` 阶段依据解析后的配置动态求值。
|
||||
- **互斥挂载**:
|
||||
- 当 `redis.enabled = false`(默认):`cache_memory`、`driver_inproc_worker` 与 `driver_inproc_cron` 自动进入 `ACTIVE` 状态;分布式插件进入 `SKIPPED` 状态,达成零外部中间件极简单体。
|
||||
- 当 `redis.enabled = true`:`cache`、`driver_asynq_worker` 与 `driver_asynq_cron` 自动激活,无缝升级为分布式高可用架构。
|
||||
- **动态拔插可组合性**:所有互斥插件可同时通过 `app.Use(...)` 注册,装配根无需编写侵入式的 `if-else` 条件分支,全面实现架构的时空可组合性与高内聚。
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -344,3 +344,21 @@ app.Run() → Reconcile/Apply # 插件内 Bind/Get 读取已解析值
|
||||
| domain/其他 | `plugins/domain/auth/session.go`、`plugins/domain/cap/service.go`、`plugins/domain/message_gateway/service/service.go`、`plugins/domain/system/plugin.go`、`plugins/domain/risk_control/middleware.go`、`plugins/domain/risk_control/middleware_test.go`、`plugins/domain/risk_control/logstore/provider.go` |
|
||||
|
||||
> 注:`risk_control/middleware.go`、`cap/service.go`、`message_gateway/service/service.go` 等处以 `config.Config != nil` 做存在性判断的分支,在注入式配置模型下不再可能,迁移时一并消除。
|
||||
|
||||
---
|
||||
|
||||
## 8. 落地回写(P1 + P2 已实施)
|
||||
|
||||
实施结果与本设计原述的差异,均已按下列口径落地:
|
||||
|
||||
| # | 设计原述 | 落地结果 | 缘由 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| R1 | §4.3 C1、§6 把 `app.session_age<=0` 列为内核解析错误 | 引擎不做值域校验,`ErrConfigInvalid` 保留但未在内核使用;值域由声明者在 `Bind` 之后校验(P3 由 auth 承担) | 引擎被设计成不认识任何业务 key 的语义,把业务规则塞进内核会破坏该不变式 |
|
||||
| R2 | §3.2 `ConfigView.Source(key)` | 更名 `Origin(key)`;新增 `Value(key) (any, bool)`;`ConfigExtension` 增加 `SetSource`、`Resolved` | `Source` 与类型名 `ConfigSource` 同文件易混淆;`Value` 支撑 `core.ConfigGet[T]`(Go 方法不能带类型参数);`SetSource` 进接口以免运行时类型断言 |
|
||||
| R3 | §3.5 仅有 `WithShutdownTimeout` | 新增 `App.ShutdownTimeout()` 与 `SetShutdownTimeout(d) *App` | 组合根需在 `Prepare()` 之后把已解析预算写回内核,构造期选项无法表达该顺序 |
|
||||
| R4 | §4.2 时序图把门禁求值画在 `Prepare()` 内 | `Prepare()` 只建立解析屏障,门禁在 `reconcileLocked` 每轮调和中求值 | `App.Use` 可在 `Prepare()` 之后继续挂载插件;只在 `Prepare` 求值会留下一批永不判定的门禁 |
|
||||
| R5 | 未涉及 | `App` 未注入 `ConfigSource` 时配置能力视为未启用,解析屏障直接放行;实现了 `ConfigGatedPlugin` 却无配置源的插件 fail fast 点名原因 | 内核存在大量不使用配置的装配路径(既有测试与嵌入式用法),不能强制要求配置源;但门禁无数据可依时必须报错,而非静默全激活 |
|
||||
| R6 | §4.1 隐含"每个 key 都有 env 覆盖" | env 覆盖面完全由声明决定。旧装载器只对部分 key 提供 env(`slow_threshold`、`conn_max_lifetime` 等从未有 env 覆盖),对拍镜像必须精确复刻该覆盖面 | 否则对拍出现假漂移;放宽某 key 的 env 覆盖是 P3 的声明选择,不构成引擎行为变更 |
|
||||
| R7 | §4.1 "向上最多 5 层查找 `config.yaml`" | 该向上查找会**越出 git worktree 边界**:从 `backend/pkg/config` 出发第 5 层可命中父级检出的 `config.yaml` | 属既有行为、非本次引入,但在 worktree 中开发会静默使用另一份检出的配置。对拍测试已改为以入库的 `config.example.yaml` 所在目录为锚;`config.yaml` 本身被 gitignore,干净克隆中不存在 |
|
||||
|
||||
分期口径:本设计 §7.3 的 P1 + P2 已实施完成;P3(27 个消费文件迁移、`pkg/idgen` 解耦)与 P4(删除 `backend/pkg/config`、移除对拍夹具)由后续计划承接。
|
||||
|
||||
Reference in New Issue
Block a user