mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 00:26:37 +08:00
feat(core): implement cordis configuration extension and migrate all plugins
This commit is contained in:
@@ -22,18 +22,28 @@ Wavelet 提供两种维度的配置能力:
|
|||||||
|
|
||||||
## 2. 插件内配置声明与绑定
|
## 2. 插件内配置声明与绑定
|
||||||
|
|
||||||
### 2.1 静态配置绑定 (`ctx.Config().Bind`)
|
### 2.1 静态配置声明与绑定 (`DeclareConfig` 与 `ctx.Config().Bind`)
|
||||||
|
|
||||||
|
静态启动配置遵循插件自包含声明与解耦规范:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type OrderStaticConfig struct {
|
type OrderStaticConfig struct {
|
||||||
PaymentGatewayURL string `yaml:"payment_gateway_url" json:"payment_gateway_url"`
|
PaymentGatewayURL string `config:"payment_gateway_url" env:"ORDER_PAYMENT_URL" default:"https://pay.example.com"`
|
||||||
TimeoutSeconds int `yaml:"timeout_seconds" json:"timeout_seconds"`
|
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 {
|
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||||
var cfg OrderStaticConfig
|
var cfg OrderStaticConfig
|
||||||
// 从 config.yaml 中的 plugins.order 节点绑定配置
|
// 从统一配置源绑定 plugins.order 节点配置(支持 YAML 与环境变量覆盖)
|
||||||
ctx.Config().Bind("plugins.order", &cfg)
|
_ = ctx.Config().Bind("plugins.order", &cfg)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -111,7 +111,8 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
|
|||||||
- **扩展点自包含注册**:
|
- **扩展点自包含注册**:
|
||||||
- **HTTP 路由**:插件自包含在 `Apply` 中通过 `ctx.Router().Group(...)` 挂载路由与中间件,禁止跨插件散落注册。
|
- **HTTP 路由**:插件自包含在 `Apply` 中通过 `ctx.Router().Group(...)` 挂载路由与中间件,禁止跨插件散落注册。
|
||||||
- **异步与定时任务**:插件自包含在 `Apply` 中通过 `ctx.Task().Register(...)` 与 `ctx.Schedule().RegisterCron(...)` 声明。
|
- **异步与定时任务**:插件自包含在 `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)` 注入。
|
- **数据迁移**:插件自包含在内部维护 `migrations/*.sql`,通过 `//go:embed` 打包并在 `Apply` 中通过 `ctx.Migrations().Register(pluginID, embedFS)` 注入。
|
||||||
- **表单一所有者原则 (Single Owner Principle)**:
|
- **表单一所有者原则 (Single Owner Principle)**:
|
||||||
- 每张数据表有且仅由一个所有者插件声明与维护(表名使用插件前缀如 `w_order_*`)。
|
- 每张数据表有且仅由一个所有者插件声明与维护(表名使用插件前缀如 `w_order_*`)。
|
||||||
|
|||||||
+1
-9
@@ -6,7 +6,6 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/core"
|
"Wavelet/core"
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -15,13 +14,6 @@ var allCmd = &cobra.Command{
|
|||||||
Use: "all",
|
Use: "all",
|
||||||
Short: "以融合模式同时启动 API、Worker 和 Scheduler",
|
Short: "以融合模式同时启动 API、Worker 和 Scheduler",
|
||||||
Run: func(_ *cobra.Command, _ []string) {
|
Run: func(_ *cobra.Command, _ []string) {
|
||||||
printStartupBanner(startupState{
|
runProfileApp(core.ProfileAll, "all (API + Worker + Scheduler)", true)
|
||||||
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)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-9
@@ -5,7 +5,6 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/core"
|
"Wavelet/core"
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -14,13 +13,6 @@ var apiCmd = &cobra.Command{
|
|||||||
Use: "api",
|
Use: "api",
|
||||||
Short: "wavelet API",
|
Short: "wavelet API",
|
||||||
Run: func(_ *cobra.Command, _ []string) {
|
Run: func(_ *cobra.Command, _ []string) {
|
||||||
printStartupBanner(startupState{
|
runProfileApp(core.ProfileAPI, "api", true)
|
||||||
mode: "api",
|
|
||||||
listensForHTTP: true,
|
|
||||||
})
|
|
||||||
app := newWaveletApp(core.ProfileAPI)
|
|
||||||
if err := app.Run(); err != nil {
|
|
||||||
log.Fatalf("[API] run failed: %v\n", err)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-25
@@ -6,7 +6,6 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"Wavelet/core"
|
"Wavelet/core"
|
||||||
"Wavelet/core/contracts"
|
"Wavelet/core/contracts"
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"Wavelet/plugins/domain/admin"
|
"Wavelet/plugins/domain/admin"
|
||||||
"Wavelet/plugins/domain/auth"
|
"Wavelet/plugins/domain/auth"
|
||||||
"Wavelet/plugins/domain/cap"
|
"Wavelet/plugins/domain/cap"
|
||||||
@@ -22,6 +21,7 @@ import (
|
|||||||
"Wavelet/plugins/drivers/driver_inproc_worker"
|
"Wavelet/plugins/drivers/driver_inproc_worker"
|
||||||
"Wavelet/plugins/infra/cache"
|
"Wavelet/plugins/infra/cache"
|
||||||
"Wavelet/plugins/infra/cache_memory"
|
"Wavelet/plugins/infra/cache_memory"
|
||||||
|
"Wavelet/plugins/infra/config"
|
||||||
"Wavelet/plugins/infra/logger"
|
"Wavelet/plugins/infra/logger"
|
||||||
"Wavelet/plugins/infra/storage"
|
"Wavelet/plugins/infra/storage"
|
||||||
"context"
|
"context"
|
||||||
@@ -38,14 +38,48 @@ import (
|
|||||||
infradb "Wavelet/plugins/infra/database"
|
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.
|
// newWaveletApp creates a core.App wired with Wavelet platform infrastructure, domain plugins, and profile drivers.
|
||||||
//
|
//
|
||||||
//nolint:contextcheck
|
//nolint:contextcheck
|
||||||
func newWaveletApp(profile core.Profile) *core.App {
|
func newWaveletApp(profile core.Profile, opts ...core.AppOption) *core.App {
|
||||||
app := core.NewApp(
|
src, err := config.NewSource()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("[App] load config source failed: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
appOpts := []core.AppOption{
|
||||||
core.WithProfile(profile),
|
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
|
// 1. Register standard infrastructure plugins
|
||||||
app.Use(
|
app.Use(
|
||||||
@@ -54,20 +88,15 @@ func newWaveletApp(profile core.Profile) *core.App {
|
|||||||
storage.New(),
|
storage.New(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 2. Register Cache and Async/Cron Drivers based on Redis configuration
|
// 2. Register Cache and Async/Cron Drivers (both gated: cache vs cache_memory, asynq vs inproc)
|
||||||
if config.Config.Redis.Enabled {
|
app.Use(
|
||||||
app.Use(
|
cache.New(),
|
||||||
cache.New(),
|
cache_memory.New(),
|
||||||
driver_asynq_worker.New(),
|
driver_asynq_worker.New(),
|
||||||
driver_asynq_cron.New(),
|
driver_inproc_worker.New(),
|
||||||
)
|
driver_asynq_cron.New(),
|
||||||
} else {
|
driver_inproc_cron.New(),
|
||||||
app.Use(
|
)
|
||||||
cache_memory.New(),
|
|
||||||
driver_inproc_worker.New(),
|
|
||||||
driver_inproc_cron.New(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Register all 8 domain business plugins (admin first to ensure schema and base config tables exist)
|
// 3. Register all 8 domain business plugins (admin first to ensure schema and base config tables exist)
|
||||||
app.Use(
|
app.Use(
|
||||||
@@ -86,7 +115,7 @@ func newWaveletApp(profile core.Profile) *core.App {
|
|||||||
|
|
||||||
// 5. Mount HTTP runtime driver
|
// 5. Mount HTTP runtime driver
|
||||||
app.Use(
|
app.Use(
|
||||||
driver_http.New(driver_http.WithAddr(config.Config.App.Addr)),
|
driver_http.New(),
|
||||||
)
|
)
|
||||||
|
|
||||||
return app
|
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)
|
return fmt.Errorf("migration: get underlying DB from GORM: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dialect := gooseDialect()
|
dialect := gooseDialect(ctx)
|
||||||
dialectStr := string(dialect)
|
dialectStr := string(dialect)
|
||||||
|
|
||||||
for _, entry := range entries {
|
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.
|
// gooseDialect returns the goose dialect based on the configured database engine.
|
||||||
func gooseDialect() goose.Dialect {
|
func gooseDialect(ctx *core.Context) goose.Dialect {
|
||||||
if !config.Config.Database.Enabled {
|
if ctx != nil && ctx.Config() != nil && ctx.Config().Bool("database.enabled", false) {
|
||||||
return goose.DialectSQLite3
|
return goose.DialectPostgres
|
||||||
}
|
}
|
||||||
return goose.DialectPostgres
|
return goose.DialectSQLite3
|
||||||
}
|
}
|
||||||
|
|
||||||
func findMigrationFS(rootFS fs.FS, dialect goose.Dialect) fs.FS {
|
func findMigrationFS(rootFS fs.FS, dialect goose.Dialect) fs.FS {
|
||||||
|
|||||||
+57
-18
@@ -5,7 +5,6 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/core"
|
"Wavelet/core"
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -22,13 +21,22 @@ func TestNewWaveletAppProfiles(t *testing.T) {
|
|||||||
|
|
||||||
for _, prof := range profiles {
|
for _, prof := range profiles {
|
||||||
t.Run(string(prof), func(t *testing.T) {
|
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)
|
require.NotNil(t, app)
|
||||||
assert.Equal(t, prof, app.Profile())
|
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()
|
plugins := app.Plugins()
|
||||||
assert.Len(t, plugins, 15)
|
assert.Len(t, plugins, 18)
|
||||||
|
|
||||||
|
require.NoError(t, app.Reconcile())
|
||||||
|
|
||||||
// Verify standard infra plugins
|
// Verify standard infra plugins
|
||||||
_, ok := app.Plugin("database")
|
_, ok := app.Plugin("database")
|
||||||
@@ -41,14 +49,29 @@ func TestNewWaveletAppProfiles(t *testing.T) {
|
|||||||
assert.True(t, ok, "storage plugin missing")
|
assert.True(t, ok, "storage plugin missing")
|
||||||
|
|
||||||
// In zero-Redis mode (default in test)
|
// In zero-Redis mode (default in test)
|
||||||
_, ok = app.Plugin("cache_memory")
|
f, ok := app.Fiber("cache_memory")
|
||||||
assert.True(t, ok, "cache_memory plugin missing")
|
assert.True(t, ok, "cache_memory fiber missing")
|
||||||
|
assert.Equal(t, core.FiberActive, f.State())
|
||||||
|
|
||||||
_, ok = app.Plugin("driver_inproc_worker")
|
f, ok = app.Fiber("cache")
|
||||||
assert.True(t, ok, "inproc worker driver missing")
|
assert.True(t, ok, "cache fiber missing")
|
||||||
|
assert.Equal(t, core.FiberSkipped, f.State())
|
||||||
|
|
||||||
_, ok = app.Plugin("driver_inproc_cron")
|
f, ok = app.Fiber("driver_inproc_worker")
|
||||||
assert.True(t, ok, "inproc scheduler driver missing")
|
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
|
// Verify domain plugins
|
||||||
_, ok = app.Plugin("auth")
|
_, ok = app.Plugin("auth")
|
||||||
@@ -83,19 +106,35 @@ func TestNewWaveletAppProfiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewWaveletAppWithRedisEnabled(t *testing.T) {
|
func TestNewWaveletAppWithRedisEnabled(t *testing.T) {
|
||||||
orig := config.Config.Redis.Enabled
|
app := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{
|
||||||
config.Config.Redis.Enabled = true
|
"redis": map[string]any{
|
||||||
defer func() { config.Config.Redis.Enabled = orig }()
|
"enabled": true,
|
||||||
|
},
|
||||||
app := newWaveletApp(core.ProfileAll)
|
}))
|
||||||
require.NotNil(t, app)
|
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.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.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.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
|
// Copyright 2026 Arctel.net
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
// Package cmd provides CLI command entry points.
|
|
||||||
//
|
|
||||||
//nolint:unused
|
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/pkg/buildinfo"
|
"Wavelet/pkg/buildinfo"
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
//nolint:unused // startup banner formatting utilities
|
|
||||||
type startupState struct {
|
type startupState struct {
|
||||||
mode string
|
mode string
|
||||||
listensForHTTP bool
|
listensForHTTP bool
|
||||||
|
env string
|
||||||
|
addr string
|
||||||
}
|
}
|
||||||
|
|
||||||
func printStartupBanner(state startupState) {
|
func printStartupBanner(state startupState) {
|
||||||
@@ -25,6 +22,15 @@ func printStartupBanner(state startupState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func formatStartupBanner(state startupState) string {
|
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{
|
lines := []string{
|
||||||
"",
|
"",
|
||||||
"__ __ _ _ ",
|
"__ __ _ _ ",
|
||||||
@@ -34,12 +40,12 @@ func formatStartupBanner(state startupState) string {
|
|||||||
" \\_/\\_/ \\__,_| \\_/ \\___|_|\\___|\\__|",
|
" \\_/\\_/ \\__,_| \\_/ \\___|_|\\___|\\__|",
|
||||||
fmt.Sprintf(" Wavelet %s", buildinfo.Version),
|
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(" Runtime: %s/%s (%s)", runtime.GOOS, runtime.GOARCH, runtime.Version()),
|
||||||
fmt.Sprintf(" Build time: %s", buildTime()),
|
fmt.Sprintf(" Build time: %s", buildTime()),
|
||||||
}
|
}
|
||||||
if state.listensForHTTP {
|
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), "")
|
lines = append(lines, fmt.Sprintf(" Mode: %s", state.mode), "")
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/pkg/buildinfo"
|
"Wavelet/pkg/buildinfo"
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -13,23 +12,19 @@ import (
|
|||||||
func TestFormatStartupBanner(t *testing.T) {
|
func TestFormatStartupBanner(t *testing.T) {
|
||||||
previousVersion := buildinfo.Version
|
previousVersion := buildinfo.Version
|
||||||
previousBuildTime := buildinfo.BuildTime
|
previousBuildTime := buildinfo.BuildTime
|
||||||
previousEnv := config.Config.App.Env
|
|
||||||
previousAddr := config.Config.App.Addr
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
buildinfo.Version = previousVersion
|
buildinfo.Version = previousVersion
|
||||||
buildinfo.BuildTime = previousBuildTime
|
buildinfo.BuildTime = previousBuildTime
|
||||||
config.Config.App.Env = previousEnv
|
|
||||||
config.Config.App.Addr = previousAddr
|
|
||||||
})
|
})
|
||||||
|
|
||||||
buildinfo.Version = "v3.2.1"
|
buildinfo.Version = "v3.2.1"
|
||||||
buildinfo.BuildTime = "2026-07-13T08:00:00Z"
|
buildinfo.BuildTime = "2026-07-13T08:00:00Z"
|
||||||
config.Config.App.Env = "production"
|
|
||||||
config.Config.App.Addr = ":3000"
|
|
||||||
|
|
||||||
banner := formatStartupBanner(startupState{
|
banner := formatStartupBanner(startupState{
|
||||||
mode: "API",
|
mode: "API",
|
||||||
listensForHTTP: true,
|
listensForHTTP: true,
|
||||||
|
env: "production",
|
||||||
|
addr: ":3000",
|
||||||
})
|
})
|
||||||
|
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
|
|||||||
@@ -7,49 +7,56 @@ import (
|
|||||||
"Wavelet/core"
|
"Wavelet/core"
|
||||||
"Wavelet/core/contracts"
|
"Wavelet/core/contracts"
|
||||||
"Wavelet/core/extpoints"
|
"Wavelet/core/extpoints"
|
||||||
"Wavelet/pkg/config"
|
"Wavelet/pkg/idgen"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/alicebob/miniredis/v2"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRedisPluggability_Simulation(t *testing.T) {
|
func TestRedisPluggability_Simulation(t *testing.T) {
|
||||||
origRedisEnabled := config.Config.Redis.Enabled
|
_ = idgen.Init(1)
|
||||||
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
|
|
||||||
}()
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
// 场景 1: 拔出 Redis (Zero-Redis Monolith 模式)
|
// 场景 1: 拔出 Redis (Zero-Redis Monolith 模式)
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
t.Run("Scenario_Unplugged_ZeroRedis_Mode", func(t *testing.T) {
|
t.Run("Scenario_Unplugged_ZeroRedis_Mode", func(t *testing.T) {
|
||||||
config.Config.Redis.Enabled = false
|
app := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{
|
||||||
|
"app": map[string]any{
|
||||||
app := newWaveletApp(core.ProfileAll)
|
"addr": "127.0.0.1:0",
|
||||||
|
},
|
||||||
|
"redis": map[string]any{
|
||||||
|
"enabled": false,
|
||||||
|
},
|
||||||
|
}))
|
||||||
require.NotNil(t, app)
|
require.NotNil(t, app)
|
||||||
|
require.NoError(t, app.Reconcile())
|
||||||
|
|
||||||
// 1. 验证插件挂载形态
|
// 1. 验证插件挂载形态
|
||||||
_, ok := app.Plugin("cache_memory")
|
f, ok := app.Fiber("cache_memory")
|
||||||
assert.True(t, ok, "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 必须挂载")
|
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.True(t, ok, "driver_inproc_cron 必须挂载")
|
||||||
|
assert.Equal(t, core.FiberActive, f.State())
|
||||||
|
|
||||||
_, ok = app.Plugin("cache")
|
f, ok = app.Fiber("cache")
|
||||||
assert.False(t, ok, "分布式 cache 不得挂载")
|
assert.True(t, ok)
|
||||||
_, ok = app.Plugin("driver_asynq_worker")
|
assert.Equal(t, core.FiberSkipped, f.State(), "分布式 cache 不得挂载")
|
||||||
assert.False(t, ok, "asynq_worker 不得挂载")
|
f, ok = app.Fiber("driver_asynq_worker")
|
||||||
_, ok = app.Plugin("driver_asynq_cron")
|
assert.True(t, ok)
|
||||||
assert.False(t, ok, "asynq_cron 不得挂载")
|
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 定时
|
// 2. 注册测试任务与 Cron 定时
|
||||||
var taskExecuted atomic.Int32
|
var taskExecuted atomic.Int32
|
||||||
@@ -114,25 +121,42 @@ func TestRedisPluggability_Simulation(t *testing.T) {
|
|||||||
// 场景 2: 插入 Redis (Distributed Cluster 模式)
|
// 场景 2: 插入 Redis (Distributed Cluster 模式)
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
t.Run("Scenario_Plugged_Redis_Mode", func(t *testing.T) {
|
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.NotNil(t, app)
|
||||||
|
require.NoError(t, app.Reconcile())
|
||||||
|
|
||||||
// 1. 验证插件挂载形态
|
// 1. 验证插件挂载形态
|
||||||
_, ok := app.Plugin("cache")
|
f, ok := app.Fiber("cache")
|
||||||
assert.True(t, ok, "分布式 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 必须挂载")
|
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.True(t, ok, "driver_asynq_cron 必须挂载")
|
||||||
|
assert.Equal(t, core.FiberActive, f.State())
|
||||||
|
|
||||||
_, ok = app.Plugin("cache_memory")
|
f, ok = app.Fiber("cache_memory")
|
||||||
assert.False(t, ok, "纯内存 cache 不得挂载")
|
assert.True(t, ok)
|
||||||
_, ok = app.Plugin("driver_inproc_worker")
|
assert.Equal(t, core.FiberSkipped, f.State(), "纯内存 cache 不得挂载")
|
||||||
assert.False(t, ok, "inproc_worker 不得挂载")
|
f, ok = app.Fiber("driver_inproc_worker")
|
||||||
_, ok = app.Plugin("driver_inproc_cron")
|
assert.True(t, ok)
|
||||||
assert.False(t, ok, "inproc_cron 不得挂载")
|
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. 注册测试任务
|
// 2. 注册测试任务
|
||||||
var asynqTaskExecuted atomic.Int32
|
var asynqTaskExecuted atomic.Int32
|
||||||
@@ -143,7 +167,7 @@ func TestRedisPluggability_Simulation(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
}, extpoints.WithTaskTimeout(3*time.Second))
|
}, extpoints.WithTaskTimeout(3*time.Second))
|
||||||
|
|
||||||
// 3. 启动应用 (连接真实运行中的 Redis 6379)
|
// 3. 启动应用
|
||||||
bootCtx, bootCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
bootCtx, bootCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer bootCancel()
|
defer bootCancel()
|
||||||
require.NoError(t, app.Start(bootCtx))
|
require.NoError(t, app.Start(bootCtx))
|
||||||
@@ -172,7 +196,7 @@ func TestRedisPluggability_Simulation(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, taskSvc)
|
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)
|
require.NoError(t, err)
|
||||||
assert.NotEmpty(t, taskID)
|
assert.NotEmpty(t, taskID)
|
||||||
|
|
||||||
@@ -190,10 +214,20 @@ func TestRedisPluggability_Simulation(t *testing.T) {
|
|||||||
// 场景 3: 往复插拔连续切换 (拔出 → 插入 → 再拔出,验证时空可组合性与零残留)
|
// 场景 3: 往复插拔连续切换 (拔出 → 插入 → 再拔出,验证时空可组合性与零残留)
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
t.Run("Scenario_Dynamic_Plug_Unplug_Sequence", func(t *testing.T) {
|
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++ {
|
for i := 1; i <= 2; i++ {
|
||||||
// 1. 拔出 Redis 运行
|
// 1. 拔出 Redis 运行
|
||||||
config.Config.Redis.Enabled = false
|
appUnplugged := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{
|
||||||
appUnplugged := newWaveletApp(core.ProfileAll)
|
"app": map[string]any{
|
||||||
|
"addr": "127.0.0.1:0",
|
||||||
|
},
|
||||||
|
"redis": map[string]any{
|
||||||
|
"enabled": false,
|
||||||
|
},
|
||||||
|
}))
|
||||||
require.NoError(t, appUnplugged.Start(context.Background()))
|
require.NoError(t, appUnplugged.Start(context.Background()))
|
||||||
|
|
||||||
cacheSvc1, err := core.Inject[contracts.CacheService](appUnplugged.Context())
|
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()))
|
require.NoError(t, appUnplugged.Stop(context.Background()))
|
||||||
|
|
||||||
// 2. 插入 Redis 运行
|
// 2. 插入 Redis 运行
|
||||||
config.Config.Redis.Enabled = true
|
appPlugged := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{
|
||||||
appPlugged := newWaveletApp(core.ProfileAll)
|
"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()))
|
require.NoError(t, appPlugged.Start(context.Background()))
|
||||||
|
|
||||||
cacheSvc2, err := core.Inject[contracts.CacheService](appPlugged.Context())
|
cacheSvc2, err := core.Inject[contracts.CacheService](appPlugged.Context())
|
||||||
|
|||||||
+54
-12
@@ -4,10 +4,12 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"Wavelet/core/extpoints"
|
||||||
"Wavelet/pkg/buildinfo"
|
"Wavelet/pkg/buildinfo"
|
||||||
"Wavelet/pkg/config"
|
"Wavelet/pkg/idgen"
|
||||||
"Wavelet/pkg/logger"
|
"Wavelet/pkg/logger"
|
||||||
"Wavelet/pkg/trace"
|
"Wavelet/pkg/trace"
|
||||||
|
"Wavelet/plugins/infra/config"
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
@@ -17,23 +19,63 @@ import (
|
|||||||
|
|
||||||
const traceShutdownTimeout = 10 * time.Second
|
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{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "wavelet",
|
Use: "wavelet",
|
||||||
PersistentPreRun: func(_ *cobra.Command, _ []string) {
|
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{
|
logger.Init(logger.Config{
|
||||||
Level: config.Config.Log.Level,
|
Level: cfg.Log.Level,
|
||||||
Format: config.Config.Log.Format,
|
Format: cfg.Log.Format,
|
||||||
Output: config.Config.Log.Output,
|
Output: cfg.Log.Output,
|
||||||
FilePath: config.Config.Log.FilePath,
|
FilePath: cfg.Log.FilePath,
|
||||||
MaxSize: config.Config.Log.MaxSize,
|
MaxSize: cfg.Log.MaxSize,
|
||||||
MaxAge: config.Config.Log.MaxAge,
|
MaxAge: cfg.Log.MaxAge,
|
||||||
MaxBackups: config.Config.Log.MaxBackups,
|
MaxBackups: cfg.Log.MaxBackups,
|
||||||
Compress: config.Config.Log.Compress,
|
Compress: cfg.Log.Compress,
|
||||||
})
|
})
|
||||||
trace.Init(trace.Config{
|
trace.Init(trace.Config{
|
||||||
AppName: config.Config.App.AppName,
|
AppName: cfg.App.AppName,
|
||||||
SamplingRate: config.Config.Otel.SamplingRate,
|
SamplingRate: cfg.OTel.SamplingRate,
|
||||||
TracerName: config.Config.Otel.TracerName,
|
TracerName: cfg.OTel.TracerName,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
PersistentPostRun: func(_ *cobra.Command, _ []string) {
|
PersistentPostRun: func(_ *cobra.Command, _ []string) {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/core"
|
"Wavelet/core"
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -14,13 +13,6 @@ var schedulerCmd = &cobra.Command{
|
|||||||
Use: "scheduler",
|
Use: "scheduler",
|
||||||
Short: "wavelet Scheduler",
|
Short: "wavelet Scheduler",
|
||||||
Run: func(_ *cobra.Command, _ []string) {
|
Run: func(_ *cobra.Command, _ []string) {
|
||||||
printStartupBanner(startupState{
|
runProfileApp(core.ProfileSchedule, "scheduler", false)
|
||||||
mode: "scheduler",
|
|
||||||
listensForHTTP: false,
|
|
||||||
})
|
|
||||||
app := newWaveletApp(core.ProfileSchedule)
|
|
||||||
if err := app.Run(); err != nil {
|
|
||||||
log.Fatalf("[Scheduler] run failed: %v\n", err)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/core"
|
"Wavelet/core"
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -14,13 +13,6 @@ var workerCmd = &cobra.Command{
|
|||||||
Use: "worker",
|
Use: "worker",
|
||||||
Short: "wavelet Worker",
|
Short: "wavelet Worker",
|
||||||
Run: func(_ *cobra.Command, _ []string) {
|
Run: func(_ *cobra.Command, _ []string) {
|
||||||
printStartupBanner(startupState{
|
runProfileApp(core.ProfileWorker, "worker", false)
|
||||||
mode: "worker",
|
|
||||||
listensForHTTP: false,
|
|
||||||
})
|
|
||||||
app := newWaveletApp(core.ProfileWorker)
|
|
||||||
if err := app.Run(); err != nil {
|
|
||||||
log.Fatalf("[Worker] run failed: %v\n", err)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package core
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"Wavelet/core/extpoints"
|
"Wavelet/core/extpoints"
|
||||||
)
|
)
|
||||||
@@ -29,3 +30,71 @@ func ConfigGet[T any](view extpoints.ConfigView, key string) (T, error) {
|
|||||||
}
|
}
|
||||||
return value, nil
|
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(),
|
storage.New(),
|
||||||
// ... platform domain plugins ...
|
// ... platform domain plugins ...
|
||||||
custom_hello.New(), // your downstream plugin
|
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_worker.New(),
|
||||||
driver_asynq_cron.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
|
package idgen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/pkg/config"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/bwmarrin/snowflake"
|
"github.com/bwmarrin/snowflake"
|
||||||
)
|
)
|
||||||
@@ -17,25 +18,44 @@ const epoch int64 = 1764547200000
|
|||||||
|
|
||||||
const maxNegativeIDRetries = 3
|
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
|
snowflake.Epoch = epoch
|
||||||
|
|
||||||
nodeID := config.Config.App.NodeID
|
n, err := snowflake.NewNode(nodeID)
|
||||||
var err error
|
|
||||||
node, err = snowflake.NewNode(nodeID)
|
|
||||||
if err != nil {
|
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)
|
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。
|
// NextUint64ID 生成下一个分布式唯一 ID。
|
||||||
// 理论上不应出现负值;若出现则最多重试 maxNegativeIDRetries 次,仍失败则 panic。
|
// 理论上不应出现负值;若出现则最多重试 maxNegativeIDRetries 次,仍失败则 panic。
|
||||||
func NextUint64ID() uint64 {
|
func NextUint64ID() uint64 {
|
||||||
|
mu.RLock()
|
||||||
|
n := node
|
||||||
|
mu.RUnlock()
|
||||||
|
|
||||||
|
if n == nil {
|
||||||
|
panic(ErrNotInitialized)
|
||||||
|
}
|
||||||
|
|
||||||
for attempt := 1; attempt <= maxNegativeIDRetries; attempt++ {
|
for attempt := 1; attempt <= maxNegativeIDRetries; attempt++ {
|
||||||
id := node.Generate().Int64()
|
id := n.Generate().Int64()
|
||||||
if id >= 0 {
|
if id >= 0 {
|
||||||
return uint64(id)
|
return uint64(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,24 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNextUint64ID(t *testing.T) {
|
func TestNextUint64ID(t *testing.T) {
|
||||||
|
require.NoError(t, Init(1))
|
||||||
id := NextUint64ID()
|
id := NextUint64ID()
|
||||||
assert.NotZero(t, id)
|
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
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"Wavelet/pkg/logger"
|
"Wavelet/pkg/logger"
|
||||||
"Wavelet/pkg/response"
|
"Wavelet/pkg/response"
|
||||||
"Wavelet/plugins/domain/admin/errs"
|
"Wavelet/plugins/domain/admin/errs"
|
||||||
@@ -147,7 +146,7 @@ func GetDatabaseInfo(c *gin.Context) {
|
|||||||
// @Failure 500 {object} response.Any "导出失败"
|
// @Failure 500 {object} response.Any "导出失败"
|
||||||
// @Router /api/v1/admin/db-export [get]
|
// @Router /api/v1/admin/db-export [get]
|
||||||
func ExportDatabase(c *gin.Context) {
|
func ExportDatabase(c *gin.Context) {
|
||||||
if !config.Config.Database.Enabled {
|
if !service.GetDBConfig().Enabled {
|
||||||
exportSQLite(c)
|
exportSQLite(c)
|
||||||
} else {
|
} else {
|
||||||
exportPostgres(c)
|
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.
|
// Apply registers admin routes, tasks, schedules, and settings into the Context.
|
||||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
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
|
// 0. Bind Services reactively
|
||||||
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
||||||
service.SetDBService(db)
|
service.SetDBService(db)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"Wavelet/plugins/domain/admin/errs"
|
"Wavelet/plugins/domain/admin/errs"
|
||||||
"Wavelet/plugins/domain/admin/model"
|
"Wavelet/plugins/domain/admin/model"
|
||||||
"context"
|
"context"
|
||||||
@@ -14,6 +13,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,9 +22,30 @@ const (
|
|||||||
logDBNameSQLite = "sqlite"
|
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.
|
// sqliteDatabasePath resolves the effective SQLite file path from configuration.
|
||||||
func sqliteDatabasePath() string {
|
func sqliteDatabasePath() string {
|
||||||
name := config.Config.Database.SQLitePath
|
name := GetDBConfig().SQLitePath
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = defaultSQLiteDBPath
|
name = defaultSQLiteDBPath
|
||||||
}
|
}
|
||||||
@@ -93,7 +114,7 @@ func GetPostgresOverview(ctx context.Context) (model.DBOverviewResponse, error)
|
|||||||
return model.DBOverviewResponse{}, errs.ErrDatabaseUninitialized
|
return model.DBOverviewResponse{}, errs.ErrDatabaseUninitialized
|
||||||
}
|
}
|
||||||
|
|
||||||
name := config.Config.Database.Database
|
name := GetDBConfig().Database
|
||||||
|
|
||||||
var version string
|
var version string
|
||||||
var ver string
|
var ver string
|
||||||
@@ -152,7 +173,7 @@ func ListDatabaseTableNames(ctx context.Context) ([]string, error) {
|
|||||||
var tables []string
|
var tables []string
|
||||||
var err error
|
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
|
err = gormDB.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").Scan(&tables).Error
|
||||||
} else {
|
} else {
|
||||||
err = gormDB.Raw("SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() ORDER BY table_name").Scan(&tables).Error
|
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.
|
// GetSQLiteInfo collects the SQLite type/name/version triple.
|
||||||
func GetSQLiteInfo(ctx context.Context) model.DatabaseInfoResponse {
|
func GetSQLiteInfo(ctx context.Context) model.DatabaseInfoResponse {
|
||||||
|
cfg := GetDBConfig()
|
||||||
info := model.DatabaseInfoResponse{
|
info := model.DatabaseInfoResponse{
|
||||||
Type: logDBNameSQLite,
|
Type: logDBNameSQLite,
|
||||||
Name: config.Config.Database.SQLitePath,
|
Name: cfg.SQLitePath,
|
||||||
Version: "SQLite",
|
Version: "SQLite",
|
||||||
}
|
}
|
||||||
if info.Name == "" {
|
if info.Name == "" {
|
||||||
@@ -309,9 +331,10 @@ func GetSQLiteInfo(ctx context.Context) model.DatabaseInfoResponse {
|
|||||||
|
|
||||||
// GetPostgresInfo collects the PostgreSQL type/name/version triple.
|
// GetPostgresInfo collects the PostgreSQL type/name/version triple.
|
||||||
func GetPostgresInfo(ctx context.Context) model.DatabaseInfoResponse {
|
func GetPostgresInfo(ctx context.Context) model.DatabaseInfoResponse {
|
||||||
|
cfg := GetDBConfig()
|
||||||
info := model.DatabaseInfoResponse{
|
info := model.DatabaseInfoResponse{
|
||||||
Type: "postgres",
|
Type: "postgres",
|
||||||
Name: config.Config.Database.Database,
|
Name: cfg.Database,
|
||||||
Version: "PostgreSQL",
|
Version: "PostgreSQL",
|
||||||
}
|
}
|
||||||
gormDB := GetDB(ctx)
|
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.
|
// NewPgDumpCommand builds the streaming pg_dump command for the active database.
|
||||||
func NewPgDumpCommand(ctx context.Context) (*exec.Cmd, string, error) {
|
func NewPgDumpCommand(ctx context.Context) (*exec.Cmd, string, error) {
|
||||||
dbCfg := config.Config.Database
|
dbCfg := GetDBConfig()
|
||||||
|
|
||||||
pgDumpPath, err := exec.LookPath("pg_dump")
|
pgDumpPath, err := exec.LookPath("pg_dump")
|
||||||
if err != nil {
|
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))
|
p := cacheplugin.New(cacheplugin.WithRedis(rdb), cacheplugin.WithRAMCapacity(64))
|
||||||
ctx := core.NewContext(context.Background())
|
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))
|
require.NoError(t, p.Apply(ctx))
|
||||||
svc, err := core.Inject[contracts.CacheService](ctx)
|
svc, err := core.Inject[contracts.CacheService](ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -4,22 +4,57 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"Wavelet/plugins/domain/admin/model"
|
"Wavelet/plugins/domain/admin/model"
|
||||||
"Wavelet/plugins/domain/admin/repository"
|
"Wavelet/plugins/domain/admin/repository"
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"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.
|
// selectSQLKeywords marks statements that return a result set instead of a row count.
|
||||||
var selectSQLKeywords = []string{"select", "show", "explain", "describe", "pragma"}
|
var selectSQLKeywords = []string{"select", "show", "explain", "describe", "pragma"}
|
||||||
|
|
||||||
// DatabaseOverview collects the runtime overview of the active database.
|
// DatabaseOverview collects the runtime overview of the active database.
|
||||||
func DatabaseOverview(ctx context.Context) (model.DBOverviewResponse, error) {
|
func DatabaseOverview(ctx context.Context) (model.DBOverviewResponse, error) {
|
||||||
if !config.Config.Database.Enabled {
|
if !GetDBConfig().Enabled {
|
||||||
return repository.GetSQLiteOverview(ctx)
|
return repository.GetSQLiteOverview(ctx)
|
||||||
}
|
}
|
||||||
return repository.GetPostgresOverview(ctx)
|
return repository.GetPostgresOverview(ctx)
|
||||||
@@ -114,7 +149,7 @@ func isSelectStatement(trimmedSQL string) bool {
|
|||||||
|
|
||||||
// DatabaseInfo returns the active database type, name and version.
|
// DatabaseInfo returns the active database type, name and version.
|
||||||
func DatabaseInfo(ctx context.Context) model.DatabaseInfoResponse {
|
func DatabaseInfo(ctx context.Context) model.DatabaseInfoResponse {
|
||||||
if !config.Config.Database.Enabled {
|
if !GetDBConfig().Enabled {
|
||||||
return repository.GetSQLiteInfo(ctx)
|
return repository.GetSQLiteInfo(ctx)
|
||||||
}
|
}
|
||||||
return repository.GetPostgresInfo(ctx)
|
return repository.GetPostgresInfo(ctx)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/core/contracts"
|
"Wavelet/core/contracts"
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"Wavelet/pkg/logger"
|
"Wavelet/pkg/logger"
|
||||||
"Wavelet/plugins/domain/admin/errs"
|
"Wavelet/plugins/domain/admin/errs"
|
||||||
"Wavelet/plugins/domain/admin/model"
|
"Wavelet/plugins/domain/admin/model"
|
||||||
@@ -139,15 +138,15 @@ func validateSwitch(ctx context.Context, target string) error {
|
|||||||
}
|
}
|
||||||
switch target {
|
switch target {
|
||||||
case targetClickHouse:
|
case targetClickHouse:
|
||||||
if !config.Config.ClickHouse.Enabled {
|
if !GetClickHouseConfig().Enabled {
|
||||||
return errors.New(errs.ErrClickHouseNotEnabled)
|
return errors.New(errs.ErrClickHouseNotEnabled)
|
||||||
}
|
}
|
||||||
case targetPostgres:
|
case targetPostgres:
|
||||||
if !config.Config.Database.Enabled {
|
if !GetDBConfig().Enabled {
|
||||||
return errors.New(errs.ErrPostgresNotEnabled)
|
return errors.New(errs.ErrPostgresNotEnabled)
|
||||||
}
|
}
|
||||||
case targetSQLite:
|
case targetSQLite:
|
||||||
if config.Config.Database.Enabled {
|
if GetDBConfig().Enabled {
|
||||||
return errors.New(errs.ErrSQLiteNotAllowedAsLogDB)
|
return errors.New(errs.ErrSQLiteNotAllowedAsLogDB)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"Wavelet/pkg/logger"
|
"Wavelet/pkg/logger"
|
||||||
"Wavelet/plugins/domain/admin/model"
|
"Wavelet/plugins/domain/admin/model"
|
||||||
"Wavelet/plugins/domain/admin/repository"
|
"Wavelet/plugins/domain/admin/repository"
|
||||||
@@ -152,12 +151,12 @@ func retentionOr(ctx context.Context, key string) int {
|
|||||||
|
|
||||||
func availableLogTargets(active string) []string {
|
func availableLogTargets(active string) []string {
|
||||||
if active == logDBNameClickHouse {
|
if active == logDBNameClickHouse {
|
||||||
if config.Config.Database.Enabled {
|
if GetDBConfig().Enabled {
|
||||||
return []string{logDBNamePostgres}
|
return []string{logDBNamePostgres}
|
||||||
}
|
}
|
||||||
return []string{logDBNameSQLite}
|
return []string{logDBNameSQLite}
|
||||||
}
|
}
|
||||||
if config.Config.ClickHouse.Enabled {
|
if GetClickHouseConfig().Enabled {
|
||||||
return []string{logDBNameClickHouse}
|
return []string{logDBNameClickHouse}
|
||||||
}
|
}
|
||||||
return []string{}
|
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.
|
// Apply registers the auth migrations, services, routes, and settings into the Context.
|
||||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
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
|
// 0. Bind DBService & CacheService from Context
|
||||||
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
||||||
setDBService(db)
|
setDBService(db)
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/core/contracts"
|
"Wavelet/core/contracts"
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -19,14 +19,38 @@ import (
|
|||||||
gsessions "github.com/gorilla/sessions"
|
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 选项
|
// GetSessionOptions 根据配置构建 Session 选项
|
||||||
func GetSessionOptions(maxAge int) sessions.Options {
|
func GetSessionOptions(maxAge int) sessions.Options {
|
||||||
|
cfg := GetSessionConfig()
|
||||||
return sessions.Options{
|
return sessions.Options{
|
||||||
Path: "/",
|
Path: "/",
|
||||||
Domain: config.Config.App.SessionDomain,
|
Domain: cfg.SessionDomain,
|
||||||
MaxAge: maxAge,
|
MaxAge: maxAge,
|
||||||
HttpOnly: config.Config.App.SessionHTTPOnly,
|
HttpOnly: cfg.SessionHTTPOnly,
|
||||||
Secure: config.Config.App.SessionSecure,
|
Secure: cfg.SessionSecure,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,7 +137,8 @@ func SetLoginSession(ctx context.Context, c *gin.Context, user *contracts.UserDT
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 根据系统配置动态设置 Session 过期时间
|
// 根据系统配置动态设置 Session 过期时间
|
||||||
maxAge := config.Config.App.SessionAge
|
cfg := GetSessionConfig()
|
||||||
|
maxAge := cfg.SessionAge
|
||||||
isSessionCookie := false
|
isSessionCookie := false
|
||||||
|
|
||||||
val, err := GetSystemConfigValue(ctx, "login_session_ttl_hours")
|
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 {
|
if isSessionCookie {
|
||||||
StripCookieMaxAgeAndExpires(c.Writer.Header(), config.Config.App.SessionCookieName)
|
StripCookieMaxAgeAndExpires(c.Writer.Header(), cfg.SessionCookieName)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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.
|
// Apply registers the cap routes and settings into the Context.
|
||||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
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
|
// 0. Bind DBService from Context
|
||||||
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
||||||
setDBService(db)
|
setDBService(db)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
package cap
|
package cap
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"Wavelet/plugins/domain/cap/pow"
|
"Wavelet/plugins/domain/cap/pow"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
@@ -161,23 +160,23 @@ func sGetAndDelete(ctx context.Context, store pow.Store, key string) (string, bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
defaultManager *Manager
|
defaultManagerMu sync.RWMutex
|
||||||
once sync.Once
|
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.
|
// GetDefaultManager yields the global singleton CAPTCHA manager.
|
||||||
func GetDefaultManager() *Manager {
|
func GetDefaultManager() *Manager {
|
||||||
once.Do(func() {
|
defaultManagerMu.RLock()
|
||||||
var secret []byte
|
defer defaultManagerMu.RUnlock()
|
||||||
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)
|
|
||||||
})
|
|
||||||
return defaultManager
|
return defaultManager
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package domain_test
|
|||||||
import (
|
import (
|
||||||
"Wavelet/core"
|
"Wavelet/core"
|
||||||
"Wavelet/core/contracts"
|
"Wavelet/core/contracts"
|
||||||
|
"Wavelet/pkg/idgen"
|
||||||
"Wavelet/plugins/domain/admin"
|
"Wavelet/plugins/domain/admin"
|
||||||
"Wavelet/plugins/domain/auth"
|
"Wavelet/plugins/domain/auth"
|
||||||
"Wavelet/plugins/domain/message_gateway"
|
"Wavelet/plugins/domain/message_gateway"
|
||||||
@@ -31,6 +32,7 @@ import (
|
|||||||
|
|
||||||
func setupTestDB(t *testing.T) *gorm.DB {
|
func setupTestDB(t *testing.T) *gorm.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
_ = idgen.Init(1)
|
||||||
dbPath := filepath.Join(t.TempDir(), "domain_test.db")
|
dbPath := filepath.Join(t.TempDir(), "domain_test.db")
|
||||||
testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -76,6 +78,8 @@ func (m *mockOAuthProvider) ExchangeCode(ctx context.Context, code string) (*con
|
|||||||
|
|
||||||
func TestAuthPlugin(t *testing.T) {
|
func TestAuthPlugin(t *testing.T) {
|
||||||
ctx := core.NewContext(context.Background())
|
ctx := core.NewContext(context.Background())
|
||||||
|
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||||
|
require.NoError(t, ctx.Config().Resolve())
|
||||||
testDB := setupTestDB(t)
|
testDB := setupTestDB(t)
|
||||||
|
|
||||||
require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx))
|
require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx))
|
||||||
@@ -140,6 +144,8 @@ func TestAuthPlugin(t *testing.T) {
|
|||||||
|
|
||||||
func TestUserPlugin(t *testing.T) {
|
func TestUserPlugin(t *testing.T) {
|
||||||
ctx := core.NewContext(context.Background())
|
ctx := core.NewContext(context.Background())
|
||||||
|
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||||
|
require.NoError(t, ctx.Config().Resolve())
|
||||||
testDB := setupTestDB(t)
|
testDB := setupTestDB(t)
|
||||||
|
|
||||||
require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx))
|
require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx))
|
||||||
@@ -242,6 +248,8 @@ func TestUserPlugin(t *testing.T) {
|
|||||||
|
|
||||||
func TestMessageGatewayPlugin(t *testing.T) {
|
func TestMessageGatewayPlugin(t *testing.T) {
|
||||||
ctx := core.NewContext(context.Background())
|
ctx := core.NewContext(context.Background())
|
||||||
|
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||||
|
require.NoError(t, ctx.Config().Resolve())
|
||||||
testDB := setupTestDB(t)
|
testDB := setupTestDB(t)
|
||||||
|
|
||||||
require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx))
|
require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx))
|
||||||
@@ -310,6 +318,8 @@ func TestMessageGatewayPlugin(t *testing.T) {
|
|||||||
|
|
||||||
func TestRiskControlPlugin(t *testing.T) {
|
func TestRiskControlPlugin(t *testing.T) {
|
||||||
ctx := core.NewContext(context.Background())
|
ctx := core.NewContext(context.Background())
|
||||||
|
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||||
|
require.NoError(t, ctx.Config().Resolve())
|
||||||
p := risk_control.New()
|
p := risk_control.New()
|
||||||
assert.Equal(t, "risk_control", p.Name())
|
assert.Equal(t, "risk_control", p.Name())
|
||||||
assert.Equal(t, "risk_control", p.Manifest().Name)
|
assert.Equal(t, "risk_control", p.Manifest().Name)
|
||||||
@@ -330,6 +340,8 @@ func TestRiskControlPlugin(t *testing.T) {
|
|||||||
|
|
||||||
func TestAdminPlugin(t *testing.T) {
|
func TestAdminPlugin(t *testing.T) {
|
||||||
ctx := core.NewContext(context.Background())
|
ctx := core.NewContext(context.Background())
|
||||||
|
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||||
|
require.NoError(t, ctx.Config().Resolve())
|
||||||
testDB := setupTestDB(t)
|
testDB := setupTestDB(t)
|
||||||
|
|
||||||
require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx))
|
require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx))
|
||||||
@@ -389,6 +401,13 @@ func TestAllDomainPluginsCombined(t *testing.T) {
|
|||||||
defer func() { _ = rdb.Close() }()
|
defer func() { _ = rdb.Close() }()
|
||||||
|
|
||||||
ctx := core.NewContext(context.Background())
|
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)
|
testDB := setupTestDB(t)
|
||||||
|
|
||||||
// Apply Infra plugins
|
// 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.
|
// Apply registers message_gateway migrations, routes, tasks, schedules, events, and settings into the Context.
|
||||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
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
|
// 0. Bind DBService, CacheService, TaskService, UserService
|
||||||
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
||||||
repository.SetDBService(db)
|
repository.SetDBService(db)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ package service
|
|||||||
import (
|
import (
|
||||||
"Wavelet/core"
|
"Wavelet/core"
|
||||||
"Wavelet/core/contracts"
|
"Wavelet/core/contracts"
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"Wavelet/pkg/logger"
|
"Wavelet/pkg/logger"
|
||||||
"Wavelet/pkg/util"
|
"Wavelet/pkg/util"
|
||||||
"Wavelet/plugins/domain/message_gateway/errs"
|
"Wavelet/plugins/domain/message_gateway/errs"
|
||||||
@@ -101,12 +100,23 @@ func FormatCode(s string) string {
|
|||||||
return s[:4] + "-" + s[4:]
|
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.
|
// CredentialKey is AES-256 hex derived from the session secret.
|
||||||
func CredentialKey() string {
|
func CredentialKey() string {
|
||||||
secret := ""
|
credentialSecretMu.RLock()
|
||||||
if config.Config != nil {
|
secret := credentialSecret
|
||||||
secret = config.Config.App.SessionSecret
|
credentialSecretMu.RUnlock()
|
||||||
}
|
|
||||||
sum := sha256.Sum256([]byte(secret))
|
sum := sha256.Sum256([]byte(secret))
|
||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package logstore
|
package logstore
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"Wavelet/pkg/idgen"
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
|
|
||||||
func newTestUserAccessStore(t *testing.T) *userAccessLogGormStore {
|
func newTestUserAccessStore(t *testing.T) *userAccessLogGormStore {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
_ = idgen.Init(1)
|
||||||
gdb, err := gorm.Open(sqlite.Open("file:logstore-"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
gdb, err := gorm.Open(sqlite.Open("file:logstore-"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, gdb.AutoMigrate(&UserAccessLog{}))
|
require.NoError(t, gdb.AutoMigrate(&UserAccessLog{}))
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
package logstore
|
package logstore
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"Wavelet/pkg/logger"
|
"Wavelet/pkg/logger"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -34,6 +33,9 @@ const resolveCacheTTL = 1 * time.Second
|
|||||||
var (
|
var (
|
||||||
configReader ConfigReader
|
configReader ConfigReader
|
||||||
|
|
||||||
|
defaultDBMu sync.RWMutex
|
||||||
|
defaultDB = dbNameSQLite
|
||||||
|
|
||||||
storeMu sync.RWMutex
|
storeMu sync.RWMutex
|
||||||
active *Store
|
active *Store
|
||||||
activeDB string
|
activeDB string
|
||||||
@@ -41,6 +43,25 @@ var (
|
|||||||
lastResolveTime time.Time
|
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 调用,测试可注入内存实现)。
|
// SetConfigReader 注入系统配置读取函数(bootstrap 调用,测试可注入内存实现)。
|
||||||
func SetConfigReader(fn ConfigReader) { configReader = fn }
|
func SetConfigReader(fn ConfigReader) { configReader = fn }
|
||||||
|
|
||||||
@@ -169,13 +190,7 @@ func resolveDatabase(ctx context.Context) (string, error) {
|
|||||||
|
|
||||||
resolved := v
|
resolved := v
|
||||||
if resolved == "" {
|
if resolved == "" {
|
||||||
resolved = dbNameSQLite
|
resolved = getDefaultDatabase()
|
||||||
if config.Config.Database.Enabled {
|
|
||||||
resolved = dbNamePostgres
|
|
||||||
}
|
|
||||||
if config.Config.ClickHouse.Enabled {
|
|
||||||
resolved = dbNameClickHouse
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
storeMu.Lock()
|
storeMu.Lock()
|
||||||
|
|||||||
@@ -6,26 +6,38 @@ package risk_control
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/core/contracts"
|
"Wavelet/core/contracts"
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"Wavelet/pkg/ginutil"
|
"Wavelet/pkg/ginutil"
|
||||||
"Wavelet/pkg/idgen"
|
"Wavelet/pkg/idgen"
|
||||||
"Wavelet/pkg/response"
|
"Wavelet/pkg/response"
|
||||||
"Wavelet/plugins/domain/risk_control/logstore"
|
"Wavelet/plugins/domain/risk_control/logstore"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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.
|
// Middleware is an alias for RiskControlMiddleware.
|
||||||
var Middleware = RiskControlMiddleware
|
var Middleware = RiskControlMiddleware
|
||||||
|
|
||||||
// RiskControlMiddleware 全局日志采集中间件
|
// RiskControlMiddleware 全局日志采集中间件
|
||||||
func RiskControlMiddleware() gin.HandlerFunc {
|
func RiskControlMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// 如果未启用 ClickHouse,直接放行
|
// 如果未启用日志采集,直接放行
|
||||||
if config.Config == nil || !config.Config.ClickHouse.Enabled {
|
if !IsAccessLogEnabled() {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ package risk_control_test
|
|||||||
import (
|
import (
|
||||||
"Wavelet/core/contracts"
|
"Wavelet/core/contracts"
|
||||||
"Wavelet/pkg/batchwriter"
|
"Wavelet/pkg/batchwriter"
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"Wavelet/pkg/ginutil"
|
"Wavelet/pkg/ginutil"
|
||||||
|
"Wavelet/pkg/idgen"
|
||||||
"Wavelet/pkg/testhelper"
|
"Wavelet/pkg/testhelper"
|
||||||
"Wavelet/plugins/domain/risk_control"
|
"Wavelet/plugins/domain/risk_control"
|
||||||
"Wavelet/plugins/domain/risk_control/logstore"
|
"Wavelet/plugins/domain/risk_control/logstore"
|
||||||
@@ -23,6 +23,10 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"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) {
|
func newTestAccessLogWriter(t *testing.T, cfg batchwriter.Config) (*batchwriter.Writer[*logstore.UserAccessLog], func() []*logstore.UserAccessLog) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -70,8 +74,8 @@ func TestRiskControlMiddleware(t *testing.T) {
|
|||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
t.Run("ClickHouse disabled", func(t *testing.T) {
|
t.Run("ClickHouse disabled", func(t *testing.T) {
|
||||||
config.Config.ClickHouse.Enabled = false
|
risk_control.SetAccessLogEnabled(false)
|
||||||
defer func() { config.Config.ClickHouse.Enabled = false }()
|
defer risk_control.SetAccessLogEnabled(false)
|
||||||
|
|
||||||
r := testhelper.NewTestGinEngine(risk_control.RiskControlMiddleware())
|
r := testhelper.NewTestGinEngine(risk_control.RiskControlMiddleware())
|
||||||
r.GET("/test", func(c *gin.Context) {
|
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) {
|
t.Run("ClickHouse enabled - Normal Authenticated Request", func(t *testing.T) {
|
||||||
config.Config.ClickHouse.Enabled = true
|
risk_control.SetAccessLogEnabled(true)
|
||||||
defer func() { config.Config.ClickHouse.Enabled = false }()
|
defer risk_control.SetAccessLogEnabled(false)
|
||||||
|
|
||||||
cfg := batchwriter.DefaultConfig()
|
cfg := batchwriter.DefaultConfig()
|
||||||
cfg.MaxBatchSize = 100
|
cfg.MaxBatchSize = 100
|
||||||
@@ -133,8 +137,8 @@ func TestRiskControlMiddleware(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("ClickHouse enabled - Unauthenticated Request", func(t *testing.T) {
|
t.Run("ClickHouse enabled - Unauthenticated Request", func(t *testing.T) {
|
||||||
config.Config.ClickHouse.Enabled = true
|
risk_control.SetAccessLogEnabled(true)
|
||||||
defer func() { config.Config.ClickHouse.Enabled = false }()
|
defer risk_control.SetAccessLogEnabled(false)
|
||||||
|
|
||||||
cfg := batchwriter.DefaultConfig()
|
cfg := batchwriter.DefaultConfig()
|
||||||
cfg.MaxBatchSize = 100
|
cfg.MaxBatchSize = 100
|
||||||
@@ -162,8 +166,8 @@ func TestRiskControlMiddleware(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("ClickHouse enabled - Buffer Full Rate Limiting", func(t *testing.T) {
|
t.Run("ClickHouse enabled - Buffer Full Rate Limiting", func(t *testing.T) {
|
||||||
config.Config.ClickHouse.Enabled = true
|
risk_control.SetAccessLogEnabled(true)
|
||||||
defer func() { config.Config.ClickHouse.Enabled = false }()
|
defer risk_control.SetAccessLogEnabled(false)
|
||||||
|
|
||||||
cfg := batchwriter.DefaultConfig()
|
cfg := batchwriter.DefaultConfig()
|
||||||
cfg.QueueSize = 2
|
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.
|
// Apply registers risk control middlewares, settings, and cleanup hooks into the Context.
|
||||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
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
|
// 0. Bind DBService
|
||||||
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
||||||
logstore.SetDBService(db)
|
logstore.SetDBService(db)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ package system
|
|||||||
import (
|
import (
|
||||||
"Wavelet/core"
|
"Wavelet/core"
|
||||||
"Wavelet/core/contracts"
|
"Wavelet/core/contracts"
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"Wavelet/pkg/logger"
|
"Wavelet/pkg/logger"
|
||||||
"Wavelet/pkg/response"
|
"Wavelet/pkg/response"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -48,6 +47,8 @@ func (p *Plugin) Manifest() core.Manifest {
|
|||||||
|
|
||||||
// Apply registers system routes.
|
// Apply registers system routes.
|
||||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||||
|
appName := ctx.Config().String("app.app_name", "Wavelet")
|
||||||
|
|
||||||
// 1. Health check
|
// 1. Health check
|
||||||
ctx.Router().GET("/healthz", func(c *gin.Context) {
|
ctx.Router().GET("/healthz", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
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{
|
c.JSON(http.StatusOK, response.OK(gin.H{
|
||||||
"configs": configs,
|
"configs": configs,
|
||||||
"app": gin.H{
|
"app": gin.H{
|
||||||
"name": config.Config.App.AppName,
|
"name": appName,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"Wavelet/pkg/idgen"
|
||||||
uploadstats "Wavelet/plugins/domain/upload/stats"
|
uploadstats "Wavelet/plugins/domain/upload/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ type testResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setupTestRouter(authUser *contracts.UserDTO) *gin.Engine {
|
func setupTestRouter(authUser *contracts.UserDTO) *gin.Engine {
|
||||||
|
_ = idgen.Init(1)
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(response.ErrorHandlerMiddleware())
|
r.Use(response.ErrorHandlerMiddleware())
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package ingest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/core/contracts"
|
"Wavelet/core/contracts"
|
||||||
|
"Wavelet/pkg/idgen"
|
||||||
"Wavelet/plugins/domain/upload/models"
|
"Wavelet/plugins/domain/upload/models"
|
||||||
"Wavelet/plugins/domain/upload/shared"
|
"Wavelet/plugins/domain/upload/shared"
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -17,6 +18,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_ = idgen.Init(1)
|
||||||
|
}
|
||||||
|
|
||||||
type testStorageService struct {
|
type testStorageService struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
mockFiles map[string][]byte
|
mockFiles map[string][]byte
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package user_test
|
|||||||
import (
|
import (
|
||||||
"Wavelet/core"
|
"Wavelet/core"
|
||||||
"Wavelet/core/contracts"
|
"Wavelet/core/contracts"
|
||||||
|
"Wavelet/pkg/idgen"
|
||||||
"Wavelet/plugins/domain/user"
|
"Wavelet/plugins/domain/user"
|
||||||
"context"
|
"context"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -21,6 +22,7 @@ import (
|
|||||||
|
|
||||||
func setupTestDB(t *testing.T) *gorm.DB {
|
func setupTestDB(t *testing.T) *gorm.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
_ = idgen.Init(1)
|
||||||
dbPath := filepath.Join(t.TempDir(), "user_test.db")
|
dbPath := filepath.Join(t.TempDir(), "user_test.db")
|
||||||
testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -36,6 +38,8 @@ func setupTestDB(t *testing.T) *gorm.DB {
|
|||||||
|
|
||||||
func TestUserPluginUnit(t *testing.T) {
|
func TestUserPluginUnit(t *testing.T) {
|
||||||
ctx := core.NewContext(context.Background())
|
ctx := core.NewContext(context.Background())
|
||||||
|
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||||
|
require.NoError(t, ctx.Config().Resolve())
|
||||||
testDB := setupTestDB(t)
|
testDB := setupTestDB(t)
|
||||||
|
|
||||||
dbPlugin := database.New(database.WithDB(testDB))
|
dbPlugin := database.New(database.WithDB(testDB))
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import (
|
|||||||
// 否则调用方(如访问日志按用户补全)会按 ID 逐条打库。
|
// 否则调用方(如访问日志按用户补全)会按 ID 逐条打库。
|
||||||
func TestGetUsersByIDsUsesSingleQuery(t *testing.T) {
|
func TestGetUsersByIDsUsesSingleQuery(t *testing.T) {
|
||||||
ctx := core.NewContext(context.Background())
|
ctx := core.NewContext(context.Background())
|
||||||
|
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||||
|
require.NoError(t, ctx.Config().Resolve())
|
||||||
testDB := setupTestDB(t)
|
testDB := setupTestDB(t)
|
||||||
require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx))
|
require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx))
|
||||||
require.NoError(t, user.New().Apply(ctx))
|
require.NoError(t, user.New().Apply(ctx))
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ package driver_asynq_cron
|
|||||||
import (
|
import (
|
||||||
"Wavelet/core"
|
"Wavelet/core"
|
||||||
"Wavelet/core/contracts"
|
"Wavelet/core/contracts"
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"context"
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -84,10 +83,45 @@ func (p *Plugin) Name() string {
|
|||||||
return "driver_asynq_cron"
|
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.
|
// Apply mounts the Asynq Cron Scheduler driver into the micro-kernel Context.
|
||||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||||
|
var rCfg redisCronConfig
|
||||||
|
_ = ctx.Config().Bind("redis", &rCfg)
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
p.coreCtx = ctx
|
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()
|
p.mu.Unlock()
|
||||||
|
|
||||||
// Bind DBService
|
// Bind DBService
|
||||||
@@ -263,15 +297,7 @@ func (p *Plugin) resolveRedisOpt() asynq.RedisConnOpt {
|
|||||||
if RedisOpt != nil {
|
if RedisOpt != nil {
|
||||||
return RedisOpt
|
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{
|
return asynq.RedisClientOpt{
|
||||||
Addr: addr,
|
Addr: "127.0.0.1:6379",
|
||||||
Username: redisCfg.Username,
|
|
||||||
Password: redisCfg.Password,
|
|
||||||
DB: redisCfg.DB,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
package driver_asynq_worker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"Wavelet/pkg/idgen"
|
||||||
"Wavelet/pkg/testhelper"
|
"Wavelet/pkg/testhelper"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -22,6 +23,10 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_ = idgen.Init(1)
|
||||||
|
}
|
||||||
|
|
||||||
type mockDBService struct {
|
type mockDBService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,10 +108,44 @@ func (p *Plugin) Name() string {
|
|||||||
return "driver_asynq_worker"
|
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.
|
// Apply mounts the Asynq Worker driver into the micro-kernel Context.
|
||||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
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.mu.Lock()
|
||||||
p.coreCtx = ctx
|
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()
|
p.mu.Unlock()
|
||||||
|
|
||||||
// 0. Bind DBService
|
// 0. Bind DBService
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
package driver_asynq_worker
|
package driver_asynq_worker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/hibiken/asynq"
|
"github.com/hibiken/asynq"
|
||||||
@@ -72,8 +71,11 @@ func GetAsynqClient() *asynq.Client {
|
|||||||
return AsynqClient
|
return AsynqClient
|
||||||
}
|
}
|
||||||
|
|
||||||
opt := NewRedisConnOpt()
|
opt := RedisOpt
|
||||||
RedisOpt = opt
|
if opt == nil {
|
||||||
|
opt = NewRedisConnOpt()
|
||||||
|
RedisOpt = opt
|
||||||
|
}
|
||||||
AsynqClient = asynq.NewClient(opt)
|
AsynqClient = asynq.NewClient(opt)
|
||||||
return AsynqClient
|
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 连接选项
|
// NewRedisConnOpt 根据配置返回对应的 asynq Redis 连接选项
|
||||||
func NewRedisConnOpt() asynq.RedisConnOpt {
|
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
|
addrs := cfg.Addrs
|
||||||
|
|
||||||
if cfg.ClusterMode {
|
if cfg.ClusterMode {
|
||||||
@@ -135,7 +161,7 @@ func NewRedisConnOpt() asynq.RedisConnOpt {
|
|||||||
|
|
||||||
// PrefixedQueue 返回带前缀的队列名,用于 Cluster 模式隔离
|
// PrefixedQueue 返回带前缀的队列名,用于 Cluster 模式隔离
|
||||||
func PrefixedQueue(queue string) string {
|
func PrefixedQueue(queue string) string {
|
||||||
prefix := config.Config.Redis.KeyPrefix
|
prefix := GetKeyPrefix()
|
||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
return queue
|
return queue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
package driver_asynq_worker
|
package driver_asynq_worker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9/maintnotifications"
|
"github.com/redis/go-redis/v9/maintnotifications"
|
||||||
@@ -42,17 +41,17 @@ func TestMaintNotificationsConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPrefixedQueue(t *testing.T) {
|
func TestPrefixedQueue(t *testing.T) {
|
||||||
oldPrefix := config.Config.Redis.KeyPrefix
|
oldPrefix := GetKeyPrefix()
|
||||||
defer func() {
|
defer func() {
|
||||||
config.Config.Redis.KeyPrefix = oldPrefix
|
SetKeyPrefix(oldPrefix)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
config.Config.Redis.KeyPrefix = "test:"
|
SetKeyPrefix("test:")
|
||||||
if got := PrefixedQueue("default"); got != "test:default" {
|
if got := PrefixedQueue("default"); got != "test:default" {
|
||||||
t.Fatalf("PrefixedQueue() = %q, want %q", got, "test:default")
|
t.Fatalf("PrefixedQueue() = %q, want %q", got, "test:default")
|
||||||
}
|
}
|
||||||
|
|
||||||
config.Config.Redis.KeyPrefix = ""
|
SetKeyPrefix("")
|
||||||
if got := PrefixedQueue("default"); got != "default" {
|
if got := PrefixedQueue("default"); got != "default" {
|
||||||
t.Fatalf("PrefixedQueue() = %q, want %q", 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
|
package driver_http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"Wavelet/pkg/trace"
|
|
||||||
"Wavelet/pkg/util"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"log"
|
"log"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-contrib/sessions/redis"
|
"github.com/gin-contrib/sessions/redis"
|
||||||
@@ -26,102 +16,82 @@ import (
|
|||||||
|
|
||||||
// BuildEngine 构建并初始化 Gin 路由引擎及全部中间件和路由
|
// BuildEngine 构建并初始化 Gin 路由引擎及全部中间件和路由
|
||||||
func BuildEngine() (*gin.Engine, error) {
|
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)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAPIPrefix(appCfg.APIPrefix)
|
||||||
|
|
||||||
// 初始化路由
|
// 初始化路由
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Recovery())
|
r.Use(gin.Recovery())
|
||||||
r.Use(corsMiddleware())
|
r.Use(corsMiddleware())
|
||||||
|
|
||||||
cfg := config.Config.Redis
|
addrs := redisCfg.Addrs
|
||||||
addrs := cfg.Addrs
|
|
||||||
sessionAddr := "localhost:6379"
|
sessionAddr := "localhost:6379"
|
||||||
if len(addrs) > 0 {
|
if len(addrs) > 0 {
|
||||||
sessionAddr = addrs[0]
|
sessionAddr = addrs[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sessionSecret := appCfg.SessionSecret
|
||||||
|
if sessionSecret == "" {
|
||||||
|
sessionSecret = "wavelet-default-session-secret"
|
||||||
|
}
|
||||||
|
|
||||||
sessionStore, err := redis.NewStoreWithDB(
|
sessionStore, err := redis.NewStoreWithDB(
|
||||||
cfg.MinIdleConn,
|
redisCfg.MinIdleConn,
|
||||||
"tcp",
|
"tcp",
|
||||||
sessionAddr,
|
sessionAddr,
|
||||||
cfg.Username,
|
redisCfg.Username,
|
||||||
cfg.Password,
|
redisCfg.Password,
|
||||||
strconv.Itoa(cfg.DB),
|
strconv.Itoa(redisCfg.DB),
|
||||||
[]byte(config.Config.App.SessionSecret),
|
[]byte(sessionSecret),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置 Session Redis Key 前缀
|
// 设置 Session Redis Key 前缀
|
||||||
if cfg.KeyPrefix != "" {
|
if redisCfg.KeyPrefix != "" {
|
||||||
if err := redis.SetKeyPrefix(sessionStore, cfg.KeyPrefix+"session:"); err != nil {
|
if err := redis.SetKeyPrefix(sessionStore, redisCfg.KeyPrefix+"session:"); err != nil {
|
||||||
log.Printf("[API] set session key prefix failed: %v\n", err)
|
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{
|
sessionStore.Options(sessions.Options{
|
||||||
Path: "/",
|
Path: "/",
|
||||||
Domain: config.Config.App.SessionDomain,
|
Domain: appCfg.SessionDomain,
|
||||||
MaxAge: config.Config.App.SessionAge,
|
MaxAge: sessionAge,
|
||||||
HttpOnly: config.Config.App.SessionHTTPOnly,
|
HttpOnly: appCfg.SessionHTTPOnly,
|
||||||
Secure: config.Config.App.SessionSecure,
|
Secure: appCfg.SessionSecure,
|
||||||
SameSite: http.SameSiteLaxMode,
|
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
|
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
|
package driver_http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"Wavelet/pkg/logger"
|
"Wavelet/pkg/logger"
|
||||||
"Wavelet/pkg/response"
|
"Wavelet/pkg/response"
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -21,6 +21,26 @@ import (
|
|||||||
otel_trace "Wavelet/pkg/trace"
|
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 {
|
func loggerMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// 初始化 Trace
|
// 初始化 Trace
|
||||||
@@ -46,7 +66,7 @@ func loggerMiddleware() gin.HandlerFunc {
|
|||||||
|
|
||||||
// 打印日志
|
// 打印日志
|
||||||
// 排除健康检查接口
|
// 排除健康检查接口
|
||||||
healthPath := config.Config.App.APIPrefix + "/health"
|
healthPath := getAPIPrefix() + "/health"
|
||||||
if c.Request.URL.Path != healthPath {
|
if c.Request.URL.Path != healthPath {
|
||||||
logger.InfoF(
|
logger.InfoF(
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -91,10 +91,31 @@ func (p *Plugin) Name() string {
|
|||||||
return "driver_http"
|
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.
|
// Apply mounts the HTTP driver plugin into the micro-kernel Context.
|
||||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
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.mu.Lock()
|
||||||
p.coreCtx = ctx
|
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()
|
p.mu.Unlock()
|
||||||
|
|
||||||
// Bind DBService from Context
|
// Bind DBService from Context
|
||||||
@@ -147,7 +168,17 @@ func (p *Plugin) Start(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if p.engine == nil {
|
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
|
// 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.
|
// Apply registers the scheduler driver into the Context.
|
||||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||||
p.mu.Lock()
|
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.
|
// Apply registers the worker driver and provides contracts.TaskService.
|
||||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||||
p.coreCtx = ctx
|
p.coreCtx = ctx
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package driver_inproc_worker_test
|
|||||||
import (
|
import (
|
||||||
"Wavelet/core"
|
"Wavelet/core"
|
||||||
"Wavelet/core/extpoints"
|
"Wavelet/core/extpoints"
|
||||||
|
"Wavelet/pkg/idgen"
|
||||||
"Wavelet/plugins/drivers/driver_inproc_worker"
|
"Wavelet/plugins/drivers/driver_inproc_worker"
|
||||||
"context"
|
"context"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestInprocWorkerPlugin(t *testing.T) {
|
func TestInprocWorkerPlugin(t *testing.T) {
|
||||||
|
require.NoError(t, idgen.Init(1))
|
||||||
ctx := core.NewContext(context.Background())
|
ctx := core.NewContext(context.Background())
|
||||||
p := driver_inproc_worker.New(
|
p := driver_inproc_worker.New(
|
||||||
driver_inproc_worker.WithConcurrency(2),
|
driver_inproc_worker.WithConcurrency(2),
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ func init() {
|
|||||||
|
|
||||||
func TestHTTPDriverLifecycle(t *testing.T) {
|
func TestHTTPDriverLifecycle(t *testing.T) {
|
||||||
ctx := core.NewContext(context.Background())
|
ctx := core.NewContext(context.Background())
|
||||||
|
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||||
|
require.NoError(t, ctx.Config().Resolve())
|
||||||
|
|
||||||
var globalMiddlewareCalled atomic.Bool
|
var globalMiddlewareCalled atomic.Bool
|
||||||
var groupMiddlewareCalled atomic.Bool
|
var groupMiddlewareCalled atomic.Bool
|
||||||
@@ -264,6 +266,8 @@ func TestMultipleDriversInContext(t *testing.T) {
|
|||||||
defer mr.Close()
|
defer mr.Close()
|
||||||
|
|
||||||
ctx := core.NewContext(context.Background())
|
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"))
|
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()}))
|
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"
|
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.
|
// Apply mounts the multi-layer cache service into the Context.
|
||||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
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
|
redisClient := p.redisClient
|
||||||
if redisClient == nil {
|
if redisClient == nil {
|
||||||
if Redis == nil {
|
if Redis == nil {
|
||||||
var err error
|
var err error
|
||||||
redisClient, err = InitRedis()
|
redisClient, err = InitRedisWithConfig(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -32,9 +32,11 @@ func TestCachePluginOperations(t *testing.T) {
|
|||||||
cache.WithKeyPrefix("app:"),
|
cache.WithKeyPrefix("app:"),
|
||||||
cache.WithRAMCapacity(500),
|
cache.WithRAMCapacity(500),
|
||||||
)
|
)
|
||||||
assert.Equal(t, "cache", p.Name())
|
|
||||||
|
|
||||||
ctx := core.NewContext(context.Background())
|
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))
|
require.NoError(t, p.Apply(ctx))
|
||||||
|
|
||||||
svc, err := core.Inject[contracts.CacheService](ctx)
|
svc, err := core.Inject[contracts.CacheService](ctx)
|
||||||
|
|||||||
Vendored
+26
-7
@@ -4,12 +4,12 @@
|
|||||||
package cache
|
package cache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/redis/go-redis/extra/redisotel/v9"
|
"github.com/redis/go-redis/extra/redisotel/v9"
|
||||||
@@ -18,18 +18,37 @@ import (
|
|||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Redis 全局 Redis 客户端实例
|
var (
|
||||||
var Redis redis.UniversalClient
|
// Redis 全局 Redis 客户端实例
|
||||||
|
Redis redis.UniversalClient
|
||||||
|
|
||||||
// InitRedis 初始化全局/默认 Redis 客户端实例
|
keyPrefixMu sync.RWMutex
|
||||||
func InitRedis() (redis.UniversalClient, error) {
|
keyPrefix string
|
||||||
cfg := config.Config.Redis
|
)
|
||||||
|
|
||||||
|
// 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 {
|
if !cfg.Enabled {
|
||||||
log.Println("[Redis] is disabled, skipping Redis initialization")
|
log.Println("[Redis] is disabled, skipping Redis initialization")
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SetKeyPrefix(cfg.KeyPrefix)
|
||||||
|
|
||||||
var client redis.UniversalClient
|
var client redis.UniversalClient
|
||||||
|
|
||||||
if cfg.ClusterMode {
|
if cfg.ClusterMode {
|
||||||
@@ -114,7 +133,7 @@ func redisMaintNotificationsConfig(enabled bool) *maintnotifications.Config {
|
|||||||
|
|
||||||
// PrefixedKey 返回带前缀的 Key
|
// PrefixedKey 返回带前缀的 Key
|
||||||
func PrefixedKey(key string) string {
|
func PrefixedKey(key string) string {
|
||||||
prefix := config.Config.Redis.KeyPrefix
|
prefix := GetKeyPrefix()
|
||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
return key
|
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.
|
// Apply mounts the in-memory cache service into the Context.
|
||||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||||
svc, err := newMemoryCacheService(p.capacity, ctx.Events())
|
svc, err := newMemoryCacheService(p.capacity, ctx.Events())
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -34,35 +33,35 @@ var (
|
|||||||
chDB *gorm.DB
|
chDB *gorm.DB
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
// InitClickHouseWithConfig initializes the ClickHouse connection using the provided configuration.
|
||||||
if !config.Config.ClickHouse.Enabled {
|
func InitClickHouseWithConfig(cfg ClickHouseConfig) error {
|
||||||
return
|
if !cfg.Enabled {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.Config.ClickHouse
|
|
||||||
if cfg.Database == "" {
|
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
|
var err error
|
||||||
ChConn, err = clickhouse.Open(opts)
|
ChConn, err = clickhouse.Open(opts)
|
||||||
if err != nil {
|
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 {
|
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{
|
chDB, err = gorm.Open(clickhouseDriver.New(clickhouseDriver.Config{
|
||||||
DSN: buildClickHouseDSN(),
|
DSN: buildClickHouseDSN(cfg),
|
||||||
}), &gorm.Config{
|
}), &gorm.Config{
|
||||||
SkipDefaultTransaction: true,
|
SkipDefaultTransaction: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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(
|
if err = chDB.Use(
|
||||||
@@ -74,12 +73,12 @@ func init() {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
log.Fatalf("[ClickHouse] init trace failed: %v\n", err)
|
return fmt.Errorf("[ClickHouse] init trace failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlDB, err := chDB.DB()
|
sqlDB, err := chDB.DB()
|
||||||
if err != nil {
|
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)
|
sqlDB.SetMaxIdleConns(cfg.MaxIdleConn)
|
||||||
@@ -87,11 +86,10 @@ func init() {
|
|||||||
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second)
|
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second)
|
||||||
|
|
||||||
log.Println("[ClickHouse] connection established successfully")
|
log.Println("[ClickHouse] connection established successfully")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildClickHouseOptions() *clickhouse.Options {
|
func buildClickHouseOptions(cfg ClickHouseConfig) *clickhouse.Options {
|
||||||
cfg := config.Config.ClickHouse
|
|
||||||
|
|
||||||
return &clickhouse.Options{
|
return &clickhouse.Options{
|
||||||
Addr: cfg.Hosts,
|
Addr: cfg.Hosts,
|
||||||
Auth: clickhouse.Auth{
|
Auth: clickhouse.Auth{
|
||||||
@@ -114,9 +112,7 @@ func buildClickHouseOptions() *clickhouse.Options {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildClickHouseDSN() string {
|
func buildClickHouseDSN(cfg ClickHouseConfig) string {
|
||||||
cfg := config.Config.ClickHouse
|
|
||||||
|
|
||||||
chURL := &url.URL{
|
chURL := &url.URL{
|
||||||
Scheme: "clickhouse",
|
Scheme: "clickhouse",
|
||||||
Host: strings.Join(cfg.Hosts, ","),
|
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"
|
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.
|
// Apply mounts the database service into the Context.
|
||||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
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
|
targetDB := p.db
|
||||||
if targetDB == nil {
|
if targetDB == nil {
|
||||||
var err error
|
var err error
|
||||||
targetDB, err = InitDB()
|
targetDB, err = InitDBWithConfig(dbCfg, appCfg.Env == "production" || appCfg.Env == "prod")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if chCfg.Enabled {
|
||||||
|
if err := InitClickHouseWithConfig(chCfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
svc := &dbServiceImpl{
|
svc := &dbServiceImpl{
|
||||||
primary: targetDB,
|
primary: targetDB,
|
||||||
namedDBs: p.namedDBs,
|
namedDBs: p.namedDBs,
|
||||||
|
|||||||
@@ -37,9 +37,11 @@ func TestDatabasePlugin(t *testing.T) {
|
|||||||
database.WithDB(gdb),
|
database.WithDB(gdb),
|
||||||
database.WithNamedDB("analytics", namedDB),
|
database.WithNamedDB("analytics", namedDB),
|
||||||
)
|
)
|
||||||
assert.Equal(t, "database", p.Name())
|
|
||||||
|
|
||||||
ctx := core.NewContext(context.Background())
|
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))
|
require.NoError(t, p.Apply(ctx))
|
||||||
|
|
||||||
svc, err := core.Inject[contracts.DBService](ctx)
|
svc, err := core.Inject[contracts.DBService](ctx)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Wavelet/pkg/config"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -30,15 +29,19 @@ const sqliteDirMode = 0o750
|
|||||||
|
|
||||||
// InitDB 初始化主数据库实例(支持 PostgreSQL / SQLite)
|
// InitDB 初始化主数据库实例(支持 PostgreSQL / SQLite)
|
||||||
func InitDB() (*gorm.DB, error) {
|
func InitDB() (*gorm.DB, error) {
|
||||||
if !config.Config.Database.Enabled {
|
return InitDBWithConfig(Config{}, false)
|
||||||
return initSQLite()
|
|
||||||
}
|
|
||||||
return initPostgres()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// initSQLite 初始化 SQLite 数据库(PostgreSQL 禁用时的后备方案)
|
// InitDBWithConfig initializes the main database with the provided config.
|
||||||
func initSQLite() (*gorm.DB, error) {
|
func InitDBWithConfig(cfg Config, isProd bool) (*gorm.DB, error) {
|
||||||
sqlitePath := config.Config.Database.SQLitePath
|
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 == "" {
|
if sqlitePath == "" {
|
||||||
sqlitePath = "./data/wavelet.db"
|
sqlitePath = "./data/wavelet.db"
|
||||||
}
|
}
|
||||||
@@ -54,9 +57,9 @@ func initSQLite() (*gorm.DB, error) {
|
|||||||
targetDB, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{
|
targetDB, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{
|
||||||
DisableForeignKeyConstraintWhenMigrating: true,
|
DisableForeignKeyConstraintWhenMigrating: true,
|
||||||
Logger: &gormZapLogger{
|
Logger: &gormZapLogger{
|
||||||
logLevel: parseLogLevel(config.Config.Database.LogLevel),
|
logLevel: parseLogLevel(cfg.LogLevel),
|
||||||
slowThreshold: config.Config.Database.SlowThreshold,
|
slowThreshold: cfg.SlowThreshold,
|
||||||
ignoreRecordNotFoundError: config.Config.App.IsProduction(),
|
ignoreRecordNotFoundError: isProd,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -81,24 +84,21 @@ func initSQLite() (*gorm.DB, error) {
|
|||||||
return targetDB, nil
|
return targetDB, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// initPostgres 初始化 PostgreSQL 数据库
|
func initPostgresWithConfig(cfg Config, isProd bool) (*gorm.DB, error) {
|
||||||
func initPostgres() (*gorm.DB, error) {
|
|
||||||
dbConfig := config.Config.Database
|
|
||||||
|
|
||||||
// 构建主库 DSN 并连接
|
// 构建主库 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{
|
pgConfig := postgres.Config{
|
||||||
DSN: primaryDSN,
|
DSN: primaryDSN,
|
||||||
PreferSimpleProtocol: dbConfig.PreferSimpleProtocol,
|
PreferSimpleProtocol: cfg.PreferSimpleProtocol,
|
||||||
}
|
}
|
||||||
|
|
||||||
targetDB, err := gorm.Open(postgres.New(pgConfig), &gorm.Config{
|
targetDB, err := gorm.Open(postgres.New(pgConfig), &gorm.Config{
|
||||||
DisableForeignKeyConstraintWhenMigrating: true,
|
DisableForeignKeyConstraintWhenMigrating: true,
|
||||||
Logger: &gormZapLogger{
|
Logger: &gormZapLogger{
|
||||||
logLevel: parseLogLevel(config.Config.Database.LogLevel),
|
logLevel: parseLogLevel(cfg.LogLevel),
|
||||||
slowThreshold: config.Config.Database.SlowThreshold,
|
slowThreshold: cfg.SlowThreshold,
|
||||||
ignoreRecordNotFoundError: config.Config.App.IsProduction(),
|
ignoreRecordNotFoundError: isProd,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -110,9 +110,9 @@ func initPostgres() (*gorm.DB, error) {
|
|||||||
tracing.NewPlugin(
|
tracing.NewPlugin(
|
||||||
tracing.WithoutMetrics(),
|
tracing.WithoutMetrics(),
|
||||||
tracing.WithAttributes(
|
tracing.WithAttributes(
|
||||||
attribute.String("db.instance", dbConfig.Database),
|
attribute.String("db.instance", cfg.Database),
|
||||||
attribute.String("db.ip", dbConfig.Host),
|
attribute.String("db.ip", cfg.Host),
|
||||||
attribute.String("server.address", net.JoinHostPort(dbConfig.Host, strconv.Itoa(dbConfig.Port))),
|
attribute.String("server.address", net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))),
|
||||||
attribute.String("db.system", "PostgreSQL"),
|
attribute.String("db.system", "PostgreSQL"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -120,21 +120,21 @@ func initPostgres() (*gorm.DB, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(dbConfig.Replicas) > 0 {
|
if len(cfg.Replicas) > 0 {
|
||||||
var replicaDialectors []gorm.Dialector
|
var replicaDialectors []gorm.Dialector
|
||||||
for _, replica := range dbConfig.Replicas {
|
for _, replica := range cfg.Replicas {
|
||||||
username := replica.Username
|
username := replica.Username
|
||||||
if username == "" {
|
if username == "" {
|
||||||
username = dbConfig.Username
|
username = cfg.Username
|
||||||
}
|
}
|
||||||
password := replica.Password
|
password := replica.Password
|
||||||
if 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{
|
replicaDialectors = append(replicaDialectors, postgres.New(postgres.Config{
|
||||||
DSN: replicaDSN,
|
DSN: replicaDSN,
|
||||||
PreferSimpleProtocol: dbConfig.PreferSimpleProtocol,
|
PreferSimpleProtocol: cfg.PreferSimpleProtocol,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,15 +143,15 @@ func initPostgres() (*gorm.DB, error) {
|
|||||||
Policy: dbresolver.RandomPolicy{},
|
Policy: dbresolver.RandomPolicy{},
|
||||||
})
|
})
|
||||||
|
|
||||||
resolver.SetMaxIdleConns(dbConfig.MaxIdleConn).
|
resolver.SetMaxIdleConns(cfg.MaxIdleConn).
|
||||||
SetMaxOpenConns(dbConfig.MaxOpenConn).
|
SetMaxOpenConns(cfg.MaxOpenConn).
|
||||||
SetConnMaxLifetime(time.Duration(dbConfig.ConnMaxLifetime) * time.Second).
|
SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second).
|
||||||
SetConnMaxIdleTime(time.Duration(dbConfig.ConnMaxIdleTime) * time.Second)
|
SetConnMaxIdleTime(time.Duration(cfg.ConnMaxIdleTime) * time.Second)
|
||||||
|
|
||||||
if err = targetDB.Use(resolver); err != nil {
|
if err = targetDB.Use(resolver); err != nil {
|
||||||
return nil, err
|
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 {
|
} else {
|
||||||
log.Println("[PostgreSQL] initialized in Standalone mode")
|
log.Println("[PostgreSQL] initialized in Standalone mode")
|
||||||
}
|
}
|
||||||
@@ -162,18 +162,17 @@ func initPostgres() (*gorm.DB, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlDB.SetMaxIdleConns(dbConfig.MaxIdleConn)
|
sqlDB.SetMaxIdleConns(cfg.MaxIdleConn)
|
||||||
sqlDB.SetMaxOpenConns(dbConfig.MaxOpenConn)
|
sqlDB.SetMaxOpenConns(cfg.MaxOpenConn)
|
||||||
sqlDB.SetConnMaxLifetime(time.Duration(dbConfig.ConnMaxLifetime) * time.Second)
|
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second)
|
||||||
sqlDB.SetConnMaxIdleTime(time.Duration(dbConfig.ConnMaxIdleTime) * time.Second)
|
sqlDB.SetConnMaxIdleTime(time.Duration(cfg.ConnMaxIdleTime) * time.Second)
|
||||||
|
|
||||||
db = targetDB
|
db = targetDB
|
||||||
return targetDB, nil
|
return targetDB, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildDSN 构建 PostgreSQL DSN
|
// buildDSN 构建 PostgreSQL DSN
|
||||||
func buildDSN(host string, port int, username, password string) string {
|
func buildDSN(cfg Config, host string, port int, username, password string) string {
|
||||||
cfg := config.Config.Database
|
|
||||||
pqURL := &url.URL{
|
pqURL := &url.URL{
|
||||||
Scheme: "postgres",
|
Scheme: "postgres",
|
||||||
Host: net.JoinHostPort(host, strconv.Itoa(port)),
|
Host: net.JoinHostPort(host, strconv.Itoa(port)),
|
||||||
|
|||||||
@@ -41,11 +41,25 @@ func setupTestDB(t *testing.T) *gorm.DB {
|
|||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDatabasePlugin(t *testing.T) {
|
func prepareTestContext(values map[string]any, declarers ...core.Plugin) *core.Context {
|
||||||
ctx := core.NewContext(context.Background())
|
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))
|
p := database.New(database.WithDB(testDB))
|
||||||
|
ctx := prepareTestContext(nil, p)
|
||||||
|
|
||||||
require.Equal(t, "database", p.Name())
|
require.Equal(t, "database", p.Name())
|
||||||
require.NoError(t, p.Apply(ctx))
|
require.NoError(t, p.Apply(ctx))
|
||||||
|
|
||||||
@@ -70,8 +84,9 @@ func TestDatabasePlugin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCachePluginRAMOnly(t *testing.T) {
|
func TestCachePluginRAMOnly(t *testing.T) {
|
||||||
ctx := core.NewContext(context.Background())
|
|
||||||
p := cache.New()
|
p := cache.New()
|
||||||
|
ctx := prepareTestContext(nil, p)
|
||||||
|
|
||||||
require.Equal(t, "cache", p.Name())
|
require.Equal(t, "cache", p.Name())
|
||||||
require.NoError(t, p.Apply(ctx))
|
require.NoError(t, p.Apply(ctx))
|
||||||
|
|
||||||
@@ -142,12 +157,12 @@ func TestCachePluginWithRedisAndPubSub(t *testing.T) {
|
|||||||
})
|
})
|
||||||
defer func() { _ = rdb.Close() }()
|
defer func() { _ = rdb.Close() }()
|
||||||
|
|
||||||
ctx1 := core.NewContext(context.Background())
|
|
||||||
ctx2 := core.NewContext(context.Background())
|
|
||||||
|
|
||||||
p1 := cache.New(cache.WithRedis(rdb), cache.WithKeyPrefix("test:"))
|
p1 := cache.New(cache.WithRedis(rdb), cache.WithKeyPrefix("test:"))
|
||||||
p2 := 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, p1.Apply(ctx1))
|
||||||
require.NoError(t, p2.Apply(ctx2))
|
require.NoError(t, p2.Apply(ctx2))
|
||||||
|
|
||||||
@@ -294,14 +309,20 @@ func TestStoragePlugin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAllInfraPluginsCombined(t *testing.T) {
|
func TestAllInfraPluginsCombined(t *testing.T) {
|
||||||
ctx := core.NewContext(context.Background())
|
|
||||||
testDB := setupTestDB(t)
|
testDB := setupTestDB(t)
|
||||||
memBackend := newMemoryBackend()
|
memBackend := newMemoryBackend()
|
||||||
|
|
||||||
require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx))
|
dbP := database.New(database.WithDB(testDB))
|
||||||
require.NoError(t, cache.New().Apply(ctx))
|
cacheP := cache.New()
|
||||||
require.NoError(t, logger.New().Apply(ctx))
|
logP := logger.New()
|
||||||
require.NoError(t, storage.New(storage.WithBackend(memBackend)).Apply(ctx))
|
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
|
// Using3 to resolve dependencies concurrently
|
||||||
var resolved bool
|
var resolved bool
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ Wavelet 贯彻了 Cordis 核心范式,通过形式化保证解决组件系统
|
|||||||
### 5.1 彻底根除集中式包与建立 backend/ 顶级总包
|
### 5.1 彻底根除集中式包与建立 backend/ 顶级总包
|
||||||
在过去的传统单体架构中,集中式的 `internal/model/`、`internal/repository/` 以及 `internal/` 目录往往成为大杂烩,随着团队扩展导致模块边界失控与隐式耦合。在本次 Cordis 架构重构中,我们实施了彻底的物理清退与顶级前后端分包:
|
在过去的传统单体架构中,集中式的 `internal/model/`、`internal/repository/` 以及 `internal/` 目录往往成为大杂烩,随着团队扩展导致模块边界失控与隐式耦合。在本次 Cordis 架构重构中,我们实施了彻底的物理清退与顶级前后端分包:
|
||||||
- **`backend/` 顶级总包**:汇聚所有 Go 后端代码(`cmd/`、`core/`、`plugins/`、`pkg/`、`main.go`),根目录仅保留顶级功能域。
|
- **`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/`。
|
- **`internal/` 目录**:**100% 物理清除**。通用的无状态基础库平移至 `backend/pkg/`,所有业务全部下沉至 `backend/plugins/domain/`。
|
||||||
- **`pkg/model/` 目录**:**100% 物理清除**。消灭集中式数据模型。
|
- **`pkg/model/` 目录**:**100% 物理清除**。消灭集中式数据模型。
|
||||||
- **`pkg/repository/` 目录**:**100% 物理清除**。消灭集中式仓储。
|
- **`pkg/repository/` 目录**:**100% 物理清除**。消灭集中式仓储。
|
||||||
@@ -162,4 +164,23 @@ Wavelet 贯彻了 Cordis 核心范式,通过形式化保证解决组件系统
|
|||||||
2. **Pub/Sub 并发安全防线**:在启动 Redis Pub/Sub 监听协程前,严格捕获局部客户端实例,彻底消除测试或重启期间对可变全局客户端的数据竞争(Data Race Free)。
|
2. **Pub/Sub 并发安全防线**:在启动 Redis Pub/Sub 监听协程前,严格捕获局部客户端实例,彻底消除测试或重启期间对可变全局客户端的数据竞争(Data Race Free)。
|
||||||
3. **零旁路读写 (No Bypass)**:严禁插件 A 跨界旁路直接操作属于插件 B 的数据表,跨域调用一律面向 `backend/core/contracts` 契约编程或发布事件。
|
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` |
|
| 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` 做存在性判断的分支,在注入式配置模型下不再可能,迁移时一并消除。
|
> 注:`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