From c77b5358e26bf5f098d45cdae11a36af1c7555a9 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 29 Aug 2026 09:32:53 +0800 Subject: [PATCH 01/10] feat(core): add configuration declaration registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配置读取框架的内核侧抽象:插件用带 config/env/default/autoEnable/secret tag 的结构体声明自己读哪些字段,注册表按 key 归集并对重复声明做一致性 校验,为后续按声明解析与门禁求值提供基础。 --- backend/core/extpoints/config.go | 276 +++++++++++++++++++++++ backend/core/extpoints/config_resolve.go | 25 ++ backend/core/extpoints/config_test.go | 86 +++++++ 3 files changed, 387 insertions(+) create mode 100644 backend/core/extpoints/config.go create mode 100644 backend/core/extpoints/config_resolve.go create mode 100644 backend/core/extpoints/config_test.go diff --git a/backend/core/extpoints/config.go b/backend/core/extpoints/config.go new file mode 100644 index 00000000..b9a4b452 --- /dev/null +++ b/backend/core/extpoints/config.go @@ -0,0 +1,276 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints + +import ( + "errors" + "fmt" + "reflect" + "strings" + "sync" + "time" +) + +// Sentinel errors returned by the configuration extension point. +var ( + // ErrConfigConflict is returned when the same key is declared with disagreeing metadata. + ErrConfigConflict = errors.New("extpoints: conflicting configuration declarations") + + // ErrConfigType is returned when a value cannot be converted to the declared type. + ErrConfigType = errors.New("extpoints: configuration value type mismatch") + + // ErrConfigUnknownKey is returned when a configuration key was never declared. + ErrConfigUnknownKey = errors.New("extpoints: unknown configuration key") + + // ErrConfigNotResolved is returned when typed reads happen before resolution. + ErrConfigNotResolved = errors.New("extpoints: configuration not resolved; run App.Prepare first") + + // ErrConfigTarget is returned when a binding target is not an addressable struct pointer. + ErrConfigTarget = errors.New("extpoints: configuration binding target must be a non-nil struct pointer") + + // ErrConfigNoSource is returned when resolution is attempted without a registered source. + ErrConfigNoSource = errors.New("extpoints: no configuration source registered") +) + +// Configuration origin labels reported by ConfigView.Origin and ConfigEntry.Origin. +const ( + // OriginEnv marks a value that came from an environment variable. + OriginEnv = "env" + // OriginAutoEnable marks a boolean enabled by the presence of another environment variable. + OriginAutoEnable = "auto-enable" + // OriginFile marks a value that came from the configuration file. + OriginFile = "file" + // OriginDefault marks a value that came from a declaration default. + OriginDefault = "default" +) + +// durationType distinguishes time.Duration from plain int64 during tag walking and decoding. +var durationType = reflect.TypeFor[time.Duration]() + +// ConfigSource abstracts where raw configuration values come from, keeping the +// micro-kernel free of concrete loaders such as viper. +type ConfigSource interface { + // Lookup returns the raw value stored at a dotted path in the configuration file. + Lookup(path string) (any, bool) + // LookupEnv returns the raw value of an environment variable. + LookupEnv(name string) (string, bool) + // Describe returns a human readable identity for the source, used in diagnostics. + Describe() string +} + +// ConfigBinding declares that a plugin reads every `config` tagged field of Target +// under a dotted configuration prefix. +type ConfigBinding struct { + // Prefix is the dotted configuration path, e.g. "redis". An empty prefix means + // each field's `config` tag is already a full path. + Prefix string + // Target must be a non-nil pointer to a struct carrying `config` tags. + Target any +} + +// configField is a single leaf discovered while walking a binding struct's tags. +// key is the fully qualified dotted path used for resolution; path is the raw `config` +// tag value used to locate the Go field again during Bind. +type configField struct { + key string + path string + env string + autoEnable string + def string + secret bool + typ reflect.Type +} + +// configDecl is the registered form of a configField, attributed to its declaring plugin. +type configDecl struct { + key string + pluginID string + env string + autoEnable string + def string + secret bool + typ reflect.Type +} + +// ConfigEntry is a redacted, self-describing view of one effective configuration key. +type ConfigEntry struct { + Key string + PluginID string + Env string + Origin string + Value string +} + +// ConfigView is the read-only surface over effective configuration values. +// Keys are dotted paths such as "redis.enabled". +type ConfigView interface { + Value(key string) (any, bool) + String(key, fallback string) string + Bool(key string, fallback bool) bool + Int(key string, fallback int) int + Duration(key string, fallback time.Duration) time.Duration + Strings(key string) []string + WasSet(envName string) bool + Origin(key string) string +} + +// ConfigExtension is the plugin-facing configuration extension point mounted on the +// root Context and shared by every forked plugin scope. +type ConfigExtension interface { + ConfigView + + // SetSource installs the raw value source after construction, letting the composition + // root build the adapter once the kernel Context already exists. + SetSource(src ConfigSource) + // Declare registers plugin-owned configuration bindings before Apply runs. + Declare(pluginID string, bindings ...ConfigBinding) error + // Bind resolves and assigns the configuration values for a tagged struct. + Bind(prefix string, target any) error + // Resolve computes the effective value of every declared key once. + Resolve() error + // Resolved reports whether Resolve has already run. + Resolved() bool + // Entries returns the redacted effective configuration ordered by key. + Entries() []ConfigEntry +} + +// ConfigRegistry implements ConfigExtension. Declarations are additive; values are +// computed once by Resolve and reused by every later read. +type ConfigRegistry struct { + mu sync.RWMutex + src ConfigSource + decls map[string]*configDecl + order []string + values map[string]any + origins map[string]string +} + +// NewConfigRegistry creates an empty configuration registry. A nil src is allowed so +// that the kernel can construct the registry before the composition root injects one. +func NewConfigRegistry(src ConfigSource) *ConfigRegistry { + return &ConfigRegistry{ + src: src, + decls: make(map[string]*configDecl), + values: make(map[string]any), + origins: make(map[string]string), + } +} + +// SetSource installs the raw value source. It is intended for the composition root, +// which builds the adapter after the kernel Context already exists. +func (r *ConfigRegistry) SetSource(src ConfigSource) { + r.mu.Lock() + defer r.mu.Unlock() + r.src = src +} + +// Declare registers every `config` tagged leaf of each binding's target struct. +// Repeated declarations of the same key are accepted only when their env, default, +// auto-enable and secret metadata agree; disagreement is ErrConfigConflict. +func (r *ConfigRegistry) Declare(pluginID string, bindings ...ConfigBinding) error { + r.mu.Lock() + defer r.mu.Unlock() + + for _, b := range bindings { + if err := r.declareBinding(pluginID, b); err != nil { + return err + } + } + return nil +} + +func (r *ConfigRegistry) declareBinding(pluginID string, b ConfigBinding) error { + target, err := bindingStruct(b.Target, b.Prefix) + if err != nil { + return err + } + + fields, err := walkConfigFields(target.Type(), b.Prefix) + if err != nil { + return err + } + for _, f := range fields { + if err := r.addDecl(pluginID, f); err != nil { + return err + } + } + return nil +} + +// bindingStruct validates that a binding or bind target is a usable struct pointer. +func bindingStruct(target any, prefix string) (reflect.Value, error) { + rv := reflect.ValueOf(target) + if !rv.IsValid() || rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct { + return reflect.Value{}, fmt.Errorf("%w: prefix %q received %T", ErrConfigTarget, prefix, target) + } + return rv.Elem(), nil +} + +// walkConfigFields collects leaf configuration declarations from `config` tagged fields. +// A field without a `config` tag is skipped, except for embedded structs which are +// recursed into so their own tags resolve under the same prefix. +func walkConfigFields(t reflect.Type, prefix string) ([]configField, error) { + var out []configField + + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + if sf.PkgPath != "" { + continue + } + + path := sf.Tag.Get("config") + if path == "-" { + continue + } + if path == "" { + if sf.Type.Kind() == reflect.Struct && sf.Type != durationType { + nested, err := walkConfigFields(sf.Type, prefix) + if err != nil { + return nil, err + } + out = append(out, nested...) + } + continue + } + + out = append(out, configField{ + key: joinKey(prefix, path), + path: path, + env: sf.Tag.Get("env"), + autoEnable: sf.Tag.Get("autoEnable"), + def: sf.Tag.Get("default"), + secret: strings.EqualFold(sf.Tag.Get("secret"), "true"), + typ: sf.Type, + }) + } + + return out, nil +} + +func joinKey(prefix, path string) string { + if prefix == "" { + return path + } + return prefix + "." + path +} + +// addDecl records one leaf, enforcing the shared-declaration consistency rule. +func (r *ConfigRegistry) addDecl(pluginID string, f configField) error { + if existing, ok := r.decls[f.key]; ok { + if existing.env != f.env || existing.def != f.def || + existing.autoEnable != f.autoEnable || existing.secret != f.secret { + return fmt.Errorf( + "%w: key %q declared by plugin %q and plugin %q with disagreeing env/default/autoEnable/secret metadata", + ErrConfigConflict, f.key, existing.pluginID, pluginID) + } + return nil + } + + r.decls[f.key] = &configDecl{ + key: f.key, pluginID: pluginID, env: f.env, + autoEnable: f.autoEnable, def: f.def, secret: f.secret, typ: f.typ, + } + r.order = append(r.order, f.key) + return nil +} diff --git a/backend/core/extpoints/config_resolve.go b/backend/core/extpoints/config_resolve.go new file mode 100644 index 00000000..00546aa5 --- /dev/null +++ b/backend/core/extpoints/config_resolve.go @@ -0,0 +1,25 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints + +import "sort" + +// Entries returns the effective configuration as redacted, key-sorted entries. +func (r *ConfigRegistry) Entries() []ConfigEntry { + r.mu.RLock() + defer r.mu.RUnlock() + + keys := append([]string(nil), r.order...) + sort.Strings(keys) + + out := make([]ConfigEntry, 0, len(keys)) + for _, key := range keys { + d := r.decls[key] + out = append(out, ConfigEntry{ + Key: d.key, PluginID: d.pluginID, Env: d.env, + Origin: r.origins[key], Value: "pending", + }) + } + return out +} diff --git a/backend/core/extpoints/config_test.go b/backend/core/extpoints/config_test.go new file mode 100644 index 00000000..3021866e --- /dev/null +++ b/backend/core/extpoints/config_test.go @@ -0,0 +1,86 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "Wavelet/core/extpoints" +) + +// fakeSource is an in-memory extpoints.ConfigSource used by configuration engine tests. +type fakeSource struct { + values map[string]any + env map[string]string +} + +func newFakeSource() *fakeSource { + return &fakeSource{values: map[string]any{}, env: map[string]string{}} +} + +func (f *fakeSource) Lookup(path string) (any, bool) { + v, ok := f.values[path] + return v, ok +} + +func (f *fakeSource) LookupEnv(name string) (string, bool) { + v, ok := f.env[name] + return v, ok +} + +func (f *fakeSource) Describe() string { return "fake" } + +// redisConfig mirrors how a plugin declares the configuration it reads. +type redisConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"` + Addrs []string `config:"addrs" env:"REDIS_ADDR"` + DB int `config:"db" env:"REDIS_DB"` + KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"` + Dial time.Duration `config:"dial_timeout" env:"REDIS_DIAL_TIMEOUT"` + Ignored string `config:"-"` + private string +} + +func TestDeclareRegistersTaggedLeafKeys(t *testing.T) { + r := extpoints.NewConfigRegistry(newFakeSource()) + + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + + keys := make([]string, 0) + for _, e := range r.Entries() { + keys = append(keys, e.Key) + } + assert.Equal(t, []string{ + "redis.addrs", "redis.db", "redis.dial_timeout", "redis.enabled", "redis.key_prefix", + }, keys) +} + +func TestDeclareRejectsNonStructPointerTarget(t *testing.T) { + r := extpoints.NewConfigRegistry(newFakeSource()) + + assert.ErrorIs(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: redisConfig{}}), + extpoints.ErrConfigTarget) + assert.ErrorIs(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: (*redisConfig)(nil)}), + extpoints.ErrConfigTarget) +} + +func TestDeclareAllowsIdenticalDuplicateAndRejectsConflictingMetadata(t *testing.T) { + r := extpoints.NewConfigRegistry(newFakeSource()) + binding := extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}} + require.NoError(t, r.Declare("cache", binding)) + require.NoError(t, r.Declare("cache_memory", binding), "identical shared declarations must be allowed") + + type conflictingConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ON" default:"true"` + } + err := r.Declare("driver_http", extpoints.ConfigBinding{Prefix: "redis", Target: &conflictingConfig{}}) + require.ErrorIs(t, err, extpoints.ErrConfigConflict) + assert.Contains(t, err.Error(), "redis.enabled") + assert.Contains(t, err.Error(), "cache") + assert.Contains(t, err.Error(), "driver_http") +} From 4653afc5542feadc63c16387a1fcf4df1fddbd81 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 29 Aug 2026 09:38:39 +0800 Subject: [PATCH 02/10] feat(core): resolve declared configuration with env and file precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 显式 env > autoEnable > 配置文件 > default 的优先级链解析每个已声明 key,支持标量 env 填充切片、duration 与结构体切片解码,非法 env 值不再 静默回退而是报 ErrConfigType。 --- backend/core/extpoints/config.go | 13 +- backend/core/extpoints/config_resolve.go | 143 ++++++++++++- backend/core/extpoints/config_test.go | 80 ++++++++ backend/core/extpoints/config_value.go | 243 +++++++++++++++++++++++ 4 files changed, 472 insertions(+), 7 deletions(-) create mode 100644 backend/core/extpoints/config_value.go diff --git a/backend/core/extpoints/config.go b/backend/core/extpoints/config.go index b9a4b452..2258cd6a 100644 --- a/backend/core/extpoints/config.go +++ b/backend/core/extpoints/config.go @@ -138,12 +138,13 @@ type ConfigExtension interface { // ConfigRegistry implements ConfigExtension. Declarations are additive; values are // computed once by Resolve and reused by every later read. type ConfigRegistry struct { - mu sync.RWMutex - src ConfigSource - decls map[string]*configDecl - order []string - values map[string]any - origins map[string]string + mu sync.RWMutex + src ConfigSource + decls map[string]*configDecl + order []string + values map[string]any + origins map[string]string + resolved bool } // NewConfigRegistry creates an empty configuration registry. A nil src is allowed so diff --git a/backend/core/extpoints/config_resolve.go b/backend/core/extpoints/config_resolve.go index 00546aa5..d28a17b9 100644 --- a/backend/core/extpoints/config_resolve.go +++ b/backend/core/extpoints/config_resolve.go @@ -3,7 +3,148 @@ package extpoints -import "sort" +import ( + "errors" + "fmt" + "reflect" + "sort" +) + +// Resolve computes the effective value of every declared key. Priority is, in order: +// an explicit environment override, an auto-enable trigger, the configuration file, +// then the declared default. Resolution is idempotent; later declarations resolve lazily. +func (r *ConfigRegistry) Resolve() error { + r.mu.Lock() + defer r.mu.Unlock() + + if r.src == nil { + return ErrConfigNoSource + } + + var errs []error + for _, key := range r.order { + if _, done := r.values[key]; done { + continue + } + if err := r.resolveLocked(key); err != nil { + errs = append(errs, err) + } + } + r.resolved = true + + return errors.Join(errs...) +} + +// Resolved reports whether Resolve has already run. +func (r *ConfigRegistry) Resolved() bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.resolved +} + +// resolveLocked computes the effective value of one key. The caller must hold r.mu. +func (r *ConfigRegistry) resolveLocked(key string) error { + d, ok := r.decls[key] + if !ok { + return fmt.Errorf("%w: %s", ErrConfigUnknownKey, key) + } + + if d.env != "" { + if raw, found := r.src.LookupEnv(d.env); found { + value, err := convertValue(raw, d.typ) + if err != nil { + return fmt.Errorf("%w: key %q from environment %s: %w", ErrConfigType, key, d.env, err) + } + r.values[key], r.origins[key] = value, OriginEnv + return nil + } + } + + if d.autoEnable != "" && d.typ.Kind() == reflect.Bool { + if _, found := r.src.LookupEnv(d.autoEnable); found { + r.values[key], r.origins[key] = true, OriginAutoEnable + return nil + } + } + + if raw, found := r.src.Lookup(key); found { + value, err := convertValue(raw, d.typ) + if err != nil { + return fmt.Errorf("%w: key %q from %s: %w", ErrConfigType, key, r.src.Describe(), err) + } + r.values[key], r.origins[key] = value, OriginFile + return nil + } + + if d.def != "" { + value, err := convertValue(d.def, d.typ) + if err != nil { + return fmt.Errorf("%w: default %q for key %q: %w", ErrConfigType, d.def, key, err) + } + r.values[key], r.origins[key] = value, OriginDefault + return nil + } + + r.values[key] = reflect.New(d.typ).Elem().Interface() + r.origins[key] = "" + return nil +} + +// Bind resolves the tagged fields of target and assigns them in place. Prefixes that +// were never declared self-register, so only gates need DeclareConfig. +func (r *ConfigRegistry) Bind(prefix string, target any) error { + r.mu.Lock() + defer r.mu.Unlock() + + if r.src == nil { + return ErrConfigNoSource + } + if !r.resolved { + return fmt.Errorf("%w: Bind(%q, %T) ran before App.Prepare", ErrConfigNotResolved, prefix, target) + } + + elem, err := bindingStruct(target, prefix) + if err != nil { + return err + } + fields, err := walkConfigFields(elem.Type(), prefix) + if err != nil { + return err + } + + for _, f := range fields { + if _, declared := r.decls[f.key]; !declared { + if err := r.addDecl("bind:"+prefix, f); err != nil { + return err + } + } + if _, done := r.values[f.key]; !done { + if err := r.resolveLocked(f.key); err != nil { + return err + } + } + } + + return assignFields(elem, fields, r.values) +} + +// assignFields writes resolved values into a freshly walked target struct. +func assignFields(elem reflect.Value, fields []configField, values map[string]any) error { + for _, f := range fields { + field := elem.FieldByName(fieldNameForPath(elem.Type(), f.path)) + if !field.IsValid() || !field.CanSet() { + return fmt.Errorf("%w: field for key %q is not settable", ErrConfigTarget, f.key) + } + + value := reflect.ValueOf(values[f.key]) + if !value.Type().AssignableTo(field.Type()) { + return fmt.Errorf("%w: key %q resolves to %s, field expects %s", + ErrConfigType, f.key, value.Type(), field.Type()) + } + field.Set(value) + } + return nil +} // Entries returns the effective configuration as redacted, key-sorted entries. func (r *ConfigRegistry) Entries() []ConfigEntry { diff --git a/backend/core/extpoints/config_test.go b/backend/core/extpoints/config_test.go index 3021866e..f89ecce8 100644 --- a/backend/core/extpoints/config_test.go +++ b/backend/core/extpoints/config_test.go @@ -84,3 +84,83 @@ func TestDeclareAllowsIdenticalDuplicateAndRejectsConflictingMetadata(t *testing assert.Contains(t, err.Error(), "cache") assert.Contains(t, err.Error(), "driver_http") } + +// queueConfig is a composite element mirroring worker.queues in config.yaml. +type queueConfig struct { + Name string `config:"name"` + Priority int `config:"priority"` +} + +type workerConfig struct { + Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY"` + Queues []queueConfig `config:"queues"` +} + +type sessionConfig struct { + Secret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"` + Age int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"` +} + +func TestResolveScalarDurationAndSlice(t *testing.T) { + src := newFakeSource() + src.values["redis.db"] = 1 + src.values["redis.dial_timeout"] = "5s" + src.values["redis.addrs"] = []any{"127.0.0.1:6379"} + src.env["REDIS_KEY_PREFIX"] = "refresh:" + + r := extpoints.NewConfigRegistry(src) + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + require.NoError(t, r.Resolve()) + + var got redisConfig + require.NoError(t, r.Bind("redis", &got)) + assert.Equal(t, redisConfig{ + Addrs: []string{"127.0.0.1:6379"}, DB: 1, KeyPrefix: "refresh:", Dial: 5 * time.Second, + }, got) +} + +func TestResolveFillsSliceFromScalarEnvironmentValue(t *testing.T) { + src := newFakeSource() + src.env["REDIS_ADDR"] = "redis:6379" + + r := extpoints.NewConfigRegistry(src) + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + require.NoError(t, r.Resolve()) + + var got redisConfig + require.NoError(t, r.Bind("redis", &got)) + assert.Equal(t, []string{"redis:6379"}, got.Addrs) +} + +func TestResolveCompositeSliceOfStructs(t *testing.T) { + src := newFakeSource() + src.values["worker.concurrency"] = 20 + src.values["worker.queues"] = []any{ + map[string]any{"name": "webhook", "priority": 10}, + map[string]any{"name": "default", "priority": 3}, + } + + r := extpoints.NewConfigRegistry(src) + require.NoError(t, r.Declare("asynq_worker", extpoints.ConfigBinding{Prefix: "worker", Target: &workerConfig{}})) + require.NoError(t, r.Resolve()) + + var got workerConfig + require.NoError(t, r.Bind("worker", &got)) + assert.Equal(t, workerConfig{ + Concurrency: 20, + Queues: []queueConfig{{Name: "webhook", Priority: 10}, {Name: "default", Priority: 3}}, + }, got) +} + +func TestResolveReportsTypeMismatchOnBadEnvironmentValue(t *testing.T) { + src := newFakeSource() + src.env["WORKER_CONCURRENCY"] = "many" + + r := extpoints.NewConfigRegistry(src) + require.NoError(t, r.Declare("asynq_worker", extpoints.ConfigBinding{Prefix: "worker", Target: &workerConfig{}})) + + err := r.Resolve() + require.ErrorIs(t, err, extpoints.ErrConfigType) + assert.Contains(t, err.Error(), "worker.concurrency") + assert.Contains(t, err.Error(), "WORKER_CONCURRENCY") +} diff --git a/backend/core/extpoints/config_value.go b/backend/core/extpoints/config_value.go new file mode 100644 index 00000000..1660f94b --- /dev/null +++ b/backend/core/extpoints/config_value.go @@ -0,0 +1,243 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints + +import ( + "fmt" + "reflect" + "strconv" + "strings" + "time" +) + +// convertValue coerces a raw value coming from the configuration file or an +// environment variable into the declared Go type. +func convertValue(raw any, typ reflect.Type) (any, error) { + if typ == durationType { + return convertDuration(raw) + } + + switch typ.Kind() { + case reflect.Bool: + return convertBool(raw) + case reflect.String: + return convertString(raw) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return convertNumeric(raw, typ, signedNumbers) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return convertNumeric(raw, typ, unsignedNumbers) + case reflect.Float32, reflect.Float64: + return convertNumeric(raw, typ, floatingNumbers) + case reflect.Slice: + return convertSlice(raw, typ) + case reflect.Struct: + return convertStruct(raw, typ) + default: + return nil, fmt.Errorf("%w: %s is not a supported configuration type", ErrConfigType, typ) + } +} + +func convertBool(raw any) (any, error) { + switch v := raw.(type) { + case bool: + return v, nil + case string: + parsed, err := strconv.ParseBool(strings.TrimSpace(v)) + if err != nil { + return nil, fmt.Errorf("%w: %q is not a boolean", ErrConfigType, v) + } + return parsed, nil + default: + return nil, fmt.Errorf("%w: %v is not a boolean", ErrConfigType, raw) + } +} + +func convertString(raw any) (any, error) { + switch v := raw.(type) { + case string: + return v, nil + case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return fmt.Sprint(v), nil + default: + return nil, fmt.Errorf("%w: %v is not a string", ErrConfigType, raw) + } +} + +// numericString extracts the textual form of a value so environment overrides, +// which always arrive as strings, share one parsing path with file values. +func numericString(raw any) (string, bool) { + switch v := raw.(type) { + case string: + return strings.TrimSpace(v), true + case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return fmt.Sprint(v), true + default: + return "", false + } +} + +// numericKind selects which strconv family converts a raw value. +type numericKind int + +const ( + signedNumbers numericKind = iota + unsignedNumbers + floatingNumbers +) + +// convertNumeric parses a raw value into the numeric type declared by typ. The three +// numeric families share one implementation because they differ only in the strconv +// call and the reflect setter. +func convertNumeric(raw any, typ reflect.Type, family numericKind) (any, error) { + text, ok := numericString(raw) + if !ok { + return nil, fmt.Errorf("%w: %v is not a %s", ErrConfigType, raw, typ) + } + + out := reflect.New(typ).Elem() + var err error + + switch family { + case signedNumbers: + parsed, parseErr := strconv.ParseInt(text, 10, typ.Bits()) + out.SetInt(parsed) + err = parseErr + case unsignedNumbers: + parsed, parseErr := strconv.ParseUint(text, 10, typ.Bits()) + out.SetUint(parsed) + err = parseErr + default: + parsed, parseErr := strconv.ParseFloat(text, typ.Bits()) + out.SetFloat(parsed) + err = parseErr + } + + if err != nil { + return nil, fmt.Errorf("%w: %q is not a valid %s", ErrConfigType, text, typ) + } + return out.Interface(), nil +} + +// convertDuration accepts both Go duration strings such as "200ms" and integer +// nanoseconds, mirroring what the previous viper based decoding supported. +func convertDuration(raw any) (any, error) { + if v, ok := raw.(time.Duration); ok { + return v, nil + } + + text, ok := numericString(raw) + if !ok { + return nil, fmt.Errorf("%w: %v is not a duration", ErrConfigType, raw) + } + + if parsed, err := time.ParseDuration(text); err == nil { + return parsed, nil + } + + nanos, err := strconv.ParseInt(text, 10, 64) + if err != nil { + return nil, fmt.Errorf("%w: %q is not a valid duration", ErrConfigType, text) + } + return time.Duration(nanos), nil +} + +// convertSlice promotes a scalar into a single-element slice so that a value such as +// REDIS_ADDR=redis:6379 can populate the redis.addrs list. +func convertSlice(raw any, typ reflect.Type) (any, error) { + items, ok := sliceItems(raw) + if !ok { + items = []any{raw} + } + + out := reflect.MakeSlice(typ, 0, len(items)) + for _, item := range items { + converted, err := convertValue(item, typ.Elem()) + if err != nil { + return nil, err + } + out = reflect.Append(out, reflect.ValueOf(converted)) + } + return out.Interface(), nil +} + +// sliceItems normalises the several slice shapes a loader may produce. +func sliceItems(raw any) ([]any, bool) { + switch v := raw.(type) { + case []any: + return v, true + case []string: + items := make([]any, len(v)) + for i, s := range v { + items[i] = s + } + return items, true + } + + rv := reflect.ValueOf(raw) + if rv.IsValid() && rv.Kind() == reflect.Slice { + items := make([]any, rv.Len()) + for i := range items { + items[i] = rv.Index(i).Interface() + } + return items, true + } + return nil, false +} + +func convertStruct(raw any, typ reflect.Type) (any, error) { + table, ok := asStringMap(raw) + if !ok { + return nil, fmt.Errorf("%w: %v is not a mapping, cannot decode into %s", ErrConfigType, raw, typ) + } + + fields, err := walkConfigFields(typ, "") + if err != nil { + return nil, err + } + + out := reflect.New(typ).Elem() + for _, f := range fields { + item, present := table[f.path] + if !present || item == nil { + continue + } + + converted, err := convertValue(item, f.typ) + if err != nil { + return nil, fmt.Errorf("%w: %s.%s: %w", ErrConfigType, typ.Name(), f.key, err) + } + out.FieldByName(fieldNameForPath(typ, f.path)).Set(reflect.ValueOf(converted)) + } + return out.Interface(), nil +} + +// asStringMap normalises the two map shapes produced by YAML decoders. +func asStringMap(raw any) (map[string]any, bool) { + switch v := raw.(type) { + case map[string]any: + return v, true + case map[any]any: + out := make(map[string]any, len(v)) + for key, val := range v { + name, ok := key.(string) + if !ok { + return nil, false + } + out[name] = val + } + return out, true + default: + return nil, false + } +} + +// fieldNameForPath maps a declared config path back to the Go struct field carrying it. +func fieldNameForPath(t reflect.Type, path string) string { + for i := 0; i < t.NumField(); i++ { + if t.Field(i).Tag.Get("config") == path { + return t.Field(i).Name + } + } + return "" +} From c0869cb878ea2e121debb4bff66c1457d1919350 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 29 Aug 2026 09:43:33 +0800 Subject: [PATCH 03/10] feat(core): expose read-only config view, generic getter and redacted dump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐 Value/String/Bool/Int/Duration/Strings/WasSet/Origin 只读访问器与 按 secret 脱敏的 Entries 导出,新增 core.ConfigGet[T] 泛型读取入口,并用 编译期断言钉住 ConfigRegistry 对 ConfigExtension 的完整实现。 --- backend/core/config.go | 31 ++++++ backend/core/config_test.go | 74 ++++++++++++++ backend/core/extpoints/config.go | 7 ++ backend/core/extpoints/config_resolve.go | 125 ++++++++++++++++++++++- backend/core/extpoints/config_test.go | 79 ++++++++++++++ 5 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 backend/core/config.go create mode 100644 backend/core/config_test.go diff --git a/backend/core/config.go b/backend/core/config.go new file mode 100644 index 00000000..5d8b1080 --- /dev/null +++ b/backend/core/config.go @@ -0,0 +1,31 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "fmt" + + "Wavelet/core/extpoints" +) + +// ConfigGet reads one resolved configuration value with its declared type. It is the +// generic counterpart of the fallback accessors on ConfigView, used when a caller must +// distinguish "unset" from "set to the zero value". +func ConfigGet[T any](view extpoints.ConfigView, key string) (T, error) { + var zero T + if view == nil { + return zero, extpoints.ErrConfigNotResolved + } + + raw, ok := view.Value(key) + if !ok { + return zero, fmt.Errorf("%w: %s", extpoints.ErrConfigUnknownKey, key) + } + + value, ok := raw.(T) + if !ok { + return zero, fmt.Errorf("%w: key %q holds %T, want %T", extpoints.ErrConfigType, key, raw, zero) + } + return value, nil +} diff --git a/backend/core/config_test.go b/backend/core/config_test.go new file mode 100644 index 00000000..79b317e8 --- /dev/null +++ b/backend/core/config_test.go @@ -0,0 +1,74 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package core_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "Wavelet/core" + "Wavelet/core/extpoints" +) + +// mapSource implements extpoints.ConfigSource over static maps. +type mapSource struct { + values map[string]any + env map[string]string +} + +func (m *mapSource) Lookup(path string) (any, bool) { + v, ok := m.values[path] + return v, ok +} + +func (m *mapSource) LookupEnv(name string) (string, bool) { + v, ok := m.env[name] + return v, ok +} + +func (m *mapSource) Describe() string { return "map" } + +type otelConfig struct { + SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE"` +} + +// newOtelRegistry declares the otel section against a source carrying the given file values. +func newOtelRegistry(t *testing.T, values map[string]any) extpoints.ConfigExtension { + t.Helper() + + r := extpoints.NewConfigRegistry(&mapSource{values: values, env: map[string]string{}}) + require.NoError(t, r.Declare("host", extpoints.ConfigBinding{Prefix: "otel", Target: &otelConfig{}})) + require.NoError(t, r.Resolve()) + return r +} + +func TestConfigGetReturnsDeclaredType(t *testing.T) { + view := newOtelRegistry(t, map[string]any{"otel.sampling_rate": 0.25}) + + rate, err := core.ConfigGet[float64](view, "otel.sampling_rate") + require.NoError(t, err) + assert.Equal(t, 0.25, rate) +} + +func TestConfigGetRejectsTypeMismatch(t *testing.T) { + view := newOtelRegistry(t, map[string]any{"otel.sampling_rate": 0.25}) + + text, err := core.ConfigGet[string](view, "otel.sampling_rate") + require.ErrorIs(t, err, extpoints.ErrConfigType) + assert.Empty(t, text) +} + +func TestConfigGetRejectsUndeclaredKey(t *testing.T) { + view := newOtelRegistry(t, nil) + + _, err := core.ConfigGet[float64](view, "otel.unregistered") + require.ErrorIs(t, err, extpoints.ErrConfigUnknownKey) +} + +func TestConfigGetRejectsNilView(t *testing.T) { + _, err := core.ConfigGet[float64](nil, "otel.sampling_rate") + require.ErrorIs(t, err, extpoints.ErrConfigNotResolved) +} diff --git a/backend/core/extpoints/config.go b/backend/core/extpoints/config.go index 2258cd6a..b81e53f9 100644 --- a/backend/core/extpoints/config.go +++ b/backend/core/extpoints/config.go @@ -45,9 +45,16 @@ const ( OriginDefault = "default" ) +// RedactedValue replaces the printed value of keys declared with secret:"true". +const RedactedValue = "******" + // durationType distinguishes time.Duration from plain int64 during tag walking and decoding. var durationType = reflect.TypeFor[time.Duration]() +// ConfigRegistry must satisfy the full extension contract, so a missing accessor is a +// compile error rather than a runtime surprise inside a plugin Apply. +var _ ConfigExtension = (*ConfigRegistry)(nil) + // ConfigSource abstracts where raw configuration values come from, keeping the // micro-kernel free of concrete loaders such as viper. type ConfigSource interface { diff --git a/backend/core/extpoints/config_resolve.go b/backend/core/extpoints/config_resolve.go index d28a17b9..09207c30 100644 --- a/backend/core/extpoints/config_resolve.go +++ b/backend/core/extpoints/config_resolve.go @@ -8,6 +8,7 @@ import ( "fmt" "reflect" "sort" + "time" ) // Resolve computes the effective value of every declared key. Priority is, in order: @@ -148,8 +149,8 @@ func assignFields(elem reflect.Value, fields []configField, values map[string]an // Entries returns the effective configuration as redacted, key-sorted entries. func (r *ConfigRegistry) Entries() []ConfigEntry { - r.mu.RLock() - defer r.mu.RUnlock() + r.mu.Lock() + defer r.mu.Unlock() keys := append([]string(nil), r.order...) sort.Strings(keys) @@ -157,10 +158,126 @@ func (r *ConfigRegistry) Entries() []ConfigEntry { out := make([]ConfigEntry, 0, len(keys)) for _, key := range keys { d := r.decls[key] + if _, done := r.values[key]; !done && r.src != nil { + _ = r.resolveLocked(key) + } out = append(out, ConfigEntry{ - Key: d.key, PluginID: d.pluginID, Env: d.env, - Origin: r.origins[key], Value: "pending", + Key: d.key, + PluginID: d.pluginID, + Env: d.env, + Origin: r.origins[key], + Value: formatEntryValue(r.values[key], d.secret), }) } return out } + +// formatEntryValue renders one effective value for diagnostics, masking secrets. +func formatEntryValue(value any, secret bool) string { + if secret { + return RedactedValue + } + if value == nil { + return "" + } + return fmt.Sprint(value) +} + +// Value returns the resolved value for key, lazily resolving a declared key that has +// not been computed yet. Missing and unresolvable keys report false rather than an +// error so gates and diagnostics can keep using the fallback accessors. +func (r *ConfigRegistry) Value(key string) (any, bool) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, done := r.values[key]; !done { + if r.src == nil { + return nil, false + } + if _, declared := r.decls[key]; !declared { + return nil, false + } + if err := r.resolveLocked(key); err != nil { + return nil, false + } + } + + value, ok := r.values[key] + return value, ok +} + +// String returns the string value of key or fallback when absent or mismatched. +func (r *ConfigRegistry) String(key, fallback string) string { + if value, ok := r.Value(key); ok { + if converted, err := convertString(value); err == nil { + return converted.(string) + } + } + return fallback +} + +// Bool returns the boolean value of key or fallback when absent or mismatched. +func (r *ConfigRegistry) Bool(key string, fallback bool) bool { + if value, ok := r.Value(key); ok { + if converted, err := convertBool(value); err == nil { + return converted.(bool) + } + } + return fallback +} + +// Int returns the int value of key or fallback when absent or mismatched. +func (r *ConfigRegistry) Int(key string, fallback int) int { + if value, ok := r.Value(key); ok { + converted, err := convertNumeric(value, reflect.TypeFor[int](), signedNumbers) + if err == nil { + return converted.(int) + } + } + return fallback +} + +// Duration returns the time.Duration value of key or fallback when absent or mismatched. +func (r *ConfigRegistry) Duration(key string, fallback time.Duration) time.Duration { + if value, ok := r.Value(key); ok { + if converted, err := convertDuration(value); err == nil { + return converted.(time.Duration) + } + } + return fallback +} + +// Strings returns the []string value of key, or nil when absent. +func (r *ConfigRegistry) Strings(key string) []string { + value, ok := r.Value(key) + if !ok { + return nil + } + + converted, err := convertSlice(value, reflect.TypeFor[[]string]()) + if err != nil { + return nil + } + + list, _ := converted.([]string) + return list +} + +// WasSet reports whether an environment variable is present, regardless of its value. +func (r *ConfigRegistry) WasSet(envName string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + + if r.src == nil { + return false + } + _, found := r.src.LookupEnv(envName) + return found +} + +// Origin reports where a key's effective value came from; "" means the zero value. +func (r *ConfigRegistry) Origin(key string) string { + r.mu.RLock() + defer r.mu.RUnlock() + return r.origins[key] +} diff --git a/backend/core/extpoints/config_test.go b/backend/core/extpoints/config_test.go index f89ecce8..5315ec50 100644 --- a/backend/core/extpoints/config_test.go +++ b/backend/core/extpoints/config_test.go @@ -164,3 +164,82 @@ func TestResolveReportsTypeMismatchOnBadEnvironmentValue(t *testing.T) { assert.Contains(t, err.Error(), "worker.concurrency") assert.Contains(t, err.Error(), "WORKER_CONCURRENCY") } + +func TestViewAccessorsAndOrigins(t *testing.T) { + src := newFakeSource() + src.values["redis.db"] = 1 + src.env["REDIS_ADDR"] = "redis:6379" + src.env["REDIS_ENABLED"] = "false" + + r := extpoints.NewConfigRegistry(src) + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + require.NoError(t, r.Declare("auth", extpoints.ConfigBinding{Prefix: "app", Target: &sessionConfig{}})) + require.NoError(t, r.Resolve()) + + assert.Equal(t, extpoints.OriginEnv, r.Origin("redis.addrs")) + assert.Equal(t, []string{"redis:6379"}, r.Strings("redis.addrs")) + assert.False(t, r.Bool("redis.enabled", true)) + assert.Equal(t, 1, r.Int("redis.db", 0)) + assert.Equal(t, "86400", r.String("app.session_age", "0")) + assert.Equal(t, "fallback", r.String("redis.missing", "fallback")) + assert.Zero(t, r.Duration("redis.dial_timeout", 0)) + assert.True(t, r.WasSet("REDIS_ADDR")) + assert.False(t, r.WasSet("REDIS_NOPE")) +} + +func TestAutoEnableBeatsFileValueButLosesToExplicitEnv(t *testing.T) { + src := newFakeSource() + src.env["REDIS_ADDR"] = "redis:6379" + src.values["redis.enabled"] = false + + r := extpoints.NewConfigRegistry(src) + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + require.NoError(t, r.Resolve()) + assert.True(t, r.Bool("redis.enabled", false), "REDIS_ADDR presence implies enabled") + assert.Equal(t, extpoints.OriginAutoEnable, r.Origin("redis.enabled")) + + explicit := newFakeSource() + explicit.env["REDIS_ADDR"] = "redis:6379" + explicit.env["REDIS_ENABLED"] = "false" + + r2 := extpoints.NewConfigRegistry(explicit) + require.NoError(t, r2.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + require.NoError(t, r2.Resolve()) + assert.False(t, r2.Bool("redis.enabled", true), "explicit REDIS_ENABLED must win over auto-enable") + assert.Equal(t, extpoints.OriginEnv, r2.Origin("redis.enabled")) +} + +func TestEntriesRedactSecretsAndReportDefaults(t *testing.T) { + r := extpoints.NewConfigRegistry(newFakeSource()) + require.NoError(t, r.Declare("auth", extpoints.ConfigBinding{Prefix: "app", Target: &sessionConfig{}})) + require.NoError(t, r.Resolve()) + + entries := map[string]extpoints.ConfigEntry{} + for _, e := range r.Entries() { + entries[e.Key] = e + } + + assert.Equal(t, extpoints.RedactedValue, entries["app.session_secret"].Value) + assert.Equal(t, extpoints.OriginDefault, entries["app.session_age"].Origin) + assert.Equal(t, "86400", entries["app.session_age"].Value) +} + +func TestBindRejectsReadsBeforeSourceIsRegistered(t *testing.T) { + r := extpoints.NewConfigRegistry(nil) + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + + assert.ErrorIs(t, r.Resolve(), extpoints.ErrConfigNoSource) + + var cfg redisConfig + assert.ErrorIs(t, r.Bind("redis", &cfg), extpoints.ErrConfigNoSource) +} + +func TestBindRejectsReadsBeforeResolution(t *testing.T) { + r := extpoints.NewConfigRegistry(newFakeSource()) + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + + var cfg redisConfig + err := r.Bind("redis", &cfg) + require.ErrorIs(t, err, extpoints.ErrConfigNotResolved) + assert.Contains(t, err.Error(), "App.Prepare") +} From 39a81f7723034680711621f671508081e3ee15a0 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 29 Aug 2026 09:47:59 +0800 Subject: [PATCH 04/10] feat(core): mount the configuration extension point on the kernel Context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context 新增 Config() 访问器,注册表随 Fork 共享(配置声明是进程级事实), 并在 types.go 导出配置别名与 ConfigGatedPlugin 可选接口,为插件门禁做准备。 --- backend/core/config_test.go | 18 ++++++++++++++++++ backend/core/context.go | 10 ++++++++++ backend/core/types.go | 29 +++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/backend/core/config_test.go b/backend/core/config_test.go index 79b317e8..826b3609 100644 --- a/backend/core/config_test.go +++ b/backend/core/config_test.go @@ -72,3 +72,21 @@ func TestConfigGetRejectsNilView(t *testing.T) { _, err := core.ConfigGet[float64](nil, "otel.sampling_rate") require.ErrorIs(t, err, extpoints.ErrConfigNotResolved) } + +func TestContextConfigIsSharedAcrossForks(t *testing.T) { + ctx := core.NewContext(nil) + child := ctx.Fork() + + require.NotNil(t, ctx.Config()) + assert.Same(t, ctx.Config(), child.Config(), "configuration declarations are process-wide facts") + + require.NoError(t, child.Config().Declare("cache", + extpoints.ConfigBinding{Prefix: "otel", Target: &otelConfig{}})) + + declared := false + for _, entry := range ctx.Config().Entries() { + declared = declared || entry.Key == "otel.sampling_rate" + } + assert.True(t, declared, "a declaration made in a plugin scope must be visible to the root") + assert.False(t, ctx.Config().Resolved()) +} diff --git a/backend/core/context.go b/backend/core/context.go index 617e860c..671fddde 100644 --- a/backend/core/context.go +++ b/backend/core/context.go @@ -27,6 +27,7 @@ type Context struct { tasks extpoints.TaskExtension schedules extpoints.ScheduleExtension settings extpoints.SettingExtension + config extpoints.ConfigExtension mu sync.RWMutex children []*Context @@ -56,6 +57,7 @@ func NewContext(base context.Context) *Context { tasks: extpoints.NewTaskRegistry(), schedules: extpoints.NewScheduleRegistry(), settings: extpoints.NewSettingRegistry(), + config: extpoints.NewConfigRegistry(nil), values: make(map[any]any), } } @@ -167,6 +169,7 @@ func (c *Context) ForkWithContext(base context.Context) *Context { tasks: c.tasks, schedules: c.schedules, settings: c.settings, + config: c.config, values: make(map[any]any), } @@ -235,6 +238,13 @@ func (c *Context) Setting() extpoints.SettingExtension { return c.Settings() } +// Config returns the process-level configuration extension point. The registry is +// shared by every fork because configuration declarations are global facts, and it +// carries no per-scope disposers: values are resolved once before Apply runs. +func (c *Context) Config() extpoints.ConfigExtension { + return c.config +} + // OnDispose registers a cleanup callback function to be executed when this Context is disposed. // It accepts func() error, func(), or Disposer. func (c *Context) OnDispose(fn any) { diff --git a/backend/core/types.go b/backend/core/types.go index f8baae9d..5fb521fc 100644 --- a/backend/core/types.go +++ b/backend/core/types.go @@ -154,3 +154,32 @@ type SettingExtension = extpoints.SettingExtension // SettingSchema re-exports extpoints.SettingSchema. type SettingSchema = extpoints.SettingSchema + +// ConfigExtension re-exports extpoints.ConfigExtension. +type ConfigExtension = extpoints.ConfigExtension + +// ConfigSource re-exports extpoints.ConfigSource. +type ConfigSource = extpoints.ConfigSource + +// ConfigBinding re-exports extpoints.ConfigBinding. +type ConfigBinding = extpoints.ConfigBinding + +// ConfigView re-exports extpoints.ConfigView. +type ConfigView = extpoints.ConfigView + +// ConfigEntry re-exports extpoints.ConfigEntry. +type ConfigEntry = extpoints.ConfigEntry + +// ConfigGatedPlugin is an optional interface for plugins whose activation depends on +// configuration. The kernel evaluates the gate during reconciliation, before the +// plugin's Apply runs, so every key read by ConfigEnabled must be published through +// DeclareConfig. +type ConfigGatedPlugin interface { + Plugin + + // DeclareConfig publishes the configuration bindings consumed by ConfigEnabled. + DeclareConfig() []extpoints.ConfigBinding + + // ConfigEnabled reports whether this plugin should activate for the resolved values. + ConfigEnabled(view extpoints.ConfigView) bool +} From afaa8f79eba8e49a17fd67f73c8ba9f3a5d917f5 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 29 Aug 2026 09:53:10 +0800 Subject: [PATCH 05/10] feat(core): add skipped fiber state for configuration gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fiber 新增 SKIPPED 态与 Skip/Skipped 方法:门禁为假的插件在 Apply 之前 即被排除并释放其作用域 Context,为互斥实现(cache 与 cache_memory 等) 同时挂载由内核择一激活铺路。 --- backend/core/fiber.go | 24 +++++++++++++++++ backend/core/fiber_test.go | 54 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/backend/core/fiber.go b/backend/core/fiber.go index 635dcaae..899711b7 100644 --- a/backend/core/fiber.go +++ b/backend/core/fiber.go @@ -27,6 +27,10 @@ const ( // FiberDisposed indicates the plugin has been completely unmounted and its context disposed. FiberDisposed FiberState = "DISPOSED" + + // FiberSkipped indicates the plugin never activated because its configuration gate + // evaluated to false, so an alternative provider took over. + FiberSkipped FiberState = "SKIPPED" ) // Fiber wraps a Plugin instance with a dedicated scoped Context and manages its @@ -134,6 +138,26 @@ func (f *Fiber) Load() error { return nil } +// Skip transitions a pending plugin to FiberSkipped and releases its scoped Context. +// Active plugins are left untouched, which makes the call safe to replay on every +// reconciliation pass, including for plugins mounted after the first gate evaluation. +func (f *Fiber) Skip() error { + f.mu.Lock() + if f.state != FiberPending { + f.mu.Unlock() + return nil + } + f.state = FiberSkipped + f.mu.Unlock() + + return f.ctx.Dispose() +} + +// Skipped reports whether the plugin was excluded by its configuration gate. +func (f *Fiber) Skipped() bool { + return f.State() == FiberSkipped +} + // Unload tears down the plugin: ACTIVE -> UNLOADING -> DISPOSED. func (f *Fiber) Unload() error { f.mu.Lock() diff --git a/backend/core/fiber_test.go b/backend/core/fiber_test.go index b37e6ab7..273b56bf 100644 --- a/backend/core/fiber_test.go +++ b/backend/core/fiber_test.go @@ -5,6 +5,7 @@ package core_test import ( "Wavelet/core" + "Wavelet/core/extpoints" "context" "reflect" "testing" @@ -108,3 +109,56 @@ func TestFiber_UnsatisfiedDependencyReturnsError(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "unsatisfied dependencies") } + +// gateConfig is the configuration section consumed by gatedPlugin below. +type gateConfig struct { + Enabled bool `config:"enabled" env:"GATE_ENABLED"` +} + +// gatedPlugin implements core.ConfigGatedPlugin: two instances with opposite +// expectations model the mutually exclusive cache / cache_memory driver pair. +type gatedPlugin struct { + name string + enabled bool + applied bool +} + +func (g *gatedPlugin) Name() string { return g.name } + +func (g *gatedPlugin) Apply(_ *core.Context) error { + g.applied = true + return nil +} + +func (g *gatedPlugin) DeclareConfig() []extpoints.ConfigBinding { + return []extpoints.ConfigBinding{{Prefix: "gate", Target: &gateConfig{}}} +} + +func (g *gatedPlugin) ConfigEnabled(view extpoints.ConfigView) bool { + return view.Bool("gate.enabled", false) == g.enabled +} + +func TestFiberSkipMovesToSkippedStateAndDisposesScope(t *testing.T) { + root := core.NewContext(nil) + plugin := &gatedPlugin{name: "cache", enabled: true} + f := core.NewFiber(root, plugin) + require.Equal(t, core.FiberPending, f.State()) + + require.NoError(t, f.Skip()) + + assert.Equal(t, core.FiberSkipped, f.State()) + assert.True(t, f.Skipped()) + assert.False(t, plugin.applied, "a skipped plugin must never reach Apply") + assert.NoError(t, f.Unload(), "unloading a skipped fiber is a no-op") +} + +func TestFiberSkipIsInertForActiveFibers(t *testing.T) { + root := core.NewContext(nil) + f := core.NewFiber(root, &gatedPlugin{name: "cache", enabled: true}) + require.NoError(t, f.Load()) + + require.NoError(t, f.Skip()) + + assert.Equal(t, core.FiberActive, f.State(), "Skip only applies to pending fibers") + assert.False(t, f.Skipped()) +} From 4acb529b8598e7fa47ae87ad453db0b83b23fb72 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 29 Aug 2026 10:03:56 +0800 Subject: [PATCH 06/10] feat(core): add config resolution barrier and plugin gating to App MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App 新增 WithConfigSource / WithConfigDecl / Prepare / ShutdownTimeout / SetShutdownTimeout;Use 收集门禁插件的提前声明,调和循环内求值门禁并跳过 被关闭的插件,使组合根无需再跨插件读配置选实现。未注入配置源的 App 保持 原行为,被门禁但无配置源则 fail fast 点名原因。 --- backend/core/app.go | 167 ++++++++++++++++++++++++++++++++++++--- backend/core/app_test.go | 90 +++++++++++++++++++++ 2 files changed, 244 insertions(+), 13 deletions(-) diff --git a/backend/core/app.go b/backend/core/app.go index c4bd75b0..59e461be 100644 --- a/backend/core/app.go +++ b/backend/core/app.go @@ -68,22 +68,54 @@ func WithShutdownTimeout(timeout time.Duration) AppOption { } } +// WithConfigSource installs the raw configuration source adapter, typically built by an +// infrastructure package outside the kernel, before any plugin is applied. +func WithConfigSource(src ConfigSource) AppOption { + return func(a *App) { + if src == nil { + return + } + // Installed during Prepare so the option order, including WithContext, is irrelevant. + a.configSource = src + } +} + +// WithConfigDecl lets the composition root declare the configuration it reads itself, +// so host-level values take part in conflict validation and the redacted report. The +// bindings are registered during Prepare, so option order does not matter. +func WithConfigDecl(pluginID string, bindings ...ConfigBinding) AppOption { + return func(a *App) { + if len(bindings) == 0 { + return + } + if a.hostDeclOwner == "" { + a.hostDeclOwner = pluginID + } + a.hostDeclBindings = append(a.hostDeclBindings, bindings...) + } +} + // App is the unified assembly entrypoint and runtime aspect dispatcher of the Cordis micro-kernel. // It manages plugin collection, dependency mounting, migration execution, profile-based driver startup, // and graceful signal-driven LIFO shutdown. type App struct { - mu sync.RWMutex - ctx *Context - profile Profile - plugins []Plugin - pluginMap map[string]Plugin - fibers []*Fiber - fiberMap map[string]*Fiber - applied bool - running bool - startedDrivers []Driver - migrationEngine MigrationEngine - shutdownTimeout time.Duration + mu sync.RWMutex + ctx *Context + profile Profile + plugins []Plugin + pluginMap map[string]Plugin + fibers []*Fiber + fiberMap map[string]*Fiber + applied bool + running bool + startedDrivers []Driver + migrationEngine MigrationEngine + shutdownTimeout time.Duration + configSource ConfigSource + hostDeclOwner string + hostDeclBindings []ConfigBinding + prepared bool + applyErr error } // NewApp creates a new Cordis application instance with default options. @@ -162,6 +194,11 @@ func (a *App) Use(plugins ...Plugin) *App { a.fiberMap[name] = f } a.pluginMap[name] = p + + if gated, ok := p.(ConfigGatedPlugin); ok && a.applyErr == nil { + // Gates are evaluated before Apply, so their keys must be declared at mount time. + a.applyErr = a.ctx.Config().Declare(name, gated.DeclareConfig()...) + } } return a @@ -227,10 +264,29 @@ func (a *App) Reconcile() error { } func (a *App) reconcileLocked() error { + if err := a.prepareLocked(); err != nil { + return err + } + for { progress := false for _, f := range a.fibers { - if f.State() == FiberPending && f.DependenciesSatisfied(a.ctx) { + if f.State() != FiberPending { + continue + } + + gated, skip, err := a.evaluateGateLocked(f) + if err != nil { + return err + } + if gated && skip { + if err := f.Skip(); err != nil { + return fmt.Errorf("core: skip gated fiber %q failed: %w", f.Name(), err) + } + continue + } + + if f.DependenciesSatisfied(a.ctx) { if err := f.Load(); err != nil { return fmt.Errorf("core: load fiber %q failed: %w", f.Name(), err) } @@ -255,6 +311,24 @@ func (a *App) reconcileLocked() error { return nil } +// evaluateGateLocked reports whether a configuration-gated plugin is excluded by the +// resolved values. Plugins that do not implement the gate interface are never skipped. +func (a *App) evaluateGateLocked(f *Fiber) (gated bool, skip bool, err error) { + gatedPlugin, ok := f.plugin.(ConfigGatedPlugin) + if !ok { + return false, false, nil + } + + view := a.ctx.Config() + if !view.Resolved() { + return true, false, fmt.Errorf( + "core: plugin %q is configuration-gated but the App has no ConfigSource; "+ + "pass core.WithConfigSource or remove DeclareConfig", f.Name()) + } + + return true, !gatedPlugin.ConfigEnabled(view), nil +} + // ApplyPlugins applies all registered plugins on the application Context via reactive reconciliation. // It is idempotent and only applies plugins once per App instance. func (a *App) ApplyPlugins() error { @@ -264,11 +338,78 @@ func (a *App) ApplyPlugins() error { return nil } a.applied = true + + declaredErr, prepareErr := a.applyErr, a.prepareLocked() a.mu.Unlock() + if declaredErr != nil { + return declaredErr + } + if prepareErr != nil { + return prepareErr + } + return a.Reconcile() } +// Prepare resolves declared configuration and establishes the resolution barrier that +// gates and plugin Bind calls depend on. It is idempotent and runs implicitly from +// ApplyPlugins; callers that need resolved values earlier — for example to size a +// shutdown budget — invoke it explicitly right after mounting plugins. +func (a *App) Prepare() error { + a.mu.Lock() + defer a.mu.Unlock() + + if a.applyErr != nil { + return a.applyErr + } + return a.prepareLocked() +} + +// prepareLocked installs the injected source, registers host declarations and resolves +// every declared key once. An App without a ConfigSource leaves configuration unused, +// so kernel-level usage stays opt-in for embedders that configure nothing. +func (a *App) prepareLocked() error { + if a.prepared { + return nil + } + if a.configSource == nil { + a.prepared = true + return nil + } + + config := a.ctx.Config() + config.SetSource(a.configSource) + + if err := config.Declare(a.hostDeclOwner, a.hostDeclBindings...); err != nil { + return err + } + if err := config.Resolve(); err != nil { + return err + } + a.prepared = true + + return nil +} + +// ShutdownTimeout returns the graceful shutdown budget for the application. +func (a *App) ShutdownTimeout() time.Duration { + a.mu.RLock() + defer a.mu.RUnlock() + return a.shutdownTimeout +} + +// SetShutdownTimeout replaces the graceful shutdown budget, ignoring non-positive +// values so a missing configuration key can never shrink the kernel fallback to zero. +func (a *App) SetShutdownTimeout(timeout time.Duration) *App { + a.mu.Lock() + defer a.mu.Unlock() + if timeout > 0 { + a.shutdownTimeout = timeout + } + return a +} + // RunMigrations dispatches migration execution across all registered plugin migration entries. func (a *App) RunMigrations() error { entries := a.ctx.Migrations().Entries() diff --git a/backend/core/app_test.go b/backend/core/app_test.go index 4db66152..f30f5fa8 100644 --- a/backend/core/app_test.go +++ b/backend/core/app_test.go @@ -504,3 +504,93 @@ func TestAppIdempotencyAndErrorStates(t *testing.T) { assert.Contains(t, err.Error(), "sql migrate error") assert.False(t, app3.IsRunning()) } + +// newGateSource builds a configuration source whose only key decides the test gates. +func newGateSource(enabled bool) *mapSource { + return &mapSource{ + values: map[string]any{"gate.enabled": enabled}, + env: map[string]string{}, + } +} + +func TestAppPrepareResolvesThenGatesDuringReconcile(t *testing.T) { + primary := &gatedPlugin{name: "cache", enabled: true} + fallback := &gatedPlugin{name: "cache_memory", enabled: false} + + app := core.NewApp(core.WithConfigSource(newGateSource(true))) + app.Use(primary, fallback) + require.NoError(t, app.Prepare()) + + cacheFiber, ok := app.Fiber("cache") + require.True(t, ok) + require.Equal(t, core.FiberPending, cacheFiber.State(), "Prepare only builds the resolution barrier") + assert.True(t, app.Context().Config().Resolved()) + + require.NoError(t, app.Reconcile()) + + assert.Equal(t, core.FiberActive, cacheFiber.State()) + + memoryFiber, ok := app.Fiber("cache_memory") + require.True(t, ok) + assert.Equal(t, core.FiberSkipped, memoryFiber.State()) + assert.False(t, fallback.applied, "the gated-out provider must never reach Apply") +} + +func TestAppGatesPluginsMountedAfterPrepare(t *testing.T) { + app := core.NewApp(core.WithConfigSource(newGateSource(true))) + require.NoError(t, app.Prepare()) + + late := &gatedPlugin{name: "cache_memory", enabled: false} + app.Use(late) + require.NoError(t, app.Reconcile()) + + fiber, ok := app.Fiber("cache_memory") + require.True(t, ok) + assert.Equal(t, core.FiberSkipped, fiber.State(), + "plugins mounted after Prepare must still be gated") +} + +func TestAppApplyPluginsGatesImplicitly(t *testing.T) { + app := core.NewApp(core.WithConfigSource(newGateSource(false))) + app.Use(&gatedPlugin{name: "cache", enabled: true}) + + require.NoError(t, app.ApplyPlugins()) + + fiber, ok := app.Fiber("cache") + require.True(t, ok) + assert.Equal(t, core.FiberSkipped, fiber.State(), + "ApplyPlugins must resolve and gate without an explicit Prepare call") +} + +func TestAppPrepareReportsConfigurationErrors(t *testing.T) { + src := &mapSource{ + values: map[string]any{"gate.enabled": "yes"}, + env: map[string]string{}, + } + app := core.NewApp(core.WithConfigSource(src)) + app.Use(&gatedPlugin{name: "cache", enabled: true}) + + err := app.Prepare() + require.Error(t, err) + assert.Contains(t, err.Error(), "gate.enabled") +} + +func TestAppGatedPluginWithoutConfigSourceFailsFast(t *testing.T) { + app := core.NewApp() + app.Use(&gatedPlugin{name: "cache", enabled: true}) + + err := app.ApplyPlugins() + require.Error(t, err) + assert.Contains(t, err.Error(), "cache") + assert.Contains(t, err.Error(), "ConfigSource") +} + +func TestAppSetShutdownTimeoutIgnoresNonPositive(t *testing.T) { + app := core.NewApp() + + app.SetShutdownTimeout(0) + assert.Equal(t, 10*time.Second, app.ShutdownTimeout(), "zero must not shrink the kernel fallback") + + app.SetShutdownTimeout(45 * time.Second) + assert.Equal(t, 45*time.Second, app.ShutdownTimeout()) +} From 696809899eb863ab10fb8eb1257681dfb16df3a0 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 29 Aug 2026 10:06:46 +0800 Subject: [PATCH 07/10] feat(infra): add viper backed configuration source adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现 core.ConfigSource:按 CONFIG_PATH 或向上查找定位 config.yaml,缺文件 降级为纯环境变量来源,坏文件返回错误而非 log.Fatalf,并把 key 命中与"设为 零值"区分开来。viper 依赖被隔离在此包,内核保持零具体运行时依赖。 --- backend/plugins/infra/config/source.go | 126 ++++++++++++++++++++ backend/plugins/infra/config/source_test.go | 93 +++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 backend/plugins/infra/config/source.go create mode 100644 backend/plugins/infra/config/source_test.go diff --git a/backend/plugins/infra/config/source.go b/backend/plugins/infra/config/source.go new file mode 100644 index 00000000..fd6c711f --- /dev/null +++ b/backend/plugins/infra/config/source.go @@ -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 = "" + +// 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 +} diff --git a/backend/plugins/infra/config/source_test.go b/backend/plugins/infra/config/source_test.go new file mode 100644 index 00000000..8372e156 --- /dev/null +++ b/backend/plugins/infra/config/source_test.go @@ -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) +} From 8bd59511a8fc5d37891bed1535c817b87476a99c Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 29 Aug 2026 10:15:32 +0800 Subject: [PATCH 08/10] refactor(config): make legacy loader reentrant and add engine parity test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load(configPath, testMode) 用私有 viper 实例替代包级全局,使同一输入可反复 求值;对拍测试以入库的 config.example.yaml 为必备基准(本地 config.yaml 存在时加测),在四类 env 场景下逐 key 比对新引擎与旧装载器,并以变异检验 确认其能发现漂移。 --- backend/pkg/config/config.go | 44 ++-- backend/pkg/config/parity_test.go | 327 ++++++++++++++++++++++++++++++ 2 files changed, 351 insertions(+), 20 deletions(-) create mode 100644 backend/pkg/config/parity_test.go diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index 52e18829..0f0eb949 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -55,19 +55,15 @@ func isTest() bool { return false } -func init() { - // 加载配置文件路径 - configPath := os.Getenv("CONFIG_PATH") - if configPath == "" { - configPath = findConfigPath("config.yaml") - } +// 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) - // 设置配置文件 - viper.SetConfigFile(configPath) - viper.AutomaticEnv() - - // 读取配置文件(可选:找不到文件时使用空默认值 + 环境变量) - if err := viper.ReadInConfig(); err != nil { + if err := v.ReadInConfig(); err != nil { var notFound viper.ConfigFileNotFoundError if !errors.As(err, ¬Found) { // 文件存在但读取/解析失败 @@ -76,15 +72,15 @@ func init() { } } log.Println("[Config] no config file found, using environment variables only") - viper.SetConfigType("yaml") - if err := viper.ReadConfig(strings.NewReader("")); err != nil { + 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 := viper.Unmarshal(&c); err != nil { + if err := v.Unmarshal(&c); err != nil { log.Fatalf("[Config] parse config failed: %v\n", err) } @@ -95,18 +91,26 @@ func init() { applyDefaults(&c) // Disable standard DB/Redis initializations during tests to prevent connection attempts. - if isTest() { + if testMode { c.Database.Enabled = false c.Database.SQLitePath = ":memory:" c.Redis.Enabled = false c.ClickHouse.Enabled = false } - // 设置全局配置 - Config = &c + return &c +} - // 打印配置 - printConfig(&c) +func init() { + // 加载配置文件路径 + configPath := os.Getenv("CONFIG_PATH") + if configPath == "" { + configPath = findConfigPath("config.yaml") + } + + // 设置全局配置并打印 + Config = load(configPath, isTest()) + printConfig(Config) } func applyDefaults(c *configModel) { diff --git a/backend/pkg/config/parity_test.go b/backend/pkg/config/parity_test.go new file mode 100644 index 00000000..02918aa0 --- /dev/null +++ b/backend/pkg/config/parity_test.go @@ -0,0 +1,327 @@ +// 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))) + } +} From b43c429544d7e76224d783d7e5c32abeeb8954d4 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 29 Aug 2026 10:17:57 +0800 Subject: [PATCH 09/10] chore(arch): forbid viper and mapstructure inside the micro-kernel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配置装载实现必须留在 plugins/infra/config 适配器里,内核只依赖 ConfigSource 抽象;把 viper 与 mapstructure 加入 1.1 禁止清单,防止配置 装载依赖重新渗回 core(已用临时探针文件反向验证检查生效)。 --- scripts/check_cordis_architecture.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/check_cordis_architecture.sh b/scripts/check_cordis_architecture.sh index 0874a908..6d862863 100755 --- a/scripts/check_cordis_architecture.sh +++ b/scripts/check_cordis_architecture.sh @@ -53,12 +53,12 @@ echo -e "${BOLD}═════════════════════ # ============================================================================== log_check "1. 检查微内核 (backend/core/) 纯洁度..." -# 1.1 禁止直接依赖重型 Web/ORM/Worker 框架 -CORE_FRAMEWORK_IMPORTS=$(rg -n '"github.com/gin-gonic/gin"|"gorm.io/gorm"|"github.com/hibiken/asynq"|"github.com/robfig/cron' \ +# 1.1 禁止直接依赖重型 Web/ORM/Worker/Config 框架 +CORE_FRAMEWORK_IMPORTS=$(rg -n '"github.com/gin-gonic/gin"|"gorm.io/gorm"|"github.com/hibiken/asynq"|"github.com/robfig/cron|"github.com/spf13/viper"|"github.com/mitchellh/mapstructure"' \ "${BACKEND_DIR}/core/" --glob '*.go' -g '!*contracts*' -g '!*_test.go' || true) if [ -n "${CORE_FRAMEWORK_IMPORTS}" ]; then - log_fail "backend/core/ 严禁导入具体 Web/ORM/Worker 运行时框架 (gin, gorm, asynq, cron):" + log_fail "backend/core/ 严禁导入具体 Web/ORM/Worker/Config 运行时框架 (gin, gorm, asynq, cron, viper, mapstructure):" echo "${CORE_FRAMEWORK_IMPORTS}" >&2 else log_pass "backend/core/ 无重型框架依赖" From ed8491addfc23ff990a3164a8a0c89e0fc08068a Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 29 Aug 2026 10:53:53 +0800 Subject: [PATCH 10/10] feat(core): implement cordis configuration extension and migrate all plugins --- .agents/skills/new-setting/SKILL.md | 20 +- AGENTS.md | 3 +- backend/cmd/all.go | 10 +- backend/cmd/api.go | 10 +- backend/cmd/app.go | 79 +++-- backend/cmd/app_test.go | 75 +++- backend/cmd/banner.go | 20 +- backend/cmd/banner_test.go | 9 +- backend/cmd/redis_plug_test.go | 115 ++++-- backend/cmd/root.go | 66 +++- backend/cmd/scheduler.go | 10 +- backend/cmd/worker.go | 10 +- backend/core/config.go | 69 ++++ backend/downstream/README.md | 2 +- backend/pkg/config/config.go | 259 -------------- backend/pkg/config/config_test.go | 17 - backend/pkg/config/model.go | 140 -------- backend/pkg/config/parity_test.go | 327 ------------------ backend/pkg/idgen/snowflake.go | 36 +- backend/pkg/idgen/snowflake_test.go | 15 + backend/plugins/domain/admin/config.go | 12 + backend/plugins/domain/admin/handler/db.go | 3 +- backend/plugins/domain/admin/model/config.go | 20 ++ backend/plugins/domain/admin/plugin.go | 16 + backend/plugins/domain/admin/repository/db.go | 37 +- .../domain/admin/repository/flush_log_test.go | 7 + backend/plugins/domain/admin/service/db.go | 41 ++- .../domain/admin/service/log_switch.go | 7 +- .../plugins/domain/admin/service/status.go | 5 +- backend/plugins/domain/auth/config.go | 14 + backend/plugins/domain/auth/plugin.go | 12 + backend/plugins/domain/auth/session.go | 37 +- backend/plugins/domain/cap/plugin.go | 16 + backend/plugins/domain/cap/service.go | 29 +- backend/plugins/domain/domain_test.go | 19 + .../plugins/domain/message_gateway/plugin.go | 15 + .../domain/message_gateway/service/service.go | 20 +- .../domain/risk_control/logstore/gorm_test.go | 2 + .../domain/risk_control/logstore/provider.go | 31 +- .../plugins/domain/risk_control/middleware.go | 18 +- .../domain/risk_control/middleware_test.go | 22 +- backend/plugins/domain/risk_control/plugin.go | 24 ++ backend/plugins/domain/system/plugin.go | 5 +- .../domain/upload/handler/routers_test.go | 2 + .../domain/upload/ingest/ingest_test.go | 5 + backend/plugins/domain/user/plugin_test.go | 4 + .../plugins/domain/user/users_by_ids_test.go | 2 + .../drivers/driver_asynq_cron/plugin.go | 46 ++- .../drivers/driver_asynq_worker/config.go | 28 ++ .../driver_asynq_worker/executor_test.go | 5 + .../drivers/driver_asynq_worker/plugin.go | 34 ++ .../drivers/driver_asynq_worker/utils.go | 36 +- .../drivers/driver_asynq_worker/utils_test.go | 9 +- backend/plugins/drivers/driver_http/config.go | 27 ++ backend/plugins/drivers/driver_http/engine.go | 114 +++--- .../drivers/driver_http/middlewares.go | 24 +- backend/plugins/drivers/driver_http/plugin.go | 33 +- .../drivers/driver_inproc_cron/plugin.go | 16 + .../drivers/driver_inproc_worker/plugin.go | 16 + .../driver_inproc_worker/plugin_test.go | 2 + backend/plugins/drivers/drivers_test.go | 4 + backend/plugins/infra/cache/config.go | 25 ++ backend/plugins/infra/cache/plugin.go | 19 +- backend/plugins/infra/cache/plugin_test.go | 6 +- backend/plugins/infra/cache/redis.go | 33 +- backend/plugins/infra/cache_memory/plugin.go | 17 + backend/plugins/infra/database/clickhouse.go | 34 +- backend/plugins/infra/database/config.go | 57 +++ backend/plugins/infra/database/plugin.go | 30 +- backend/plugins/infra/database/plugin_test.go | 6 +- backend/plugins/infra/database/postgres.go | 79 +++-- backend/plugins/infra/infra_test.go | 43 ++- docs/WAVELET_WHITE_PAPER.md | 21 ++ ...26-08-29-cordis-config-extension-design.md | 18 + 74 files changed, 1354 insertions(+), 1145 deletions(-) delete mode 100644 backend/pkg/config/config.go delete mode 100644 backend/pkg/config/config_test.go delete mode 100644 backend/pkg/config/model.go delete mode 100644 backend/pkg/config/parity_test.go create mode 100644 backend/plugins/domain/admin/config.go create mode 100644 backend/plugins/domain/admin/model/config.go create mode 100644 backend/plugins/domain/auth/config.go create mode 100644 backend/plugins/drivers/driver_asynq_worker/config.go create mode 100644 backend/plugins/drivers/driver_http/config.go create mode 100644 backend/plugins/infra/cache/config.go create mode 100644 backend/plugins/infra/database/config.go diff --git a/.agents/skills/new-setting/SKILL.md b/.agents/skills/new-setting/SKILL.md index f951221e..332718de 100644 --- a/.agents/skills/new-setting/SKILL.md +++ b/.agents/skills/new-setting/SKILL.md @@ -22,18 +22,28 @@ Wavelet 提供两种维度的配置能力: ## 2. 插件内配置声明与绑定 -### 2.1 静态配置绑定 (`ctx.Config().Bind`) +### 2.1 静态配置声明与绑定 (`DeclareConfig` 与 `ctx.Config().Bind`) + +静态启动配置遵循插件自包含声明与解耦规范: ```go type OrderStaticConfig struct { - PaymentGatewayURL string `yaml:"payment_gateway_url" json:"payment_gateway_url"` - TimeoutSeconds int `yaml:"timeout_seconds" json:"timeout_seconds"` + PaymentGatewayURL string `config:"payment_gateway_url" env:"ORDER_PAYMENT_URL" default:"https://pay.example.com"` + TimeoutSeconds int `config:"timeout_seconds" env:"ORDER_TIMEOUT" default:"30"` + ApiKey string `config:"api_key" env:"ORDER_API_KEY" secret:"true"` +} + +// 可选:实现 DeclareConfig 声明配置模式(若需门禁求值则实现 core.ConfigGatedPlugin) +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "plugins.order", Target: &OrderStaticConfig{}}, + } } func (p *Plugin) Apply(ctx *core.Context) error { var cfg OrderStaticConfig - // 从 config.yaml 中的 plugins.order 节点绑定配置 - ctx.Config().Bind("plugins.order", &cfg) + // 从统一配置源绑定 plugins.order 节点配置(支持 YAML 与环境变量覆盖) + _ = ctx.Config().Bind("plugins.order", &cfg) return nil } ``` diff --git a/AGENTS.md b/AGENTS.md index e5a91785..09cdf6e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,8 @@ Strong success criteria let you loop independently. Weak criteria ("make it work - **扩展点自包含注册**: - **HTTP 路由**:插件自包含在 `Apply` 中通过 `ctx.Router().Group(...)` 挂载路由与中间件,禁止跨插件散落注册。 - **异步与定时任务**:插件自包含在 `Apply` 中通过 `ctx.Task().Register(...)` 与 `ctx.Schedule().RegisterCron(...)` 声明。 - - **动态配置**:插件自包含在 `Apply` 中通过 `ctx.Settings().Register(core.SettingSchema{...})` 声明配置模式,通过 `ctx.Config().Bind(...)` 绑定 YAML 配置。 + - **静态启动配置**:插件自包含在 `Apply` 中通过 `ctx.Config().Bind("", &cfg)` 读取**自己声明**的配置,字段以 tag 表达来源:`config`(yaml 路径)、`env`(覆盖变量名)、`default`、`autoEnable`(该变量存在即置真)、`secret`(导出脱敏)。需要在 `Apply` 之前被门禁求值的键,必须在 `DeclareConfig()` 中提前声明并实现 `core.ConfigGatedPlugin`。新增基础设施 key 保持顶层命名(`redis.*`),插件私有配置归 `plugins..*`。**严禁**再造全局配置单例或在 `backend/pkg/` 读取配置。 + - **动态设置**:插件自包含在 `Apply` 中通过 `ctx.Settings().Register(core.SettingSchema{...})` 声明可热更新的管理台设置模式(与上面的静态启动配置分属两层)。 - **数据迁移**:插件自包含在内部维护 `migrations/*.sql`,通过 `//go:embed` 打包并在 `Apply` 中通过 `ctx.Migrations().Register(pluginID, embedFS)` 注入。 - **表单一所有者原则 (Single Owner Principle)**: - 每张数据表有且仅由一个所有者插件声明与维护(表名使用插件前缀如 `w_order_*`)。 diff --git a/backend/cmd/all.go b/backend/cmd/all.go index 0db6b9c4..66984459 100644 --- a/backend/cmd/all.go +++ b/backend/cmd/all.go @@ -6,7 +6,6 @@ package cmd import ( "Wavelet/core" - "log" "github.com/spf13/cobra" ) @@ -15,13 +14,6 @@ var allCmd = &cobra.Command{ Use: "all", Short: "以融合模式同时启动 API、Worker 和 Scheduler", Run: func(_ *cobra.Command, _ []string) { - printStartupBanner(startupState{ - mode: "all (API + Worker + Scheduler)", - listensForHTTP: true, - }) - app := newWaveletApp(core.ProfileAll) - if err := app.Run(); err != nil { - log.Fatalf("[All] run failed: %v\n", err) - } + runProfileApp(core.ProfileAll, "all (API + Worker + Scheduler)", true) }, } diff --git a/backend/cmd/api.go b/backend/cmd/api.go index 5209d552..c335b81b 100644 --- a/backend/cmd/api.go +++ b/backend/cmd/api.go @@ -5,7 +5,6 @@ package cmd import ( "Wavelet/core" - "log" "github.com/spf13/cobra" ) @@ -14,13 +13,6 @@ var apiCmd = &cobra.Command{ Use: "api", Short: "wavelet API", Run: func(_ *cobra.Command, _ []string) { - printStartupBanner(startupState{ - mode: "api", - listensForHTTP: true, - }) - app := newWaveletApp(core.ProfileAPI) - if err := app.Run(); err != nil { - log.Fatalf("[API] run failed: %v\n", err) - } + runProfileApp(core.ProfileAPI, "api", true) }, } diff --git a/backend/cmd/app.go b/backend/cmd/app.go index 81e8964a..099a0154 100644 --- a/backend/cmd/app.go +++ b/backend/cmd/app.go @@ -6,7 +6,6 @@ package cmd import ( "Wavelet/core" "Wavelet/core/contracts" - "Wavelet/pkg/config" "Wavelet/plugins/domain/admin" "Wavelet/plugins/domain/auth" "Wavelet/plugins/domain/cap" @@ -22,6 +21,7 @@ import ( "Wavelet/plugins/drivers/driver_inproc_worker" "Wavelet/plugins/infra/cache" "Wavelet/plugins/infra/cache_memory" + "Wavelet/plugins/infra/config" "Wavelet/plugins/infra/logger" "Wavelet/plugins/infra/storage" "context" @@ -38,14 +38,48 @@ import ( infradb "Wavelet/plugins/infra/database" ) +const ( + defaultShutdownTimeout = 15 * time.Second + defaultHTTPAddr = "127.0.0.1:3000" +) + +// runProfileApp prepares and runs the application for a given profile. +func runProfileApp(profile core.Profile, mode string, listensForHTTP bool) { + app := newWaveletApp(profile) + if err := app.Prepare(); err != nil { + log.Fatalf("[%s] prepare failed: %v\n", mode, err) + } + state := startupState{ + mode: mode, + listensForHTTP: listensForHTTP, + env: app.Context().Config().String("app.env", "production"), + } + if listensForHTTP { + state.addr = app.Context().Config().String("app.addr", defaultHTTPAddr) + } + printStartupBanner(state) + if err := app.Run(); err != nil { + log.Fatalf("[%s] run failed: %v\n", mode, err) + } +} + // newWaveletApp creates a core.App wired with Wavelet platform infrastructure, domain plugins, and profile drivers. // //nolint:contextcheck -func newWaveletApp(profile core.Profile) *core.App { - app := core.NewApp( +func newWaveletApp(profile core.Profile, opts ...core.AppOption) *core.App { + src, err := config.NewSource() + if err != nil { + log.Fatalf("[App] load config source failed: %v\n", err) + } + + appOpts := []core.AppOption{ core.WithProfile(profile), - core.WithShutdownTimeout(time.Duration(config.Config.App.GracefulShutdownTimeout)*time.Second), - ) + core.WithConfigSource(src), + core.WithShutdownTimeout(defaultShutdownTimeout), + } + appOpts = append(appOpts, opts...) + + app := core.NewApp(appOpts...) // 1. Register standard infrastructure plugins app.Use( @@ -54,20 +88,15 @@ func newWaveletApp(profile core.Profile) *core.App { storage.New(), ) - // 2. Register Cache and Async/Cron Drivers based on Redis configuration - if config.Config.Redis.Enabled { - app.Use( - cache.New(), - driver_asynq_worker.New(), - driver_asynq_cron.New(), - ) - } else { - app.Use( - cache_memory.New(), - driver_inproc_worker.New(), - driver_inproc_cron.New(), - ) - } + // 2. Register Cache and Async/Cron Drivers (both gated: cache vs cache_memory, asynq vs inproc) + app.Use( + cache.New(), + cache_memory.New(), + driver_asynq_worker.New(), + driver_inproc_worker.New(), + driver_asynq_cron.New(), + driver_inproc_cron.New(), + ) // 3. Register all 8 domain business plugins (admin first to ensure schema and base config tables exist) app.Use( @@ -86,7 +115,7 @@ func newWaveletApp(profile core.Profile) *core.App { // 5. Mount HTTP runtime driver app.Use( - driver_http.New(driver_http.WithAddr(config.Config.App.Addr)), + driver_http.New(), ) return app @@ -236,7 +265,7 @@ func (e *gooseEngine) Migrate(ctx *core.Context, entries []core.MigrationEntry) return fmt.Errorf("migration: get underlying DB from GORM: %w", err) } - dialect := gooseDialect() + dialect := gooseDialect(ctx) dialectStr := string(dialect) for _, entry := range entries { @@ -267,11 +296,11 @@ func (e *gooseEngine) Migrate(ctx *core.Context, entries []core.MigrationEntry) } // gooseDialect returns the goose dialect based on the configured database engine. -func gooseDialect() goose.Dialect { - if !config.Config.Database.Enabled { - return goose.DialectSQLite3 +func gooseDialect(ctx *core.Context) goose.Dialect { + if ctx != nil && ctx.Config() != nil && ctx.Config().Bool("database.enabled", false) { + return goose.DialectPostgres } - return goose.DialectPostgres + return goose.DialectSQLite3 } func findMigrationFS(rootFS fs.FS, dialect goose.Dialect) fs.FS { diff --git a/backend/cmd/app_test.go b/backend/cmd/app_test.go index 89665419..2625dea2 100644 --- a/backend/cmd/app_test.go +++ b/backend/cmd/app_test.go @@ -5,7 +5,6 @@ package cmd import ( "Wavelet/core" - "Wavelet/pkg/config" "testing" "github.com/stretchr/testify/assert" @@ -22,13 +21,22 @@ func TestNewWaveletAppProfiles(t *testing.T) { for _, prof := range profiles { t.Run(string(prof), func(t *testing.T) { - app := newWaveletApp(prof) + app := newWaveletApp(prof, core.WithConfigValues(map[string]any{ + "app": map[string]any{ + "addr": "127.0.0.1:0", + }, + "redis": map[string]any{ + "enabled": false, + }, + })) require.NotNil(t, app) assert.Equal(t, prof, app.Profile()) - // 3 infra + (1 cache + 2 worker/cron) + 8 domain + 1 http driver = 15 plugins + // 3 infra + 2 cache + 4 worker/cron + 8 domain + 1 http driver = 18 plugins plugins := app.Plugins() - assert.Len(t, plugins, 15) + assert.Len(t, plugins, 18) + + require.NoError(t, app.Reconcile()) // Verify standard infra plugins _, ok := app.Plugin("database") @@ -41,14 +49,29 @@ func TestNewWaveletAppProfiles(t *testing.T) { assert.True(t, ok, "storage plugin missing") // In zero-Redis mode (default in test) - _, ok = app.Plugin("cache_memory") - assert.True(t, ok, "cache_memory plugin missing") + f, ok := app.Fiber("cache_memory") + assert.True(t, ok, "cache_memory fiber missing") + assert.Equal(t, core.FiberActive, f.State()) - _, ok = app.Plugin("driver_inproc_worker") - assert.True(t, ok, "inproc worker driver missing") + f, ok = app.Fiber("cache") + assert.True(t, ok, "cache fiber missing") + assert.Equal(t, core.FiberSkipped, f.State()) - _, ok = app.Plugin("driver_inproc_cron") - assert.True(t, ok, "inproc scheduler driver missing") + f, ok = app.Fiber("driver_inproc_worker") + assert.True(t, ok, "inproc worker driver fiber missing") + assert.Equal(t, core.FiberActive, f.State()) + + f, ok = app.Fiber("driver_asynq_worker") + assert.True(t, ok, "asynq worker driver fiber missing") + assert.Equal(t, core.FiberSkipped, f.State()) + + f, ok = app.Fiber("driver_inproc_cron") + assert.True(t, ok, "inproc scheduler driver fiber missing") + assert.Equal(t, core.FiberActive, f.State()) + + f, ok = app.Fiber("driver_asynq_cron") + assert.True(t, ok, "asynq scheduler driver fiber missing") + assert.Equal(t, core.FiberSkipped, f.State()) // Verify domain plugins _, ok = app.Plugin("auth") @@ -83,19 +106,35 @@ func TestNewWaveletAppProfiles(t *testing.T) { } func TestNewWaveletAppWithRedisEnabled(t *testing.T) { - orig := config.Config.Redis.Enabled - config.Config.Redis.Enabled = true - defer func() { config.Config.Redis.Enabled = orig }() - - app := newWaveletApp(core.ProfileAll) + app := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{ + "redis": map[string]any{ + "enabled": true, + }, + })) require.NotNil(t, app) + require.NoError(t, app.Reconcile()) - _, ok := app.Plugin("cache") + f, ok := app.Fiber("cache") assert.True(t, ok, "cache plugin missing in Redis mode") + assert.Equal(t, core.FiberActive, f.State()) - _, ok = app.Plugin("driver_asynq_worker") + f, ok = app.Fiber("driver_asynq_worker") assert.True(t, ok, "asynq worker driver missing in Redis mode") + assert.Equal(t, core.FiberActive, f.State()) - _, ok = app.Plugin("driver_asynq_cron") + f, ok = app.Fiber("driver_asynq_cron") assert.True(t, ok, "asynq scheduler driver missing in Redis mode") + assert.Equal(t, core.FiberActive, f.State()) + + f, ok = app.Fiber("cache_memory") + assert.True(t, ok) + assert.Equal(t, core.FiberSkipped, f.State()) + + f, ok = app.Fiber("driver_inproc_worker") + assert.True(t, ok) + assert.Equal(t, core.FiberSkipped, f.State()) + + f, ok = app.Fiber("driver_inproc_cron") + assert.True(t, ok) + assert.Equal(t, core.FiberSkipped, f.State()) } diff --git a/backend/cmd/banner.go b/backend/cmd/banner.go index 21bde4ec..882d2a06 100644 --- a/backend/cmd/banner.go +++ b/backend/cmd/banner.go @@ -1,23 +1,20 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -// Package cmd provides CLI command entry points. -// -//nolint:unused package cmd import ( "Wavelet/pkg/buildinfo" - "Wavelet/pkg/config" "fmt" "runtime" "strings" ) -//nolint:unused // startup banner formatting utilities type startupState struct { mode string listensForHTTP bool + env string + addr string } func printStartupBanner(state startupState) { @@ -25,6 +22,15 @@ func printStartupBanner(state startupState) { } func formatStartupBanner(state startupState) string { + env := state.env + if env == "" { + env = "production" + } + addr := state.addr + if addr == "" { + addr = "127.0.0.1:3000" + } + lines := []string{ "", "__ __ _ _ ", @@ -34,12 +40,12 @@ func formatStartupBanner(state startupState) string { " \\_/\\_/ \\__,_| \\_/ \\___|_|\\___|\\__|", fmt.Sprintf(" Wavelet %s", buildinfo.Version), "", - fmt.Sprintf(" Environment: %s", config.Config.App.Env), + fmt.Sprintf(" Environment: %s", env), fmt.Sprintf(" Runtime: %s/%s (%s)", runtime.GOOS, runtime.GOARCH, runtime.Version()), fmt.Sprintf(" Build time: %s", buildTime()), } if state.listensForHTTP { - lines = append(lines, fmt.Sprintf(" Listening: http://%s", config.Config.App.Addr)) + lines = append(lines, fmt.Sprintf(" Listening: http://%s", addr)) } lines = append(lines, fmt.Sprintf(" Mode: %s", state.mode), "") return strings.Join(lines, "\n") diff --git a/backend/cmd/banner_test.go b/backend/cmd/banner_test.go index 0faf4774..75f5ea9e 100644 --- a/backend/cmd/banner_test.go +++ b/backend/cmd/banner_test.go @@ -5,7 +5,6 @@ package cmd import ( "Wavelet/pkg/buildinfo" - "Wavelet/pkg/config" "strings" "testing" ) @@ -13,23 +12,19 @@ import ( func TestFormatStartupBanner(t *testing.T) { previousVersion := buildinfo.Version previousBuildTime := buildinfo.BuildTime - previousEnv := config.Config.App.Env - previousAddr := config.Config.App.Addr t.Cleanup(func() { buildinfo.Version = previousVersion buildinfo.BuildTime = previousBuildTime - config.Config.App.Env = previousEnv - config.Config.App.Addr = previousAddr }) buildinfo.Version = "v3.2.1" buildinfo.BuildTime = "2026-07-13T08:00:00Z" - config.Config.App.Env = "production" - config.Config.App.Addr = ":3000" banner := formatStartupBanner(startupState{ mode: "API", listensForHTTP: true, + env: "production", + addr: ":3000", }) for _, want := range []string{ diff --git a/backend/cmd/redis_plug_test.go b/backend/cmd/redis_plug_test.go index 7c085866..a4fb8e0f 100644 --- a/backend/cmd/redis_plug_test.go +++ b/backend/cmd/redis_plug_test.go @@ -7,49 +7,56 @@ import ( "Wavelet/core" "Wavelet/core/contracts" "Wavelet/core/extpoints" - "Wavelet/pkg/config" + "Wavelet/pkg/idgen" "context" "fmt" "sync/atomic" "testing" "time" + "github.com/alicebob/miniredis/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRedisPluggability_Simulation(t *testing.T) { - origRedisEnabled := config.Config.Redis.Enabled - origAddr := config.Config.App.Addr - config.Config.App.Addr = "127.0.0.1:0" - defer func() { - config.Config.Redis.Enabled = origRedisEnabled - config.Config.App.Addr = origAddr - }() + _ = idgen.Init(1) // ══════════════════════════════════════════════════════════════════════════ // 场景 1: 拔出 Redis (Zero-Redis Monolith 模式) // ══════════════════════════════════════════════════════════════════════════ t.Run("Scenario_Unplugged_ZeroRedis_Mode", func(t *testing.T) { - config.Config.Redis.Enabled = false - - app := newWaveletApp(core.ProfileAll) + app := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{ + "app": map[string]any{ + "addr": "127.0.0.1:0", + }, + "redis": map[string]any{ + "enabled": false, + }, + })) require.NotNil(t, app) + require.NoError(t, app.Reconcile()) // 1. 验证插件挂载形态 - _, ok := app.Plugin("cache_memory") + f, ok := app.Fiber("cache_memory") assert.True(t, ok, "cache_memory 必须挂载") - _, ok = app.Plugin("driver_inproc_worker") + assert.Equal(t, core.FiberActive, f.State()) + f, ok = app.Fiber("driver_inproc_worker") assert.True(t, ok, "driver_inproc_worker 必须挂载") - _, ok = app.Plugin("driver_inproc_cron") + assert.Equal(t, core.FiberActive, f.State()) + f, ok = app.Fiber("driver_inproc_cron") assert.True(t, ok, "driver_inproc_cron 必须挂载") + assert.Equal(t, core.FiberActive, f.State()) - _, ok = app.Plugin("cache") - assert.False(t, ok, "分布式 cache 不得挂载") - _, ok = app.Plugin("driver_asynq_worker") - assert.False(t, ok, "asynq_worker 不得挂载") - _, ok = app.Plugin("driver_asynq_cron") - assert.False(t, ok, "asynq_cron 不得挂载") + f, ok = app.Fiber("cache") + assert.True(t, ok) + assert.Equal(t, core.FiberSkipped, f.State(), "分布式 cache 不得挂载") + f, ok = app.Fiber("driver_asynq_worker") + assert.True(t, ok) + assert.Equal(t, core.FiberSkipped, f.State(), "asynq_worker 不得挂载") + f, ok = app.Fiber("driver_asynq_cron") + assert.True(t, ok) + assert.Equal(t, core.FiberSkipped, f.State(), "asynq_cron 不得挂载") // 2. 注册测试任务与 Cron 定时 var taskExecuted atomic.Int32 @@ -114,25 +121,42 @@ func TestRedisPluggability_Simulation(t *testing.T) { // 场景 2: 插入 Redis (Distributed Cluster 模式) // ══════════════════════════════════════════════════════════════════════════ t.Run("Scenario_Plugged_Redis_Mode", func(t *testing.T) { - config.Config.Redis.Enabled = true + mr, err := miniredis.Run() + require.NoError(t, err) + defer mr.Close() - app := newWaveletApp(core.ProfileAll) + app := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{ + "app": map[string]any{ + "addr": "127.0.0.1:0", + }, + "redis": map[string]any{ + "enabled": true, + "addrs": []string{mr.Addr()}, + }, + })) require.NotNil(t, app) + require.NoError(t, app.Reconcile()) // 1. 验证插件挂载形态 - _, ok := app.Plugin("cache") + f, ok := app.Fiber("cache") assert.True(t, ok, "分布式 cache 必须挂载") - _, ok = app.Plugin("driver_asynq_worker") + assert.Equal(t, core.FiberActive, f.State()) + f, ok = app.Fiber("driver_asynq_worker") assert.True(t, ok, "driver_asynq_worker 必须挂载") - _, ok = app.Plugin("driver_asynq_cron") + assert.Equal(t, core.FiberActive, f.State()) + f, ok = app.Fiber("driver_asynq_cron") assert.True(t, ok, "driver_asynq_cron 必须挂载") + assert.Equal(t, core.FiberActive, f.State()) - _, ok = app.Plugin("cache_memory") - assert.False(t, ok, "纯内存 cache 不得挂载") - _, ok = app.Plugin("driver_inproc_worker") - assert.False(t, ok, "inproc_worker 不得挂载") - _, ok = app.Plugin("driver_inproc_cron") - assert.False(t, ok, "inproc_cron 不得挂载") + f, ok = app.Fiber("cache_memory") + assert.True(t, ok) + assert.Equal(t, core.FiberSkipped, f.State(), "纯内存 cache 不得挂载") + f, ok = app.Fiber("driver_inproc_worker") + assert.True(t, ok) + assert.Equal(t, core.FiberSkipped, f.State(), "inproc_worker 不得挂载") + f, ok = app.Fiber("driver_inproc_cron") + assert.True(t, ok) + assert.Equal(t, core.FiberSkipped, f.State(), "inproc_cron 不得挂载") // 2. 注册测试任务 var asynqTaskExecuted atomic.Int32 @@ -143,7 +167,7 @@ func TestRedisPluggability_Simulation(t *testing.T) { return nil }, extpoints.WithTaskTimeout(3*time.Second)) - // 3. 启动应用 (连接真实运行中的 Redis 6379) + // 3. 启动应用 bootCtx, bootCancel := context.WithTimeout(context.Background(), 5*time.Second) defer bootCancel() require.NoError(t, app.Start(bootCtx)) @@ -172,7 +196,7 @@ func TestRedisPluggability_Simulation(t *testing.T) { require.NoError(t, err) require.NotNil(t, taskSvc) - taskID, err := taskSvc.Dispatch(reqCtx, "test:asynq_task", []byte("payload_plugged"), "unit_test") + taskID, err := taskSvc.Dispatch(reqCtx, "test:asynq_task", []byte("payload_plugged"), "default") require.NoError(t, err) assert.NotEmpty(t, taskID) @@ -190,10 +214,20 @@ func TestRedisPluggability_Simulation(t *testing.T) { // 场景 3: 往复插拔连续切换 (拔出 → 插入 → 再拔出,验证时空可组合性与零残留) // ══════════════════════════════════════════════════════════════════════════ t.Run("Scenario_Dynamic_Plug_Unplug_Sequence", func(t *testing.T) { + mr, err := miniredis.Run() + require.NoError(t, err) + defer mr.Close() + for i := 1; i <= 2; i++ { // 1. 拔出 Redis 运行 - config.Config.Redis.Enabled = false - appUnplugged := newWaveletApp(core.ProfileAll) + appUnplugged := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{ + "app": map[string]any{ + "addr": "127.0.0.1:0", + }, + "redis": map[string]any{ + "enabled": false, + }, + })) require.NoError(t, appUnplugged.Start(context.Background())) cacheSvc1, err := core.Inject[contracts.CacheService](appUnplugged.Context()) @@ -203,8 +237,15 @@ func TestRedisPluggability_Simulation(t *testing.T) { require.NoError(t, appUnplugged.Stop(context.Background())) // 2. 插入 Redis 运行 - config.Config.Redis.Enabled = true - appPlugged := newWaveletApp(core.ProfileAll) + appPlugged := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{ + "app": map[string]any{ + "addr": "127.0.0.1:0", + }, + "redis": map[string]any{ + "enabled": true, + "addrs": []string{mr.Addr()}, + }, + })) require.NoError(t, appPlugged.Start(context.Background())) cacheSvc2, err := core.Inject[contracts.CacheService](appPlugged.Context()) diff --git a/backend/cmd/root.go b/backend/cmd/root.go index cdc7dfd0..4049fcb7 100644 --- a/backend/cmd/root.go +++ b/backend/cmd/root.go @@ -4,10 +4,12 @@ package cmd import ( + "Wavelet/core/extpoints" "Wavelet/pkg/buildinfo" - "Wavelet/pkg/config" + "Wavelet/pkg/idgen" "Wavelet/pkg/logger" "Wavelet/pkg/trace" + "Wavelet/plugins/infra/config" "context" "log" "time" @@ -17,23 +19,63 @@ import ( const traceShutdownTimeout = 10 * time.Second +type hostConfig struct { + App struct { + AppName string `config:"app_name" env:"APP_NAME" default:"Wavelet"` + Env string `config:"env" env:"APP_ENV" default:"production"` + NodeID int64 `config:"node_id" env:"APP_NODE_ID" default:"1"` + Addr string `config:"addr" env:"APP_ADDR" default:"127.0.0.1:3000"` + } `config:"app"` + Log struct { + Level string `config:"level" env:"LOG_LEVEL" default:"info"` + Format string `config:"format" env:"LOG_FORMAT" default:"json"` + Output string `config:"output" env:"LOG_OUTPUT" default:"stdout"` + FilePath string `config:"file_path" env:"LOG_FILE_PATH" default:"./logs/app.log"` + MaxSize int `config:"max_size" env:"LOG_MAX_SIZE" default:"100"` + MaxAge int `config:"max_age" env:"LOG_MAX_AGE" default:"30"` + MaxBackups int `config:"max_backups" env:"LOG_MAX_BACKUPS" default:"10"` + Compress bool `config:"compress" env:"LOG_COMPRESS" default:"true"` + } `config:"log"` + OTel struct { + SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE" default:"1.0"` + TracerName string `config:"tracer_name" env:"OTEL_TRACER_NAME" default:"github.com/Rain-kl/Wavelet"` + } `config:"otel"` +} + var rootCmd = &cobra.Command{ Use: "wavelet", PersistentPreRun: func(_ *cobra.Command, _ []string) { + src, err := config.NewSource() + if err != nil { + log.Fatalf("[CMD] load config source failed: %v", err) + } + var cfg hostConfig + reg := extpoints.NewConfigRegistry(src) + _ = reg.Declare("host", extpoints.ConfigBinding{Target: &cfg}) + if err := reg.Resolve(); err != nil { + log.Fatalf("[CMD] resolve host config failed: %v", err) + } + _ = reg.Bind("", &cfg) + + // Initialize idgen snowflake generator + if err := idgen.Init(cfg.App.NodeID); err != nil { + log.Fatalf("[CMD] init idgen failed: %v", err) + } + logger.Init(logger.Config{ - Level: config.Config.Log.Level, - Format: config.Config.Log.Format, - Output: config.Config.Log.Output, - FilePath: config.Config.Log.FilePath, - MaxSize: config.Config.Log.MaxSize, - MaxAge: config.Config.Log.MaxAge, - MaxBackups: config.Config.Log.MaxBackups, - Compress: config.Config.Log.Compress, + Level: cfg.Log.Level, + Format: cfg.Log.Format, + Output: cfg.Log.Output, + FilePath: cfg.Log.FilePath, + MaxSize: cfg.Log.MaxSize, + MaxAge: cfg.Log.MaxAge, + MaxBackups: cfg.Log.MaxBackups, + Compress: cfg.Log.Compress, }) trace.Init(trace.Config{ - AppName: config.Config.App.AppName, - SamplingRate: config.Config.Otel.SamplingRate, - TracerName: config.Config.Otel.TracerName, + AppName: cfg.App.AppName, + SamplingRate: cfg.OTel.SamplingRate, + TracerName: cfg.OTel.TracerName, }) }, PersistentPostRun: func(_ *cobra.Command, _ []string) { diff --git a/backend/cmd/scheduler.go b/backend/cmd/scheduler.go index c3a499ad..3afdb9ce 100644 --- a/backend/cmd/scheduler.go +++ b/backend/cmd/scheduler.go @@ -5,7 +5,6 @@ package cmd import ( "Wavelet/core" - "log" "github.com/spf13/cobra" ) @@ -14,13 +13,6 @@ var schedulerCmd = &cobra.Command{ Use: "scheduler", Short: "wavelet Scheduler", Run: func(_ *cobra.Command, _ []string) { - printStartupBanner(startupState{ - mode: "scheduler", - listensForHTTP: false, - }) - app := newWaveletApp(core.ProfileSchedule) - if err := app.Run(); err != nil { - log.Fatalf("[Scheduler] run failed: %v\n", err) - } + runProfileApp(core.ProfileSchedule, "scheduler", false) }, } diff --git a/backend/cmd/worker.go b/backend/cmd/worker.go index 9c954a58..5011341b 100644 --- a/backend/cmd/worker.go +++ b/backend/cmd/worker.go @@ -5,7 +5,6 @@ package cmd import ( "Wavelet/core" - "log" "github.com/spf13/cobra" ) @@ -14,13 +13,6 @@ var workerCmd = &cobra.Command{ Use: "worker", Short: "wavelet Worker", Run: func(_ *cobra.Command, _ []string) { - printStartupBanner(startupState{ - mode: "worker", - listensForHTTP: false, - }) - app := newWaveletApp(core.ProfileWorker) - if err := app.Run(); err != nil { - log.Fatalf("[Worker] run failed: %v\n", err) - } + runProfileApp(core.ProfileWorker, "worker", false) }, } diff --git a/backend/core/config.go b/backend/core/config.go index 5d8b1080..a52bde7a 100644 --- a/backend/core/config.go +++ b/backend/core/config.go @@ -5,6 +5,7 @@ package core import ( "fmt" + "strings" "Wavelet/core/extpoints" ) @@ -29,3 +30,71 @@ func ConfigGet[T any](view extpoints.ConfigView, key string) (T, error) { } return value, nil } + +// MapSource implements ConfigSource backed by an in-memory map, ideal for unit tests. +type MapSource struct { + values map[string]any + env map[string]string +} + +// NewMapSource creates a new MapSource with the provided key-value mappings. +func NewMapSource(values map[string]any) *MapSource { + vals := make(map[string]any, len(values)) + for k, v := range values { + vals[k] = v + } + return &MapSource{ + values: vals, + env: make(map[string]string), + } +} + +// Lookup returns the value at the given path, supporting both flat keys and nested maps. +func (m *MapSource) Lookup(path string) (any, bool) { + if m == nil || m.values == nil { + return nil, false + } + if v, ok := m.values[path]; ok { + return v, true + } + parts := strings.Split(path, ".") + var cur any = m.values + for _, part := range parts { + mCur, ok := cur.(map[string]any) + if !ok { + return nil, false + } + cur, ok = mCur[part] + if !ok { + return nil, false + } + } + return cur, true +} + +// LookupEnv returns the environment variable value. +func (m *MapSource) LookupEnv(name string) (string, bool) { + if m == nil || m.env == nil { + return "", false + } + v, ok := m.env[name] + return v, ok +} + +// SetEnv sets an environment variable for testing. +func (m *MapSource) SetEnv(name, value string) { + if m.env == nil { + m.env = make(map[string]string) + } + m.env[name] = value +} + +// Describe describes the MapSource. +func (m *MapSource) Describe() string { + return "" +} + +// 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)) +} diff --git a/backend/downstream/README.md b/backend/downstream/README.md index 2deb1c5e..2c6a0925 100644 --- a/backend/downstream/README.md +++ b/backend/downstream/README.md @@ -35,7 +35,7 @@ type Plugin interface { storage.New(), // ... platform domain plugins ... custom_hello.New(), // your downstream plugin - driver_http.New(driver_http.WithAddr(config.Config.App.Addr)), + driver_http.New(), driver_asynq_worker.New(), driver_asynq_cron.New(), ) diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go deleted file mode 100644 index 0f0eb949..00000000 --- a/backend/pkg/config/config.go +++ /dev/null @@ -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)) -} diff --git a/backend/pkg/config/config_test.go b/backend/pkg/config/config_test.go deleted file mode 100644 index b79e48ee..00000000 --- a/backend/pkg/config/config_test.go +++ /dev/null @@ -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") - } -} diff --git a/backend/pkg/config/model.go b/backend/pkg/config/model.go deleted file mode 100644 index 91321943..00000000 --- a/backend/pkg/config/model.go +++ /dev/null @@ -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"` -} diff --git a/backend/pkg/config/parity_test.go b/backend/pkg/config/parity_test.go deleted file mode 100644 index 02918aa0..00000000 --- a/backend/pkg/config/parity_test.go +++ /dev/null @@ -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))) - } -} diff --git a/backend/pkg/idgen/snowflake.go b/backend/pkg/idgen/snowflake.go index 8e01c11c..3081178b 100644 --- a/backend/pkg/idgen/snowflake.go +++ b/backend/pkg/idgen/snowflake.go @@ -5,9 +5,10 @@ package idgen import ( - "Wavelet/pkg/config" + "errors" "fmt" "log" + "sync" "github.com/bwmarrin/snowflake" ) @@ -17,25 +18,44 @@ const epoch int64 = 1764547200000 const maxNegativeIDRetries = 3 -var node *snowflake.Node +var ( + mu sync.RWMutex + node *snowflake.Node +) -func init() { +// Init initializes the snowflake ID generator with the given node ID. +func Init(nodeID int64) error { snowflake.Epoch = epoch - nodeID := config.Config.App.NodeID - var err error - node, err = snowflake.NewNode(nodeID) + n, err := snowflake.NewNode(nodeID) if err != nil { - log.Fatalf("[Snowflake] init failed: %v\n", err) + return fmt.Errorf("idgen: init node %d failed: %w", nodeID, err) } + + mu.Lock() + node = n + mu.Unlock() + log.Printf("[Snowflake] initialized with node ID: %d, epoch: 2025-12-01\n", nodeID) + return nil } +// ErrNotInitialized indicates NextUint64ID was called before Init. +var ErrNotInitialized = errors.New("idgen: Init must be called before generating IDs") + // NextUint64ID 生成下一个分布式唯一 ID。 // 理论上不应出现负值;若出现则最多重试 maxNegativeIDRetries 次,仍失败则 panic。 func NextUint64ID() uint64 { + mu.RLock() + n := node + mu.RUnlock() + + if n == nil { + panic(ErrNotInitialized) + } + for attempt := 1; attempt <= maxNegativeIDRetries; attempt++ { - id := node.Generate().Int64() + id := n.Generate().Int64() if id >= 0 { return uint64(id) } diff --git a/backend/pkg/idgen/snowflake_test.go b/backend/pkg/idgen/snowflake_test.go index d0e5cdfa..82ef3407 100644 --- a/backend/pkg/idgen/snowflake_test.go +++ b/backend/pkg/idgen/snowflake_test.go @@ -7,9 +7,24 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNextUint64ID(t *testing.T) { + require.NoError(t, Init(1)) id := NextUint64ID() assert.NotZero(t, id) } + +func TestNextUint64ID_PanicsWhenNotInitialized(t *testing.T) { + mu.Lock() + node = nil + mu.Unlock() + + assert.PanicsWithError(t, ErrNotInitialized.Error(), func() { + NextUint64ID() + }) + + // Restore initialization for other tests + require.NoError(t, Init(1)) +} diff --git a/backend/plugins/domain/admin/config.go b/backend/plugins/domain/admin/config.go new file mode 100644 index 00000000..12763bcb --- /dev/null +++ b/backend/plugins/domain/admin/config.go @@ -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 diff --git a/backend/plugins/domain/admin/handler/db.go b/backend/plugins/domain/admin/handler/db.go index 3d585616..e6d986f8 100644 --- a/backend/plugins/domain/admin/handler/db.go +++ b/backend/plugins/domain/admin/handler/db.go @@ -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) diff --git a/backend/plugins/domain/admin/model/config.go b/backend/plugins/domain/admin/model/config.go new file mode 100644 index 00000000..355b6b71 --- /dev/null +++ b/backend/plugins/domain/admin/model/config.go @@ -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"` +} diff --git a/backend/plugins/domain/admin/plugin.go b/backend/plugins/domain/admin/plugin.go index 25b15110..1772823c 100644 --- a/backend/plugins/domain/admin/plugin.go +++ b/backend/plugins/domain/admin/plugin.go @@ -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) diff --git a/backend/plugins/domain/admin/repository/db.go b/backend/plugins/domain/admin/repository/db.go index bd83c9e5..e721dffd 100644 --- a/backend/plugins/domain/admin/repository/db.go +++ b/backend/plugins/domain/admin/repository/db.go @@ -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 { diff --git a/backend/plugins/domain/admin/repository/flush_log_test.go b/backend/plugins/domain/admin/repository/flush_log_test.go index 11004154..09125581 100644 --- a/backend/plugins/domain/admin/repository/flush_log_test.go +++ b/backend/plugins/domain/admin/repository/flush_log_test.go @@ -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) diff --git a/backend/plugins/domain/admin/service/db.go b/backend/plugins/domain/admin/service/db.go index 5b7be170..c1d2ec95 100644 --- a/backend/plugins/domain/admin/service/db.go +++ b/backend/plugins/domain/admin/service/db.go @@ -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) diff --git a/backend/plugins/domain/admin/service/log_switch.go b/backend/plugins/domain/admin/service/log_switch.go index 652d079d..6145191b 100644 --- a/backend/plugins/domain/admin/service/log_switch.go +++ b/backend/plugins/domain/admin/service/log_switch.go @@ -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) } } diff --git a/backend/plugins/domain/admin/service/status.go b/backend/plugins/domain/admin/service/status.go index 75f72a76..dd3ef455 100644 --- a/backend/plugins/domain/admin/service/status.go +++ b/backend/plugins/domain/admin/service/status.go @@ -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{} diff --git a/backend/plugins/domain/auth/config.go b/backend/plugins/domain/auth/config.go new file mode 100644 index 00000000..c01b416e --- /dev/null +++ b/backend/plugins/domain/auth/config.go @@ -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"` +} diff --git a/backend/plugins/domain/auth/plugin.go b/backend/plugins/domain/auth/plugin.go index 639f0ccd..fdc35ab2 100644 --- a/backend/plugins/domain/auth/plugin.go +++ b/backend/plugins/domain/auth/plugin.go @@ -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) diff --git a/backend/plugins/domain/auth/session.go b/backend/plugins/domain/auth/session.go index 4adb7417..97141413 100644 --- a/backend/plugins/domain/auth/session.go +++ b/backend/plugins/domain/auth/session.go @@ -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 diff --git a/backend/plugins/domain/cap/plugin.go b/backend/plugins/domain/cap/plugin.go index 20e21b4f..ae047a78 100644 --- a/backend/plugins/domain/cap/plugin.go +++ b/backend/plugins/domain/cap/plugin.go @@ -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) diff --git a/backend/plugins/domain/cap/service.go b/backend/plugins/domain/cap/service.go index 6cd13689..b062fffe 100644 --- a/backend/plugins/domain/cap/service.go +++ b/backend/plugins/domain/cap/service.go @@ -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 } diff --git a/backend/plugins/domain/domain_test.go b/backend/plugins/domain/domain_test.go index 606af4de..d32c538b 100644 --- a/backend/plugins/domain/domain_test.go +++ b/backend/plugins/domain/domain_test.go @@ -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 diff --git a/backend/plugins/domain/message_gateway/plugin.go b/backend/plugins/domain/message_gateway/plugin.go index e697c351..af8edef0 100644 --- a/backend/plugins/domain/message_gateway/plugin.go +++ b/backend/plugins/domain/message_gateway/plugin.go @@ -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) diff --git a/backend/plugins/domain/message_gateway/service/service.go b/backend/plugins/domain/message_gateway/service/service.go index 3890f14c..dfd33db5 100644 --- a/backend/plugins/domain/message_gateway/service/service.go +++ b/backend/plugins/domain/message_gateway/service/service.go @@ -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[:]) } diff --git a/backend/plugins/domain/risk_control/logstore/gorm_test.go b/backend/plugins/domain/risk_control/logstore/gorm_test.go index 725d2a6f..e2eaf6a9 100644 --- a/backend/plugins/domain/risk_control/logstore/gorm_test.go +++ b/backend/plugins/domain/risk_control/logstore/gorm_test.go @@ -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{})) diff --git a/backend/plugins/domain/risk_control/logstore/provider.go b/backend/plugins/domain/risk_control/logstore/provider.go index 83b6abd5..ed16fc43 100644 --- a/backend/plugins/domain/risk_control/logstore/provider.go +++ b/backend/plugins/domain/risk_control/logstore/provider.go @@ -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() diff --git a/backend/plugins/domain/risk_control/middleware.go b/backend/plugins/domain/risk_control/middleware.go index 8d65c6d9..7bf9c2fa 100644 --- a/backend/plugins/domain/risk_control/middleware.go +++ b/backend/plugins/domain/risk_control/middleware.go @@ -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 } diff --git a/backend/plugins/domain/risk_control/middleware_test.go b/backend/plugins/domain/risk_control/middleware_test.go index f1bd7bb6..e9319004 100644 --- a/backend/plugins/domain/risk_control/middleware_test.go +++ b/backend/plugins/domain/risk_control/middleware_test.go @@ -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 diff --git a/backend/plugins/domain/risk_control/plugin.go b/backend/plugins/domain/risk_control/plugin.go index 83c0c699..3976fc3a 100644 --- a/backend/plugins/domain/risk_control/plugin.go +++ b/backend/plugins/domain/risk_control/plugin.go @@ -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) diff --git a/backend/plugins/domain/system/plugin.go b/backend/plugins/domain/system/plugin.go index fd82f9ac..97a3b35d 100644 --- a/backend/plugins/domain/system/plugin.go +++ b/backend/plugins/domain/system/plugin.go @@ -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, }, })) }) diff --git a/backend/plugins/domain/upload/handler/routers_test.go b/backend/plugins/domain/upload/handler/routers_test.go index 36ab068c..aef62038 100644 --- a/backend/plugins/domain/upload/handler/routers_test.go +++ b/backend/plugins/domain/upload/handler/routers_test.go @@ -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()) diff --git a/backend/plugins/domain/upload/ingest/ingest_test.go b/backend/plugins/domain/upload/ingest/ingest_test.go index 7dbf92a2..de3cefc2 100644 --- a/backend/plugins/domain/upload/ingest/ingest_test.go +++ b/backend/plugins/domain/upload/ingest/ingest_test.go @@ -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 diff --git a/backend/plugins/domain/user/plugin_test.go b/backend/plugins/domain/user/plugin_test.go index 9bab4c53..387d2757 100644 --- a/backend/plugins/domain/user/plugin_test.go +++ b/backend/plugins/domain/user/plugin_test.go @@ -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)) diff --git a/backend/plugins/domain/user/users_by_ids_test.go b/backend/plugins/domain/user/users_by_ids_test.go index 9a96e1fa..2c71ce14 100644 --- a/backend/plugins/domain/user/users_by_ids_test.go +++ b/backend/plugins/domain/user/users_by_ids_test.go @@ -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)) diff --git a/backend/plugins/drivers/driver_asynq_cron/plugin.go b/backend/plugins/drivers/driver_asynq_cron/plugin.go index a57e2105..5e620bdf 100644 --- a/backend/plugins/drivers/driver_asynq_cron/plugin.go +++ b/backend/plugins/drivers/driver_asynq_cron/plugin.go @@ -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", } } diff --git a/backend/plugins/drivers/driver_asynq_worker/config.go b/backend/plugins/drivers/driver_asynq_worker/config.go new file mode 100644 index 00000000..efaf01cc --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_worker/config.go @@ -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"` +} diff --git a/backend/plugins/drivers/driver_asynq_worker/executor_test.go b/backend/plugins/drivers/driver_asynq_worker/executor_test.go index 23db8f0a..25774a19 100644 --- a/backend/plugins/drivers/driver_asynq_worker/executor_test.go +++ b/backend/plugins/drivers/driver_asynq_worker/executor_test.go @@ -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 } diff --git a/backend/plugins/drivers/driver_asynq_worker/plugin.go b/backend/plugins/drivers/driver_asynq_worker/plugin.go index 5fd31bc0..cd29567e 100644 --- a/backend/plugins/drivers/driver_asynq_worker/plugin.go +++ b/backend/plugins/drivers/driver_asynq_worker/plugin.go @@ -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 diff --git a/backend/plugins/drivers/driver_asynq_worker/utils.go b/backend/plugins/drivers/driver_asynq_worker/utils.go index 9e00beeb..2b7d2e02 100644 --- a/backend/plugins/drivers/driver_asynq_worker/utils.go +++ b/backend/plugins/drivers/driver_asynq_worker/utils.go @@ -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 } diff --git a/backend/plugins/drivers/driver_asynq_worker/utils_test.go b/backend/plugins/drivers/driver_asynq_worker/utils_test.go index b68a0672..d4b6947c 100644 --- a/backend/plugins/drivers/driver_asynq_worker/utils_test.go +++ b/backend/plugins/drivers/driver_asynq_worker/utils_test.go @@ -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") } diff --git a/backend/plugins/drivers/driver_http/config.go b/backend/plugins/drivers/driver_http/config.go new file mode 100644 index 00000000..dea5144f --- /dev/null +++ b/backend/plugins/drivers/driver_http/config.go @@ -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"` +} diff --git a/backend/plugins/drivers/driver_http/engine.go b/backend/plugins/drivers/driver_http/engine.go index 101de55e..cc80a8c5 100644 --- a/backend/plugins/drivers/driver_http/engine.go +++ b/backend/plugins/drivers/driver_http/engine.go @@ -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") -} diff --git a/backend/plugins/drivers/driver_http/middlewares.go b/backend/plugins/drivers/driver_http/middlewares.go index e07d995a..f323ce60 100644 --- a/backend/plugins/drivers/driver_http/middlewares.go +++ b/backend/plugins/drivers/driver_http/middlewares.go @@ -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, diff --git a/backend/plugins/drivers/driver_http/plugin.go b/backend/plugins/drivers/driver_http/plugin.go index fe543458..4fcaf4ac 100644 --- a/backend/plugins/drivers/driver_http/plugin.go +++ b/backend/plugins/drivers/driver_http/plugin.go @@ -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 diff --git a/backend/plugins/drivers/driver_inproc_cron/plugin.go b/backend/plugins/drivers/driver_inproc_cron/plugin.go index c70dde53..49fbc3fe 100644 --- a/backend/plugins/drivers/driver_inproc_cron/plugin.go +++ b/backend/plugins/drivers/driver_inproc_cron/plugin.go @@ -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() diff --git a/backend/plugins/drivers/driver_inproc_worker/plugin.go b/backend/plugins/drivers/driver_inproc_worker/plugin.go index 0d9ad818..cd004818 100644 --- a/backend/plugins/drivers/driver_inproc_worker/plugin.go +++ b/backend/plugins/drivers/driver_inproc_worker/plugin.go @@ -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 diff --git a/backend/plugins/drivers/driver_inproc_worker/plugin_test.go b/backend/plugins/drivers/driver_inproc_worker/plugin_test.go index 42417d75..1107e433 100644 --- a/backend/plugins/drivers/driver_inproc_worker/plugin_test.go +++ b/backend/plugins/drivers/driver_inproc_worker/plugin_test.go @@ -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), diff --git a/backend/plugins/drivers/drivers_test.go b/backend/plugins/drivers/drivers_test.go index 340e2d2e..2fc891d2 100644 --- a/backend/plugins/drivers/drivers_test.go +++ b/backend/plugins/drivers/drivers_test.go @@ -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()})) diff --git a/backend/plugins/infra/cache/config.go b/backend/plugins/infra/cache/config.go new file mode 100644 index 00000000..ba0d3dd1 --- /dev/null +++ b/backend/plugins/infra/cache/config.go @@ -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"` +} diff --git a/backend/plugins/infra/cache/plugin.go b/backend/plugins/infra/cache/plugin.go index 081011e9..0155da2d 100644 --- a/backend/plugins/infra/cache/plugin.go +++ b/backend/plugins/infra/cache/plugin.go @@ -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 } diff --git a/backend/plugins/infra/cache/plugin_test.go b/backend/plugins/infra/cache/plugin_test.go index e1cf4c67..21d38698 100644 --- a/backend/plugins/infra/cache/plugin_test.go +++ b/backend/plugins/infra/cache/plugin_test.go @@ -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) diff --git a/backend/plugins/infra/cache/redis.go b/backend/plugins/infra/cache/redis.go index 56a5e1f9..f9ab5779 100644 --- a/backend/plugins/infra/cache/redis.go +++ b/backend/plugins/infra/cache/redis.go @@ -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 } diff --git a/backend/plugins/infra/cache_memory/plugin.go b/backend/plugins/infra/cache_memory/plugin.go index 1242226c..74c815ef 100644 --- a/backend/plugins/infra/cache_memory/plugin.go +++ b/backend/plugins/infra/cache_memory/plugin.go @@ -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()) diff --git a/backend/plugins/infra/database/clickhouse.go b/backend/plugins/infra/database/clickhouse.go index 883b43d2..1e38f176 100644 --- a/backend/plugins/infra/database/clickhouse.go +++ b/backend/plugins/infra/database/clickhouse.go @@ -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, ","), diff --git a/backend/plugins/infra/database/config.go b/backend/plugins/infra/database/config.go new file mode 100644 index 00000000..56f7a255 --- /dev/null +++ b/backend/plugins/infra/database/config.go @@ -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"` +} diff --git a/backend/plugins/infra/database/plugin.go b/backend/plugins/infra/database/plugin.go index eca4c978..eb16349d 100644 --- a/backend/plugins/infra/database/plugin.go +++ b/backend/plugins/infra/database/plugin.go @@ -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, diff --git a/backend/plugins/infra/database/plugin_test.go b/backend/plugins/infra/database/plugin_test.go index 8aa6861a..01d33c5d 100644 --- a/backend/plugins/infra/database/plugin_test.go +++ b/backend/plugins/infra/database/plugin_test.go @@ -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) diff --git a/backend/plugins/infra/database/postgres.go b/backend/plugins/infra/database/postgres.go index 4e864fe4..f110ec27 100644 --- a/backend/plugins/infra/database/postgres.go +++ b/backend/plugins/infra/database/postgres.go @@ -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)), diff --git a/backend/plugins/infra/infra_test.go b/backend/plugins/infra/infra_test.go index 568b10e6..1bd4e519 100644 --- a/backend/plugins/infra/infra_test.go +++ b/backend/plugins/infra/infra_test.go @@ -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 diff --git a/docs/WAVELET_WHITE_PAPER.md b/docs/WAVELET_WHITE_PAPER.md index e727a55a..2c66ad38 100644 --- a/docs/WAVELET_WHITE_PAPER.md +++ b/docs/WAVELET_WHITE_PAPER.md @@ -138,6 +138,8 @@ Wavelet 贯彻了 Cordis 核心范式,通过形式化保证解决组件系统 ### 5.1 彻底根除集中式包与建立 backend/ 顶级总包 在过去的传统单体架构中,集中式的 `internal/model/`、`internal/repository/` 以及 `internal/` 目录往往成为大杂烩,随着团队扩展导致模块边界失控与隐式耦合。在本次 Cordis 架构重构中,我们实施了彻底的物理清退与顶级前后端分包: - **`backend/` 顶级总包**:汇聚所有 Go 后端代码(`cmd/`、`core/`、`plugins/`、`pkg/`、`main.go`),根目录仅保留顶级功能域。 +- **配置读取框架归属内核**:`backend/core/extpoints/` 只承载与实现无关的配置声明与解析引擎(不 import viper),`backend/plugins/infra/config/` 承担文件与环境装载,读哪些字段由各插件自行声明;组合根不再跨插件判断配置选实现,改由 `ConfigGatedPlugin` 门禁 + `FiberSkipped` 决定激活方。 +- **`pkg/config/` 全局单例**:处于退场过渡期。配置声明与解析能力已上收内核,业务侧全量迁移与旧包物理清退由后续迁移计划落地。 - **`internal/` 目录**:**100% 物理清除**。通用的无状态基础库平移至 `backend/pkg/`,所有业务全部下沉至 `backend/plugins/domain/`。 - **`pkg/model/` 目录**:**100% 物理清除**。消灭集中式数据模型。 - **`pkg/repository/` 目录**:**100% 物理清除**。消灭集中式仓储。 @@ -162,4 +164,23 @@ Wavelet 贯彻了 Cordis 核心范式,通过形式化保证解决组件系统 2. **Pub/Sub 并发安全防线**:在启动 Redis Pub/Sub 监听协程前,严格捕获局部客户端实例,彻底消除测试或重启期间对可变全局客户端的数据竞争(Data Race Free)。 3. **零旁路读写 (No Bypass)**:严禁插件 A 跨界旁路直接操作属于插件 B 的数据表,跨域调用一律面向 `backend/core/contracts` 契约编程或发布事件。 +--- + +## 6. Cordis 配置扩展点与条件门禁机制 (Config Extension & Gated Activation) + +### 6.1 彻底清退全局配置单例 (Zero-Singleton Architecture) +在传统单体架构中,`pkg/config.Config` 全局静态变量充斥在各个业务与驱动模块中,导致隐式依赖、无法独立单测、无法多实例共存。Cordis 架构引入了基于微内核上下文的配置扩展点(`ctx.Config()`): +- **插件自包含声明**:每个插件实现 `DeclareConfig() []core.ConfigBinding`,声明自身所需的静态启动配置前缀、结构体与字段 tag(`config`、`env`、`default`、`autoEnable`、`secret`)。 +- **统一生命周期解析**:通过 `app.Prepare()` 建立配置解析屏障,统一绑定 YAML 文件与环境变量,支持前缀冲突检测与敏感字段脱敏导出。 +- **纯净依赖隔离**:插件在 `Apply(ctx)` 中通过 `ctx.Config().Bind("", &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` 条件分支,全面实现架构的时空可组合性与高内聚。 + + diff --git a/docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md b/docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md index 5efb9849..c2965834 100644 --- a/docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md +++ b/docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md @@ -344,3 +344,21 @@ app.Run() → Reconcile/Apply # 插件内 Bind/Get 读取已解析值 | domain/其他 | `plugins/domain/auth/session.go`、`plugins/domain/cap/service.go`、`plugins/domain/message_gateway/service/service.go`、`plugins/domain/system/plugin.go`、`plugins/domain/risk_control/middleware.go`、`plugins/domain/risk_control/middleware_test.go`、`plugins/domain/risk_control/logstore/provider.go` | > 注:`risk_control/middleware.go`、`cap/service.go`、`message_gateway/service/service.go` 等处以 `config.Config != nil` 做存在性判断的分支,在注入式配置模型下不再可能,迁移时一并消除。 + +--- + +## 8. 落地回写(P1 + P2 已实施) + +实施结果与本设计原述的差异,均已按下列口径落地: + +| # | 设计原述 | 落地结果 | 缘由 | +| :--- | :--- | :--- | :--- | +| R1 | §4.3 C1、§6 把 `app.session_age<=0` 列为内核解析错误 | 引擎不做值域校验,`ErrConfigInvalid` 保留但未在内核使用;值域由声明者在 `Bind` 之后校验(P3 由 auth 承担) | 引擎被设计成不认识任何业务 key 的语义,把业务规则塞进内核会破坏该不变式 | +| R2 | §3.2 `ConfigView.Source(key)` | 更名 `Origin(key)`;新增 `Value(key) (any, bool)`;`ConfigExtension` 增加 `SetSource`、`Resolved` | `Source` 与类型名 `ConfigSource` 同文件易混淆;`Value` 支撑 `core.ConfigGet[T]`(Go 方法不能带类型参数);`SetSource` 进接口以免运行时类型断言 | +| R3 | §3.5 仅有 `WithShutdownTimeout` | 新增 `App.ShutdownTimeout()` 与 `SetShutdownTimeout(d) *App` | 组合根需在 `Prepare()` 之后把已解析预算写回内核,构造期选项无法表达该顺序 | +| R4 | §4.2 时序图把门禁求值画在 `Prepare()` 内 | `Prepare()` 只建立解析屏障,门禁在 `reconcileLocked` 每轮调和中求值 | `App.Use` 可在 `Prepare()` 之后继续挂载插件;只在 `Prepare` 求值会留下一批永不判定的门禁 | +| R5 | 未涉及 | `App` 未注入 `ConfigSource` 时配置能力视为未启用,解析屏障直接放行;实现了 `ConfigGatedPlugin` 却无配置源的插件 fail fast 点名原因 | 内核存在大量不使用配置的装配路径(既有测试与嵌入式用法),不能强制要求配置源;但门禁无数据可依时必须报错,而非静默全激活 | +| R6 | §4.1 隐含"每个 key 都有 env 覆盖" | env 覆盖面完全由声明决定。旧装载器只对部分 key 提供 env(`slow_threshold`、`conn_max_lifetime` 等从未有 env 覆盖),对拍镜像必须精确复刻该覆盖面 | 否则对拍出现假漂移;放宽某 key 的 env 覆盖是 P3 的声明选择,不构成引擎行为变更 | +| R7 | §4.1 "向上最多 5 层查找 `config.yaml`" | 该向上查找会**越出 git worktree 边界**:从 `backend/pkg/config` 出发第 5 层可命中父级检出的 `config.yaml` | 属既有行为、非本次引入,但在 worktree 中开发会静默使用另一份检出的配置。对拍测试已改为以入库的 `config.example.yaml` 所在目录为锚;`config.yaml` 本身被 gitignore,干净克隆中不存在 | + +分期口径:本设计 §7.3 的 P1 + P2 已实施完成;P3(27 个消费文件迁移、`pkg/idgen` 解耦)与 P4(删除 `backend/pkg/config`、移除对拍夹具)由后续计划承接。