mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-09 09:06:36 +08:00
efa75558af
telebot types LongPoller.Timeout as time.Duration and sends int(timeout / time.Second) to getUpdates, so the literal 10 meant ten nanoseconds: Telegram received timeout=0, long polling never held the connection, and the adapter polled the Bot API in a tight loop instead. Use 10 seconds and extract the settings so the conversion is asserted. The adapter also has no media temp-dir cleanup (downloadMedia creates an MkdirTemp per attachment and nothing removes it); that is left as a separate change rather than bundled here.
2586 lines
86 KiB
Markdown
2586 lines
86 KiB
Markdown
# Cordis 配置扩展点(框架与门禁)Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 在微内核中落地"插件声明配置字段、内核按声明解析、门禁决定插件激活"的配置扩展点,并证明其解析结果与现有 `backend/pkg/config` 逐 key 等价。
|
||
|
||
**Architecture:** `core/extpoints` 实现纯 stdlib 的配置引擎(声明注册表 + 优先级解析 + 冲突校验 + 脱敏导出),viper 装载隔离在 `plugins/infra/config` 适配器内;`App.Prepare()` 作为解析屏障,`Fiber` 新增 `FiberSkipped` 态承载门禁结果。
|
||
|
||
**Tech Stack:** Go 1.25.7、`github.com/spf13/viper v1.21.0`(仅适配器)、`github.com/stretchr/testify`(测试)、`github.com/google/go-cmp`(对拍)、golangci-lint(gofumpt + cyclop/funlen/mnd/revive/dupl/gosec)。
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md`
|
||
|
||
---
|
||
|
||
## 本计划范围(对应 spec §7.3 的 P1 + P2)
|
||
|
||
本计划交付**内核能力**,不改动任何业务插件与 `cmd`:完成后旧的全局单例 `config.Config` 仍然是生产路径的唯一配置来源,应用行为零变化,新增能力由单测与新旧对拍证明。
|
||
|
||
**下一个计划(P3 + P4,本计划完成后另行编写)** 才做 27 个消费文件的迁移、`pkg/idgen` 解耦与 `backend/pkg/config` 删除。分期理由:迁移的声明结构体写法依赖本计划定稿的 tag 与 API 形状,先写会在实现过程中失真。
|
||
|
||
## 文件结构
|
||
|
||
| 文件 | 职责 | 动作 |
|
||
| :--- | :--- | :--- |
|
||
| `backend/core/extpoints/config.go` | 配置引擎的抽象与声明注册表:`ConfigSource`、`ConfigBinding`、`ConfigEntry`、`ConfigView`、`ConfigExtension`、`ConfigRegistry.Declare` + tag 遍历 + 冲突校验 | Create |
|
||
| `backend/core/extpoints/config_value.go` | 值解码:`convertValue` 及其 bool/int/uint/float/string/duration/slice/struct 分支 | Create |
|
||
| `backend/core/extpoints/config_resolve.go` | `Resolve` 优先级链、`Bind` 赋值、只读访问器、`Entries` 脱敏导出 | Create |
|
||
| `backend/core/extpoints/config_test.go` | 引擎单测(外部测试包 `extpoints_test`,fake source) | Create |
|
||
| `backend/core/config.go` | `ConfigGet[T]` 泛型读取入口 | Create |
|
||
| `backend/core/config_test.go` | 泛型读取与 `Context.Config()` 接入测试 | Create |
|
||
| `backend/core/types.go` | `ConfigExtension`/`ConfigBinding`/`ConfigEntry`/`ConfigSource` 别名 + `ConfigGatedPlugin` 可选接口 | Modify |
|
||
| `backend/core/context.go` | `config` 字段、`NewContext` 初始化、`Fork` 共享、`Config()` 访问器 | Modify |
|
||
| `backend/core/fiber.go` | `FiberSkipped` 状态与 `Skip()` | Modify |
|
||
| `backend/core/app.go` | `WithConfigSource`/`WithConfigDecl`/`Prepare`/`SetShutdownTimeout`、`Use` 收集声明、`reconcileLocked` 门禁求值 | Modify |
|
||
| `backend/plugins/infra/config/source.go` | viper + yaml 适配器,实现 `core.ConfigSource`,保留 `CONFIG_PATH` 与向上查找语义 | Create |
|
||
| `backend/plugins/infra/config/source_test.go` | 适配器单测(`t.TempDir()` + `t.Setenv`) | Create |
|
||
| `backend/pkg/config/config.go` | 抽出可重入 `load(configPath string, testMode bool)`(仅重构,行为不变) | Modify |
|
||
| `backend/pkg/config/parity_test.go` | 新旧解析对拍(临时文件,P4 随旧包删除) | Create then Delete in P4 |
|
||
| `scripts/check_cordis_architecture.sh` | 微内核禁 viper 检查项 | Modify |
|
||
|
||
**约束提示:** 所有新增导出符号必须带符合 `go-documentation` 规范的文档注释(`revive` 会检查);测试统一用 `t.TempDir()`,禁止相对路径创建临时目录。
|
||
|
||
---
|
||
|
||
## Task 1: 配置引擎的抽象与声明注册表
|
||
|
||
**Files:**
|
||
- Create: `backend/core/extpoints/config.go`
|
||
- Test: `backend/core/extpoints/config_test.go`
|
||
|
||
- [ ] **Step 1: 写失败的测试**
|
||
|
||
创建 `backend/core/extpoints/config_test.go`:
|
||
|
||
```go
|
||
// Copyright 2026 Arctel.net
|
||
// SPDX-License-Identifier: Apache-2.0
|
||
|
||
package extpoints_test
|
||
|
||
import (
|
||
"Wavelet/core/extpoints"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// 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")
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试确认失败**
|
||
|
||
Run: `cd backend && go test ./core/extpoints/ -run 'TestDeclare' -v`
|
||
Expected: 编译失败,报 `undefined: extpoints.NewConfigRegistry`、`undefined: extpoints.ConfigBinding`、`undefined: extpoints.ErrConfigTarget`、`undefined: extpoints.ErrConfigConflict`。
|
||
|
||
- [ ] **Step 3: 写最小实现**
|
||
|
||
创建 `backend/core/extpoints/config.go`:
|
||
|
||
```go
|
||
// 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")
|
||
|
||
// ErrConfigInvalid is returned when a resolved value violates a declared value range.
|
||
// Reserved for source-level value checks; per-plugin value ranges are validated by the
|
||
// declaring plugin after Bind (see spec §4.3 C1).
|
||
ErrConfigInvalid = errors.New("extpoints: invalid configuration value")
|
||
|
||
// 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
|
||
resolved bool
|
||
}
|
||
|
||
// 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
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 补齐测试所需的占位实现**
|
||
|
||
此时 `Entries`、`Resolve`、`Bind`、访问器尚未实现,Step 1 的测试用到 `Entries`。先创建 `backend/core/extpoints/config_resolve.go` 骨架,仅让 `Entries` 返回声明清单(值与来源在 Task 3/4 填充):
|
||
|
||
```go
|
||
// 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
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 运行测试确认通过**
|
||
|
||
Run: `cd backend && go test ./core/extpoints/ -run 'TestDeclare' -v`
|
||
Expected: `--- PASS: TestDeclareRegistersTaggedLeafKeys`、`--- PASS: TestDeclareRejectsNonStructPointerTarget`、`--- PASS: TestDeclareAllowsIdenticalDuplicateAndRejectsConflictingMetadata`,`ok Wavelet/core/extpoints`。
|
||
|
||
- [ ] **Step 6: 格式与静态检查**
|
||
|
||
Run: `cd backend && golangci-lint fmt ./core/extpoints/ && golangci-lint run ./core/extpoints/`
|
||
Expected: 无告警输出,退出码 0。
|
||
|
||
- [ ] **Step 7: 提交**
|
||
|
||
```bash
|
||
git add backend/core/extpoints/config.go backend/core/extpoints/config_resolve.go backend/core/extpoints/config_test.go
|
||
git commit -m "feat(core): add configuration declaration registry"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: 值解码(标量、时长、切片、结构体)
|
||
|
||
**Files:**
|
||
- Create: `backend/core/extpoints/config_value.go`
|
||
- Test: `backend/core/extpoints/config_test.go`(追加)
|
||
|
||
- [ ] **Step 1: 追加失败的测试**
|
||
|
||
在 `backend/core/extpoints/config_test.go` 末尾追加(`fakeSource`、`redisConfig` 复用 Task 1 的定义):
|
||
|
||
```go
|
||
// 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())
|
||
|
||
assert.Equal(t, []string{"redis:6379"}, r.Strings("redis.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")
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试确认失败**
|
||
|
||
Run: `cd backend && go test ./core/extpoints/ -run 'TestResolve' -v`
|
||
Expected: 编译失败,报 `r.Resolve undefined`、`r.Bind undefined`、`r.Strings undefined`。
|
||
|
||
- [ ] **Step 3: 写实现**
|
||
|
||
创建 `backend/core/extpoints/config_value.go`:
|
||
|
||
```go
|
||
// 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 convertInt(raw, typ)
|
||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||
return convertUint(raw, typ)
|
||
case reflect.Float32, reflect.Float64:
|
||
return convertFloat(raw, typ)
|
||
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
|
||
}
|
||
}
|
||
|
||
func convertInt(raw any, typ reflect.Type) (any, error) {
|
||
text, ok := numericString(raw)
|
||
if !ok {
|
||
return nil, fmt.Errorf("%w: %v is not an integer", ErrConfigType, raw)
|
||
}
|
||
parsed, err := strconv.ParseInt(text, 10, typ.Bits())
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: %q is not a valid %s", ErrConfigType, text, typ)
|
||
}
|
||
out := reflect.New(typ).Elem()
|
||
out.SetInt(parsed)
|
||
return out.Interface(), nil
|
||
}
|
||
|
||
func convertUint(raw any, typ reflect.Type) (any, error) {
|
||
text, ok := numericString(raw)
|
||
if !ok {
|
||
return nil, fmt.Errorf("%w: %v is not an unsigned integer", ErrConfigType, raw)
|
||
}
|
||
parsed, err := strconv.ParseUint(text, 10, typ.Bits())
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: %q is not a valid %s", ErrConfigType, text, typ)
|
||
}
|
||
out := reflect.New(typ).Elem()
|
||
out.SetUint(parsed)
|
||
return out.Interface(), nil
|
||
}
|
||
|
||
func convertFloat(raw any, typ reflect.Type) (any, error) {
|
||
text, ok := numericString(raw)
|
||
if !ok {
|
||
return nil, fmt.Errorf("%w: %v is not a float", ErrConfigType, raw)
|
||
}
|
||
parsed, err := strconv.ParseFloat(text, typ.Bits())
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: %q is not a valid %s", ErrConfigType, text, typ)
|
||
}
|
||
out := reflect.New(typ).Elem()
|
||
out.SetFloat(parsed)
|
||
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) {
|
||
switch v := raw.(type) {
|
||
case time.Duration:
|
||
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
|
||
}
|
||
|
||
func convertSlice(raw any, typ reflect.Type) (any, error) {
|
||
items, ok := sliceItems(raw)
|
||
if !ok {
|
||
// Scalar-to-single-element promotion keeps REDIS_ADDR populating redis.addrs.
|
||
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 := 0; i < rv.Len(); i++ {
|
||
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.key]
|
||
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(indexFieldName(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
|
||
}
|
||
}
|
||
|
||
// indexFieldName maps a declared config path back to the Go struct field carrying it.
|
||
func indexFieldName(t reflect.Type, key string) string {
|
||
for i := 0; i < t.NumField(); i++ {
|
||
if t.Field(i).Tag.Get("config") == key {
|
||
return t.Field(i).Name
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 写解析与绑定实现**
|
||
|
||
在 `backend/core/extpoints/config_resolve.go` **末尾追加**下列实现(保留 Task 1 写入的文件头、`Entries` 占位与 `sort` import;本步骤新增用到 `errors`、`fmt`、`reflect`,不要引入 `sync`/`time`):
|
||
|
||
```go
|
||
// 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 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
|
||
}
|
||
}
|
||
}
|
||
|
||
for _, f := range fields {
|
||
value := r.values[f.key]
|
||
field := elem.FieldByName(indexFieldName(elem.Type(), f.path))
|
||
if !field.IsValid() || !field.CanSet() {
|
||
return fmt.Errorf("%w: field for key %q is not settable", ErrConfigTarget, f.key)
|
||
}
|
||
rv := reflect.ValueOf(value)
|
||
if !rv.Type().AssignableTo(field.Type()) {
|
||
return fmt.Errorf("%w: key %q resolves to %s, field expects %s",
|
||
ErrConfigType, f.key, rv.Type(), field.Type())
|
||
}
|
||
field.Set(rv)
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
注意:`ErrConfigUnknownKey` 等全部哨兵错误已在 Task 1 的 `config.go` 错误块中定义,本步骤不要重复声明。
|
||
|
||
- [ ] **Step 5: 运行测试确认通过**
|
||
|
||
Run: `cd backend && go test ./core/extpoints/ -run 'TestResolve|TestDeclare' -v`
|
||
Expected: 全部 `--- PASS`,`ok Wavelet/core/extpoints`。若报 `Entries redeclared`,说明 Step 4 误把整文件替换而非追加。
|
||
|
||
- [ ] **Step 6: 格式与静态检查**
|
||
|
||
Run: `cd backend && golangci-lint fmt ./core/extpoints/ && golangci-lint run ./core/extpoints/`
|
||
Expected: 无告警。(`convertValue` 保持 8 个分支,若 `cyclop` 仍报复杂度过高,把 `Slice`/`Struct` 两分支拆成独立函数,不要放宽 lint 配置。)
|
||
|
||
- [ ] **Step 7: 提交**
|
||
|
||
```bash
|
||
git add backend/core/extpoints/
|
||
git commit -m "feat(core): resolve declared configuration with env and file precedence"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: 只读访问器、泛型读取与脱敏导出
|
||
|
||
**Files:**
|
||
- Modify: `backend/core/extpoints/config_resolve.go`(追加访问器)
|
||
- Modify: `backend/core/extpoints/config.go`(`Entries` 用到的 secret 判断)
|
||
- Create: `backend/core/config.go`
|
||
- Test: `backend/core/extpoints/config_test.go`(追加)、`backend/core/config_test.go`
|
||
|
||
- [ ] **Step 1: 追加失败的测试**
|
||
|
||
在 `backend/core/extpoints/config_test.go` 末尾追加:
|
||
|
||
```go
|
||
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, "redis:6379", r.Strings("redis.addrs")[0])
|
||
assert.False(t, r.Bool("redis.enabled", true))
|
||
assert.Equal(t, 1, r.Int("redis.db", 0))
|
||
assert.Equal(t, "86400", r.String("redis.missing", "86400"))
|
||
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)
|
||
}
|
||
```
|
||
|
||
新增脱敏常量到 `backend/core/extpoints/config.go`(Task 1 未定义它,因为此处才首次使用):
|
||
|
||
```go
|
||
// RedactedValue replaces the printed value of keys declared with secret:"true".
|
||
const RedactedValue = "******"
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试确认失败**
|
||
|
||
Run: `cd backend && go test ./core/extpoints/ -run 'TestView|TestAutoEnable|TestEntries|TestBindRejects' -v`
|
||
Expected: 编译失败,报 `r.Bool undefined`、`extpoints.RedactedValue undefined` 等;`Entries` 已有但返回 `"pending"` 占位,故 `TestEntriesRedactSecretsAndReportDefaults` 亦失败。
|
||
|
||
- [ ] **Step 3: 实现访问器**
|
||
|
||
在 `backend/core/extpoints/config_resolve.go` 末尾追加:
|
||
|
||
```go
|
||
// Value returns the resolved value for key, lazily resolving it when a source is
|
||
// available. Unresolvable and missing keys report false rather than an error so
|
||
// gates and diagnostics can keep using fallback accessors.
|
||
func (r *ConfigRegistry) Value(key string) (any, bool) {
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
|
||
if r.src == nil {
|
||
value, ok := r.values[key]
|
||
return value, ok
|
||
}
|
||
if _, done := r.values[key]; !done {
|
||
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 {
|
||
if converted, err := convertInt(value, reflect.TypeFor[int]()); err == nil {
|
||
return int(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]
|
||
}
|
||
```
|
||
|
||
把 `Entries` 的占位实现替换为真实值与脱敏:
|
||
|
||
```go
|
||
// Entries returns the effective configuration as redacted, key-sorted entries.
|
||
func (r *ConfigRegistry) Entries() []ConfigEntry {
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
|
||
keys := append([]string(nil), r.order...)
|
||
sort.Strings(keys)
|
||
|
||
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: 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)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 实现泛型读取入口**
|
||
|
||
创建 `backend/core/config.go`:
|
||
|
||
```go
|
||
// 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, and returns
|
||
// ErrConfigNotResolved when the key has neither been declared nor resolved.
|
||
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
|
||
}
|
||
```
|
||
|
||
创建 `backend/core/config_test.go`:
|
||
|
||
```go
|
||
// Copyright 2026 Arctel.net
|
||
// SPDX-License-Identifier: Apache-2.0
|
||
|
||
package core_test
|
||
|
||
import (
|
||
"Wavelet/core"
|
||
"Wavelet/core/extpoints"
|
||
"testing"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
type otelConfig struct {
|
||
SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE"`
|
||
}
|
||
|
||
func TestConfigGetReturnsDeclaredType(t *testing.T) {
|
||
r := extpoints.NewConfigRegistry(nil)
|
||
require.NoError(t, r.Declare("host", extpoints.ConfigBinding{Prefix: "otel", Target: &otelConfig{}}))
|
||
|
||
rate, err := core.ConfigGet[float64](r, "otel.sampling_rate")
|
||
require.ErrorIs(t, err, extpoints.ErrConfigUnknownKey)
|
||
assert.Equal(t, 0.0, rate)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 运行测试确认通过**
|
||
|
||
Run: `cd backend && go test ./core/... -run 'TestView|TestAutoEnable|TestEntries|TestBindRejects|TestConfigGet' -v`
|
||
Expected: 全部 `--- PASS`。
|
||
|
||
- [ ] **Step 6: 格式与静态检查**
|
||
|
||
Run: `cd backend && golangci-lint fmt ./core/... && golangci-lint run ./core/...`
|
||
Expected: 无告警。若 `dupl` 因 `convertInt`/`convertUint` 结构相似报警,为其中之一加注释说明类型不同不可合并,或拆出公共 reflect 设置函数;不得关闭 `dupl`。
|
||
|
||
- [ ] **Step 7: 提交**
|
||
|
||
```bash
|
||
git add backend/core/
|
||
git commit -m "feat(core): add read-only config view accessors and generic getter"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: 把配置注册表挂到 Context 并在 types.go 导出别名
|
||
|
||
**Files:**
|
||
- Modify: `backend/core/context.go`(`config` 字段、`NewContext`、`Fork`、`Config()`)
|
||
- Modify: `backend/core/types.go`(别名)
|
||
- Test: `backend/core/config_test.go`(追加)、`backend/core/context_test.go`(追加断言)
|
||
|
||
- [ ] **Step 1: 追加失败的测试**
|
||
|
||
在 `backend/core/config_test.go` 末尾追加:
|
||
|
||
```go
|
||
func TestContextConfigIsSharedAcrossForks(t *testing.T) {
|
||
ctx := core.NewContext(nil)
|
||
child := ctx.Fork()
|
||
|
||
require.NoError(t, child.Config().Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &otelConfig{}}))
|
||
|
||
rate, err := core.ConfigGet[float64](ctx.Config(), "redis.sampling_rate")
|
||
require.ErrorIs(t, err, extpoints.ErrConfigUnknownKey)
|
||
assert.Zero(t, rate)
|
||
assert.False(t, ctx.Config().Resolved())
|
||
}
|
||
```
|
||
|
||
在 `backend/core/context_test.go` 中已有的 Context 构造测试里追加一行断言(沿用该文件现有测试函数与变量名):
|
||
|
||
```go
|
||
require.NotNil(t, ctx.Config(), "every Context must expose the configuration extension")
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试确认失败**
|
||
|
||
Run: `cd backend && go test ./core/ -run 'TestContextConfigIsSharedAcrossForks' -v`
|
||
Expected: 编译失败,报 `ctx.Config undefined`、`core.ConfigBinding undefined`(测试里改用 `extpoints.ConfigBinding` 后该项消除)。
|
||
|
||
- [ ] **Step 3: 写实现**
|
||
|
||
`backend/core/context.go` 的 `Context` 结构体字段中,在 `settings` 之后加一行:
|
||
|
||
```go
|
||
settings extpoints.SettingExtension
|
||
config extpoints.ConfigExtension
|
||
```
|
||
|
||
`NewContext` 的返回字面量中,在 `settings: extpoints.NewSettingRegistry(),` 之后加:
|
||
|
||
```go
|
||
config: extpoints.NewConfigRegistry(nil),
|
||
```
|
||
|
||
`ForkWithContext` 的 child 字面量中,在 `settings: c.settings,` 之后加:
|
||
|
||
```go
|
||
config: c.config,
|
||
```
|
||
|
||
在 `Setting()` 别名方法之后加访问器:
|
||
|
||
```go
|
||
// Config returns the process-level configuration extension point. The registry is
|
||
// shared by every fork because configuration declarations are global facts, and it
|
||
// intentionally carries no per-scope disposers: values are resolved once before Apply.
|
||
func (c *Context) Config() extpoints.ConfigExtension {
|
||
return c.config
|
||
}
|
||
```
|
||
|
||
`backend/core/types.go` 末尾追加别名与新接口:
|
||
|
||
```go
|
||
// 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 before any Apply runs, so keys 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
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试确认通过**
|
||
|
||
Run: `cd backend && go test ./core/... -v -run 'TestContextConfig|TestFiber|TestApp|TestConfigGet'`
|
||
Expected: 新增用例 `--- PASS`,既有用例无回归。
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add backend/core/context.go backend/core/types.go backend/core/config_test.go backend/core/context_test.go
|
||
git commit -m "feat(core): mount the configuration extension point on the kernel Context"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Fiber 门禁跳过态
|
||
|
||
**Files:**
|
||
- Modify: `backend/core/fiber.go`
|
||
- Test: `backend/core/fiber_test.go`(追加)
|
||
|
||
- [ ] **Step 1: 追加失败的测试**
|
||
|
||
在 `backend/core/fiber_test.go` 末尾追加:
|
||
|
||
```go
|
||
// gatedPlugin is a minimal plugin used to exercise configuration gating.
|
||
type gatedPlugin struct {
|
||
name string
|
||
enabled bool
|
||
applied bool
|
||
}
|
||
|
||
func (g *gatedPlugin) Name() string { return g.name }
|
||
func (g *gatedPlugin) Apply(ctx *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
|
||
}
|
||
|
||
type gateConfig struct {
|
||
Enabled bool `config:"enabled" env:"GATE_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 TestFiberSkipIsIdempotentForActiveFibers(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")
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试确认失败**
|
||
|
||
Run: `cd backend && go test ./core/ -run 'TestFiberSkip' -v`
|
||
Expected: 编译失败,报 `undefined: core.FiberSkipped`、`f.Skip undefined`、`f.Skipped undefined`。
|
||
|
||
- [ ] **Step 3: 写实现**
|
||
|
||
`backend/core/fiber.go` 的 `FiberState` 常量块中,在 `FiberDisposed` 之后追加一个状态:
|
||
|
||
```go
|
||
// FiberSkipped indicates the plugin never activated because its configuration gate
|
||
// evaluated to false, so an alternative provider took over.
|
||
FiberSkipped FiberState = "SKIPPED"
|
||
```
|
||
|
||
`Load` 之后追加 `Skip`:
|
||
|
||
```go
|
||
// Skip transitions a pending plugin to FiberSkipped and releases its scoped Context.
|
||
// Active plugins are left untouched, making the call safe to replay during reconcile.
|
||
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
|
||
}
|
||
```
|
||
|
||
> **不要改 `DependenciesSatisfied`**:依赖能否满足完全由 IoC 容器解析决定。被跳过的插件从未执行 `Apply`,也就没有 `core.Provide`,其消费者自然解析不到服务并在 `Reconcile` 里报"waiting for"。用 Fiber 状态做短路是错误语义。
|
||
|
||
- [ ] **Step 4: 运行测试确认通过**
|
||
|
||
Run: `cd backend && go test ./core/ -run 'TestFiber' -v`
|
||
Expected: 新增两个用例 `--- PASS`,既有 `TestFiber_ConfluenceAndReactiveActivation`、`TestFiber_UnsatisfiedDependencyReturnsError` 无回归。
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add backend/core/fiber.go backend/core/fiber_test.go
|
||
git commit -m "feat(core): add skipped fiber state for configuration gates"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: App 装配选项、解析屏障与门禁求值
|
||
|
||
**Files:**
|
||
- Modify: `backend/core/app.go`
|
||
- Test: `backend/core/app_test.go`(追加)
|
||
|
||
- [ ] **Step 1: 追加失败的测试**
|
||
|
||
在 `backend/core/app_test.go` 末尾追加(复用 Task 5 的 `gatedPlugin`/`gateConfig`;`mapSource` 为本测试自备的内存源):
|
||
|
||
```go
|
||
// mapSource implements core.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" }
|
||
|
||
func newGateSource(enabled bool) *mapSource {
|
||
src := &mapSource{values: map[string]any{"gate.enabled": enabled}, env: map[string]string{}}
|
||
return src
|
||
}
|
||
|
||
func TestAppPrepareResolvesAndGatesPlugins(t *testing.T) {
|
||
redisLike := &gatedPlugin{name: "cache", enabled: true}
|
||
redisAlt := &gatedPlugin{name: "cache_memory", enabled: false}
|
||
|
||
app := core.NewApp(
|
||
core.WithProfile(core.ProfileAPI),
|
||
core.WithConfigSource(newGateSource(true)),
|
||
)
|
||
app.Use(redisLike, redisAlt)
|
||
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())
|
||
|
||
require.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, redisAlt.applied)
|
||
}
|
||
|
||
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 added after Prepare must still be gated")
|
||
}
|
||
|
||
func TestAppApplyPluginsGatesImplicitly(t *testing.T) {
|
||
redisLike := &gatedPlugin{name: "cache", enabled: true}
|
||
app := core.NewApp(core.WithConfigSource(newGateSource(false)))
|
||
app.Use(redisLike)
|
||
|
||
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 implicitly")
|
||
}
|
||
|
||
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 TestAppSetShutdownTimeoutOverridesDefault(t *testing.T) {
|
||
app := core.NewApp()
|
||
app.SetShutdownTimeout(0)
|
||
assert.NotZero(t, app.ShutdownTimeout(), "zero durations must not shrink the kernel fallback")
|
||
|
||
app.SetShutdownTimeout(45 * time.Second)
|
||
assert.Equal(t, 45*time.Second, app.ShutdownTimeout())
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试确认失败**
|
||
|
||
Run: `cd backend && go test ./core/ -run 'TestAppPrepare|TestAppStartRunsPrepare|TestAppSetShutdown' -v`
|
||
Expected: 编译失败,报 `core.WithConfigSource undefined`、`app.Prepare undefined`、`app.ShutdownTimeout undefined`。
|
||
|
||
- [ ] **Step 3: 写实现**
|
||
|
||
`backend/core/app.go` 中,`App` 结构体追加两个字段:
|
||
|
||
```go
|
||
migrationEngine MigrationEngine
|
||
shutdownTimeout time.Duration
|
||
configSource ConfigSource
|
||
prepared bool
|
||
```
|
||
|
||
`AppOption` 区追加选项(放在 `WithShutdownTimeout` 之后):
|
||
|
||
```go
|
||
// 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
|
||
}
|
||
a.configSource = src
|
||
a.ctx.Config().SetSource(src)
|
||
}
|
||
}
|
||
|
||
// WithConfigDecl lets the composition root declare the configuration it reads itself,
|
||
// so host-level values participate in conflict validation and redacted reporting.
|
||
func WithConfigDecl(pluginID string, bindings ...ConfigBinding) AppOption {
|
||
return func(a *App) {
|
||
if len(bindings) == 0 {
|
||
return
|
||
}
|
||
if err := a.ctx.Config().Declare(pluginID, bindings...); err != nil {
|
||
a.applyErr = err
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`App` 结构体再加 `applyErr error` 字段,并在 `NewApp` 末尾返回前保持原逻辑(`applyErr` 由 `Prepare` 首次上报)。
|
||
|
||
追加 `Prepare`、`ShutdownTimeout`、`SetShutdownTimeout`:
|
||
|
||
```go
|
||
// Prepare resolves declared configuration and evaluates plugin gates. It is idempotent
|
||
// and runs implicitly from ApplyPlugins, so callers that need resolved values earlier
|
||
// (for example to size a shutdown budget) can invoke it explicitly.
|
||
func (a *App) Prepare() error {
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
|
||
if err := a.applyErr; err != nil {
|
||
return err
|
||
}
|
||
return a.prepareLocked()
|
||
}
|
||
|
||
func (a *App) prepareLocked() error {
|
||
if a.prepared {
|
||
return nil
|
||
}
|
||
|
||
if err := a.ctx.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.
|
||
func (a *App) SetShutdownTimeout(timeout time.Duration) *App {
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
if timeout > 0 {
|
||
a.shutdownTimeout = timeout
|
||
}
|
||
return a
|
||
}
|
||
```
|
||
|
||
> **门禁为什么在 `reconcileLocked` 内求值而不是 `Prepare` 里一次性遍历**:`App.Use` 可以在 `Prepare` 之后继续挂载插件(下游定制与动态装配)。只在 `Prepare` 求值会留下一批永不判定的门禁;放在调和循环里则任何时刻新挂载的插件都会被正确判定,且 `Fiber.Skip` 自带"仅 Pending 可跳过"守卫,重复遍历安全。
|
||
|
||
`Use` 中,为每个成功登记的插件收集声明(放在 `a.pluginMap[name] = p` 之前):
|
||
|
||
```go
|
||
if gated, ok := p.(ConfigGatedPlugin); ok {
|
||
if err := a.ctx.Config().Declare(name, gated.DeclareConfig()...); err != nil {
|
||
if a.applyErr == nil {
|
||
a.applyErr = err
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`ApplyPlugins` 与 `reconcileLocked` 接入屏障(`ApplyPlugins` 已持锁,改调用 `prepareLocked`):
|
||
|
||
```go
|
||
func (a *App) ApplyPlugins() error {
|
||
a.mu.Lock()
|
||
if a.applied {
|
||
a.mu.Unlock()
|
||
return nil
|
||
}
|
||
a.applied = true
|
||
|
||
if err := a.applyErr; err != nil {
|
||
a.mu.Unlock()
|
||
return err
|
||
}
|
||
if err := a.prepareLocked(); err != nil {
|
||
a.mu.Unlock()
|
||
return err
|
||
}
|
||
a.mu.Unlock()
|
||
|
||
return a.Reconcile()
|
||
}
|
||
```
|
||
|
||
把 `reconcileLocked` 的内层循环替换为带门禁判定的版本(其余保持不变):
|
||
|
||
```go
|
||
func (a *App) reconcileLocked() error {
|
||
if err := a.prepareLocked(); err != nil {
|
||
return err
|
||
}
|
||
|
||
view := a.ctx.Config()
|
||
|
||
for {
|
||
progress := false
|
||
for _, f := range a.fibers {
|
||
if f.State() != FiberPending {
|
||
continue
|
||
}
|
||
|
||
if gated, ok := f.plugin.(ConfigGatedPlugin); ok {
|
||
if !view.Resolved() {
|
||
continue
|
||
}
|
||
if !gated.ConfigEnabled(view) {
|
||
if err := f.Skip(); err != nil {
|
||
return fmt.Errorf("core: skip gated plugin %q: %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)
|
||
}
|
||
progress = true
|
||
}
|
||
}
|
||
if !progress {
|
||
break
|
||
}
|
||
}
|
||
|
||
// ...existing unsatisfied-dependency reporting unchanged
|
||
}
|
||
```
|
||
|
||
`unsatisfied` 收集循环无需改动:它只统计 `FiberPending`,被门禁排除的插件已是 `FiberSkipped`。
|
||
|
||
- [ ] **Step 4: 运行测试确认通过**
|
||
|
||
Run: `cd backend && go test ./core/ -v`
|
||
Expected: 全部 `--- PASS`,包括既有 `TestApp*`、`TestFiber*`、`TestContext*`。
|
||
|
||
- [ ] **Step 5: 全量回归(应用行为必须不变)**
|
||
|
||
Run: `cd backend && go test ./... && go build -o /dev/null ./...`
|
||
Expected: 全绿。此时业务插件与 `cmd` 仍走旧的全局单例,因此运行行为与迁移前完全一致——这是本计划的关键安全属性。
|
||
|
||
- [ ] **Step 6: 格式与静态检查**
|
||
|
||
Run: `cd backend && golangci-lint fmt ./core/... && golangci-lint run ./core/...`
|
||
Expected: 无告警。
|
||
|
||
- [ ] **Step 7: 提交**
|
||
|
||
```bash
|
||
git add backend/core/app.go backend/core/app_test.go
|
||
git commit -m "feat(core): add config resolution barrier and plugin gating to App"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: viper 配置源适配器(plugins/infra/config)
|
||
|
||
**Files:**
|
||
- Create: `backend/plugins/infra/config/source.go`
|
||
- Create: `backend/plugins/infra/config/source_test.go`
|
||
|
||
- [ ] **Step 1: 写失败的测试**
|
||
|
||
创建 `backend/plugins/infra/config/source_test.go`:
|
||
|
||
```go
|
||
// 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) string {
|
||
t.Helper()
|
||
|
||
dir := t.TempDir()
|
||
path := filepath.Join(dir, "config.yaml")
|
||
require.NoError(t, os.WriteFile(path, []byte(sampleYAML), 0o600))
|
||
return path
|
||
}
|
||
|
||
func TestSourceLooksUpNestedPaths(t *testing.T) {
|
||
src, err := config.NewSource(config.WithPath(writeConfig(t)))
|
||
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 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) {
|
||
dir := t.TempDir()
|
||
path := filepath.Join(dir, "config.yaml")
|
||
require.NoError(t, os.WriteFile(path, []byte("app: [unclosed\n"), 0o600))
|
||
|
||
_, err := config.NewSource(config.WithPath(path))
|
||
require.Error(t, err)
|
||
}
|
||
|
||
func TestSourceLookupEnvReadsProcessEnvironment(t *testing.T) {
|
||
t.Setenv("WAVELET_SOURCE_PROBE", "present")
|
||
|
||
src, err := config.NewSource(config.WithPath(writeConfig(t)))
|
||
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)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试确认失败**
|
||
|
||
Run: `cd backend && go test ./plugins/infra/config/ -v`
|
||
Expected: 编译失败,报 `package Wavelet/plugins/infra/config is not in std` / `undefined: config.NewSource`。
|
||
|
||
- [ ] **Step 3: 写实现**
|
||
|
||
创建 `backend/plugins/infra/config/source.go`:
|
||
|
||
```go
|
||
// 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 it out of core preserves the micro-kernel
|
||
// rule against importing concrete runtime dependencies.
|
||
package config
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
|
||
"github.com/spf13/viper"
|
||
)
|
||
|
||
// DefaultFileName is the configuration file looked up when CONFIG_PATH is unset.
|
||
const DefaultFileName = "config.yaml"
|
||
|
||
// EnvOnlyOrigin is reported by Describe when no configuration file was loaded.
|
||
const EnvOnlyOrigin = "<env only>"
|
||
|
||
// maxSearchDepth bounds the upward directory walk so a misconfigured working set
|
||
// 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 previous pkg/config behaviour.
|
||
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):
|
||
// 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
|
||
}
|
||
|
||
func isNotFound(err error) bool {
|
||
var notFound viper.ConfigFileNotFoundError
|
||
return errors.As(err, ¬Found) || errors.Is(err, os.ErrNotExist)
|
||
}
|
||
|
||
// Lookup returns the raw value stored at a dotted path, or false when the file was not
|
||
// loaded or the path is absent.
|
||
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
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试确认通过**
|
||
|
||
Run: `cd backend && go test ./plugins/infra/config/ -v`
|
||
Expected: 四个用例全部 `--- PASS`。
|
||
|
||
- [ ] **Step 5: 格式与静态检查**
|
||
|
||
Run: `cd backend && golangci-lint fmt ./plugins/infra/config/ && golangci-lint run ./plugins/infra/config/`
|
||
Expected: 无告警。
|
||
|
||
- [ ] **Step 6: 提交**
|
||
|
||
```bash
|
||
git add backend/plugins/infra/config/
|
||
git commit -m "feat(infra): add viper backed configuration source adapter"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: 旧配置包可重入重构 + 新旧对拍
|
||
|
||
**Files:**
|
||
- Modify: `backend/pkg/config/config.go:57-108`
|
||
- Create: `backend/pkg/config/parity_test.go`(临时文件,P4 随旧包一并删除)
|
||
|
||
- [ ] **Step 1: 重构旧加载器为可重入函数**
|
||
|
||
把 `backend/pkg/config/config.go` 的 `init()` 拆成 `load` + `init`。原实现使用包级 `viper` 全局并在 `init` 里内联全部步骤,对拍需要能反复调用且不受 `isTest()` 干扰:
|
||
|
||
```go
|
||
// load reads configuration from configPath, applies defaults and environment overrides,
|
||
// and optionally disables external services for in-test runs.
|
||
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)
|
||
applyEnvOverrides(&c)
|
||
applyDefaults(&c)
|
||
|
||
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)
|
||
}
|
||
```
|
||
|
||
同步调整:`import` 增加 `"errors"`;删除原 `init` 里的 `viper.SetConfigFile`/`viper.AutomaticEnv`/`viper.ReadInConfig` 等包级调用与 `if _, ok := err.(viper.ConfigFileNotFoundError); !ok` 断言(改用上面的 `errors.As`)。
|
||
|
||
> **行为等价说明(评审时核对)**:`viper.AutomaticEnv()` 只影响按键读取,`Unmarshal` 走的是 `AllKeys`,因此去掉 `AutomaticEnv` 不改变解析结果;环境变量覆盖仍由 `applyEnvOverrides` 负责。
|
||
|
||
- [ ] **Step 2: 运行旧测试确认无回归**
|
||
|
||
Run: `cd backend && go test ./pkg/config/ ./cmd/ -v -run 'TestApplyEnvOverrides|Test' 2>&1 | tail -30`
|
||
Expected: `pkg/config` 的 `TestApplyEnvOverridesRedisMaintNotifications` 通过;`cmd` 包既有用例结果与改动前一致(先运行一次改动前的 `go test ./cmd/` 记录基线)。
|
||
|
||
- [ ] **Step 3: 写对拍测试**
|
||
|
||
创建 `backend/pkg/config/parity_test.go`:
|
||
|
||
```go
|
||
// Copyright 2026 Arctel.net
|
||
// SPDX-License-Identifier: Apache-2.0
|
||
|
||
// Temporary migration harness: proves the new kernel configuration engine resolves
|
||
// every key identically to pkg/config before the legacy singleton is deleted in P4.
|
||
// Delete this file together with backend/pkg/config.
|
||
package config
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"reflect"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/google/go-cmp/cmp"
|
||
"github.com/spf13/viper"
|
||
|
||
"Wavelet/core/extpoints"
|
||
)
|
||
|
||
// yamlSource is a test-local core.ConfigSource over the repository config 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() }
|
||
|
||
// engineAppConfig mirrors appConfig with engine tags.
|
||
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 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" env:"DB_CONN_MAX_LIFETIME"`
|
||
ConnMaxIdleTime int `config:"conn_max_idle_time" env:"DB_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" env:"DB_APPLICATION_NAME"`
|
||
SearchPath string `config:"search_path" env:"DB_SEARCH_PATH"`
|
||
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 []engineReplicaConfig `config:"replicas"`
|
||
SlowThreshold time.Duration `config:"slow_threshold" env:"DB_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" 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"`
|
||
}
|
||
|
||
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" env:"CLICKHOUSE_MAX_IDLE_CONN"`
|
||
MaxOpenConn int `config:"max_open_conn" env:"CLICKHOUSE_MAX_OPEN_CONN"`
|
||
ConnMaxLifetime int `config:"conn_max_lifetime" env:"CLICKHOUSE_CONN_MAX_LIFETIME"`
|
||
DialTimeout int `config:"dial_timeout" env:"CLICKHOUSE_DIAL_TIMEOUT"`
|
||
BlockBufferSize uint8 `config:"block_buffer_size" env:"CLICKHOUSE_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" env:"LOG_FILE_PATH"`
|
||
MaxSize int `config:"max_size" env:"LOG_MAX_SIZE"`
|
||
MaxAge int `config:"max_age" env:"LOG_MAX_AGE"`
|
||
MaxBackups int `config:"max_backups" env:"LOG_MAX_BACKUPS"`
|
||
Compress bool `config:"compress" env:"LOG_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"`
|
||
}
|
||
|
||
// engineReplicaConfig mirrors databaseReplicaConfig, a composite element of database.replicas.
|
||
type engineReplicaConfig struct {
|
||
Host string `config:"host"`
|
||
Port int `config:"port"`
|
||
Username string `config:"username"`
|
||
Password string `config:"password"`
|
||
}
|
||
|
||
// engineQueueConfig and engineWorkerConfig mirror the worker section, whose defaults
|
||
// legitimately move to driver_asynq_worker in P3; the repository file declares them
|
||
// explicitly, so parity is unaffected.
|
||
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"`
|
||
}
|
||
|
||
func repositoryConfig(t *testing.T) string {
|
||
t.Helper()
|
||
|
||
path := filepath.Join("..", "..", "config.yaml")
|
||
if _, statErr := os.Stat(path); statErr != nil {
|
||
t.Skip("repository root config.yaml is unavailable")
|
||
}
|
||
return path
|
||
}
|
||
|
||
// 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())
|
||
}
|
||
}
|
||
|
||
// durationType mirrors the engine's own notion of a scalar duration field.
|
||
var durationType = reflect.TypeFor[time.Duration]()
|
||
|
||
func TestEngineParityWithLegacyLoader(t *testing.T) {
|
||
path := repositoryConfig(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 and duration parsing",
|
||
env: map[string]string{
|
||
"LOG_LEVEL": "debug", "APP_ADDR": ":9999", "DB_SLOW_THRESHOLD": "1s",
|
||
"REDIS_MAINT_NOTIFICATIONS": "true", "OTEL_SAMPLING_RATE": "0.5",
|
||
},
|
||
},
|
||
}
|
||
|
||
for _, scenario := range scenarios {
|
||
t.Run(scenario.name, func(t *testing.T) {
|
||
for name, value := range scenario.env {
|
||
t.Setenv(name, value)
|
||
}
|
||
|
||
legacy := load(path, false)
|
||
|
||
src := newYAMLSource(t, path)
|
||
|
||
engine := extpoints.NewConfigRegistry(src)
|
||
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
|
||
var database engineDatabaseConfig
|
||
var redis engineRedisConfig
|
||
var clickhouse engineClickHouseConfig
|
||
var log engineLogConfig
|
||
var otel engineOtelConfig
|
||
var worker engineWorkerConfig
|
||
for _, binding := range []struct {
|
||
prefix string
|
||
target any
|
||
}{
|
||
{"app", &app}, {"database", &database}, {"redis", &redis},
|
||
{"clickhouse", &clickhouse}, {"log", &log}, {"otel", &otel}, {"worker", &worker},
|
||
} {
|
||
require.NoError(t, engine.Bind(binding.prefix, binding.target))
|
||
}
|
||
|
||
// The legacy loader keeps two code-level defaults outside its tags; the engine
|
||
// expresses them as declared defaults, so normalise before diffing (spec C1).
|
||
if legacy.App.SessionAge <= 0 {
|
||
legacy.App.SessionAge = 86400
|
||
}
|
||
if legacy.Otel.TracerName == "" {
|
||
legacy.Otel.TracerName = "github.com/Rain-kl/Wavelet"
|
||
}
|
||
|
||
legacyFlat := map[string]string{}
|
||
flatten("app", reflect.ValueOf(legacy.App), legacyFlat)
|
||
flatten("database", reflect.ValueOf(legacy.Database), legacyFlat)
|
||
flatten("redis", reflect.ValueOf(legacy.Redis), legacyFlat)
|
||
flatten("clickhouse", reflect.ValueOf(legacy.ClickHouse), legacyFlat)
|
||
flatten("log", reflect.ValueOf(legacy.Log), legacyFlat)
|
||
flatten("otel", reflect.ValueOf(legacy.Otel), legacyFlat)
|
||
flatten("worker", reflect.ValueOf(legacy.Worker), legacyFlat)
|
||
|
||
engineFlat := map[string]string{}
|
||
flatten("app", reflect.ValueOf(app), engineFlat)
|
||
flatten("database", reflect.ValueOf(database), engineFlat)
|
||
flatten("redis", reflect.ValueOf(redis), engineFlat)
|
||
flatten("clickhouse", reflect.ValueOf(clickhouse), engineFlat)
|
||
flatten("log", reflect.ValueOf(log), engineFlat)
|
||
flatten("otel", reflect.ValueOf(otel), engineFlat)
|
||
flatten("worker", reflect.ValueOf(worker), engineFlat)
|
||
|
||
assert.Empty(t, cmp.Diff(legacyFlat, engineFlat), "engine resolution drifted from legacy loader")
|
||
})
|
||
}
|
||
}
|
||
```
|
||
|
||
测试文件的 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`。
|
||
|
||
- [ ] **Step 4: 运行对拍确认等价**
|
||
|
||
Run: `cd backend && go test ./pkg/config/ -run TestEngineParityWithLegacyLoader -v`
|
||
Expected: 四个场景全部 `--- PASS`,无任何 `drifted` 断言输出。若出现 drift,逐项核对是否为 spec §4.3 登记的 C1–C5 有意差异:是则在该场景补注释说明差异来源,否则按 drift 修复引擎。
|
||
|
||
- [ ] **Step 5: 确认既有测试与构建仍全绿**
|
||
|
||
Run: `cd backend && go test ./... && go build -o /dev/null ./...`
|
||
Expected: 全绿。
|
||
|
||
- [ ] **Step 6: 提交**
|
||
|
||
```bash
|
||
git add backend/pkg/config/
|
||
git commit -m "refactor(config): make legacy loader reentrant and add engine parity test"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: 架构门禁脚本禁止 core 引入 viper
|
||
|
||
**Files:**
|
||
- Modify: `scripts/check_cordis_architecture.sh:57-65`
|
||
|
||
- [ ] **Step 1: 扩展检查项**
|
||
|
||
把 `CORE_FRAMEWORK_IMPORTS` 的匹配式加入 viper、mapstructure 与 gin 的兄弟框架,使配置装载依赖无法渗进内核:
|
||
|
||
```bash
|
||
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)
|
||
```
|
||
|
||
同步更新失败提示文案,列出新增的两个包:
|
||
|
||
```bash
|
||
log_fail "backend/core/ 严禁导入具体 Web/ORM/Worker/Config 运行时框架 (gin, gorm, asynq, cron, viper, mapstructure):"
|
||
```
|
||
|
||
- [ ] **Step 2: 运行脚本确认通过**
|
||
|
||
Run: `./scripts/check_cordis_architecture.sh`
|
||
Expected: `✓ backend/core/ 无重型框架依赖`,末行 `✓ 所有 Cordis 架构合规性检查全部通过 (0 Violations)!`,退出码 0。
|
||
|
||
- [ ] **Step 3: 反向验证检查有效性**
|
||
|
||
Run: `printf 'package core\n\nimport _ "github.com/spf13/viper"\n' > backend/core/zz_probe_tmp.go && ./scripts/check_cordis_architecture.sh; STATUS=$?; rm backend/core/zz_probe_tmp.go; exit $STATUS`
|
||
Expected: 报 `✗ [FAIL] backend/core/ 严禁导入具体 Web/ORM/Worker/Config 运行时框架`,退出码非 0。(临时文件必须删除,用后确认 `git status --short` 干净。)
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
git add scripts/check_cordis_architecture.sh
|
||
git commit -m "chore(arch): forbid viper and mapstructure inside the micro-kernel"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: 收尾验证与文档同步
|
||
|
||
**Files:**
|
||
- Modify: `AGENTS.md`(Cordis 分层清单补配置扩展点条目)
|
||
- Modify: `docs/WAVELET_WHITE_PAPER.md`(§5.1 顶级分包说明)
|
||
|
||
- [ ] **Step 1: 写内核侧用法文档**
|
||
|
||
在 `AGENTS.md` 的“严格遵循事项 (Guardrails)”中,`扩展点自包含注册` 列表内 `动态配置` 一条之后补充:
|
||
|
||
```markdown
|
||
- **静态配置声明**:插件在 `Apply` 中通过 `ctx.Config().Bind("<prefix>", &cfg)` 读取自己声明的配置(字段用 `config` / `env` / `default` / `autoEnable` / `secret` tag 声明);需要在 `Apply` 之前被门禁求值的键,必须在 `DeclareConfig()` 中提前声明。**严禁**新增全局配置单例或在 `backend/pkg/` 读取配置。
|
||
```
|
||
|
||
- [ ] **Step 2: 修正白皮书漂移**
|
||
|
||
在 `docs/WAVELET_WHITE_PAPER.md` §5.1 的顶级分包说明后追加一条:
|
||
|
||
```markdown
|
||
- **配置所有权下沉**:`backend/pkg/config` 全局单例已废除。`core/extpoints` 只提供配置声明与解析引擎(不 import viper),`plugins/infra/config` 承担文件与环境装载,读哪些字段由各插件自行声明;组合根不再跨插件判断配置选实现,改由 `ConfigGatedPlugin` 门禁 + `FiberSkipped` 决定激活方。
|
||
```
|
||
|
||
- [ ] **Step 3: 全量门禁**
|
||
|
||
Run: `make code-check`
|
||
Expected: 架构脚本 0 Violations;`golangci-lint run` 无输出;前端 tsc 与 eslint 无新增错误(本计划未触碰前端,若报既有错误需确认为基线问题)。
|
||
|
||
- [ ] **Step 4: 格式化**
|
||
|
||
Run: `make format`
|
||
Expected: gofumpt 无 diff 或自动格式化;`git status --short` 中出现的文件需一并纳入下一步。
|
||
|
||
- [ ] **Step 5: 实跑确认应用行为未变**
|
||
|
||
Run: `cd backend && go run main.go all 2>&1 | head -40`
|
||
Expected: banner 正常输出、迁移日志正常、`[Config] loaded configuration` 出现,进程正常启动后 Ctrl-C 优雅退出。**这是 P1/P2 的验收线:新能力已就位,生产路径仍走旧单例,行为必须与改动前一致。**
|
||
|
||
- [ ] **Step 6: 提交**
|
||
|
||
```bash
|
||
git add AGENTS.md docs/WAVELET_WHITE_PAPER.md
|
||
git commit -m "docs(config): record the configuration extension point ownership rules"
|
||
```
|
||
|
||
---
|
||
|
||
## 完成标准(本计划)
|
||
|
||
1. `cd backend && go test ./...` 与 `go build ./...` 全绿;`make code-check` 与 `./scripts/check_cordis_architecture.sh` 零违规。
|
||
2. 引擎单测覆盖:优先级四档、`autoEnable` 与显式 env 的相对优先、标量 env→切片、`time.Duration`、结构体切片、冲突校验、脱敏导出、无 source 时的错误路径。
|
||
3. `TestEngineParityWithLegacyLoader` 四个场景零 drift,证明除 spec §4.3 的 C1–C5 外解析结果与旧实现逐 key 等价。
|
||
4. 同时挂载门禁谓词相反的两个插件时,恰好一个 `FiberActive`、一个 `FiberSkipped`,且被跳过者 `Apply` 从未执行。
|
||
5. `core/` 不出现 viper/mapstructure import,且架构脚本能主动拦截该违规。
|
||
6. 生产启动路径行为未变(仍由 `config.Config` 供值),业务插件与 `cmd` 零改动。
|
||
|
||
---
|
||
|
||
## 与 spec 的偏差(实施时须回写 spec)
|
||
|
||
计划编写阶段的自审发现三处与本 spec 已批准版本不一致的实现,均属实现期发现的正确性问题。在 Task 10 落库时一并回写 `docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md`:
|
||
|
||
| # | spec 原述 | 本计划实现 | 理由 |
|
||
| :--- | :--- | :--- | :--- |
|
||
| S1 | §4.3 C1 与 §6 把 `app.session_age<=0` 的判定列为内核解析错误(`ErrConfigInvalid`) | 引擎不做值域校验;`ErrConfigInvalid` 保留为源级校验占位,值域由声明者在 `Bind` 之后自行校验(auth 校验 `SessionAge > 0` 并使 `Apply` 失败) | 引擎被设计成不认识任何业务 key 的语义,让它知道"session_age 必须为正"会破坏该不变式并把业务规则塞进内核 |
|
||
| S2 | §3.2 `ConfigView` 含 `Source(key) string` | 更名 `Origin(key)`,并新增 `Value(key) (any, bool)`;`ConfigExtension` 新增 `SetSource(src)` 与 `Resolved()` | `Source` 与类型名 `ConfigSource` 在同文件内易混淆;`Value` 是 `core.ConfigGet[T]` 的支撑(Go 方法不能带类型参数);`SetSource` 进接口以避免 `WithConfigSource` 里的运行时类型断言 |
|
||
| S3 | §3.5 只给出 `WithShutdownTimeout` 与 `app.Prepare()` | 额外新增 `App.ShutdownTimeout()` 读取器与 `SetShutdownTimeout(d) *App`(链式,与既有 `WithProfile` 风格一致) | 组合根需要在 `Prepare()` 之后把已解析的预算写回内核,原先只有构造期选项,无法表达该顺序 |
|
||
|
||
回写时同步修正 §7.3 的分期编号:本计划覆盖 P1 + P2,P3 + P4 由后续计划承接(`pkg/idgen` 解耦、27 个文件迁移、删除 `backend/pkg/config`)。
|