mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-01 06:36:38 +08:00
merge: feat(core): implement cordis configuration extension and migrate all plugins
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package admin
|
||||
|
||||
import "Wavelet/plugins/domain/admin/model"
|
||||
|
||||
// DatabaseConfig aliases model.DatabaseConfig.
|
||||
type DatabaseConfig = model.DatabaseConfig
|
||||
|
||||
// ClickHouseConfig aliases model.ClickHouseConfig.
|
||||
type ClickHouseConfig = model.ClickHouseConfig
|
||||
@@ -4,7 +4,6 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/logger"
|
||||
"Wavelet/pkg/response"
|
||||
"Wavelet/plugins/domain/admin/errs"
|
||||
@@ -147,7 +146,7 @@ func GetDatabaseInfo(c *gin.Context) {
|
||||
// @Failure 500 {object} response.Any "导出失败"
|
||||
// @Router /api/v1/admin/db-export [get]
|
||||
func ExportDatabase(c *gin.Context) {
|
||||
if !config.Config.Database.Enabled {
|
||||
if !service.GetDBConfig().Enabled {
|
||||
exportSQLite(c)
|
||||
} else {
|
||||
exportPostgres(c)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package model
|
||||
|
||||
// DatabaseConfig holds database configuration needed by the admin plugin.
|
||||
type DatabaseConfig struct {
|
||||
Enabled bool `config:"enabled" env:"DB_ENABLED" default:"false" autoEnable:"DB_HOST"`
|
||||
Host string `config:"host" env:"DB_HOST"`
|
||||
Port int `config:"port" env:"DB_PORT" default:"5432"`
|
||||
Database string `config:"database" env:"DB_DATABASE"`
|
||||
Username string `config:"username" env:"DB_USERNAME"`
|
||||
Password string `config:"password" env:"DB_PASSWORD" secret:"true"`
|
||||
SQLitePath string `config:"sqlite_path" env:"DB_SQLITE_PATH" default:"./data/wavelet.db"`
|
||||
}
|
||||
|
||||
// ClickHouseConfig holds clickhouse enablement status needed by admin log queries/switching.
|
||||
type ClickHouseConfig struct {
|
||||
Enabled bool `config:"enabled" env:"CLICKHOUSE_ENABLED" default:"false" autoEnable:"CLICKHOUSE_HOST"`
|
||||
}
|
||||
@@ -66,8 +66,24 @@ func (p *Plugin) Manifest() core.Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
// DeclareConfig declares configuration bindings consumed by the admin plugin.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "database", Target: &model.DatabaseConfig{}},
|
||||
{Prefix: "clickhouse", Target: &model.ClickHouseConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// Apply registers admin routes, tasks, schedules, and settings into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var dbCfg model.DatabaseConfig
|
||||
_ = ctx.Config().Bind("database", &dbCfg)
|
||||
service.SetDBConfig(dbCfg)
|
||||
|
||||
var chCfg model.ClickHouseConfig
|
||||
_ = ctx.Config().Bind("clickhouse", &chCfg)
|
||||
service.SetClickHouseConfig(chCfg)
|
||||
|
||||
// 0. Bind Services reactively
|
||||
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
||||
service.SetDBService(db)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/plugins/domain/admin/errs"
|
||||
"Wavelet/plugins/domain/admin/model"
|
||||
"context"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -22,9 +22,30 @@ const (
|
||||
logDBNameSQLite = "sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
dbConfigMu sync.RWMutex
|
||||
dbConfig = model.DatabaseConfig{
|
||||
SQLitePath: defaultSQLiteDBPath,
|
||||
}
|
||||
)
|
||||
|
||||
// SetDBConfig sets the database configuration.
|
||||
func SetDBConfig(cfg model.DatabaseConfig) {
|
||||
dbConfigMu.Lock()
|
||||
defer dbConfigMu.Unlock()
|
||||
dbConfig = cfg
|
||||
}
|
||||
|
||||
// GetDBConfig gets the database configuration.
|
||||
func GetDBConfig() model.DatabaseConfig {
|
||||
dbConfigMu.RLock()
|
||||
defer dbConfigMu.RUnlock()
|
||||
return dbConfig
|
||||
}
|
||||
|
||||
// sqliteDatabasePath resolves the effective SQLite file path from configuration.
|
||||
func sqliteDatabasePath() string {
|
||||
name := config.Config.Database.SQLitePath
|
||||
name := GetDBConfig().SQLitePath
|
||||
if name == "" {
|
||||
name = defaultSQLiteDBPath
|
||||
}
|
||||
@@ -93,7 +114,7 @@ func GetPostgresOverview(ctx context.Context) (model.DBOverviewResponse, error)
|
||||
return model.DBOverviewResponse{}, errs.ErrDatabaseUninitialized
|
||||
}
|
||||
|
||||
name := config.Config.Database.Database
|
||||
name := GetDBConfig().Database
|
||||
|
||||
var version string
|
||||
var ver string
|
||||
@@ -152,7 +173,7 @@ func ListDatabaseTableNames(ctx context.Context) ([]string, error) {
|
||||
var tables []string
|
||||
var err error
|
||||
|
||||
if !config.Config.Database.Enabled {
|
||||
if !GetDBConfig().Enabled {
|
||||
err = gormDB.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").Scan(&tables).Error
|
||||
} else {
|
||||
err = gormDB.Raw("SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() ORDER BY table_name").Scan(&tables).Error
|
||||
@@ -288,9 +309,10 @@ func scanRowAsMap(rows *sql.Rows, cols []string) (map[string]any, error) {
|
||||
|
||||
// GetSQLiteInfo collects the SQLite type/name/version triple.
|
||||
func GetSQLiteInfo(ctx context.Context) model.DatabaseInfoResponse {
|
||||
cfg := GetDBConfig()
|
||||
info := model.DatabaseInfoResponse{
|
||||
Type: logDBNameSQLite,
|
||||
Name: config.Config.Database.SQLitePath,
|
||||
Name: cfg.SQLitePath,
|
||||
Version: "SQLite",
|
||||
}
|
||||
if info.Name == "" {
|
||||
@@ -309,9 +331,10 @@ func GetSQLiteInfo(ctx context.Context) model.DatabaseInfoResponse {
|
||||
|
||||
// GetPostgresInfo collects the PostgreSQL type/name/version triple.
|
||||
func GetPostgresInfo(ctx context.Context) model.DatabaseInfoResponse {
|
||||
cfg := GetDBConfig()
|
||||
info := model.DatabaseInfoResponse{
|
||||
Type: "postgres",
|
||||
Name: config.Config.Database.Database,
|
||||
Name: cfg.Database,
|
||||
Version: "PostgreSQL",
|
||||
}
|
||||
gormDB := GetDB(ctx)
|
||||
@@ -343,7 +366,7 @@ func OpenSQLiteExportFile() (*os.File, os.FileInfo, error) {
|
||||
|
||||
// NewPgDumpCommand builds the streaming pg_dump command for the active database.
|
||||
func NewPgDumpCommand(ctx context.Context) (*exec.Cmd, string, error) {
|
||||
dbCfg := config.Config.Database
|
||||
dbCfg := GetDBConfig()
|
||||
|
||||
pgDumpPath, err := exec.LookPath("pg_dump")
|
||||
if err != nil {
|
||||
|
||||
@@ -44,6 +44,13 @@ func newFlushLogTestCache(t *testing.T) (contracts.CacheService, *miniredis.Mini
|
||||
|
||||
p := cacheplugin.New(cacheplugin.WithRedis(rdb), cacheplugin.WithRAMCapacity(64))
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(map[string]any{
|
||||
"redis": map[string]any{
|
||||
"enabled": true,
|
||||
"addrs": []string{mr.Addr()},
|
||||
},
|
||||
}))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
require.NoError(t, p.Apply(ctx))
|
||||
svc, err := core.Inject[contracts.CacheService](ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -4,22 +4,57 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/plugins/domain/admin/model"
|
||||
"Wavelet/plugins/domain/admin/repository"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
dbConfigMu sync.RWMutex
|
||||
dbConfig model.DatabaseConfig
|
||||
chConfig model.ClickHouseConfig
|
||||
)
|
||||
|
||||
// SetDBConfig sets the database configuration in service and repository.
|
||||
func SetDBConfig(cfg model.DatabaseConfig) {
|
||||
dbConfigMu.Lock()
|
||||
defer dbConfigMu.Unlock()
|
||||
dbConfig = cfg
|
||||
repository.SetDBConfig(cfg)
|
||||
}
|
||||
|
||||
// GetDBConfig returns the database configuration.
|
||||
func GetDBConfig() model.DatabaseConfig {
|
||||
dbConfigMu.RLock()
|
||||
defer dbConfigMu.RUnlock()
|
||||
return dbConfig
|
||||
}
|
||||
|
||||
// SetClickHouseConfig sets the clickhouse configuration.
|
||||
func SetClickHouseConfig(cfg model.ClickHouseConfig) {
|
||||
dbConfigMu.Lock()
|
||||
defer dbConfigMu.Unlock()
|
||||
chConfig = cfg
|
||||
}
|
||||
|
||||
// GetClickHouseConfig returns the clickhouse configuration.
|
||||
func GetClickHouseConfig() model.ClickHouseConfig {
|
||||
dbConfigMu.RLock()
|
||||
defer dbConfigMu.RUnlock()
|
||||
return chConfig
|
||||
}
|
||||
|
||||
// selectSQLKeywords marks statements that return a result set instead of a row count.
|
||||
var selectSQLKeywords = []string{"select", "show", "explain", "describe", "pragma"}
|
||||
|
||||
// DatabaseOverview collects the runtime overview of the active database.
|
||||
func DatabaseOverview(ctx context.Context) (model.DBOverviewResponse, error) {
|
||||
if !config.Config.Database.Enabled {
|
||||
if !GetDBConfig().Enabled {
|
||||
return repository.GetSQLiteOverview(ctx)
|
||||
}
|
||||
return repository.GetPostgresOverview(ctx)
|
||||
@@ -114,7 +149,7 @@ func isSelectStatement(trimmedSQL string) bool {
|
||||
|
||||
// DatabaseInfo returns the active database type, name and version.
|
||||
func DatabaseInfo(ctx context.Context) model.DatabaseInfoResponse {
|
||||
if !config.Config.Database.Enabled {
|
||||
if !GetDBConfig().Enabled {
|
||||
return repository.GetSQLiteInfo(ctx)
|
||||
}
|
||||
return repository.GetPostgresInfo(ctx)
|
||||
|
||||
@@ -5,7 +5,6 @@ package service
|
||||
|
||||
import (
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/logger"
|
||||
"Wavelet/plugins/domain/admin/errs"
|
||||
"Wavelet/plugins/domain/admin/model"
|
||||
@@ -139,15 +138,15 @@ func validateSwitch(ctx context.Context, target string) error {
|
||||
}
|
||||
switch target {
|
||||
case targetClickHouse:
|
||||
if !config.Config.ClickHouse.Enabled {
|
||||
if !GetClickHouseConfig().Enabled {
|
||||
return errors.New(errs.ErrClickHouseNotEnabled)
|
||||
}
|
||||
case targetPostgres:
|
||||
if !config.Config.Database.Enabled {
|
||||
if !GetDBConfig().Enabled {
|
||||
return errors.New(errs.ErrPostgresNotEnabled)
|
||||
}
|
||||
case targetSQLite:
|
||||
if config.Config.Database.Enabled {
|
||||
if GetDBConfig().Enabled {
|
||||
return errors.New(errs.ErrSQLiteNotAllowedAsLogDB)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/logger"
|
||||
"Wavelet/plugins/domain/admin/model"
|
||||
"Wavelet/plugins/domain/admin/repository"
|
||||
@@ -152,12 +151,12 @@ func retentionOr(ctx context.Context, key string) int {
|
||||
|
||||
func availableLogTargets(active string) []string {
|
||||
if active == logDBNameClickHouse {
|
||||
if config.Config.Database.Enabled {
|
||||
if GetDBConfig().Enabled {
|
||||
return []string{logDBNamePostgres}
|
||||
}
|
||||
return []string{logDBNameSQLite}
|
||||
}
|
||||
if config.Config.ClickHouse.Enabled {
|
||||
if GetClickHouseConfig().Enabled {
|
||||
return []string{logDBNameClickHouse}
|
||||
}
|
||||
return []string{}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package auth
|
||||
|
||||
// SessionConfig defines the session configuration declared by the auth plugin.
|
||||
type SessionConfig struct {
|
||||
SessionCookieName string `config:"session_cookie_name" env:"APP_SESSION_COOKIE_NAME" default:"wavelet_session"`
|
||||
SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
|
||||
SessionDomain string `config:"session_domain" env:"APP_SESSION_DOMAIN"`
|
||||
SessionAge int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"`
|
||||
SessionHTTPOnly bool `config:"session_http_only" env:"APP_SESSION_HTTP_ONLY" default:"true"`
|
||||
SessionSecure bool `config:"session_secure" env:"APP_SESSION_SECURE"`
|
||||
}
|
||||
@@ -73,8 +73,20 @@ func (p *Plugin) Manifest() core.Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
// DeclareConfig declares configuration bindings for the auth plugin.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "app", Target: &SessionConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// Apply registers the auth migrations, services, routes, and settings into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var cfg SessionConfig
|
||||
if err := ctx.Config().Bind("app", &cfg); err == nil {
|
||||
SetSessionConfig(cfg)
|
||||
}
|
||||
|
||||
// 0. Bind DBService & CacheService from Context
|
||||
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
||||
setDBService(db)
|
||||
|
||||
@@ -5,13 +5,13 @@ package auth
|
||||
|
||||
import (
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/config"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -19,14 +19,38 @@ import (
|
||||
gsessions "github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
var (
|
||||
sessConfigMu sync.RWMutex
|
||||
sessConfig = SessionConfig{
|
||||
SessionCookieName: "wavelet_session",
|
||||
SessionAge: 86400,
|
||||
SessionHTTPOnly: true,
|
||||
}
|
||||
)
|
||||
|
||||
// SetSessionConfig updates the active session configuration.
|
||||
func SetSessionConfig(cfg SessionConfig) {
|
||||
sessConfigMu.Lock()
|
||||
defer sessConfigMu.Unlock()
|
||||
sessConfig = cfg
|
||||
}
|
||||
|
||||
// GetSessionConfig returns the active session configuration.
|
||||
func GetSessionConfig() SessionConfig {
|
||||
sessConfigMu.RLock()
|
||||
defer sessConfigMu.RUnlock()
|
||||
return sessConfig
|
||||
}
|
||||
|
||||
// GetSessionOptions 根据配置构建 Session 选项
|
||||
func GetSessionOptions(maxAge int) sessions.Options {
|
||||
cfg := GetSessionConfig()
|
||||
return sessions.Options{
|
||||
Path: "/",
|
||||
Domain: config.Config.App.SessionDomain,
|
||||
Domain: cfg.SessionDomain,
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: config.Config.App.SessionHTTPOnly,
|
||||
Secure: config.Config.App.SessionSecure,
|
||||
HttpOnly: cfg.SessionHTTPOnly,
|
||||
Secure: cfg.SessionSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
@@ -113,7 +137,8 @@ func SetLoginSession(ctx context.Context, c *gin.Context, user *contracts.UserDT
|
||||
}
|
||||
|
||||
// 根据系统配置动态设置 Session 过期时间
|
||||
maxAge := config.Config.App.SessionAge
|
||||
cfg := GetSessionConfig()
|
||||
maxAge := cfg.SessionAge
|
||||
isSessionCookie := false
|
||||
|
||||
val, err := GetSystemConfigValue(ctx, "login_session_ttl_hours")
|
||||
@@ -137,7 +162,7 @@ func SetLoginSession(ctx context.Context, c *gin.Context, user *contracts.UserDT
|
||||
}
|
||||
|
||||
if isSessionCookie {
|
||||
StripCookieMaxAgeAndExpires(c.Writer.Header(), config.Config.App.SessionCookieName)
|
||||
StripCookieMaxAgeAndExpires(c.Writer.Header(), cfg.SessionCookieName)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -41,8 +41,24 @@ func (p *Plugin) Manifest() core.Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
type capAppConfig struct {
|
||||
SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
|
||||
}
|
||||
|
||||
// DeclareConfig declares configuration bindings for the cap plugin.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "app", Target: &capAppConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// Apply registers the cap routes and settings into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var cfg capAppConfig
|
||||
if err := ctx.Config().Bind("app", &cfg); err == nil && cfg.SessionSecret != "" {
|
||||
SetSecret([]byte(cfg.SessionSecret))
|
||||
}
|
||||
|
||||
// 0. Bind DBService from Context
|
||||
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
||||
setDBService(db)
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
package cap
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/plugins/domain/cap/pow"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
@@ -161,23 +160,23 @@ func sGetAndDelete(ctx context.Context, store pow.Store, key string) (string, bo
|
||||
}
|
||||
|
||||
var (
|
||||
defaultManager *Manager
|
||||
once sync.Once
|
||||
defaultManagerMu sync.RWMutex
|
||||
defaultManager *Manager
|
||||
)
|
||||
|
||||
// SetSecret sets the shared secret used by the default manager.
|
||||
func SetSecret(secret []byte) {
|
||||
defaultManagerMu.Lock()
|
||||
defer defaultManagerMu.Unlock()
|
||||
if len(secret) > 0 {
|
||||
store := pow.NewMemoryStore(1 * time.Minute)
|
||||
defaultManager = NewManager(secret, store)
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultManager yields the global singleton CAPTCHA manager.
|
||||
func GetDefaultManager() *Manager {
|
||||
once.Do(func() {
|
||||
var secret []byte
|
||||
if config.Config != nil && strings.TrimSpace(config.Config.App.SessionSecret) != "" {
|
||||
secret = []byte(config.Config.App.SessionSecret)
|
||||
}
|
||||
if len(secret) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
store := pow.NewMemoryStore(1 * time.Minute)
|
||||
defaultManager = NewManager(secret, store)
|
||||
})
|
||||
defaultManagerMu.RLock()
|
||||
defer defaultManagerMu.RUnlock()
|
||||
return defaultManager
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package domain_test
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/idgen"
|
||||
"Wavelet/plugins/domain/admin"
|
||||
"Wavelet/plugins/domain/auth"
|
||||
"Wavelet/plugins/domain/message_gateway"
|
||||
@@ -31,6 +32,7 @@ import (
|
||||
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
_ = idgen.Init(1)
|
||||
dbPath := filepath.Join(t.TempDir(), "domain_test.db")
|
||||
testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
@@ -76,6 +78,8 @@ func (m *mockOAuthProvider) ExchangeCode(ctx context.Context, code string) (*con
|
||||
|
||||
func TestAuthPlugin(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
testDB := setupTestDB(t)
|
||||
|
||||
require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx))
|
||||
@@ -140,6 +144,8 @@ func TestAuthPlugin(t *testing.T) {
|
||||
|
||||
func TestUserPlugin(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
testDB := setupTestDB(t)
|
||||
|
||||
require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx))
|
||||
@@ -242,6 +248,8 @@ func TestUserPlugin(t *testing.T) {
|
||||
|
||||
func TestMessageGatewayPlugin(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
testDB := setupTestDB(t)
|
||||
|
||||
require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx))
|
||||
@@ -310,6 +318,8 @@ func TestMessageGatewayPlugin(t *testing.T) {
|
||||
|
||||
func TestRiskControlPlugin(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
p := risk_control.New()
|
||||
assert.Equal(t, "risk_control", p.Name())
|
||||
assert.Equal(t, "risk_control", p.Manifest().Name)
|
||||
@@ -330,6 +340,8 @@ func TestRiskControlPlugin(t *testing.T) {
|
||||
|
||||
func TestAdminPlugin(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
testDB := setupTestDB(t)
|
||||
|
||||
require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx))
|
||||
@@ -389,6 +401,13 @@ func TestAllDomainPluginsCombined(t *testing.T) {
|
||||
defer func() { _ = rdb.Close() }()
|
||||
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(map[string]any{
|
||||
"redis": map[string]any{
|
||||
"enabled": true,
|
||||
"addrs": []string{mr.Addr()},
|
||||
},
|
||||
}))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
testDB := setupTestDB(t)
|
||||
|
||||
// Apply Infra plugins
|
||||
|
||||
@@ -72,8 +72,23 @@ func (p *Plugin) Manifest() core.Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
type mgAppConfig struct {
|
||||
SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
|
||||
}
|
||||
|
||||
// DeclareConfig declares configuration bindings for the message_gateway plugin.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "app", Target: &mgAppConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// Apply registers message_gateway migrations, routes, tasks, schedules, events, and settings into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var cfg mgAppConfig
|
||||
if err := ctx.Config().Bind("app", &cfg); err == nil && cfg.SessionSecret != "" {
|
||||
service.SetCredentialSecret(cfg.SessionSecret)
|
||||
}
|
||||
// 0. Bind DBService, CacheService, TaskService, UserService
|
||||
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
||||
repository.SetDBService(db)
|
||||
|
||||
@@ -7,7 +7,6 @@ package service
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/logger"
|
||||
"Wavelet/pkg/util"
|
||||
"Wavelet/plugins/domain/message_gateway/errs"
|
||||
@@ -101,12 +100,23 @@ func FormatCode(s string) string {
|
||||
return s[:4] + "-" + s[4:]
|
||||
}
|
||||
|
||||
var (
|
||||
credentialSecretMu sync.RWMutex
|
||||
credentialSecret string
|
||||
)
|
||||
|
||||
// SetCredentialSecret sets the secret used to derive CredentialKey.
|
||||
func SetCredentialSecret(secret string) {
|
||||
credentialSecretMu.Lock()
|
||||
defer credentialSecretMu.Unlock()
|
||||
credentialSecret = secret
|
||||
}
|
||||
|
||||
// CredentialKey is AES-256 hex derived from the session secret.
|
||||
func CredentialKey() string {
|
||||
secret := ""
|
||||
if config.Config != nil {
|
||||
secret = config.Config.App.SessionSecret
|
||||
}
|
||||
credentialSecretMu.RLock()
|
||||
secret := credentialSecret
|
||||
credentialSecretMu.RUnlock()
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package logstore
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/idgen"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
|
||||
func newTestUserAccessStore(t *testing.T) *userAccessLogGormStore {
|
||||
t.Helper()
|
||||
_ = idgen.Init(1)
|
||||
gdb, err := gorm.Open(sqlite.Open("file:logstore-"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, gdb.AutoMigrate(&UserAccessLog{}))
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package logstore
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/logger"
|
||||
"context"
|
||||
"errors"
|
||||
@@ -34,6 +33,9 @@ const resolveCacheTTL = 1 * time.Second
|
||||
var (
|
||||
configReader ConfigReader
|
||||
|
||||
defaultDBMu sync.RWMutex
|
||||
defaultDB = dbNameSQLite
|
||||
|
||||
storeMu sync.RWMutex
|
||||
active *Store
|
||||
activeDB string
|
||||
@@ -41,6 +43,25 @@ var (
|
||||
lastResolveTime time.Time
|
||||
)
|
||||
|
||||
// SetDefaultDatabases configures the fallback database based on database and clickhouse enablement.
|
||||
func SetDefaultDatabases(dbEnabled, chEnabled bool) {
|
||||
defaultDBMu.Lock()
|
||||
defer defaultDBMu.Unlock()
|
||||
defaultDB = dbNameSQLite
|
||||
if dbEnabled {
|
||||
defaultDB = dbNamePostgres
|
||||
}
|
||||
if chEnabled {
|
||||
defaultDB = dbNameClickHouse
|
||||
}
|
||||
}
|
||||
|
||||
func getDefaultDatabase() string {
|
||||
defaultDBMu.RLock()
|
||||
defer defaultDBMu.RUnlock()
|
||||
return defaultDB
|
||||
}
|
||||
|
||||
// SetConfigReader 注入系统配置读取函数(bootstrap 调用,测试可注入内存实现)。
|
||||
func SetConfigReader(fn ConfigReader) { configReader = fn }
|
||||
|
||||
@@ -169,13 +190,7 @@ func resolveDatabase(ctx context.Context) (string, error) {
|
||||
|
||||
resolved := v
|
||||
if resolved == "" {
|
||||
resolved = dbNameSQLite
|
||||
if config.Config.Database.Enabled {
|
||||
resolved = dbNamePostgres
|
||||
}
|
||||
if config.Config.ClickHouse.Enabled {
|
||||
resolved = dbNameClickHouse
|
||||
}
|
||||
resolved = getDefaultDatabase()
|
||||
}
|
||||
|
||||
storeMu.Lock()
|
||||
|
||||
@@ -6,26 +6,38 @@ package risk_control
|
||||
|
||||
import (
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/ginutil"
|
||||
"Wavelet/pkg/idgen"
|
||||
"Wavelet/pkg/response"
|
||||
"Wavelet/plugins/domain/risk_control/logstore"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var accessLogEnabled atomic.Bool
|
||||
|
||||
// SetAccessLogEnabled enables or disables access log collection.
|
||||
func SetAccessLogEnabled(enabled bool) {
|
||||
accessLogEnabled.Store(enabled)
|
||||
}
|
||||
|
||||
// IsAccessLogEnabled reports whether access log collection is enabled.
|
||||
func IsAccessLogEnabled() bool {
|
||||
return accessLogEnabled.Load()
|
||||
}
|
||||
|
||||
// Middleware is an alias for RiskControlMiddleware.
|
||||
var Middleware = RiskControlMiddleware
|
||||
|
||||
// RiskControlMiddleware 全局日志采集中间件
|
||||
func RiskControlMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 如果未启用 ClickHouse,直接放行
|
||||
if config.Config == nil || !config.Config.ClickHouse.Enabled {
|
||||
// 如果未启用日志采集,直接放行
|
||||
if !IsAccessLogEnabled() {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ package risk_control_test
|
||||
import (
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/batchwriter"
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/ginutil"
|
||||
"Wavelet/pkg/idgen"
|
||||
"Wavelet/pkg/testhelper"
|
||||
"Wavelet/plugins/domain/risk_control"
|
||||
"Wavelet/plugins/domain/risk_control/logstore"
|
||||
@@ -23,6 +23,10 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_ = idgen.Init(1)
|
||||
}
|
||||
|
||||
func newTestAccessLogWriter(t *testing.T, cfg batchwriter.Config) (*batchwriter.Writer[*logstore.UserAccessLog], func() []*logstore.UserAccessLog) {
|
||||
t.Helper()
|
||||
|
||||
@@ -70,8 +74,8 @@ func TestRiskControlMiddleware(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("ClickHouse disabled", func(t *testing.T) {
|
||||
config.Config.ClickHouse.Enabled = false
|
||||
defer func() { config.Config.ClickHouse.Enabled = false }()
|
||||
risk_control.SetAccessLogEnabled(false)
|
||||
defer risk_control.SetAccessLogEnabled(false)
|
||||
|
||||
r := testhelper.NewTestGinEngine(risk_control.RiskControlMiddleware())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
@@ -87,8 +91,8 @@ func TestRiskControlMiddleware(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("ClickHouse enabled - Normal Authenticated Request", func(t *testing.T) {
|
||||
config.Config.ClickHouse.Enabled = true
|
||||
defer func() { config.Config.ClickHouse.Enabled = false }()
|
||||
risk_control.SetAccessLogEnabled(true)
|
||||
defer risk_control.SetAccessLogEnabled(false)
|
||||
|
||||
cfg := batchwriter.DefaultConfig()
|
||||
cfg.MaxBatchSize = 100
|
||||
@@ -133,8 +137,8 @@ func TestRiskControlMiddleware(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("ClickHouse enabled - Unauthenticated Request", func(t *testing.T) {
|
||||
config.Config.ClickHouse.Enabled = true
|
||||
defer func() { config.Config.ClickHouse.Enabled = false }()
|
||||
risk_control.SetAccessLogEnabled(true)
|
||||
defer risk_control.SetAccessLogEnabled(false)
|
||||
|
||||
cfg := batchwriter.DefaultConfig()
|
||||
cfg.MaxBatchSize = 100
|
||||
@@ -162,8 +166,8 @@ func TestRiskControlMiddleware(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("ClickHouse enabled - Buffer Full Rate Limiting", func(t *testing.T) {
|
||||
config.Config.ClickHouse.Enabled = true
|
||||
defer func() { config.Config.ClickHouse.Enabled = false }()
|
||||
risk_control.SetAccessLogEnabled(true)
|
||||
defer risk_control.SetAccessLogEnabled(false)
|
||||
|
||||
cfg := batchwriter.DefaultConfig()
|
||||
cfg.QueueSize = 2
|
||||
|
||||
@@ -68,8 +68,32 @@ func (p *Plugin) Manifest() core.Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
type rcClickHouseConfig struct {
|
||||
Enabled bool `config:"enabled" env:"CLICKHOUSE_ENABLED" default:"false" autoEnable:"CLICKHOUSE_HOST"`
|
||||
}
|
||||
|
||||
type rcDBConfig struct {
|
||||
Enabled bool `config:"enabled" env:"DB_ENABLED" default:"false" autoEnable:"DB_HOST"`
|
||||
}
|
||||
|
||||
// DeclareConfig declares configuration bindings for the risk_control plugin.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "clickhouse", Target: &rcClickHouseConfig{}},
|
||||
{Prefix: "database", Target: &rcDBConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// Apply registers risk control middlewares, settings, and cleanup hooks into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var chCfg rcClickHouseConfig
|
||||
_ = ctx.Config().Bind("clickhouse", &chCfg)
|
||||
var dbCfg rcDBConfig
|
||||
_ = ctx.Config().Bind("database", &dbCfg)
|
||||
|
||||
SetAccessLogEnabled(chCfg.Enabled)
|
||||
logstore.SetDefaultDatabases(dbCfg.Enabled, chCfg.Enabled)
|
||||
|
||||
// 0. Bind DBService
|
||||
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
||||
logstore.SetDBService(db)
|
||||
|
||||
@@ -7,7 +7,6 @@ package system
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/logger"
|
||||
"Wavelet/pkg/response"
|
||||
"net/http"
|
||||
@@ -48,6 +47,8 @@ func (p *Plugin) Manifest() core.Manifest {
|
||||
|
||||
// Apply registers system routes.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
appName := ctx.Config().String("app.app_name", "Wavelet")
|
||||
|
||||
// 1. Health check
|
||||
ctx.Router().GET("/healthz", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
@@ -65,7 +66,7 @@ func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
c.JSON(http.StatusOK, response.OK(gin.H{
|
||||
"configs": configs,
|
||||
"app": gin.H{
|
||||
"name": config.Config.App.AppName,
|
||||
"name": appName,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"Wavelet/pkg/idgen"
|
||||
uploadstats "Wavelet/plugins/domain/upload/stats"
|
||||
)
|
||||
|
||||
@@ -36,6 +37,7 @@ type testResponse struct {
|
||||
}
|
||||
|
||||
func setupTestRouter(authUser *contracts.UserDTO) *gin.Engine {
|
||||
_ = idgen.Init(1)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(response.ErrorHandlerMiddleware())
|
||||
|
||||
@@ -5,6 +5,7 @@ package ingest
|
||||
|
||||
import (
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/idgen"
|
||||
"Wavelet/plugins/domain/upload/models"
|
||||
"Wavelet/plugins/domain/upload/shared"
|
||||
"bytes"
|
||||
@@ -17,6 +18,10 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_ = idgen.Init(1)
|
||||
}
|
||||
|
||||
type testStorageService struct {
|
||||
mu sync.RWMutex
|
||||
mockFiles map[string][]byte
|
||||
|
||||
@@ -6,6 +6,7 @@ package user_test
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/idgen"
|
||||
"Wavelet/plugins/domain/user"
|
||||
"context"
|
||||
"path/filepath"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
_ = idgen.Init(1)
|
||||
dbPath := filepath.Join(t.TempDir(), "user_test.db")
|
||||
testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
@@ -36,6 +38,8 @@ func setupTestDB(t *testing.T) *gorm.DB {
|
||||
|
||||
func TestUserPluginUnit(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
testDB := setupTestDB(t)
|
||||
|
||||
dbPlugin := database.New(database.WithDB(testDB))
|
||||
|
||||
@@ -21,6 +21,8 @@ import (
|
||||
// 否则调用方(如访问日志按用户补全)会按 ID 逐条打库。
|
||||
func TestGetUsersByIDsUsesSingleQuery(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
testDB := setupTestDB(t)
|
||||
require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx))
|
||||
require.NoError(t, user.New().Apply(ctx))
|
||||
|
||||
@@ -7,7 +7,6 @@ package driver_asynq_cron
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/config"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
@@ -84,10 +83,45 @@ func (p *Plugin) Name() string {
|
||||
return "driver_asynq_cron"
|
||||
}
|
||||
|
||||
type redisCronConfig struct {
|
||||
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
|
||||
Addrs []string `config:"addrs" env:"REDIS_ADDR"`
|
||||
Username string `config:"username" env:"REDIS_USERNAME"`
|
||||
Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"`
|
||||
DB int `config:"db" env:"REDIS_DB"`
|
||||
}
|
||||
|
||||
// DeclareConfig declares configuration bindings for driver_asynq_cron.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "redis", Target: &redisCronConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigEnabled gates plugin activation when Redis is enabled.
|
||||
func (p *Plugin) ConfigEnabled(view core.ConfigView) bool {
|
||||
return view.Bool("redis.enabled", false)
|
||||
}
|
||||
|
||||
// Apply mounts the Asynq Cron Scheduler driver into the micro-kernel Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var rCfg redisCronConfig
|
||||
_ = ctx.Config().Bind("redis", &rCfg)
|
||||
|
||||
p.mu.Lock()
|
||||
p.coreCtx = ctx
|
||||
if p.redisOpt == nil {
|
||||
addr := "127.0.0.1:6379"
|
||||
if len(rCfg.Addrs) > 0 && rCfg.Addrs[0] != "" {
|
||||
addr = rCfg.Addrs[0]
|
||||
}
|
||||
p.redisOpt = asynq.RedisClientOpt{
|
||||
Addr: addr,
|
||||
Username: rCfg.Username,
|
||||
Password: rCfg.Password,
|
||||
DB: rCfg.DB,
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
// Bind DBService
|
||||
@@ -263,15 +297,7 @@ func (p *Plugin) resolveRedisOpt() asynq.RedisConnOpt {
|
||||
if RedisOpt != nil {
|
||||
return RedisOpt
|
||||
}
|
||||
redisCfg := config.Config.Redis
|
||||
addr := "127.0.0.1:6379"
|
||||
if len(redisCfg.Addrs) > 0 && redisCfg.Addrs[0] != "" {
|
||||
addr = redisCfg.Addrs[0]
|
||||
}
|
||||
return asynq.RedisClientOpt{
|
||||
Addr: addr,
|
||||
Username: redisCfg.Username,
|
||||
Password: redisCfg.Password,
|
||||
DB: redisCfg.DB,
|
||||
Addr: "127.0.0.1:6379",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package driver_asynq_worker
|
||||
|
||||
type queueConfig struct {
|
||||
Name string `config:"name"`
|
||||
Priority int `config:"priority"`
|
||||
}
|
||||
|
||||
type workerConfig struct {
|
||||
Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY" default:"10"`
|
||||
StrictPriority bool `config:"strict_priority" env:"WORKER_STRICT_PRIORITY" default:"false"`
|
||||
Queues []queueConfig `config:"queues"`
|
||||
}
|
||||
|
||||
type redisWorkerConfig struct {
|
||||
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
|
||||
Addrs []string `config:"addrs" env:"REDIS_ADDR"`
|
||||
Username string `config:"username" env:"REDIS_USERNAME"`
|
||||
Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"`
|
||||
DB int `config:"db" env:"REDIS_DB"`
|
||||
ClusterMode bool `config:"cluster_mode" env:"REDIS_CLUSTER_MODE"`
|
||||
MasterName string `config:"master_name" env:"REDIS_MASTER_NAME"`
|
||||
KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"`
|
||||
PoolSize int `config:"pool_size" env:"REDIS_POOL_SIZE"`
|
||||
MaintNotifications bool `config:"maint_notifications" env:"REDIS_MAINT_NOTIFICATIONS" default:"false"`
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package driver_asynq_worker
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/idgen"
|
||||
"Wavelet/pkg/testhelper"
|
||||
"context"
|
||||
"errors"
|
||||
@@ -22,6 +23,10 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_ = idgen.Init(1)
|
||||
}
|
||||
|
||||
type mockDBService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
@@ -108,10 +108,44 @@ func (p *Plugin) Name() string {
|
||||
return "driver_asynq_worker"
|
||||
}
|
||||
|
||||
// DeclareConfig declares configuration bindings consumed by the Asynq worker driver.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "worker", Target: &workerConfig{}},
|
||||
{Prefix: "redis", Target: &redisWorkerConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigEnabled gates plugin activation when Redis is enabled.
|
||||
func (p *Plugin) ConfigEnabled(view core.ConfigView) bool {
|
||||
return view.Bool("redis.enabled", false)
|
||||
}
|
||||
|
||||
// Apply mounts the Asynq Worker driver into the micro-kernel Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var wCfg workerConfig
|
||||
_ = ctx.Config().Bind("worker", &wCfg)
|
||||
var rCfg redisWorkerConfig
|
||||
_ = ctx.Config().Bind("redis", &rCfg)
|
||||
|
||||
p.mu.Lock()
|
||||
p.coreCtx = ctx
|
||||
if p.concurrency == defaultConcurrency && wCfg.Concurrency > 0 {
|
||||
p.concurrency = wCfg.Concurrency
|
||||
}
|
||||
p.strictPriority = wCfg.StrictPriority
|
||||
if len(p.queues) == 1 && p.queues["default"] == 1 && len(wCfg.Queues) > 0 {
|
||||
qMap := make(map[string]int, len(wCfg.Queues))
|
||||
for _, q := range wCfg.Queues {
|
||||
qMap[q.Name] = q.Priority
|
||||
}
|
||||
p.queues = qMap
|
||||
}
|
||||
if p.redisOpt == nil {
|
||||
p.redisOpt = NewRedisConnOptWithConfig(rCfg)
|
||||
}
|
||||
RedisOpt = p.redisOpt
|
||||
ResetAsynqClient()
|
||||
p.mu.Unlock()
|
||||
|
||||
// 0. Bind DBService
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package driver_asynq_worker
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"sync"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
@@ -72,8 +71,11 @@ func GetAsynqClient() *asynq.Client {
|
||||
return AsynqClient
|
||||
}
|
||||
|
||||
opt := NewRedisConnOpt()
|
||||
RedisOpt = opt
|
||||
opt := RedisOpt
|
||||
if opt == nil {
|
||||
opt = NewRedisConnOpt()
|
||||
RedisOpt = opt
|
||||
}
|
||||
AsynqClient = asynq.NewClient(opt)
|
||||
return AsynqClient
|
||||
}
|
||||
@@ -88,9 +90,33 @@ func ResetAsynqClient() {
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
keyPrefixMu sync.RWMutex
|
||||
keyPrefix string
|
||||
)
|
||||
|
||||
// SetKeyPrefix sets the redis key prefix for queue names.
|
||||
func SetKeyPrefix(prefix string) {
|
||||
keyPrefixMu.Lock()
|
||||
defer keyPrefixMu.Unlock()
|
||||
keyPrefix = prefix
|
||||
}
|
||||
|
||||
// GetKeyPrefix returns the redis key prefix for queue names.
|
||||
func GetKeyPrefix() string {
|
||||
keyPrefixMu.RLock()
|
||||
defer keyPrefixMu.RUnlock()
|
||||
return keyPrefix
|
||||
}
|
||||
|
||||
// NewRedisConnOpt 根据配置返回对应的 asynq Redis 连接选项
|
||||
func NewRedisConnOpt() asynq.RedisConnOpt {
|
||||
cfg := config.Config.Redis
|
||||
return NewRedisConnOptWithConfig(redisWorkerConfig{})
|
||||
}
|
||||
|
||||
// NewRedisConnOptWithConfig returns the asynq RedisConnOpt based on the provided configuration.
|
||||
func NewRedisConnOptWithConfig(cfg redisWorkerConfig) asynq.RedisConnOpt {
|
||||
SetKeyPrefix(cfg.KeyPrefix)
|
||||
addrs := cfg.Addrs
|
||||
|
||||
if cfg.ClusterMode {
|
||||
@@ -135,7 +161,7 @@ func NewRedisConnOpt() asynq.RedisConnOpt {
|
||||
|
||||
// PrefixedQueue 返回带前缀的队列名,用于 Cluster 模式隔离
|
||||
func PrefixedQueue(queue string) string {
|
||||
prefix := config.Config.Redis.KeyPrefix
|
||||
prefix := GetKeyPrefix()
|
||||
if prefix == "" {
|
||||
return queue
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package driver_asynq_worker
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"testing"
|
||||
|
||||
"github.com/redis/go-redis/v9/maintnotifications"
|
||||
@@ -42,17 +41,17 @@ func TestMaintNotificationsConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPrefixedQueue(t *testing.T) {
|
||||
oldPrefix := config.Config.Redis.KeyPrefix
|
||||
oldPrefix := GetKeyPrefix()
|
||||
defer func() {
|
||||
config.Config.Redis.KeyPrefix = oldPrefix
|
||||
SetKeyPrefix(oldPrefix)
|
||||
}()
|
||||
|
||||
config.Config.Redis.KeyPrefix = "test:"
|
||||
SetKeyPrefix("test:")
|
||||
if got := PrefixedQueue("default"); got != "test:default" {
|
||||
t.Fatalf("PrefixedQueue() = %q, want %q", got, "test:default")
|
||||
}
|
||||
|
||||
config.Config.Redis.KeyPrefix = ""
|
||||
SetKeyPrefix("")
|
||||
if got := PrefixedQueue("default"); got != "default" {
|
||||
t.Fatalf("PrefixedQueue() = %q, want %q", got, "default")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package driver_http
|
||||
|
||||
type httpAppConfig struct {
|
||||
Addr string `config:"addr" env:"APP_ADDR" default:":8000"`
|
||||
AppName string `config:"app_name" env:"APP_NAME" default:"Wavelet"`
|
||||
APIPrefix string `config:"api_prefix" env:"APP_API_PREFIX" default:"/api/v1"`
|
||||
Env string `config:"env" env:"APP_ENV" default:"development"`
|
||||
GracefulShutdownTimeout int `config:"graceful_shutdown_timeout" env:"APP_GRACEFUL_SHUTDOWN_TIMEOUT" default:"30"`
|
||||
SessionCookieName string `config:"session_cookie_name" env:"APP_SESSION_COOKIE_NAME" default:"wavelet_session"`
|
||||
SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
|
||||
SessionDomain string `config:"session_domain" env:"APP_SESSION_DOMAIN"`
|
||||
SessionAge int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"`
|
||||
SessionHTTPOnly bool `config:"session_http_only" env:"APP_SESSION_HTTP_ONLY" default:"true"`
|
||||
SessionSecure bool `config:"session_secure" env:"APP_SESSION_SECURE"`
|
||||
}
|
||||
|
||||
type httpRedisConfig struct {
|
||||
Addrs []string `config:"addrs" env:"REDIS_ADDR"`
|
||||
Username string `config:"username" env:"REDIS_USERNAME"`
|
||||
Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"`
|
||||
DB int `config:"db" env:"REDIS_DB"`
|
||||
KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"`
|
||||
MinIdleConn int `config:"min_idle_conn" env:"REDIS_MIN_IDLE_CONN"`
|
||||
}
|
||||
@@ -4,19 +4,9 @@
|
||||
package driver_http
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/trace"
|
||||
"Wavelet/pkg/util"
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/redis"
|
||||
@@ -26,102 +16,82 @@ import (
|
||||
|
||||
// BuildEngine 构建并初始化 Gin 路由引擎及全部中间件和路由
|
||||
func BuildEngine() (*gin.Engine, error) {
|
||||
return BuildEngineWithConfig(httpAppConfig{}, httpRedisConfig{})
|
||||
}
|
||||
|
||||
// BuildEngineWithConfig constructs the Gin engine with explicitly injected configuration.
|
||||
func BuildEngineWithConfig(appCfg httpAppConfig, redisCfg httpRedisConfig) (*gin.Engine, error) {
|
||||
// 运行模式
|
||||
if config.Config.App.IsProduction() {
|
||||
if appCfg.Env == "production" || appCfg.Env == "prod" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
setAPIPrefix(appCfg.APIPrefix)
|
||||
|
||||
// 初始化路由
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(corsMiddleware())
|
||||
|
||||
cfg := config.Config.Redis
|
||||
addrs := cfg.Addrs
|
||||
addrs := redisCfg.Addrs
|
||||
sessionAddr := "localhost:6379"
|
||||
if len(addrs) > 0 {
|
||||
sessionAddr = addrs[0]
|
||||
}
|
||||
|
||||
sessionSecret := appCfg.SessionSecret
|
||||
if sessionSecret == "" {
|
||||
sessionSecret = "wavelet-default-session-secret"
|
||||
}
|
||||
|
||||
sessionStore, err := redis.NewStoreWithDB(
|
||||
cfg.MinIdleConn,
|
||||
redisCfg.MinIdleConn,
|
||||
"tcp",
|
||||
sessionAddr,
|
||||
cfg.Username,
|
||||
cfg.Password,
|
||||
strconv.Itoa(cfg.DB),
|
||||
[]byte(config.Config.App.SessionSecret),
|
||||
redisCfg.Username,
|
||||
redisCfg.Password,
|
||||
strconv.Itoa(redisCfg.DB),
|
||||
[]byte(sessionSecret),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 设置 Session Redis Key 前缀
|
||||
if cfg.KeyPrefix != "" {
|
||||
if err := redis.SetKeyPrefix(sessionStore, cfg.KeyPrefix+"session:"); err != nil {
|
||||
if redisCfg.KeyPrefix != "" {
|
||||
if err := redis.SetKeyPrefix(sessionStore, redisCfg.KeyPrefix+"session:"); err != nil {
|
||||
log.Printf("[API] set session key prefix failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
sessionCookieName := appCfg.SessionCookieName
|
||||
if sessionCookieName == "" {
|
||||
sessionCookieName = "wavelet_session"
|
||||
}
|
||||
|
||||
sessionAge := appCfg.SessionAge
|
||||
if sessionAge <= 0 {
|
||||
sessionAge = 86400
|
||||
}
|
||||
|
||||
sessionStore.Options(sessions.Options{
|
||||
Path: "/",
|
||||
Domain: config.Config.App.SessionDomain,
|
||||
MaxAge: config.Config.App.SessionAge,
|
||||
HttpOnly: config.Config.App.SessionHTTPOnly,
|
||||
Secure: config.Config.App.SessionSecure,
|
||||
Domain: appCfg.SessionDomain,
|
||||
MaxAge: sessionAge,
|
||||
HttpOnly: appCfg.SessionHTTPOnly,
|
||||
Secure: appCfg.SessionSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
r.Use(sessions.Sessions(config.Config.App.SessionCookieName, sessionStore))
|
||||
r.Use(sessions.Sessions(sessionCookieName, sessionStore))
|
||||
|
||||
appName := appCfg.AppName
|
||||
if appName == "" {
|
||||
appName = "Wavelet"
|
||||
}
|
||||
|
||||
// 补充中间件
|
||||
r.Use(otelgin.Middleware(config.Config.App.AppName), errorHandlerMiddleware(), loggerMiddleware())
|
||||
r.Use(otelgin.Middleware(appName), errorHandlerMiddleware(), loggerMiddleware())
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Serve 启动 HTTP API 服务。onStarted 仅会在 HTTP 地址成功绑定后调用。
|
||||
func Serve(onStarted func()) {
|
||||
r, err := BuildEngine()
|
||||
if err != nil {
|
||||
log.Fatalf("[API] init session store failed: %v\n", err)
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: config.Config.App.Addr,
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", config.Config.App.Addr)
|
||||
if err != nil {
|
||||
log.Fatalf("[API] server failed to listen on %s: %v\n", config.Config.App.Addr, err)
|
||||
}
|
||||
if onStarted != nil {
|
||||
onStarted()
|
||||
}
|
||||
|
||||
util.Go(func() {
|
||||
log.Printf("[API] server listening on %s\n", config.Config.App.Addr)
|
||||
if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("[API] server failed: %v\n", err)
|
||||
}
|
||||
})
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Config.App.GracefulShutdownTimeout)*time.Second)
|
||||
|
||||
trace.Shutdown(shutdownCtx)
|
||||
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("[API] server forced to shutdown: %v\n", err)
|
||||
cancel()
|
||||
os.Exit(1)
|
||||
}
|
||||
cancel()
|
||||
|
||||
log.Println("[API] server exited")
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
package driver_http
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"Wavelet/pkg/logger"
|
||||
"Wavelet/pkg/response"
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -21,6 +21,26 @@ import (
|
||||
otel_trace "Wavelet/pkg/trace"
|
||||
)
|
||||
|
||||
var (
|
||||
apiPrefixMu sync.RWMutex
|
||||
apiPrefix = "/api/v1"
|
||||
)
|
||||
|
||||
func setAPIPrefix(prefix string) {
|
||||
if prefix == "" {
|
||||
return
|
||||
}
|
||||
apiPrefixMu.Lock()
|
||||
defer apiPrefixMu.Unlock()
|
||||
apiPrefix = prefix
|
||||
}
|
||||
|
||||
func getAPIPrefix() string {
|
||||
apiPrefixMu.RLock()
|
||||
defer apiPrefixMu.RUnlock()
|
||||
return apiPrefix
|
||||
}
|
||||
|
||||
func loggerMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 初始化 Trace
|
||||
@@ -46,7 +66,7 @@ func loggerMiddleware() gin.HandlerFunc {
|
||||
|
||||
// 打印日志
|
||||
// 排除健康检查接口
|
||||
healthPath := config.Config.App.APIPrefix + "/health"
|
||||
healthPath := getAPIPrefix() + "/health"
|
||||
if c.Request.URL.Path != healthPath {
|
||||
logger.InfoF(
|
||||
ctx,
|
||||
|
||||
@@ -91,10 +91,31 @@ func (p *Plugin) Name() string {
|
||||
return "driver_http"
|
||||
}
|
||||
|
||||
// DeclareConfig declares configuration bindings for driver_http.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "app", Target: &httpAppConfig{}},
|
||||
{Prefix: "redis", Target: &httpRedisConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// Apply mounts the HTTP driver plugin into the micro-kernel Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var appCfg httpAppConfig
|
||||
if err := ctx.Config().Bind("app", &appCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
var redisCfg httpRedisConfig
|
||||
_ = ctx.Config().Bind("redis", &redisCfg)
|
||||
|
||||
p.mu.Lock()
|
||||
p.coreCtx = ctx
|
||||
if p.addr == defaultAddr && appCfg.Addr != "" {
|
||||
p.addr = appCfg.Addr
|
||||
}
|
||||
if appCfg.GracefulShutdownTimeout > 0 {
|
||||
p.shutdownTimeout = time.Duration(appCfg.GracefulShutdownTimeout) * time.Second
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
// Bind DBService from Context
|
||||
@@ -147,7 +168,17 @@ func (p *Plugin) Start(ctx context.Context) error {
|
||||
}
|
||||
|
||||
if p.engine == nil {
|
||||
p.engine = gin.New()
|
||||
var appCfg httpAppConfig
|
||||
var redisCfg httpRedisConfig
|
||||
if p.coreCtx != nil {
|
||||
_ = p.coreCtx.Config().Bind("app", &appCfg)
|
||||
_ = p.coreCtx.Config().Bind("redis", &redisCfg)
|
||||
}
|
||||
var err error
|
||||
p.engine, err = BuildEngineWithConfig(appCfg, redisCfg)
|
||||
if err != nil {
|
||||
p.engine = gin.New()
|
||||
}
|
||||
}
|
||||
|
||||
// Mount routes collected in Context RouterExtension
|
||||
|
||||
@@ -38,6 +38,22 @@ func (p *Plugin) Manifest() core.Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
type redisGateConfig struct {
|
||||
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
|
||||
}
|
||||
|
||||
// DeclareConfig declares configuration bindings for driver_inproc_cron.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "redis", Target: &redisGateConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigEnabled gates plugin activation when Redis is disabled.
|
||||
func (p *Plugin) ConfigEnabled(view core.ConfigView) bool {
|
||||
return !view.Bool("redis.enabled", false)
|
||||
}
|
||||
|
||||
// Apply registers the scheduler driver into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
p.mu.Lock()
|
||||
|
||||
@@ -98,6 +98,22 @@ func (p *Plugin) Manifest() core.Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
type redisGateConfig struct {
|
||||
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
|
||||
}
|
||||
|
||||
// DeclareConfig declares the configuration bindings consumed by this plugin.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "redis", Target: &redisGateConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigEnabled gates plugin activation when Redis is disabled.
|
||||
func (p *Plugin) ConfigEnabled(view core.ConfigView) bool {
|
||||
return !view.Bool("redis.enabled", false)
|
||||
}
|
||||
|
||||
// Apply registers the worker driver and provides contracts.TaskService.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
p.coreCtx = ctx
|
||||
|
||||
@@ -6,6 +6,7 @@ package driver_inproc_worker_test
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/extpoints"
|
||||
"Wavelet/pkg/idgen"
|
||||
"Wavelet/plugins/drivers/driver_inproc_worker"
|
||||
"context"
|
||||
"sync/atomic"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
)
|
||||
|
||||
func TestInprocWorkerPlugin(t *testing.T) {
|
||||
require.NoError(t, idgen.Init(1))
|
||||
ctx := core.NewContext(context.Background())
|
||||
p := driver_inproc_worker.New(
|
||||
driver_inproc_worker.WithConcurrency(2),
|
||||
|
||||
@@ -32,6 +32,8 @@ func init() {
|
||||
|
||||
func TestHTTPDriverLifecycle(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
|
||||
var globalMiddlewareCalled atomic.Bool
|
||||
var groupMiddlewareCalled atomic.Bool
|
||||
@@ -264,6 +266,8 @@ func TestMultipleDriversInContext(t *testing.T) {
|
||||
defer mr.Close()
|
||||
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(nil))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
|
||||
httpPlugin := driver_http.New(driver_http.WithAddr("127.0.0.1:0"))
|
||||
workerPlugin := driver_asynq_worker.New(driver_asynq_worker.WithRedisOpt(asynq.RedisClientOpt{Addr: mr.Addr()}))
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cache
|
||||
|
||||
// RedisConfig declares the configuration read by the Redis cache provider.
|
||||
type RedisConfig struct {
|
||||
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
|
||||
Addrs []string `config:"addrs" env:"REDIS_ADDR"`
|
||||
Username string `config:"username" env:"REDIS_USERNAME"`
|
||||
Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"`
|
||||
DB int `config:"db" env:"REDIS_DB"`
|
||||
ClusterMode bool `config:"cluster_mode" env:"REDIS_CLUSTER_MODE"`
|
||||
MasterName string `config:"master_name" env:"REDIS_MASTER_NAME"`
|
||||
KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"`
|
||||
PoolSize int `config:"pool_size" env:"REDIS_POOL_SIZE"`
|
||||
MinIdleConn int `config:"min_idle_conn" env:"REDIS_MIN_IDLE_CONN"`
|
||||
DialTimeout int `config:"dial_timeout" env:"REDIS_DIAL_TIMEOUT"`
|
||||
ReadTimeout int `config:"read_timeout" env:"REDIS_READ_TIMEOUT"`
|
||||
WriteTimeout int `config:"write_timeout" env:"REDIS_WRITE_TIMEOUT"`
|
||||
MaxRetries int `config:"max_retries" env:"REDIS_MAX_RETRIES"`
|
||||
PoolTimeout int `config:"pool_timeout" env:"REDIS_POOL_TIMEOUT"`
|
||||
ConnMaxIdleTime int `config:"conn_max_idle_time" env:"REDIS_CONN_MAX_IDLE_TIME"`
|
||||
MaintNotifications bool `config:"maint_notifications" env:"REDIS_MAINT_NOTIFICATIONS" default:"false"`
|
||||
}
|
||||
+18
-1
@@ -77,13 +77,30 @@ func (p *Plugin) Name() string {
|
||||
return "cache"
|
||||
}
|
||||
|
||||
// DeclareConfig declares the configuration bindings consumed by this plugin.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "redis", Target: &RedisConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigEnabled gates plugin activation based on whether Redis is enabled.
|
||||
func (p *Plugin) ConfigEnabled(view core.ConfigView) bool {
|
||||
return view.Bool("redis.enabled", false)
|
||||
}
|
||||
|
||||
// Apply mounts the multi-layer cache service into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var cfg RedisConfig
|
||||
if err := ctx.Config().Bind("redis", &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
redisClient := p.redisClient
|
||||
if redisClient == nil {
|
||||
if Redis == nil {
|
||||
var err error
|
||||
redisClient, err = InitRedis()
|
||||
redisClient, err = InitRedisWithConfig(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+4
-2
@@ -32,9 +32,11 @@ func TestCachePluginOperations(t *testing.T) {
|
||||
cache.WithKeyPrefix("app:"),
|
||||
cache.WithRAMCapacity(500),
|
||||
)
|
||||
assert.Equal(t, "cache", p.Name())
|
||||
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(map[string]any{
|
||||
"redis.enabled": true,
|
||||
}))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
require.NoError(t, p.Apply(ctx))
|
||||
|
||||
svc, err := core.Inject[contracts.CacheService](ctx)
|
||||
|
||||
Vendored
+26
-7
@@ -4,12 +4,12 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/extra/redisotel/v9"
|
||||
@@ -18,18 +18,37 @@ import (
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
)
|
||||
|
||||
// Redis 全局 Redis 客户端实例
|
||||
var Redis redis.UniversalClient
|
||||
var (
|
||||
// Redis 全局 Redis 客户端实例
|
||||
Redis redis.UniversalClient
|
||||
|
||||
// InitRedis 初始化全局/默认 Redis 客户端实例
|
||||
func InitRedis() (redis.UniversalClient, error) {
|
||||
cfg := config.Config.Redis
|
||||
keyPrefixMu sync.RWMutex
|
||||
keyPrefix string
|
||||
)
|
||||
|
||||
// SetKeyPrefix sets the package-level key prefix.
|
||||
func SetKeyPrefix(prefix string) {
|
||||
keyPrefixMu.Lock()
|
||||
defer keyPrefixMu.Unlock()
|
||||
keyPrefix = prefix
|
||||
}
|
||||
|
||||
// GetKeyPrefix returns the package-level key prefix.
|
||||
func GetKeyPrefix() string {
|
||||
keyPrefixMu.RLock()
|
||||
defer keyPrefixMu.RUnlock()
|
||||
return keyPrefix
|
||||
}
|
||||
|
||||
// InitRedisWithConfig initializes the Redis client using the provided RedisConfig.
|
||||
func InitRedisWithConfig(cfg RedisConfig) (redis.UniversalClient, error) {
|
||||
if !cfg.Enabled {
|
||||
log.Println("[Redis] is disabled, skipping Redis initialization")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
SetKeyPrefix(cfg.KeyPrefix)
|
||||
|
||||
var client redis.UniversalClient
|
||||
|
||||
if cfg.ClusterMode {
|
||||
@@ -114,7 +133,7 @@ func redisMaintNotificationsConfig(enabled bool) *maintnotifications.Config {
|
||||
|
||||
// PrefixedKey 返回带前缀的 Key
|
||||
func PrefixedKey(key string) string {
|
||||
prefix := config.Config.Redis.KeyPrefix
|
||||
prefix := GetKeyPrefix()
|
||||
if prefix == "" {
|
||||
return key
|
||||
}
|
||||
|
||||
@@ -54,6 +54,23 @@ func (p *Plugin) Manifest() core.Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
// redisGateConfig declares the Redis gate configuration for cache_memory.
|
||||
type redisGateConfig struct {
|
||||
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
|
||||
}
|
||||
|
||||
// DeclareConfig declares the configuration bindings consumed by this plugin.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "redis", Target: &redisGateConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigEnabled gates plugin activation when Redis is disabled.
|
||||
func (p *Plugin) ConfigEnabled(view core.ConfigView) bool {
|
||||
return !view.Bool("redis.enabled", false)
|
||||
}
|
||||
|
||||
// Apply mounts the in-memory cache service into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
svc, err := newMemoryCacheService(p.capacity, ctx.Events())
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package config adapts viper to the kernel configuration source contract. It is a
|
||||
// runtime adapter rather than a core.Plugin: it owns no routes, services or tasks and
|
||||
// therefore never appears in app.Use. Keeping viper here preserves the micro-kernel
|
||||
// rule against importing concrete runtime dependencies.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// DefaultFileName is the configuration file looked up when CONFIG_PATH is unset.
|
||||
const DefaultFileName = "config.yaml"
|
||||
|
||||
// EnvOnlyOrigin is reported by Describe when no configuration file was loaded.
|
||||
const EnvOnlyOrigin = "<env only>"
|
||||
|
||||
// maxSearchDepth bounds the upward directory walk so a misconfigured working directory
|
||||
// cannot make the loader scan the whole filesystem.
|
||||
const maxSearchDepth = 5
|
||||
|
||||
// Option configures a Source.
|
||||
type Option func(*Source)
|
||||
|
||||
// WithPath pins the configuration file, bypassing CONFIG_PATH and the upward search.
|
||||
func WithPath(path string) Option {
|
||||
return func(s *Source) {
|
||||
s.path = path
|
||||
}
|
||||
}
|
||||
|
||||
// Source implements core.ConfigSource over a configuration file plus the process environment.
|
||||
type Source struct {
|
||||
v *viper.Viper
|
||||
path string
|
||||
found bool
|
||||
}
|
||||
|
||||
// NewSource loads the configuration file. A missing file is not an error: the source
|
||||
// then serves environment values only, matching the behaviour the previous pkg/config
|
||||
// loader had for deployments that configure everything through the environment.
|
||||
func NewSource(opts ...Option) (*Source, error) {
|
||||
s := &Source{}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
|
||||
if s.path == "" {
|
||||
s.path = os.Getenv("CONFIG_PATH")
|
||||
}
|
||||
if s.path == "" {
|
||||
s.path = findConfigPath(DefaultFileName)
|
||||
}
|
||||
|
||||
v := viper.New()
|
||||
v.SetConfigFile(s.path)
|
||||
|
||||
err := v.ReadInConfig()
|
||||
switch {
|
||||
case err == nil:
|
||||
s.found = true
|
||||
case isNotFound(err):
|
||||
// No file: fall through to environment-only lookups.
|
||||
default:
|
||||
if _, statErr := os.Stat(s.path); statErr == nil { //nolint:gosec // s.path comes from CONFIG_PATH or a bounded upward search
|
||||
return nil, fmt.Errorf("infra/config: read %s: %w", s.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
s.v = v
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// isNotFound reports whether the loader failed only because the file is absent.
|
||||
func isNotFound(err error) bool {
|
||||
var notFound viper.ConfigFileNotFoundError
|
||||
return errors.As(err, ¬Found) || errors.Is(err, fs.ErrNotExist)
|
||||
}
|
||||
|
||||
// Lookup returns the raw value stored at a dotted path, or false when the file was not
|
||||
// loaded or the path is absent. Declared defaults therefore stay distinguishable from
|
||||
// values explicitly set to a zero.
|
||||
func (s *Source) Lookup(path string) (any, bool) {
|
||||
if !s.found || !s.v.IsSet(path) {
|
||||
return nil, false
|
||||
}
|
||||
return s.v.Get(path), true
|
||||
}
|
||||
|
||||
// LookupEnv reads a process environment variable.
|
||||
func (s *Source) LookupEnv(name string) (string, bool) {
|
||||
return os.LookupEnv(name)
|
||||
}
|
||||
|
||||
// Describe returns the loaded file path, or EnvOnlyOrigin when running on environment values.
|
||||
func (s *Source) Describe() string {
|
||||
if !s.found {
|
||||
return EnvOnlyOrigin
|
||||
}
|
||||
return s.path
|
||||
}
|
||||
|
||||
// findConfigPath searches upward from the working directory so tests and binaries run
|
||||
// from backend/ still find the repository-root configuration file.
|
||||
func findConfigPath(configPath string) string {
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
return configPath
|
||||
}
|
||||
|
||||
dir := "."
|
||||
for range maxSearchDepth {
|
||||
dir += "/.."
|
||||
path := dir + "/" + configPath
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return configPath
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"Wavelet/plugins/infra/config"
|
||||
)
|
||||
|
||||
const sampleYAML = "" +
|
||||
"app:\n addr: \":8000\"\n node_id: 1\n" +
|
||||
"database:\n enabled: false\n port: 5432\n slow_threshold: 200ms\n" +
|
||||
"redis:\n addrs:\n - \"127.0.0.1:6379\"\n"
|
||||
|
||||
func writeConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(body), 0o600))
|
||||
return path
|
||||
}
|
||||
|
||||
func TestSourceLooksUpNestedPaths(t *testing.T) {
|
||||
src, err := config.NewSource(config.WithPath(writeConfig(t, sampleYAML)))
|
||||
require.NoError(t, err)
|
||||
|
||||
value, ok := src.Lookup("database.port")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 5432, value)
|
||||
|
||||
_, ok = src.Lookup("database.missing")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestSourceKeepsZeroValuedKeysDistinctFromMissing(t *testing.T) {
|
||||
src, err := config.NewSource(config.WithPath(writeConfig(t, sampleYAML)))
|
||||
require.NoError(t, err)
|
||||
|
||||
value, ok := src.Lookup("database.enabled")
|
||||
require.True(t, ok, "an explicitly set false must not look like a missing key")
|
||||
assert.Equal(t, false, value)
|
||||
}
|
||||
|
||||
func TestSourceTreatsUnsetFileAsEnvOnly(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "absent.yaml")
|
||||
|
||||
src, err := config.NewSource(config.WithPath(missing))
|
||||
require.NoError(t, err, "a missing configuration file must fall back to environment values")
|
||||
|
||||
_, ok := src.Lookup("app.addr")
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, config.EnvOnlyOrigin, src.Describe())
|
||||
}
|
||||
|
||||
func TestSourceRejectsMalformedFile(t *testing.T) {
|
||||
src, err := config.NewSource(config.WithPath(writeConfig(t, "app: [unclosed\n")))
|
||||
|
||||
assert.Nil(t, src)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "infra/config")
|
||||
}
|
||||
|
||||
func TestSourceLookupEnvReadsProcessEnvironment(t *testing.T) {
|
||||
t.Setenv("WAVELET_SOURCE_PROBE", "present")
|
||||
|
||||
src, err := config.NewSource(config.WithPath(writeConfig(t, sampleYAML)))
|
||||
require.NoError(t, err)
|
||||
|
||||
value, ok := src.LookupEnv("WAVELET_SOURCE_PROBE")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "present", value)
|
||||
|
||||
_, ok = src.LookupEnv("WAVELET_SOURCE_ABSENT")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestSourcePrefersConfigPathEnvironmentVariable(t *testing.T) {
|
||||
t.Setenv("CONFIG_PATH", writeConfig(t, "app:\n addr: \":9100\"\n"))
|
||||
|
||||
src, err := config.NewSource()
|
||||
require.NoError(t, err)
|
||||
|
||||
value, ok := src.Lookup("app.addr")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, ":9100", value)
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -34,35 +33,35 @@ var (
|
||||
chDB *gorm.DB
|
||||
)
|
||||
|
||||
func init() {
|
||||
if !config.Config.ClickHouse.Enabled {
|
||||
return
|
||||
// InitClickHouseWithConfig initializes the ClickHouse connection using the provided configuration.
|
||||
func InitClickHouseWithConfig(cfg ClickHouseConfig) error {
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := config.Config.ClickHouse
|
||||
if cfg.Database == "" {
|
||||
log.Fatalf("[ClickHouse] database name is required (expected: wavelet)\n")
|
||||
return fmt.Errorf("[ClickHouse] database name is required (expected: wavelet)")
|
||||
}
|
||||
|
||||
opts := buildClickHouseOptions()
|
||||
opts := buildClickHouseOptions(cfg)
|
||||
|
||||
var err error
|
||||
ChConn, err = clickhouse.Open(opts)
|
||||
if err != nil {
|
||||
log.Fatalf("[ClickHouse] init connection failed: %v\n", err)
|
||||
return fmt.Errorf("[ClickHouse] init connection failed: %w", err)
|
||||
}
|
||||
|
||||
if err = ChConn.Ping(context.Background()); err != nil {
|
||||
log.Fatalf("[ClickHouse] ping failed: %v\n", err)
|
||||
return fmt.Errorf("[ClickHouse] ping failed: %w", err)
|
||||
}
|
||||
|
||||
chDB, err = gorm.Open(clickhouseDriver.New(clickhouseDriver.Config{
|
||||
DSN: buildClickHouseDSN(),
|
||||
DSN: buildClickHouseDSN(cfg),
|
||||
}), &gorm.Config{
|
||||
SkipDefaultTransaction: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("[ClickHouse] init gorm connection failed: %v\n", err)
|
||||
return fmt.Errorf("[ClickHouse] init gorm connection failed: %w", err)
|
||||
}
|
||||
|
||||
if err = chDB.Use(
|
||||
@@ -74,12 +73,12 @@ func init() {
|
||||
),
|
||||
),
|
||||
); err != nil {
|
||||
log.Fatalf("[ClickHouse] init trace failed: %v\n", err)
|
||||
return fmt.Errorf("[ClickHouse] init trace failed: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := chDB.DB()
|
||||
if err != nil {
|
||||
log.Fatalf("[ClickHouse] load sql db failed: %v\n", err)
|
||||
return fmt.Errorf("[ClickHouse] load sql db failed: %w", err)
|
||||
}
|
||||
|
||||
sqlDB.SetMaxIdleConns(cfg.MaxIdleConn)
|
||||
@@ -87,11 +86,10 @@ func init() {
|
||||
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second)
|
||||
|
||||
log.Println("[ClickHouse] connection established successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildClickHouseOptions() *clickhouse.Options {
|
||||
cfg := config.Config.ClickHouse
|
||||
|
||||
func buildClickHouseOptions(cfg ClickHouseConfig) *clickhouse.Options {
|
||||
return &clickhouse.Options{
|
||||
Addr: cfg.Hosts,
|
||||
Auth: clickhouse.Auth{
|
||||
@@ -114,9 +112,7 @@ func buildClickHouseOptions() *clickhouse.Options {
|
||||
}
|
||||
}
|
||||
|
||||
func buildClickHouseDSN() string {
|
||||
cfg := config.Config.ClickHouse
|
||||
|
||||
func buildClickHouseDSN(cfg ClickHouseConfig) string {
|
||||
chURL := &url.URL{
|
||||
Scheme: "clickhouse",
|
||||
Host: strings.Join(cfg.Hosts, ","),
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package database
|
||||
|
||||
import "time"
|
||||
|
||||
// ReplicaConfig declares a single read-replica configuration for PostgreSQL.
|
||||
type ReplicaConfig struct {
|
||||
Host string `config:"host"`
|
||||
Port int `config:"port"`
|
||||
Username string `config:"username"`
|
||||
Password string `config:"password" secret:"true"`
|
||||
}
|
||||
|
||||
// Config declares PostgreSQL and SQLite database configuration.
|
||||
type Config struct {
|
||||
Enabled bool `config:"enabled" env:"DB_ENABLED" default:"false" autoEnable:"DB_HOST"`
|
||||
SQLitePath string `config:"sqlite_path" env:"SQLITE_PATH"`
|
||||
Host string `config:"host" env:"DB_HOST"`
|
||||
Port int `config:"port" env:"DB_PORT" default:"5432"`
|
||||
Username string `config:"username" env:"DB_USERNAME"`
|
||||
Password string `config:"password" env:"DB_PASSWORD" secret:"true"`
|
||||
Database string `config:"database" env:"DB_NAME" default:"wavelet"`
|
||||
MaxIdleConn int `config:"max_idle_conn" env:"DB_MAX_IDLE_CONN" default:"10"`
|
||||
MaxOpenConn int `config:"max_open_conn" env:"DB_MAX_OPEN_CONN" default:"100"`
|
||||
ConnMaxLifetime int `config:"conn_max_lifetime" env:"DB_CONN_MAX_LIFETIME" default:"3600"`
|
||||
ConnMaxIdleTime int `config:"conn_max_idle_time" env:"DB_CONN_MAX_IDLE_TIME" default:"600"`
|
||||
LogLevel string `config:"log_level" env:"DB_LOG_LEVEL" default:"warn"`
|
||||
SSLMode string `config:"ssl_mode" env:"DB_SSL_MODE" default:"disable"`
|
||||
TimeZone string `config:"time_zone" env:"DB_TIMEZONE" default:"UTC"`
|
||||
ApplicationName string `config:"application_name" env:"DB_APPLICATION_NAME" default:"wavelet"`
|
||||
SearchPath string `config:"search_path" env:"DB_SEARCH_PATH" default:"public"`
|
||||
PreferSimpleProtocol bool `config:"prefer_simple_protocol" env:"DB_PREFER_SIMPLE_PROTOCOL"`
|
||||
StatementCacheCapacity int `config:"statement_cache_capacity" env:"DB_STATEMENT_CACHE_CAPACITY"`
|
||||
DefaultQueryExecMode string `config:"default_query_exec_mode" env:"DB_DEFAULT_QUERY_EXEC_MODE"`
|
||||
Replicas []ReplicaConfig `config:"replicas"`
|
||||
SlowThreshold time.Duration `config:"slow_threshold" env:"DB_SLOW_THRESHOLD" default:"200ms"`
|
||||
}
|
||||
|
||||
// ClickHouseConfig declares the configuration for ClickHouse analytical storage.
|
||||
type ClickHouseConfig struct {
|
||||
Enabled bool `config:"enabled" env:"CLICKHOUSE_ENABLED" default:"false" autoEnable:"CLICKHOUSE_HOST"`
|
||||
Hosts []string `config:"hosts" env:"CLICKHOUSE_HOST"`
|
||||
Username string `config:"username" env:"CLICKHOUSE_USERNAME"`
|
||||
Password string `config:"password" env:"CLICKHOUSE_PASSWORD" secret:"true"`
|
||||
Database string `config:"database" env:"CLICKHOUSE_NAME" default:"wavelet"`
|
||||
MaxIdleConn int `config:"max_idle_conn" env:"CLICKHOUSE_MAX_IDLE_CONN" default:"10"`
|
||||
MaxOpenConn int `config:"max_open_conn" env:"CLICKHOUSE_MAX_OPEN_CONN" default:"50"`
|
||||
ConnMaxLifetime int `config:"conn_max_lifetime" env:"CLICKHOUSE_CONN_MAX_LIFETIME" default:"3600"`
|
||||
DialTimeout int `config:"dial_timeout" env:"CLICKHOUSE_DIAL_TIMEOUT" default:"10"`
|
||||
BlockBufferSize uint8 `config:"block_buffer_size" env:"CLICKHOUSE_BLOCK_BUFFER_SIZE" default:"10"`
|
||||
}
|
||||
|
||||
type appEnvConfig struct {
|
||||
Env string `config:"env" env:"APP_ENV" default:"development"`
|
||||
}
|
||||
@@ -56,17 +56,45 @@ func (p *Plugin) Name() string {
|
||||
return "database"
|
||||
}
|
||||
|
||||
// DeclareConfig declares database configuration keys.
|
||||
func (p *Plugin) DeclareConfig() []core.ConfigBinding {
|
||||
return []core.ConfigBinding{
|
||||
{Prefix: "database", Target: &Config{}},
|
||||
{Prefix: "clickhouse", Target: &ClickHouseConfig{}},
|
||||
{Prefix: "app", Target: &appEnvConfig{}},
|
||||
}
|
||||
}
|
||||
|
||||
// Apply mounts the database service into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
var dbCfg Config
|
||||
if err := ctx.Config().Bind("database", &dbCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var chCfg ClickHouseConfig
|
||||
if err := ctx.Config().Bind("clickhouse", &chCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var appCfg appEnvConfig
|
||||
_ = ctx.Config().Bind("app", &appCfg)
|
||||
|
||||
targetDB := p.db
|
||||
if targetDB == nil {
|
||||
var err error
|
||||
targetDB, err = InitDB()
|
||||
targetDB, err = InitDBWithConfig(dbCfg, appCfg.Env == "production" || appCfg.Env == "prod")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if chCfg.Enabled {
|
||||
if err := InitClickHouseWithConfig(chCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
svc := &dbServiceImpl{
|
||||
primary: targetDB,
|
||||
namedDBs: p.namedDBs,
|
||||
|
||||
@@ -37,9 +37,11 @@ func TestDatabasePlugin(t *testing.T) {
|
||||
database.WithDB(gdb),
|
||||
database.WithNamedDB("analytics", namedDB),
|
||||
)
|
||||
assert.Equal(t, "database", p.Name())
|
||||
|
||||
ctx := core.NewContext(context.Background())
|
||||
ctx.Config().SetSource(core.NewMapSource(map[string]any{
|
||||
"database.enabled": false,
|
||||
}))
|
||||
require.NoError(t, ctx.Config().Resolve())
|
||||
require.NoError(t, p.Apply(ctx))
|
||||
|
||||
svc, err := core.Inject[contracts.DBService](ctx)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -30,15 +29,19 @@ const sqliteDirMode = 0o750
|
||||
|
||||
// InitDB 初始化主数据库实例(支持 PostgreSQL / SQLite)
|
||||
func InitDB() (*gorm.DB, error) {
|
||||
if !config.Config.Database.Enabled {
|
||||
return initSQLite()
|
||||
}
|
||||
return initPostgres()
|
||||
return InitDBWithConfig(Config{}, false)
|
||||
}
|
||||
|
||||
// initSQLite 初始化 SQLite 数据库(PostgreSQL 禁用时的后备方案)
|
||||
func initSQLite() (*gorm.DB, error) {
|
||||
sqlitePath := config.Config.Database.SQLitePath
|
||||
// InitDBWithConfig initializes the main database with the provided config.
|
||||
func InitDBWithConfig(cfg Config, isProd bool) (*gorm.DB, error) {
|
||||
if !cfg.Enabled {
|
||||
return initSQLiteWithConfig(cfg, isProd)
|
||||
}
|
||||
return initPostgresWithConfig(cfg, isProd)
|
||||
}
|
||||
|
||||
func initSQLiteWithConfig(cfg Config, isProd bool) (*gorm.DB, error) {
|
||||
sqlitePath := cfg.SQLitePath
|
||||
if sqlitePath == "" {
|
||||
sqlitePath = "./data/wavelet.db"
|
||||
}
|
||||
@@ -54,9 +57,9 @@ func initSQLite() (*gorm.DB, error) {
|
||||
targetDB, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
Logger: &gormZapLogger{
|
||||
logLevel: parseLogLevel(config.Config.Database.LogLevel),
|
||||
slowThreshold: config.Config.Database.SlowThreshold,
|
||||
ignoreRecordNotFoundError: config.Config.App.IsProduction(),
|
||||
logLevel: parseLogLevel(cfg.LogLevel),
|
||||
slowThreshold: cfg.SlowThreshold,
|
||||
ignoreRecordNotFoundError: isProd,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -81,24 +84,21 @@ func initSQLite() (*gorm.DB, error) {
|
||||
return targetDB, nil
|
||||
}
|
||||
|
||||
// initPostgres 初始化 PostgreSQL 数据库
|
||||
func initPostgres() (*gorm.DB, error) {
|
||||
dbConfig := config.Config.Database
|
||||
|
||||
func initPostgresWithConfig(cfg Config, isProd bool) (*gorm.DB, error) {
|
||||
// 构建主库 DSN 并连接
|
||||
primaryDSN := buildDSN(dbConfig.Host, dbConfig.Port, dbConfig.Username, dbConfig.Password)
|
||||
primaryDSN := buildDSN(cfg, cfg.Host, cfg.Port, cfg.Username, cfg.Password)
|
||||
|
||||
pgConfig := postgres.Config{
|
||||
DSN: primaryDSN,
|
||||
PreferSimpleProtocol: dbConfig.PreferSimpleProtocol,
|
||||
PreferSimpleProtocol: cfg.PreferSimpleProtocol,
|
||||
}
|
||||
|
||||
targetDB, err := gorm.Open(postgres.New(pgConfig), &gorm.Config{
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
Logger: &gormZapLogger{
|
||||
logLevel: parseLogLevel(config.Config.Database.LogLevel),
|
||||
slowThreshold: config.Config.Database.SlowThreshold,
|
||||
ignoreRecordNotFoundError: config.Config.App.IsProduction(),
|
||||
logLevel: parseLogLevel(cfg.LogLevel),
|
||||
slowThreshold: cfg.SlowThreshold,
|
||||
ignoreRecordNotFoundError: isProd,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -110,9 +110,9 @@ func initPostgres() (*gorm.DB, error) {
|
||||
tracing.NewPlugin(
|
||||
tracing.WithoutMetrics(),
|
||||
tracing.WithAttributes(
|
||||
attribute.String("db.instance", dbConfig.Database),
|
||||
attribute.String("db.ip", dbConfig.Host),
|
||||
attribute.String("server.address", net.JoinHostPort(dbConfig.Host, strconv.Itoa(dbConfig.Port))),
|
||||
attribute.String("db.instance", cfg.Database),
|
||||
attribute.String("db.ip", cfg.Host),
|
||||
attribute.String("server.address", net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))),
|
||||
attribute.String("db.system", "PostgreSQL"),
|
||||
),
|
||||
),
|
||||
@@ -120,21 +120,21 @@ func initPostgres() (*gorm.DB, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(dbConfig.Replicas) > 0 {
|
||||
if len(cfg.Replicas) > 0 {
|
||||
var replicaDialectors []gorm.Dialector
|
||||
for _, replica := range dbConfig.Replicas {
|
||||
for _, replica := range cfg.Replicas {
|
||||
username := replica.Username
|
||||
if username == "" {
|
||||
username = dbConfig.Username
|
||||
username = cfg.Username
|
||||
}
|
||||
password := replica.Password
|
||||
if password == "" {
|
||||
password = dbConfig.Password
|
||||
password = cfg.Password
|
||||
}
|
||||
replicaDSN := buildDSN(replica.Host, replica.Port, username, password)
|
||||
replicaDSN := buildDSN(cfg, replica.Host, replica.Port, username, password)
|
||||
replicaDialectors = append(replicaDialectors, postgres.New(postgres.Config{
|
||||
DSN: replicaDSN,
|
||||
PreferSimpleProtocol: dbConfig.PreferSimpleProtocol,
|
||||
PreferSimpleProtocol: cfg.PreferSimpleProtocol,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -143,15 +143,15 @@ func initPostgres() (*gorm.DB, error) {
|
||||
Policy: dbresolver.RandomPolicy{},
|
||||
})
|
||||
|
||||
resolver.SetMaxIdleConns(dbConfig.MaxIdleConn).
|
||||
SetMaxOpenConns(dbConfig.MaxOpenConn).
|
||||
SetConnMaxLifetime(time.Duration(dbConfig.ConnMaxLifetime) * time.Second).
|
||||
SetConnMaxIdleTime(time.Duration(dbConfig.ConnMaxIdleTime) * time.Second)
|
||||
resolver.SetMaxIdleConns(cfg.MaxIdleConn).
|
||||
SetMaxOpenConns(cfg.MaxOpenConn).
|
||||
SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second).
|
||||
SetConnMaxIdleTime(time.Duration(cfg.ConnMaxIdleTime) * time.Second)
|
||||
|
||||
if err = targetDB.Use(resolver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("[PostgreSQL] initialized in Primary-Replica mode (%d replicas)\n", len(dbConfig.Replicas))
|
||||
log.Printf("[PostgreSQL] initialized in Primary-Replica mode (%d replicas)\n", len(cfg.Replicas))
|
||||
} else {
|
||||
log.Println("[PostgreSQL] initialized in Standalone mode")
|
||||
}
|
||||
@@ -162,18 +162,17 @@ func initPostgres() (*gorm.DB, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sqlDB.SetMaxIdleConns(dbConfig.MaxIdleConn)
|
||||
sqlDB.SetMaxOpenConns(dbConfig.MaxOpenConn)
|
||||
sqlDB.SetConnMaxLifetime(time.Duration(dbConfig.ConnMaxLifetime) * time.Second)
|
||||
sqlDB.SetConnMaxIdleTime(time.Duration(dbConfig.ConnMaxIdleTime) * time.Second)
|
||||
sqlDB.SetMaxIdleConns(cfg.MaxIdleConn)
|
||||
sqlDB.SetMaxOpenConns(cfg.MaxOpenConn)
|
||||
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second)
|
||||
sqlDB.SetConnMaxIdleTime(time.Duration(cfg.ConnMaxIdleTime) * time.Second)
|
||||
|
||||
db = targetDB
|
||||
return targetDB, nil
|
||||
}
|
||||
|
||||
// buildDSN 构建 PostgreSQL DSN
|
||||
func buildDSN(host string, port int, username, password string) string {
|
||||
cfg := config.Config.Database
|
||||
func buildDSN(cfg Config, host string, port int, username, password string) string {
|
||||
pqURL := &url.URL{
|
||||
Scheme: "postgres",
|
||||
Host: net.JoinHostPort(host, strconv.Itoa(port)),
|
||||
|
||||
@@ -41,11 +41,25 @@ func setupTestDB(t *testing.T) *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func TestDatabasePlugin(t *testing.T) {
|
||||
func prepareTestContext(values map[string]any, declarers ...core.Plugin) *core.Context {
|
||||
ctx := core.NewContext(context.Background())
|
||||
testDB := setupTestDB(t)
|
||||
ctx.Config().SetSource(core.NewMapSource(values))
|
||||
for _, p := range declarers {
|
||||
if d, ok := p.(interface{ DeclareConfig() []core.ConfigBinding }); ok {
|
||||
for _, b := range d.DeclareConfig() {
|
||||
_ = ctx.Config().Declare(p.Name(), b)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = ctx.Config().Resolve()
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestDatabasePlugin(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
p := database.New(database.WithDB(testDB))
|
||||
ctx := prepareTestContext(nil, p)
|
||||
|
||||
require.Equal(t, "database", p.Name())
|
||||
require.NoError(t, p.Apply(ctx))
|
||||
|
||||
@@ -70,8 +84,9 @@ func TestDatabasePlugin(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCachePluginRAMOnly(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
p := cache.New()
|
||||
ctx := prepareTestContext(nil, p)
|
||||
|
||||
require.Equal(t, "cache", p.Name())
|
||||
require.NoError(t, p.Apply(ctx))
|
||||
|
||||
@@ -142,12 +157,12 @@ func TestCachePluginWithRedisAndPubSub(t *testing.T) {
|
||||
})
|
||||
defer func() { _ = rdb.Close() }()
|
||||
|
||||
ctx1 := core.NewContext(context.Background())
|
||||
ctx2 := core.NewContext(context.Background())
|
||||
|
||||
p1 := cache.New(cache.WithRedis(rdb), cache.WithKeyPrefix("test:"))
|
||||
p2 := cache.New(cache.WithRedis(rdb), cache.WithKeyPrefix("test:"))
|
||||
|
||||
ctx1 := prepareTestContext(map[string]any{"redis.enabled": true}, p1)
|
||||
ctx2 := prepareTestContext(map[string]any{"redis.enabled": true}, p2)
|
||||
|
||||
require.NoError(t, p1.Apply(ctx1))
|
||||
require.NoError(t, p2.Apply(ctx2))
|
||||
|
||||
@@ -294,14 +309,20 @@ func TestStoragePlugin(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAllInfraPluginsCombined(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
testDB := setupTestDB(t)
|
||||
memBackend := newMemoryBackend()
|
||||
|
||||
require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx))
|
||||
require.NoError(t, cache.New().Apply(ctx))
|
||||
require.NoError(t, logger.New().Apply(ctx))
|
||||
require.NoError(t, storage.New(storage.WithBackend(memBackend)).Apply(ctx))
|
||||
dbP := database.New(database.WithDB(testDB))
|
||||
cacheP := cache.New()
|
||||
logP := logger.New()
|
||||
storageP := storage.New(storage.WithBackend(memBackend))
|
||||
|
||||
ctx := prepareTestContext(nil, dbP, cacheP, logP, storageP)
|
||||
|
||||
require.NoError(t, dbP.Apply(ctx))
|
||||
require.NoError(t, cacheP.Apply(ctx))
|
||||
require.NoError(t, logP.Apply(ctx))
|
||||
require.NoError(t, storageP.Apply(ctx))
|
||||
|
||||
// Using3 to resolve dependencies concurrently
|
||||
var resolved bool
|
||||
|
||||
Reference in New Issue
Block a user