diff --git a/docs/superpowers/plans/2026-08-27-cordis-plugin-architecture.md b/docs/superpowers/plans/2026-08-27-cordis-plugin-architecture.md deleted file mode 100644 index a1667639..00000000 --- a/docs/superpowers/plans/2026-08-27-cordis-plugin-architecture.md +++ /dev/null @@ -1,332 +0,0 @@ -# Wavelet Cordis 微内核与全插件化改造实施计划 - -> **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:** 将 Wavelet 架构重构为基于 Cordis 理念的微内核与全插件化架构,支持一切能力插件化、自包含数据迁移、多运行切面(API/Worker/Schedule/All)与下游极简二开扩展。 - -**Architecture:** -- **Core (`core/`)**: 纯净微内核,提供 Context 服务总线、泛型 IoC 容器(`Provide/Inject/Using`)、强类型 EventBus、生命周期状态机与 6 大扩展点协议,零外部业务依赖。 -- **Drivers (`plugins/drivers/`)**: Gin HTTP Server、Asynq Worker、Asynq Scheduler 封装为标准运行时驱动插件。 -- **Infra Plugins (`plugins/infra/`)**: 数据库(GORM/DBResolver)、三层缓存(RAM/Redis/PubSub)、日志(Zap/Otel)、对象存储插件化。 -- **Domain Plugins (`plugins/domain/`)**: Auth、User、MessageGateway、RiskControl、Admin 模块拆分为扁平自包含插件,自带独立 Goose 迁移。 - -**Tech Stack:** Go 1.25+, Gin, GORM, Asynq, Redis, Zap, OpenTelemetry, Goose, Viper, Cobra. - -## Global Constraints -- 保持 `core/` 绝对纯净,禁止 import Gin、GORM、Asynq 或具体业务包。 -- 插件之间严禁相互跨包 import 具体实现,跨插件交互一律通过 `core/contracts` 接口或 `ctx.Events()` 事件总线。 -- 严格遵循 Go 单元测试规范,测试临时目录统一使用 `t.TempDir()`,测试覆盖率严格达标。 -- 完成每个 Task 后必须确保代码能通过 `go build ./...` 与 `go test ./...` 检验并及时提交 Git。 - ---- - -### Task 1: 微内核基础契约与泛型 Context 服务总线 (`core/`) - -**Files:** -- Create: `core/types.go` -- Create: `core/manifest.go` -- Create: `core/container.go` -- Create: `core/context.go` -- Test: `core/context_test.go` - -**Interfaces:** -- Produces: `core.Plugin`, `core.Manifest`, `core.Context`, `core.Provide[T]`, `core.Inject[T]`, `core.Using[T]` - -- [ ] **Step 1: 编写 Context 与 IoC 容器的失败测试** - -```go -// core/context_test.go -package core_test - -import ( - "context" - "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/Rain-kl/Wavelet/core" -) - -type SampleService interface { - Greet(name string) string -} - -type sampleServiceImpl struct{} - -func (s *sampleServiceImpl) Greet(name string) string { - return "Hello, " + name -} - -func TestContextProvideAndInject(t *testing.T) { - ctx := core.NewContext(context.Background()) - core.Provide[SampleService](ctx, &sampleServiceImpl{}) - - svc, err := core.Inject[SampleService](ctx) - require.NoError(t, err) - assert.Equal(t, "Hello, Wavelet", svc.Greet("Wavelet")) -} - -func TestContextUsing(t *testing.T) { - ctx := core.NewContext(context.Background()) - var called bool - - err := core.Using(ctx, func(s SampleService) { - called = true - assert.Equal(t, "Hello, Cordis", s.Greet("Cordis")) - }) - assert.Error(t, err, "service not ready yet") - assert.False(t, called) - - core.Provide[SampleService](ctx, &sampleServiceImpl{}) - err = core.Using(ctx, func(s SampleService) { - called = true - assert.Equal(t, "Hello, Cordis", s.Greet("Cordis")) - }) - assert.NoError(t, err) - assert.True(t, called) -} -``` - -- [ ] **Step 2: 运行测试验证失败** - -Run: `go test -v ./core` -Expected: FAIL with compilation error (package not found). - -- [ ] **Step 3: 实现 Core 核心接口与泛型容器** - -编写 `core/types.go`、`core/manifest.go`、`core/container.go`、`core/context.go`,提供基于反射与类型推导的安全泛型服务存取、Scope 隔离与 Disposer 回调链。 - -- [ ] **Step 4: 运行测试验证通过** - -Run: `go test -v ./core` -Expected: PASS - -- [ ] **Step 5: 提交 Task 1 代码** - -```bash -git add core/ -git commit -m "feat(core): implement context service hub and generic ioc container" -``` - ---- - -### Task 2: 领域扩展点规范与强类型 EventBus (`core/extpoints/`, `core/events.go`) - -**Files:** -- Create: `core/events.go` -- Create: `core/extpoints/router.go` -- Create: `core/extpoints/migration.go` -- Create: `core/extpoints/task.go` -- Create: `core/extpoints/schedule.go` -- Create: `core/extpoints/setting.go` -- Test: `core/events_test.go` -- Test: `core/extpoints/extpoints_test.go` - -**Interfaces:** -- Consumes: `core.Context` -- Produces: `core.EventBus`, `core.RouterExtension`, `core.MigrationExtension`, `core.TaskExtension`, `core.ScheduleExtension`, `core.SettingExtension` - -- [ ] **Step 1: 编写 EventBus 与扩展点测试用例** - -```go -// core/events_test.go -package core_test - -import ( - "context" - "testing" - "github.com/stretchr/testify/assert" - "github.com/Rain-kl/Wavelet/core" -) - -type UserRegisteredEvent struct { - UserID string -} - -func TestEventBusPublishSubscribe(t *testing.T) { - bus := core.NewEventBus() - var receivedID string - - bus.On("user:registered", func(ctx context.Context, e UserRegisteredEvent) error { - receivedID = e.UserID - return nil - }) - - err := bus.Emit(context.Background(), "user:registered", UserRegisteredEvent{UserID: "u_999"}) - assert.NoError(t, err) - assert.Equal(t, "u_999", receivedID) -} -``` - -- [ ] **Step 2: 运行测试验证失败** - -Run: `go test -v ./core/...` -Expected: FAIL - -- [ ] **Step 3: 实现 EventBus 与 6 大扩展点适配器** - -编写 `core/events.go` 及 `core/extpoints/` 下各个领域的挂载收集器(Router 注册收集、Goose embed.FS 聚合器、Task/Schedule 声明表、Setting 模式注册表)。 - -- [ ] **Step 4: 运行测试验证通过** - -Run: `go test -v ./core/...` -Expected: PASS - -- [ ] **Step 5: 提交 Task 2 代码** - -```bash -git add core/ -git commit -m "feat(core): add typed eventbus and domain extension points" -``` - ---- - -### Task 3: 运行时驱动插件下沉 (`plugins/drivers/`) - -**Files:** -- Create: `plugins/drivers/driver_http/plugin.go` -- Create: `plugins/drivers/driver_asynq_worker/plugin.go` -- Create: `plugins/drivers/driver_asynq_cron/plugin.go` -- Test: `plugins/drivers/drivers_test.go` - -**Interfaces:** -- Consumes: `core.Plugin`, `core.Driver`, `core.Context` -- Produces: `DriverTypeHTTP`, `DriverTypeWorker`, `DriverTypeScheduler` - -- [ ] **Step 1: 编写 Driver 生命周期测试用例** - -测试驱动在接收到 `Start(ctx)` 和 `Stop(ctx)` 信号时的平滑启动与退出状态。 - -- [ ] **Step 2: 编写 Driver 实现** - -将 Gin HTTP Server、Asynq Worker Server、Asynq Scheduler 封装为标准 `core.Driver`,并在 `Apply(ctx)` 时挂载到 Context 驱动树。 - -- [ ] **Step 3: 运行驱动单元测试** - -Run: `go test -v ./plugins/drivers/...` -Expected: PASS - -- [ ] **Step 4: 提交 Task 3 代码** - -```bash -git add plugins/drivers/ -git commit -m "feat(plugins): implement runtime drivers for http, asynq worker, and cron" -``` - ---- - -### Task 4: 基础设施服务插件化 (`plugins/infra/`) - -**Files:** -- Create: `plugins/infra/database/plugin.go` (提供 GORM DBService) -- Create: `plugins/infra/cache/plugin.go` (提供 RAM/Redis 三层缓存) -- Create: `plugins/infra/logger/plugin.go` (提供 Zap/Otel 结构化日志) -- Create: `plugins/infra/storage/plugin.go` (提供统一对象存储) -- Test: `plugins/infra/infra_test.go` - -**Interfaces:** -- Produces: `contracts.DBService`, `contracts.CacheService`, `contracts.LoggerService`, `contracts.StorageService` - -- [ ] **Step 1: 编写基础设施插件注入与提取测试** -- [ ] **Step 2: 实现 4 大基础设施插件并封装现有 pkg 与 infra 底座** -- [ ] **Step 3: 运行基础设施测试验证** - -Run: `go test -v ./plugins/infra/...` -Expected: PASS - -- [ ] **Step 4: 提交 Task 4 代码** - -```bash -git add plugins/infra/ -git commit -m "feat(plugins): package database, cache, logger, and storage as infra plugins" -``` - ---- - -### Task 5: 业务领域插件化重构 (`plugins/domain/`) - -**Files:** -- Create: `plugins/domain/auth/` (认证、Session、Passkey、专属 migrations) -- Create: `plugins/domain/user/` (用户资料、角色权限、专属 migrations) -- Create: `plugins/domain/message_gateway/` (Bot网关、推送通道、Worker消费) -- Create: `plugins/domain/risk_control/` (IP限流、风控中间件) -- Create: `plugins/domain/admin/` (控制台、系统设置) -- Test: `plugins/domain/domain_test.go` - -**Interfaces:** -- Consumes: `contracts.DBService`, `contracts.CacheService`, `contracts.LoggerService` -- Produces: `contracts.AuthService`, `contracts.UserService` - -- [ ] **Step 1: 编写 Auth 与 User 插件业务装配与独立迁移测试** -- [ ] **Step 2: 将各业务模块迁移为扁平自包含插件,嵌入专属 Goose SQL 迁移** -- [ ] **Step 3: 运行业务插件集成测试** - -Run: `go test -v ./plugins/domain/...` -Expected: PASS - -- [ ] **Step 4: 提交 Task 5 代码** - -```bash -git add plugins/domain/ -git commit -m "feat(plugins): migrate auth, user, message_gateway, risk_control, admin to domain plugins" -``` - ---- - -### Task 6: 统一装配入口与运行时切面分发器 (`core/app.go`, `cmd/`) - -**Files:** -- Create: `core/app.go` -- Modify: `internal/cmd/root.go` -- Modify: `internal/cmd/api.go` -- Modify: `internal/cmd/worker.go` -- Modify: `internal/cmd/scheduler.go` -- Modify: `internal/cmd/all.go` -- Test: `core/app_test.go` - -**Interfaces:** -- Consumes: `core.App`, `core.Plugin`, `core.Driver` -- Produces: 统一 CLI 启动与优雅停机流程 - -- [ ] **Step 1: 编写 App 生命周期与 Profile 调度测试** -- [ ] **Step 2: 实现 `core.App` 编排引擎,无缝接入 `wavelet api / worker / schedule / all`** -- [ ] **Step 3: 运行启动与角色切面集成验证** - -Run: `go test -v ./core -run TestAppProfileDispatch` -Expected: PASS - -- [ ] **Step 4: 提交 Task 6 代码** - -```bash -git add core/ internal/cmd/ -git commit -m "feat(core): implement app profile lifecycle dispatcher and wire cli commands" -``` - ---- - -### Task 7: 下游脚手架、自定义示例插件与端到端验证 - -**Files:** -- Create: `downstream/custom_plugins/order/plugin.go` -- Create: `downstream/main.go` -- Create: `downstream/config.yaml` -- Test: `downstream/e2e_test.go` - -- [ ] **Step 1: 编写下游自定义业务插件并在下游 `main.go` 组装启动** -- [ ] **Step 2: 执行全量 E2E 测试,验证数据迁移、HTTP 路由访问、Worker 任务消费与平滑停机** -- [ ] **Step 3: 运行全局质量门禁检查** - -Run: -```bash -make test -make code-check -make format -``` -Expected: 全部 PASS,0 lint 报错。 - -- [ ] **Step 4: 提交 Task 7 代码** - -```bash -git add downstream/ -git commit -m "feat(downstream): add starter scaffold, example custom plugin, and e2e tests" -``` - diff --git a/docs/superpowers/plans/2026-08-28-cordis-architecture-alignment.md b/docs/superpowers/plans/2026-08-28-cordis-architecture-alignment.md deleted file mode 100644 index d3ea6bb8..00000000 --- a/docs/superpowers/plans/2026-08-28-cordis-architecture-alignment.md +++ /dev/null @@ -1,221 +0,0 @@ -# Cordis Architecture Alignment & Refactoring 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:** Implement Cordis spatiotemporal composability (scoped revertible effects and reactive fiber lifecycle state machine) in `backend/core`, and eliminate cross-plugin direct imports in domain repositories. - -**Architecture:** -1. Build scoped extension proxies on `core.Context` that automatically attach unregister callbacks to `ctx.OnDispose` in LIFO order upon registration. -2. Introduce `core/fiber.go` implementing the Fiber state machine (`PENDING -> LOADING -> ACTIVE -> UNLOADING -> DISPOSED`) with a reactive reconciler in `App`/`Container` ensuring dependency confluence. -3. Clean up defensive boundaries in `backend/plugins/domain/user` by removing direct `database.DB(ctx)` imports in favor of `contracts.DBService`. - -**Tech Stack:** Go 1.24+, GORM, Gin, Asynq, Cordis micro-kernel paradigm. - -## Global Constraints - -- Strictly preserve `backend/pkg/util/` purity (no Gin/GORM imports). -- Zero physically hardcoded temp directories in tests (use `t.TempDir()`). -- All Go error returns and logging must adhere to project standards. -- Follow Conventional Commits (`feat(core): ...`, `refactor(user): ...`). - ---- - -### Task 1: Scoped Revertible Effects for Core Extpoints - -**Files:** -- Create: `backend/core/scoped_extpoints.go` -- Modify: `backend/core/context.go` -- Modify: `backend/core/extpoints/task.go` -- Modify: `backend/core/extpoints/schedule.go` -- Modify: `backend/core/extpoints/setting.go` -- Test: `backend/core/context_test.go` - -**Interfaces:** -- Consumes: `core.Context`, `extpoints.RouterExtension`, `extpoints.TaskExtension`, `extpoints.ScheduleExtension`, `extpoints.SettingExtension`, `core.EventBus` -- Produces: Scoped extension methods on `Context` that automatically register LIFO disposers when routes, tasks, schedules, settings, and events are registered. - -- [ ] **Step 1: Write the failing test for scoped extpoints automatic teardown** - -In `backend/core/context_test.go`, add test cases verifying that registering routes, tasks, schedules, settings, and event listeners on a child context automatically registers unregister callbacks, and calling `childCtx.Dispose()` completely rolls them back: - -```go -func TestContext_ScopedExtpoints_RevertibleEffects(t *testing.T) { - root := NewContext(context.Background()) - child := root.Fork() - - // Register route, task, schedule, setting, event on child - rd := child.Router().GET("/test-route", func() {}) - assert.Equal(t, 1, len(root.Router().Routes())) - - child.Events().On("test:event", func() {}) - assert.Equal(t, 1, root.Events().Listeners("test:event")) - - // Dispose child - err := child.Dispose() - assert.NoError(t, err) - - // All child effects should be revoked - assert.Equal(t, 0, len(root.Router().Routes())) - assert.Equal(t, 0, root.Events().Listeners("test:event")) -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test -v ./backend/core -run TestContext_ScopedExtpoints_RevertibleEffects` -Expected: FAIL (because current `Router().GET()` does not bind unregistration to `child.OnDispose`). - -- [ ] **Step 3: Implement Scoped Extpoints and Context bindings** - -1. In `backend/core/extpoints/task.go`, ensure `Unregister(taskType string) bool` exists. -2. In `backend/core/extpoints/schedule.go`, ensure `Unregister(name string) bool` exists. -3. In `backend/core/extpoints/setting.go`, ensure `Unregister(key string) bool` exists. -4. In `backend/core/scoped_extpoints.go` (or `context.go`), create scoped wrappers for `RouterExtension`, `TaskExtension`, `ScheduleExtension`, `SettingExtension` and `EventBus` that tie registrations to `ctx.OnDispose`. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test -v ./backend/core -run TestContext_ScopedExtpoints_RevertibleEffects` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add backend/core/ -git commit -m "feat(core): implement scoped revertible effects for context extpoints" -``` - ---- - -### Task 2: Plugin Fiber State Machine and Reactive Coeffects (Confluence) - -**Files:** -- Create: `backend/core/fiber.go` -- Create: `backend/core/fiber_test.go` -- Modify: `backend/core/app.go` -- Modify: `backend/core/types.go` -- Modify: `backend/core/container.go` - -**Interfaces:** -- Consumes: `core.Plugin`, `core.Context`, `core.Container` -- Produces: `core.DependentPlugin`, `core.Fiber`, `core.FiberState`, `App.Reconcile()` - -- [ ] **Step 1: Write the failing test for Fiber state machine and out-of-order registration confluence** - -In `backend/core/fiber_test.go`: - -```go -func TestFiber_ConfluenceAndReactiveActivation(t *testing.T) { - app := NewApp() - - // Plugin B depends on contracts.DBService, but is registered BEFORE DatabasePlugin (Plugin A) - pluginB := &mockDependentPlugin{ - name: "plugin-b", - deps: []reflect.Type{reflect.TypeFor[contracts.DBService]()}, - } - pluginA := &mockDBPlugin{name: "database"} - - app.Use(pluginB, pluginA) - - err := app.Start(context.Background()) - assert.NoError(t, err) - - // Verify both plugins reached FiberActive state and B executed Apply successfully after A provided DBService - assert.True(t, pluginB.applied) - assert.True(t, pluginA.applied) - - _ = app.Stop() -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test -v ./backend/core -run TestFiber_ConfluenceAndReactiveActivation` -Expected: FAIL (because current `app.ApplyPlugins()` applies in static slice order without dependency reconciliation). - -- [ ] **Step 3: Implement Fiber State Machine and Reconciler** - -1. In `backend/core/types.go`, declare: -```go -type DependentPlugin interface { - Plugin - Inject() []reflect.Type -} -``` -2. In `backend/core/fiber.go`, implement `Fiber` with states (`FiberPending`, `FiberLoading`, `FiberActive`, `FiberUnloading`, `FiberDisposed`), child scoped context, and state transition methods. -3. In `backend/core/app.go`, integrate Fibers into `App` and implement iterative dependency reconciliation during `ApplyPlugins` and on dynamic `Provide`. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test -v ./backend/core` -Expected: ALL PASS - -- [ ] **Step 5: Commit** - -```bash -git add backend/core/ -git commit -m "feat(core): implement plugin fiber state machine and reactive dependency reconciler" -``` - ---- - -### Task 3: Domain Plugin Isolation & Boundary Enforcement - -**Files:** -- Modify: `backend/plugins/domain/user/repository.go` -- Modify: `backend/plugins/domain/user/service.go` -- Modify: `backend/plugins/domain/user/handlers.go` -- Modify: `backend/plugins/domain/user/plugin.go` -- Test: `backend/plugins/domain/user/plugin_test.go` - -**Interfaces:** -- Consumes: `contracts.DBService` via `core.Inject` / `ctx.DB()` -- Produces: Decoupled User repository without direct `Wavelet/plugins/infra/database` imports. - -- [ ] **Step 1: Write/update test verifying User repository works with injected DBService** - -In `backend/plugins/domain/user/plugin_test.go`, test user CRUD operations resolving `contracts.DBService` through Context. - -- [ ] **Step 2: Run test to verify current state** - -Run: `go test -v ./backend/plugins/domain/user/...` - -- [ ] **Step 3: Refactor user repository to eliminate direct `plugins/infra/database` imports** - -In `backend/plugins/domain/user/repository.go`: -- Remove `import "Wavelet/plugins/infra/database"`. -- Obtain `*gorm.DB` via `ctx` (e.g. from context using `contracts.DBService` or context value). - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test -v ./backend/plugins/domain/user/...` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add backend/plugins/domain/user/ -git commit -m "refactor(user): decouple repository from direct database infra import" -``` - ---- - -### Task 4: Full Suite Verification & Quality Gate - -**Files:** -- Entire repository - -- [ ] **Step 1: Run all backend tests** - -Run: `cd backend && go test -v ./...` -Expected: ALL PASS - -- [ ] **Step 2: Run code-check and format** - -Run: `make code-check && make format` -Expected: 0 lint errors, clean formatting. - -- [ ] **Step 3: Commit any formatting or lint fixes** - -```bash -git commit -am "chore: format and verify code quality" -``` diff --git a/docs/superpowers/plans/2026-08-28-cordis-architecture-refactor.md b/docs/superpowers/plans/2026-08-28-cordis-architecture-refactor.md deleted file mode 100644 index 489731dd..00000000 --- a/docs/superpowers/plans/2026-08-28-cordis-architecture-refactor.md +++ /dev/null @@ -1,120 +0,0 @@ -# Cordis 架构重构实施计划 (Cordis Architecture Refactor 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:** 依据 Cordis 时空可组合性元框架,彻底消除 Wavelet 后端的包级静态单例、`init()` 隐式副作用建连以及跨插件私有实现依赖,实现微内核纯洁化与契约驱动解耦。 - -**Architecture:** -1. 移除 `backend/core/context.go` 中的特权服务快捷方法(`DB()` / `Cache()`)。 -2. 将 `infra/database` 与 `infra/cache` 的连接初始化移至 `Plugin.Apply(ctx)`,并在 `ctx.OnDispose` 中注册 LIFO 逆操作(Close)。 -3. 重构全部 8 个 Domain 业务插件(`auth`、`user`、`admin`、`cap`、`message_gateway`、`risk_control`、`system`、`upload`),彻底斩断对 `infra/database`、`infra/cache` 及其他插件内部包的直接 import,统一面向 `contracts.DBService` / `contracts.CacheService`。 -4. 清除 `admin` 等插件的包级全局变量。 - -**Tech Stack:** Go 1.24+, GORM, Redis (go-redis/v9), Cordis micro-kernel, Goose migration. - -## Global Constraints - -- 严禁任何业务插件跨包 import `Wavelet/plugins/infra/database` 或 `Wavelet/plugins/infra/cache`。 -- 严禁跨插件 import 私有实现包(如 `admin` import `risk_control/logstore`)。 -- 保持 `backend/pkg/util/` 绝对纯净,禁止导入 Web/数据库框架。 -- 重构后必须确保 `go test ./...`、`make code-check` 与 `make format` 全部 0 错误通过。 - ---- - -### Task 1: 微内核纯洁化 (`backend/core/`) - -**Files:** -- Modify: `backend/core/context.go:240-260` -- Test: `backend/core/context_test.go` - -**Interfaces:** -- Consumes: `core.Context`, `core.Inject` -- Produces: 纯净无特权方法的 `core.Context` - -- [ ] **Step 1: 编写/更新 Context 纯洁性测试** -- [ ] **Step 2: 移除 `Context.DB()` 与 `Context.Cache()` 方法** -- [ ] **Step 3: 运行 `go test ./backend/core/...` 验证通过** - ---- - -### Task 2: 基础设施插件生命周期可逆化 (`backend/plugins/infra/`) - -**Files:** -- Modify: `backend/plugins/infra/database/postgres.go` -- Modify: `backend/plugins/infra/database/plugin.go` -- Modify: `backend/plugins/infra/cache/redis.go` -- Modify: `backend/plugins/infra/cache/plugin.go` -- Test: `backend/plugins/infra/infra_test.go` - -**Interfaces:** -- Consumes: `core.Plugin`, `contracts.DBService`, `contracts.CacheService` -- Produces: `contracts.DBService` 与 `contracts.CacheService`(带 `ctx.OnDispose` 逆操作) - -- [ ] **Step 1: 移除 `infra/database` 中的 `func init()` 及全局 `var db`,在 `Plugin.Apply` 中建连并注册 `ctx.OnDispose(sqlDB.Close)`** -- [ ] **Step 2: 移除 `infra/cache` 中的 `func init()` 及全局 `var Redis`,在 `Plugin.Apply` 中建连并注册 `ctx.OnDispose(client.Close)`** -- [ ] **Step 3: 运行 `go test ./backend/plugins/infra/...` 验证通过** - ---- - -### Task 3: 核心 Domain 插件防线重塑(Auth & User 插件) - -**Files:** -- Modify: `backend/plugins/domain/auth/*` -- Modify: `backend/plugins/domain/user/*` -- Test: `backend/plugins/domain/auth/plugin_test.go` -- Test: `backend/plugins/domain/user/plugin_test.go` - -**Interfaces:** -- Consumes: `contracts.DBService`, `contracts.CacheService` -- Produces: `contracts.AuthService`, `contracts.UserService` - -- [ ] **Step 1: 移除 `auth` 插件中对 `Wavelet/plugins/infra/database` 和 `cache` 的 import,改用插件持有的 `contracts.DBService` 与 `contracts.CacheService`** -- [ ] **Step 2: 移除 `user` 插件中对 `Wavelet/plugins/infra/database` 和 `cache` 的 import,改用 `contracts.DBService` 与 `contracts.CacheService`** -- [ ] **Step 3: 运行 `go test ./backend/plugins/domain/auth/... ./backend/plugins/domain/user/...` 验证通过** - ---- - -### Task 4: 业务 Domain 插件防线重塑(Cap, MessageGateway, RiskControl, System, Upload) - -**Files:** -- Modify: `backend/plugins/domain/cap/*` -- Modify: `backend/plugins/domain/message_gateway/*` -- Modify: `backend/plugins/domain/risk_control/*` -- Modify: `backend/plugins/domain/system/*` -- Modify: `backend/plugins/domain/upload/*` -- Test: `backend/plugins/domain/domain_test.go` - -**Interfaces:** -- Consumes: `contracts.DBService`, `contracts.CacheService` - -- [ ] **Step 1: 改造 `cap`、`message_gateway`、`risk_control`、`system`、`upload` 插件,移除所有 `infra/database` 和 `infra/cache` 的直接 import** -- [ ] **Step 2: 统一各插件内部 Repository / Service 的 DB / Cache 获取途径** -- [ ] **Step 3: 运行各插件单测验证通过** - ---- - -### Task 5: Admin 插件解耦与包级全局状态清除 - -**Files:** -- Modify: `backend/plugins/domain/admin/*` -- Test: `backend/plugins/domain/admin/plugin_test.go` - -**Interfaces:** -- Consumes: `contracts.DBService`, `contracts.CacheService`, `contracts.UserService`, `contracts.AuthService`, `ctx.Tasks()` - -- [ ] **Step 1: 移除 `admin` 插件中对 `risk_control/logstore`、`driver_asynq_worker`、`infra/storage/diskcache` 等私有包的直接 import** -- [ ] **Step 2: 清除 `admin/plugin.go` 中的 `globalUserSvc`、`globalAuthSvc`、`globalCoreCtx` 等包级变量** -- [ ] **Step 3: 运行 `go test ./backend/plugins/domain/admin/...` 验证通过** - ---- - -### Task 6: 组装层对齐与全量质量门禁验证 - -**Files:** -- Modify: `backend/cmd/app.go` -- Modify: `backend/cmd/*` - -- [ ] **Step 1: 检查并适配 `cmd/app.go` 及启动指令,确保 Goose 迁移与驱动正确接入新版 `DBService`** -- [ ] **Step 2: 运行全局跨包 import 检查:`grep -r "Wavelet/plugins/infra/database" backend/plugins/domain/` 必须为空** -- [ ] **Step 3: 运行全量单元测试与基准测试:`go test ./...`** -- [ ] **Step 4: 运行质量门禁:`make code-check && make format`** diff --git a/docs/superpowers/plans/2026-08-28-migration-split-plan.md b/docs/superpowers/plans/2026-08-28-migration-split-plan.md deleted file mode 100644 index 48abc22a..00000000 --- a/docs/superpowers/plans/2026-08-28-migration-split-plan.md +++ /dev/null @@ -1,326 +0,0 @@ -# 迁移脚本拆分执行计划 - -## 背景现状 - -| 维度 | 实际状态 | -|---|---| -| 总 SQL 文件 | 26 个全局 (`pkg/migrator/goose/`) + 5 个插件 (`plugins/domain/*/migrations/`) + 1 个 ClickHouse | -| 实际运行的迁移 | **仅 26 个全局文件**(通过 `gooseEngine` → `pkg/migrator.Migrate()`) | -| 插件注册的迁移 | 3 个 (`auth`, `user`, `message_gateway`) — 注册了但被 `gooseEngine` 丢弃 | -| 有迁移文件但未注册的插件 | `admin`(2 个文件,0 个调用) | -| 无迁移文件的插件 | `upload`, `risk_control`, `cap`, `driver_asynq_worker`, `driver_asynq_cron` | -| ClickHouse 迁移 | 1 个文件 (`w_user_access_logs`),通过 `pkg/migrator.MigrateClickHouse()` 单独运行 | - -## 表所有者映射 - -以下列表基于"单一所有者原则",每个表精确映射到一个插件: - -| 表名 | 所有者插件 | 涉及全局迁移 | -|---|---|---| -| `w_users` | `domain/user` | 20260609 (create), 20260614 (seed system user) | -| `w_access_tokens` | `domain/auth` | 20260609 (create), 20260610 (is_admin), 20260611 (rm last_used_at) | -| `w_auth_sources` | `domain/auth` | 20260609 (create) | -| `w_external_accounts` | `domain/auth` | 20260609 (create) | -| `w_system_configs` | `domain/admin` | 20260609→20260611 (rename+seeds×7), 20260613 (TEXT), 20260816 (log_db) | -| `w_templates` | `domain/admin` | 20260609→20260611 (rename) | -| `w_schedules` | `driver_asynq_cron` | 20260610 (create), 20260611 (identity), 20260614 (update cleanup) | -| `w_task_executions` | `driver_asynq_worker` | 20260609→20260611 (rename) | -| `w_uploads` | `domain/upload` | 20260609→20260611 (rename), 20260613 (access_mode), 20260617 (indexes), 20260618 (drop storage_driver) | -| `w_upload_stats` | `domain/upload` | 20260617 (create+backfill) | -| `w_push_events` | `domain/message_gateway` | 20260614 (create), 20260615 (task_type), 20260616 (cleanup) | -| `w_push_histories` | `domain/message_gateway` | 20260614 (create) | -| `w_push_channels` | `domain/message_gateway` | 20260614 (create) | -| `w_message_channels` | `domain/message_gateway` | 20260816 (create) | -| `w_message_bindings` | `domain/message_gateway` | 20260816 (create) | -| `w_message_pairing_codes` | `domain/message_gateway` | 20260816 (create) | -| `w_user_access_logs` | `domain/risk_control` | 20260816 (create) + ClickHouse | - -## 执行步骤(共 8 步) - ---- - -### 步骤 1:创建 Bootstrap 迁移(保留在 `pkg/migrator`) - -**文件**:`pkg/migrator/goose/postgres/00001_bootstrap.sql` - -将以下全局迁移合并为一个 bootstrap 文件: -- **`202606090001_initial_schema.sql`** → 创建 `users`, `auth_sources`, `external_accounts`, `access_tokens`, `system_configs`, `uploads`, `task_executions`, `templates`(全部无前缀旧名) -- **`202606110003_rename_tables_to_w_prefix.sql`** → 全部重命名为 `w_` 前缀 - -**合并后,bootstrap 文件直接创建带 `w_` 前缀的表**,不再需要 rename 步骤: - -```sql --- +goose Up -CREATE TABLE IF NOT EXISTS w_users ( - id BIGINT PRIMARY KEY, - username VARCHAR(64) UNIQUE, - ... -); -CREATE TABLE IF NOT EXISTS w_access_tokens (...); -CREATE TABLE IF NOT EXISTS w_auth_sources (...); -CREATE TABLE IF NOT EXISTS w_external_accounts (...); -CREATE TABLE IF NOT EXISTS w_system_configs ( - key VARCHAR(64) PRIMARY KEY, - value TEXT NOT NULL, - ... -); -CREATE TABLE IF NOT EXISTS w_uploads (...); -CREATE TABLE IF NOT EXISTS w_task_executions (...); -CREATE TABLE IF NOT EXISTS w_templates (...); -CREATE TABLE IF NOT EXISTS w_schedules (...); -``` - -> **为什么保留在 `pkg/migrator`**:这些是平台的"初始化基座"——无论哪些插件启用,这些表都存在。将 bootstrap 放到 `pkg/migrator` 之下回避了循环依赖问题(例如 `w_system_configs` 属于 admin,但 bootstrap 时 admin 插件尚未 apply)。 - ---- - -### 步骤 2:修复 `gooseEngine` 支持插件迁移 - -**文件**:`cmd/app.go` - -```go -type gooseEngine struct{} - -func (e *gooseEngine) Migrate(_ context.Context, entries []core.MigrationEntry) error { - // 1. 先跑 bootstrap(初始化基座) - _ = migrator.Migrate() - - // 2. 再跑每个插件注册的迁移 - for _, entry := range entries { - gormDB := database.DB(context.Background()) - if gormDB == nil { - continue - } - sqlDB, err := gormDB.DB() - if err != nil { - return err - } - - goose.SetBaseFS(entry.FS) - if err := goose.SetDialect(gooseDialect()); err != nil { - return err - } - dir := entry.Dir - if dir == "" { - dir = "migrations" - } - if err := goose.Up(sqlDB, dir); err != nil { - return fmt.Errorf("migrate %s: %w", entry.PluginID, err) - } - } - - // 3. ClickHouse 迁移 - _ = migrator.MigrateClickHouse() - - return nil -} -``` - -依赖项:`gooseDialect()` 从 `pkg/migrator` 导出。 - ---- - -### 步骤 3:按表所有者拆分迁移到各插件 - -| 全局源文件 | 目标插件 | 迁移文件名 | -|---|---|---| -| `202606100002` (access_tokens is_admin) | `domain/auth` | `migrations/00002_add_access_token_is_admin.sql` | -| `202606110001` (drop last_used_at) | `domain/auth` | `migrations/00003_drop_access_token_last_used_at.sql` | -| `202606140003` (system user seed) | `domain/user` | `migrations/00002_seed_system_user.sql` | -| `202606110004` (file_access_whitelist seed) | `domain/admin` | `migrations/00003_seed_file_access_whitelist.sql` | -| `202606110005` (disk_cache configs seed) | `domain/admin` | `migrations/00004_seed_disk_cache_configs.sql` | -| `202606120002` (update_upstream_repo seed) | `domain/admin` | `migrations/00005_seed_upstream_repo_config.sql` | -| `202606130002` (system_configs value TEXT) | `domain/admin` | `migrations/00006_expand_config_value.sql` | -| `202606130003` (storage_config seed) | `domain/admin` | `migrations/00007_seed_storage_config.sql` | -| `202608160002` (log database configs) | `domain/admin` | `migrations/00008_seed_log_db_configs.sql` | -| `202606120001` (login_session_ttl) | `domain/auth` | `migrations/00004_seed_login_session_ttl.sql` | -| `202606130001` (w_uploads access_mode) | `domain/upload` | `migrations/00001_add_access_mode.sql` | -| `202606170001` (upload indexes) | `domain/upload` | `migrations/00002_add_composite_indexes.sql` | -| `202606170002` (upload stats table) | `domain/upload` | `migrations/00003_create_upload_stats.sql` | -| `202606170003` (backfill stats) | `domain/upload` | `migrations/00004_backfill_upload_stats.sql` | -| `202606180001` (drop storage_driver) | `domain/upload` | `migrations/00005_drop_storage_driver.sql` | -| `202606140001` (push tables) | `domain/message_gateway` | `migrations/00002_create_push_tables.sql` | -| `202606140004` (push channels) | `domain/message_gateway` | `migrations/00003_create_push_channels.sql` | -| `202606150001` (push task_type) | `domain/message_gateway` | `migrations/00004_add_push_task_type.sql` | -| `202606160001` (remove push config) | `domain/message_gateway` | `migrations/00005_remove_push_config.sql` | -| `202608160003` (message gateway tables) | `domain/message_gateway` | `migrations/00006_create_message_tables.sql` | -| `202606100001` (schedules) | `driver_asynq_cron` | `migrations/00001_create_schedules.sql` | -| `202606110002` (schedules identity) | `driver_asynq_cron` | `migrations/00002_alter_schedules_identity.sql` | -| `202606140005` (update cleanup schedule) | `driver_asynq_cron` | `migrations/00003_update_cleanup_schedule.sql` | -| `202608160001` (user access logs) | `domain/risk_control/logstore` | `migrations/00001_create_access_logs.sql` | -| `202608160002` (log_database configs) | `domain/admin` | (合并到 admin 步骤 7) | - ---- - -### 步骤 4:补充缺失的 `go:embed` 和 `Register()` 调用 - -**`plugins/domain/admin/plugin.go`**: -```go -//go:embed migrations/*.sql -var adminMigrations embed.FS - -// 在 Apply() 中: -ctx.Migrations().Register("admin", adminMigrations) -``` - -**`plugins/domain/upload/plugin.go`**: -```go -//go:embed migrations/*.sql -var uploadMigrations embed.FS - -// 在 Apply() 中: -ctx.Migrations().Register("upload", uploadMigrations) -``` - -**`plugins/domain/risk_control/plugin.go`**: -```go -// go:embed 由 logstore 子包自行处理(它已有自己的 moved 文件) -// 在 Apply() 中: -ctx.Migrations().Register("risk_control/logstore", logstoreMigrationFS) -``` - -**`plugins/drivers/driver_asynq_cron/plugin.go`**: -```go -//go:embed migrations/*.sql -var cronMigrations embed.FS - -// 在 Apply() 中: -ctx.Migrations().Register("driver_asynq_cron", cronMigrations) -``` - ---- - -### 步骤 5:解决 Admin 插件迁移与 Bootstrap 的冲突 - -当前 `admin/migrations/00001` 执行 `CREATE TABLE IF NOT EXISTS w_system_configs (...)`,但 bootstrap 已在步骤 1 中创建过这张表。需要: -1. **保持 `IF NOT EXISTS`** 保证幂等性 -2. **从 admin migration 中移除 `w_schedules` 和 `w_task_executions` 的 CREATE**(它们在 bootstrap 中创建,属于 driver 插件) -3. **仅保留 admin 自己的表**:`w_system_configs`, `w_templates` -4. Seed 数据使用 `ON CONFLICT DO NOTHING` 避免重复: - -当前 admin 的 seed 包含 29 个系统配置,其中约 14 个与全局迁移重复。整理后的 admin seed 应: - -```sql -INSERT INTO w_system_configs (...) VALUES - ('cap_login_enabled', 'false', ...), - ('cap_auto_solve', 'true', ...), - -- ... (所有 29 个配置) -ON CONFLICT (key) DO NOTHING; -``` - -> 全局迁移中 `202606110004` 到 `202608160002` 的 7 个种子 INSERT 将被迁移到 admin,全部使用 `ON CONFLICT DO NOTHING`。 - ---- - -### 步骤 6:清理已迁移的全局文件 - -拆分完成后,从 `pkg/migrator/goose/postgres/` 中删除以下文件: - -``` -202606100002_access_token_is_admin.sql -202606100001_create_schedules.sql -202606110001_remove_access_token_last_used_at.sql -202606110002_alter_schedules_id_auto_increment.sql -202606110004_add_file_access_whitelist_config.sql -202606110005_add_disk_cache_configs.sql -202606120001_add_login_session_ttl_config.sql -202606120002_add_update_upstream_repository_config.sql -202606130001_add_upload_access_mode.sql -202606130002_expand_system_config_value.sql -202606130003_add_storage_config.sql -202606140001_create_push_tables.sql -202606140003_add_system_user.sql -202606140004_create_push_channels.sql -202606140005_update_system_cleanup_schedule.sql -202606150001_add_task_type_to_push_events.sql -202606160001_remove_push_config.sql -202606170001_add_upload_composite_indexes.sql -202606170002_create_upload_stats_table.sql -202606170003_backfill_upload_stats.sql -202606180001_drop_upload_storage_driver.sql -202608160001_create_user_access_logs.sql -202608160002_log_database_configs.sql -202608160003_create_message_gateway.sql -``` - -**保留在 `pkg/migrator/goose/postgres/` 的仅限**: -``` -00001_bootstrap.sql (合并后的初始化基座) -``` - -**注意**:`202606110003_rename_tables_to_w_prefix.sql` 也被合并进 bootstrap。`202606090001_initial_schema.sql` 也被合并掉。 - ---- - -### 步骤 7:更新 `pkg/migrator` 导出 `gooseDialect()` - -在 `pkg/migrator/migrator.go` 中将 `gooseDialect()` 和 `migrationDir()` 改为导出,供 `cmd/app.go` 的 `gooseEngine.Migrate()` 引用。 - ---- - -### 步骤 8:验证 + 提交 - -```bash -cd /Users/ryan/Code/Go/Wavelet - -# 1. 编译验证 -go build -mod=mod ./... -go vet ./... - -# 2. 架构门禁验证 -make code-check - -# 3. 验证插件迁移注册完整性 -grep -rn 'go:embed.*migrations' plugins/domain/*/plugin.go plugins/drivers/*/plugin.go -grep -rn 'Migrations()\.Register' plugins/domain/*/plugin.go plugins/drivers/*/plugin.go -# → 每个有 migrations/ 目录的插件既要有 go:embed 又要有 Register() - -# 4. 验证 admin 插件迁移完整性 -grep -rn 'w_schedules\|w_task_executions' plugins/domain/admin/migrations/ -# → 不应有(这些属于 driver 插件) - -# 5. 提交 -git add -A && git commit -m "refactor(migration): split global SQL into per-plugin migrations - -- Merge 26 global SQLs into bootstrap + per-plugin migrations -- Fix gooseEngine to iterate plugin-registered MigrationEntry -- Add go:embed + Register() to admin, upload, risk_control, driver_asynq_cron -- Remove 23 migrated SQL files from pkg/migrator/goose/ -- Keep only bootstrap in pkg/migrator/goose/ -- All CREATE TABLE use IF NOT EXISTS, all INSERT use ON CONFLICT DO NOTHING" -``` - ---- - -## 依赖关系图 - -``` -Bootstrap (pkg/migrator) - ├── 创建 w_users, w_access_tokens, w_auth_sources, w_external_accounts - ├── 创建 w_system_configs, w_templates, w_schedules, w_task_executions - ├── 创建 w_uploads, w_upload_stats - └── 创建所有 w_ 前缀表 - │ - ├─ auth/00002 (access_tokens is_admin) - ├─ auth/00003 (drop last_used_at) - ├─ auth/00004 (login_session_ttl seed) - │ - ├─ user/00002 (system user seed) - │ - ├─ admin/00001 (w_system_configs, w_templates) [IF NOT EXISTS] - ├─ admin/00002 (29 config seeds + 2 template seeds) - ├─ admin/00003–00008 (拆分后的种子迁移) - │ - ├─ upload/00001–00005 (access_mode → indexes → stats → backfill → drop) - │ - ├─ message_gateway/00001 (w_message_* tables) - ├─ message_gateway/00002–00006 (push tables → channels → task_type → cleanup) - │ - ├─ driver_asynq_cron/00001–00003 (schedules → identity → cleanup) - │ - ├─ driver_asynq_worker/00001 (task_executions — 如果有追加操作) - │ - └─ risk_control/logstore/00001 (w_user_access_logs) -``` - -所有步骤执行的迁移顺序由 Goose 的文件名前缀控制。Bootstrap 使用 `00001_`,每个插件的迁移从 `00002_` 开始编号(`00001` 留给插件自身表 CREATE,若插件 bootstrap 已创建则从 `00002` 开始)。 \ No newline at end of file diff --git a/docs/superpowers/plans/2026-08-28-zero-redis-pluggable-architecture.md b/docs/superpowers/plans/2026-08-28-zero-redis-pluggable-architecture.md deleted file mode 100644 index 90de7a1e..00000000 --- a/docs/superpowers/plans/2026-08-28-zero-redis-pluggable-architecture.md +++ /dev/null @@ -1,84 +0,0 @@ -# Zero-Redis Pluggable Architecture Implementation Plan - -> **Goal**: Extract Redis into optional plugins and introduce lightweight in-process equivalents (`cache_memory`, `driver_inproc_worker`, `driver_inproc_cron`), enabling zero-Redis monolithic and embedded deployment modes. - -- **Architecture Spec**: [`docs/superpowers/specs/2026-08-28-zero-redis-pluggable-architecture-design.md`](file:///Users/ryan/Code/Go/Wavelet/docs/superpowers/specs/2026-08-28-zero-redis-pluggable-architecture-design.md) -- **Branch**: `main` - ---- - -## Proposed Changes - -### 1. In-Memory Cache Infrastructure Plugin (`backend/plugins/infra/cache_memory`) - -#### [NEW] `backend/plugins/infra/cache_memory/plugin.go` -- Implements `core.Plugin` (`Name() == "cache_memory"`). -- Applies `contracts.CacheService` to the Context via `core.Provide[contracts.CacheService](ctx, memCacheSvc)`. - -#### [NEW] `backend/plugins/infra/cache_memory/cache.go` -- Implements `contracts.CacheService` using `pkg/cache/ram`. -- Dispatches in-process invalidation notifications via `ctx.Events().Emit("cache:invalidate", key)`. - -#### [NEW] `backend/plugins/infra/cache_memory/plugin_test.go` -- Unit tests for Get, Set, Delete, GetOrSet, TTL expiration, and event bus emission. - ---- - -### 2. In-Process Async Worker Driver (`backend/plugins/drivers/driver_inproc_worker`) - -#### [NEW] `backend/plugins/drivers/driver_inproc_worker/plugin.go` -- Implements `core.Plugin` & `core.Driver` (`Type() == core.DriverTypeWorker`). -- Scans and executes registered tasks from `ctx.Tasks().Tasks()`. - -#### [NEW] `backend/plugins/drivers/driver_inproc_worker/executor.go` -- In-memory buffered channel queue and worker goroutine pool managed via `util.Go`. -- Supports execution timeout, retry with backoff, and graceful shutdown. - -#### [NEW] `backend/plugins/drivers/driver_inproc_worker/plugin_test.go` -- Unit tests for in-process task execution, concurrency limit, retry on error, and graceful shutdown. - ---- - -### 3. In-Process Cron Scheduler Driver (`backend/plugins/drivers/driver_inproc_cron`) - -#### [NEW] `backend/plugins/drivers/driver_inproc_cron/plugin.go` -- Implements `core.Plugin` & `core.Driver` (`Type() == core.DriverTypeScheduler`). -- Reads `ctx.Schedules().Schedules()` and schedules jobs using `robfig/cron/v3`. - -#### [NEW] `backend/plugins/drivers/driver_inproc_cron/scheduler.go` -- Handles Cron expression registration, job triggering, and graceful stopping. - -#### [NEW] `backend/plugins/drivers/driver_inproc_cron/plugin_test.go` -- Unit tests verifying cron job scheduling, execution tracking, and stop behavior. - ---- - -### 4. Admin Domain Decoupling from Redis - -#### [MODIFY] `backend/plugins/domain/admin/repository.go` -- Introduce in-memory `RingBuffer` for task output streams when Redis is nil. -- Fallback task log lookups to `RingBuffer` and `w_task_executions` table. - -#### [MODIFY] `backend/plugins/domain/admin/system_config_cache.go` -- Guard Redis PubSub listener so that when Redis is nil, it gracefully falls back to local event bus updates without spawning disconnected subscriber loops. - ---- - -### 5. Application Assembly & Profile Switching - -#### [MODIFY] `backend/cmd/app.go` -- Switch dynamically between Redis plugins (`cache`, `driver_asynq_worker`, `driver_asynq_cron`) and In-Process plugins (`cache_memory`, `driver_inproc_worker`, `driver_inproc_cron`) based on `config.Config.Redis.Enabled`. - -#### [MODIFY] `backend/cmd/app_test.go` -- Add test verifying application bootstrap in both `Redis.Enabled = true` and `Redis.Enabled = false` states. - ---- - -## Verification Plan - -### Automated Tests -1. **In-Memory Cache Tests**: `go test -v ./backend/plugins/infra/cache_memory/...` -2. **In-Process Worker Tests**: `go test -v ./backend/plugins/drivers/driver_inproc_worker/...` -3. **In-Process Cron Tests**: `go test -v ./backend/plugins/drivers/driver_inproc_cron/...` -4. **Full Test Suite**: `cd backend && go test ./...` -5. **Quality Gate**: `make code-check && make format` diff --git a/docs/superpowers/plans/2026-08-29-cordis-config-extension.md b/docs/superpowers/plans/2026-08-29-cordis-config-extension.md deleted file mode 100644 index 35e6a2ca..00000000 --- a/docs/superpowers/plans/2026-08-29-cordis-config-extension.md +++ /dev/null @@ -1,2585 +0,0 @@ -# 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 = "" - -// 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("", &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`)。 diff --git a/docs/superpowers/specs/2026-08-27-cordis-downstream-developer-guide.md b/docs/superpowers/specs/2026-08-27-cordis-downstream-developer-guide.md deleted file mode 100644 index 7547c863..00000000 --- a/docs/superpowers/specs/2026-08-27-cordis-downstream-developer-guide.md +++ /dev/null @@ -1,595 +0,0 @@ -# Wavelet Cordis 插件化架构实战开发指南与标准规范 - -- **文档类型**: 下游开发者手册 / 架构实战指南 (Cookbook & Architecture Reference) -- **目标受众**: 官方插件开发者、下游业务二开工程师、架构师 -- **版本**: v1.0.0 (2026-08-27) - ---- - -# 目录 -- [第一部分:下游项目实战开发指南与 22 个高频开发场景解答](#第一部分下游项目实战开发指南与-22-个高频开发场景解答) - - [场景 1:插件必须要实现哪些方法与契约?](#场景-1插件必须要实现哪些方法与契约) - - [场景 2:插件间如何进行单向服务调用?](#场景-2插件间如何进行单向服务调用) - - [场景 3:插件间存在双向/循环调用时如何解决(杜绝 import cycle)?](#场景-3插件间存在双向循环调用时如何解决杜绝-import-cycle) - - [场景 4:如何开发并注册一个 HTTP API 接口?如何添加路由中间件?](#场景-4如何开发并注册一个-http-api-接口如何添加路由中间件) - - [场景 5:如何获取当前登录用户信息?](#场景-5如何获取当前登录用户信息) - - [场景 6:如何开发并注册一个 Asynq 异步 Worker 任务?](#场景-6如何开发并注册一个-asynq-异步-worker-任务) - - [场景 7:如何开发并注册一个 Cron 定时任务?](#场景-7如何开发并注册一个-cron-定时任务) - - [场景 8:数据库表结构如何声明?ORM 模型规范是什么?](#场景-8数据库表结构如何声明orm-模型规范是什么) - - [场景 9:数据库如何做独立迁移?Goose SQL 怎么组织?](#场景-9数据库如何做独立迁移goose-sql-怎么组织) - - [场景 10:如果有多个业务插件需要读写同一张表怎么办?](#场景-10如果有多个业务插件需要读写同一张表怎么办) - - [场景 11:如果跨插件操作多张表,如何确保事务一致性?](#场景-11如果跨插件操作多张表如何确保事务一致性) - - [场景 12:如何发布和订阅领域事件 (EventBus)?](#场景-12如何发布和订阅领域事件-eventbus) - - [场景 13:如何向系统注册插件自定义配置(config.yaml 与管理台热加载设置)?](#场景-13如何向系统注册插件自定义配置configyaml-与管理台热加载设置) - - [场景 14:如何使用多层缓存(RAM L1 + Redis L2 + PubSub 同步)?](#场景-14如何使用多层缓存ram-l1--redis-l2--pubsub-同步) - - [场景 15:如何使用分布式锁 (DistLock) 防止并发超卖与重复消费?](#场景-15如何使用分布式锁-distlock-防止并发超卖与重复消费) - - [场景 16:如何向管理后台动态注册监控数据与管理控制台?](#场景-16如何向管理后台动态注册监控数据与管理控制台) - - [场景 17:插件如何实现健康检查探针与就绪检查 (Health Check)?](#场景-17插件如何实现健康检查探针与就绪检查-health-check) - - [场景 18:插件如何扩展其他插件的能力(如新增一种 OAuth 登录提供商 / 新增消息推送渠道)?](#场景-18插件如何扩展其他插件的能力如新增一种-oauth-登录提供商--新增消息推送渠道) - - [场景 19:插件如何编写单元测试与集成测试(Mock 上下文与依赖打桩)?](#场景-19插件如何编写单元测试与集成测试mock-上下文与依赖打桩) - - [场景 20:以不同角色(api / worker / schedule / all)启动时,插件代码如何适配?](#场景-20以不同角色api--worker--schedule--all启动时插件代码如何适配) - - [场景 21:当某个插件流量暴增需要独立拆分为微服务时,如何零成本平滑改造?](#场景-21当某个插件流量暴增需要独立拆分为微服务时如何零成本平滑改造) - - [场景 22:插件如何安全处理文件上传与大文件摄取 (upload.Ingest)?](#场景-22插件如何安全处理文件上传与大文件摄取-uploadingest) -- [第二部分:整个项目的目录结构划分与包职责定义](#第二部分整个项目的目录结构划分与包职责定义) -- [第三部分:框架核心提供给插件调用的公用能力矩阵 (Context Capability Matrix)](#第三部分框架核心提供给插件调用的公用能力矩阵-context-capability-matrix) - ---- - -# 第一部分:下游项目实战开发指南与 22 个高频开发场景解答 - -### 场景 1:插件必须要实现哪些方法与契约? -每个插件必须实现 `core.Plugin` 接口,仅需提供两个核心方法:`Name()` 与 `Apply(ctx *core.Context)`。 - -```go -package myplugin - -import "github.com/Rain-kl/Wavelet/core" - -type Plugin struct{} - -// 1. Name: 返回全局唯一的插件标识符(建议遵循命名空间规范,如 "biz.order") -func (p *Plugin) Name() string { - return "biz.order" -} - -// 2. Apply: 核心装载入口,所有的路由注册、任务注册、服务提供与依赖消费均在此完成 -func (p *Plugin) Apply(ctx *core.Context) error { - // 在此编写装载逻辑 - return nil -} -``` - ---- - -### 场景 2:插件间如何进行单向服务调用? -**规则**:插件之间**禁止直接相互 import 具体实现包**。调用方仅面向 `core/contracts` 中的纯 Interface 编程,运行时通过 Context 解析。 - -```go -// 1. 插件 A (提供者 plugins/user) 将服务注入 Context -func (p *UserPlugin) Apply(ctx *core.Context) error { - userSvc := NewUserServiceImpl(ctx.DB()) - ctx.Provide[contracts.UserService](userSvc) - return nil -} - -// 2. 插件 B (消费者 plugins/order) 声明依赖并调用 -func (p *OrderPlugin) Apply(ctx *core.Context) error { - return ctx.Using(func(userSvc contracts.UserService) { - // userSvc 已由容器自动注入就绪 - v1 := ctx.Router().Group("/api/v1/orders") - v1.POST("", func(c *gin.Context) { - userInfo, err := userSvc.GetUserProfile(c.Request.Context(), "user_123") - // 处理订单逻辑... - }) - }) -} -``` - ---- - -### 场景 3:插件间存在双向/循环调用时如何解决(杜绝 import cycle)? -**问题场景**:`auth` 登录成功后需要查 `user` 资料;`user` 重置密码后需要调 `auth` 吊销 session。若两个 package 互相 import,Go 编译器会报 `import cycle not allowed`。 - -**Cordis 解法**: -1. 接口均定义在 `core/contracts`,双方只依赖 `core/contracts`。 -2. 运行时采用 **延迟注入 (Lazy Resolution / Inject)** 或 **事件解耦 (EventBus)**: - -```go -// plugins/auth/service.go -func (s *AuthServiceImpl) OnLoginSuccess(c context.Context, uid string) { - // 延迟注入 UserService,不发生 package 级循环导入 - userSvc, err := core.Inject[contracts.UserService](s.ctx) - if err == nil { - userSvc.UpdateLastLoginTime(c, uid) - } -} -``` -*更加推荐的方式是发射领域事件*(见场景 12),由 `user` 插件自愿监听,彻底消除相互调用的硬依赖。 - ---- - -### 场景 4:如何开发并注册一个 HTTP API 接口?如何添加路由中间件? -插件通过 `ctx.Router()` 声明路由。微内核支持标准 Gin 路由组与中间件挂载: - -```go -func (p *OrderPlugin) Apply(ctx *core.Context) error { - // 获取全局或 auth 插件提供的中间件 - authSvc, _ := core.Inject[contracts.AuthService](ctx) - - // 创建带版本前缀和鉴权中间件的路由组 - group := ctx.Router().Group("/api/v1/orders", authSvc.RequireAuthMiddleware()) - - // 注册 Handler - group.GET("", p.handleListOrders) - group.POST("", p.handleCreateOrder) - group.GET("/:id", p.handleGetOrderDetail) - - return nil -} -``` - ---- - -### 场景 5:如何获取当前登录用户信息? -`auth` 插件会在上下文中注入当前用户 Session。业务 Handler 可直接调用统一 Helper: - -```go -func (p *OrderPlugin) handleCreateOrder(c *gin.Context) { - // 1. 从当前 Gin 请求上下文中提取认证用户信息 - currentUser, ok := oauth.GetCurrentUser(c) - if !ok { - response.AbortUnauthorized(c, errs.ErrUnauthorized) - return - } - - log.Printf("当前下单用户 ID: %s, 权限角色: %s", currentUser.ID, currentUser.Role) - // 2. 正常业务处理... -} -``` - ---- - -### 场景 6:如何开发并注册一个 Asynq 异步 Worker 任务? -```go -func (p *OrderPlugin) Apply(ctx *core.Context) error { - // 1. 注册 Asynq 任务类型与消费处理器 - ctx.Task().Register("order:cancel_timeout", p.handleTimeoutCancelTask) - return nil -} - -// 2. 任务执行函数 -func (p *OrderPlugin) handleTimeoutCancelTask(ctx context.Context, t *asynq.Task) error { - var payload OrderTimeoutPayload - if err := json.Unmarshal(t.Payload(), &payload); err != nil { - return err - } - // 执行超时关单业务逻辑... - return nil -} - -// 3. 业务中异步投递任务 -func (p *OrderPlugin) EnqueueTimeoutCheck(ctx context.Context, orderID string) { - p.ctx.TaskClient().EnqueueContext(ctx, asynq.NewTask("order:cancel_timeout", payloadBytes), asynq.ProcessIn(15*time.Minute)) -} -``` - ---- - -### 场景 7:如何开发并注册一个 Cron 定时任务? -```go -func (p *ReportPlugin) Apply(ctx *core.Context) error { - // 每天凌晨 2 点执行日报汇总任务 - ctx.Schedule().RegisterCron("0 2 * * *", "report:daily_summary", DailyReportPayload{Type: "all"}) - return nil -} -``` - ---- - -### 场景 8:数据库表结构如何声明?ORM 模型规范是什么? -**规范**: -1. 表名必须带有插件专有前缀(如 `w_order_`、`w_auth_`),避免跨插件表名冲突。 -2. 零值与数据库默认值严格对齐;禁止物理外键,显式建索引。 -3. 必须通过 GORM 结构体清晰声明 `gorm:"..."` 标签与 `json:"..."`。 - -```go -package models - -import "time" - -type Order struct { - ID string `gorm:"column:id;primaryKey;size:64" json:"id"` - UserID string `gorm:"column:user_id;index;size:64;not null" json:"user_id"` - Amount int64 `gorm:"column:amount;not null" json:"amount"` - Status string `gorm:"column:status;size:32;index;not null;default:'pending'" json:"status"` - CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` - DeletedAt *time.Time `gorm:"column:deleted_at;index" json:"-"` -} - -func (Order) TableName() string { - return "w_orders" -} -``` - ---- - -### 场景 9:数据库如何做独立迁移?Goose SQL 怎么组织? -**彻底告别集中大迁移目录**。每个插件在内部目录建立 `migrations/`,并通过 `//go:embed` 打包注入: - -```go -// plugins/order/plugin.go -package order - -import ( - "embed" - "github.com/Rain-kl/Wavelet/core" -) - -//go:embed migrations/*.sql -var orderMigrations embed.FS - -func (p *Plugin) Apply(ctx *core.Context) error { - // 注册本插件的专属迁移(系统启动时自动按版本号执行) - ctx.Migrations().Register("order", orderMigrations) - return nil -} -``` - -#### SQL 迁移脚本规范 (`plugins/order/migrations/00001_initial.sql`): - -每个插件只需维护一个 `00001_initial.sql`,包含该插件的全部建表语句与种子数据。 - -```sql --- +goose Up --- +goose StatementBegin -CREATE TABLE IF NOT EXISTS w_orders ( - id VARCHAR(64) PRIMARY KEY, - user_id VARCHAR(64) NOT NULL, - amount BIGINT NOT NULL, - status VARCHAR(32) NOT NULL DEFAULT 'pending', - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP -); -CREATE INDEX IF NOT EXISTS idx_w_orders_user_id ON w_orders(user_id); - --- 种子数据(使用 ON CONFLICT DO NOTHING 保证幂等) -INSERT INTO w_orders (id, user_id, amount, status, created_at, updated_at) -VALUES ('init_001', 'system', 0, 'completed', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -ON CONFLICT (id) DO NOTHING; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -DROP TABLE IF EXISTS w_orders; --- +goose StatementEnd -``` - -#### 版本管理机制 - -所有插件共享一张 `w_schema_versions` 表,以 `plugin_id` 区分: - -``` -w_schema_versions (plugin_id, version_id, applied_at) -``` - -启动时,引擎遍历每个插件: -1. 查询 `w_schema_versions WHERE plugin_id = 'order'` 获取当前最大版本号 -2. 扫描插件 `migrations/` 目录下的 `.sql` 文件 -3. 如果存在未应用的版本号 → 执行迁移 -4. 如果全部已应用 → 跳过 - -```sql --- 查看全局迁移状态 -SELECT * FROM w_schema_versions ORDER BY plugin_id, version_id; -``` - ---- - -### 场景 10:如果有多个业务插件需要读写同一张表怎么办? -**黄金准则**:**表有且仅有一个所有者插件 (Single Owner Principle)**。 -* 严禁插件 B 直接通过 SQL 修改插件 A 拥有的核心表(如订单插件直接修改用户表)。 -* **合法模式 1(服务调用)**:插件 A 提供 `UserService.DeductBalance(uid, amount)`,插件 B 调用该接口。 -* **合法模式 2(只读视图 / 共享查询 DTO)**:如果仅仅是高频联合查询(报表),插件 A 暴露只读查询接口,或通过数据库只读从库直接投影。 - ---- - -### 场景 11:如果跨插件操作多张表,如何确保事务一致性? -在插件化和微服务就绪体系下,**跨插件的强分布式事务是反模式**。 - -1. **同插件内多表操作**:直接使用本地数据库事务: - ```go - err := ctx.DB().Transaction(func(tx *gorm.DB) error { - if err := tx.Create(&order).Error; err != nil { return err } - if err := tx.Create(&orderItem).Error; err != nil { return err } - return nil - }) - ``` -2. **跨插件操作(如创建订单 + 扣减库存 + 发送通知)**: - * 采用 **最终一致性 (Eventual Consistency / Saga 模式)**。 - * 本地事务成功后,发射 `OrderCreatedEvent` 到 EventBus; - * 库存插件监听到事件后扣减库存,若失败则发布补偿事件触发订单取消。 - ---- - -### 场景 12:如何发布和订阅领域事件 (EventBus)? -```go -// 1. 定义强类型事件结构 -type OrderPaidEvent struct { - OrderID string `json:"order_id"` - UserID string `json:"user_id"` - PayAmount int64 `json:"pay_amount"` -} - -// 2. 插件 A 发布事件 -ctx.Events().Emit("order:paid", OrderPaidEvent{OrderID: "ord_1", UserID: "u_1", PayAmount: 9900}) - -// 3. 插件 B 订阅事件 -ctx.Events().On("order:paid", func(c context.Context, e OrderPaidEvent) error { - log.Printf("收到支付成功事件,开始为用户 %s 发放权益", e.UserID) - return nil -}) -``` - ---- - -### 场景 13:如何向系统注册插件自定义配置(config.yaml 与管理台热加载设置)? -```go -type OrderConfig struct { - MaxItemsPerOrder int `yaml:"max_items" json:"max_items"` - AutoCancelMins int `yaml:"auto_cancel_mins" json:"auto_cancel_mins"` -} - -func (p *OrderPlugin) Apply(ctx *core.Context) error { - var cfg OrderConfig - // 1. 自动从 config.yaml 中的 plugins.order 节点绑定配置 - ctx.Config().Bind("plugins.order", &cfg) - - // 2. 注册为管理台可动态修改的系统参数 - ctx.Settings().Register(core.SettingSchema{ - Key: "order.auto_cancel_mins", - Default: 15, - Description: "未支付订单自动取消时间 (分钟)", - }) - return nil -} -``` - ---- - -### 场景 14:如何使用多层缓存(RAM L1 + Redis L2 + PubSub 同步)? -框架提供三层穿透缓存能力,防止缓存击穿与雪崩: - -```go -func (s *OrderService) GetOrderWithCache(ctx context.Context, orderID string) (*Order, error) { - var order Order - err := s.ctx.Cache().GetOrSet(ctx, "order:"+orderID, &order, 10*time.Minute, func() (any, error) { - // Cache Miss 回源查 DB - var dbOrder Order - if err := s.db.WithContext(ctx).First(&dbOrder, "id = ?", orderID).Error; err != nil { - return nil, err - } - return &dbOrder, nil - }) - return &order, err -} - -// 当订单更新时,广播失效所有节点的 L1 内存缓存与 L2 Redis 缓存 -func (s *OrderService) InvalidateCache(ctx context.Context, orderID string) { - s.ctx.Cache().Delete(ctx, "order:"+orderID) -} -``` - ---- - -### 场景 15:如何使用分布式锁 (DistLock) 防止并发超卖与重复消费? -```go -func (s *OrderService) ProcessPayment(ctx context.Context, orderID string) error { - // 获取分布式锁,租期 5 秒 - unlock, err := s.ctx.DistLock().Lock(ctx, "lock:order:pay:"+orderID, 5*time.Second) - if err != nil { - return fmt.Errorf("当前订单正在处理中,请勿重复提交") - } - defer unlock() // 确保释放 - - // 执行扣款操作... - return nil -} -``` - ---- - -### 场景 16:如何向管理后台动态注册监控数据与管理控制台? -插件可以向管理后台扩展点注入自己的仪表盘指标和诊断探针: - -```go -func (p *OrderPlugin) Apply(ctx *core.Context) error { - ctx.Admin().RegisterMetric("order_count_today", func(c context.Context) any { - var count int64 - ctx.DB().Model(&models.Order{}).Where("created_at >= ?", todayStart()).Count(&count) - return count - }) - return nil -} -``` - ---- - -### 场景 17:插件如何实现健康检查探针与就绪检查 (Health Check)? -```go -func (p *PaymentPlugin) Apply(ctx *core.Context) error { - ctx.Health().RegisterProbe("payment_gateway", func(ctx context.Context) error { - // 测试第三方支付网关网络连通性 - return pingPaymentGateway(ctx) - }) - return nil -} -``` - ---- - -### 场景 18:插件如何扩展其他插件的能力(如新增一种 OAuth 登录提供商 / 新增消息推送渠道)? -采用 **注册表扩展点模式 (Registry Pattern)**: - -```go -// 1. 下游编写微信登录插件 plugins/oauth_wechat -func (p *WeChatOAuthPlugin) Apply(ctx *core.Context) error { - return ctx.Using(func(authRegistry contracts.AuthRegistry) { - // 向核心 auth 插件注入微信 OAuth 实现 - authRegistry.RegisterOAuthProvider("wechat", &WeChatProvider{...}) - }) -} -``` - ---- - -### 场景 19:插件如何编写单元测试与集成测试(Mock 上下文与依赖打桩)? -微内核提供轻量测试脚手架 `coretest`: - -```go -func TestOrderCreate(t *testing.T) { - // 1. 创建内存测试专用 Context - ctx := coretest.NewMockContext(t) - - // 2. Mock 依赖的 UserService - mockUserSvc := &MockUserService{ReturnUser: &contracts.UserDTO{ID: "u_1", Balance: 1000}} - ctx.Provide[contracts.UserService](mockUserSvc) - - // 3. 装载插件 - plugin := &OrderPlugin{} - require.NoError(t, plugin.Apply(ctx)) - - // 4. 发起 HTTP 接口测试 - w := ctx.PerformRequest("POST", "/api/v1/orders", `{"item_id":"item_1"}`) - assert.Equal(t, 200, w.Code) -} -``` - ---- - -### 场景 20:以不同角色(api / worker / schedule / all)启动时,插件代码如何适配? -**开发者无需做任何特殊处理**! -插件只需在一个 `Apply` 方法中把自己的路由、任务、调度全部注册进 `Context`。微内核调度器会根据运行命令自动按需激活对应的运行时驱动,不匹配的能力保持休眠。 - ---- - -### 场景 21:当某个插件流量暴增需要独立拆分为微服务时,如何零成本平滑改造? -```go -// 1. 之前单体模式:在 main.go 中加载本地实现 -app.Use(&auth.Plugin{}) // 进程内直接运行 - -// 2. 拆分为微服务后:只需将 main.go 替换为 gRPC 客户端代理插件! -app.Use(&auth_grpc_client.Plugin{RemoteAddr: "auth-service.prod:9000"}) - -// 3. 所有依赖 auth 的业务插件(如 order, user)业务代码 0 处修改! -``` - ---- - -### 场景 22:插件如何安全处理文件上传与大文件摄取 (upload.Ingest)? -**严格规则**:禁止插件自行直接写入对象存储底层 Bucket 或直连底层文件系统。统一走平台摄取服务: - -```go -func (p *OrderPlugin) handleUploadInvoice(c *gin.Context) { - fileHeader, _ := c.FormFile("file") - - // 使用平台统一摄取引擎(自动计算哈希、防重传、生成签名 URL 与入库追踪) - ingestResult, err := upload.IngestFormFile(c.Request.Context(), fileHeader, upload.IngestPolicy{ - AllowedTypes: []string{"image/png", "application/pdf"}, - MaxSizeBytes: 10 * 1024 * 1024, - }) - if err != nil { - response.AbortBadRequest(c, errs.ErrUploadFailed) - return - } - - c.JSON(200, response.OK(gin.H{"file_url": ingestResult.URL})) -} -``` - ---- - -# 第二部分:整个项目的目录结构划分与包职责定义 - -```text -Wavelet/ -├── cmd/ # CLI 命令分发与装配入口 -│ ├── root.go # Cobra 根命令 -│ ├── server.go # 综合启动器(支持 api/worker/schedule/all profile) -│ └── migrate.go # 数据库独立迁移命令行工具 -│ -├── core/ # 【微内核引擎 (Zero Business Logic)】 -│ ├── context.go # Context 上下文总线与 Fork 树 -│ ├── container.go # 基于泛型的 IoC 服务注册与解析器 -│ ├── events.go # 强类型领域事件总线 (EventBus) -│ ├── lifecycle.go # 启动/停止生命周期编排状态机 -│ ├── contracts/ # 【跨插件标准服务契约 (纯 Interface)】 -│ │ ├── auth.go # AuthService 契约 -│ │ ├── user.go # UserService 契约 -│ │ ├── cache.go # CacheService 契约 -│ │ └── database.go # DBService 契约 -│ └── extpoints/ # 扩展点定义 (Router, Task, Migration, Setting) -│ -├── plugins/ # 【官方标准插件库 (完全高内聚闭包)】 -│ ├── drivers/ # 运行时驱动插件 -│ │ ├── driver_http/ # Gin Web HTTP 驱动 -│ │ ├── driver_asynq_worker/ # Asynq Worker 并发消费驱动 -│ │ └── driver_asynq_cron/ # Asynq Cron 调度器驱动 -│ │ -│ ├── infra/ # 基础设施服务插件 -│ │ ├── database/ # GORM 多数据源与读写分离插件 -│ │ ├── cache/ # RAM + Redis + PubSub 缓存插件 -│ │ ├── logger/ # Zap + Otel 分布式链路追踪日志插件 -│ │ └── storage/ # S3 / OSS / Local 对象存储插件 -│ │ -│ └── domain/ # 业务领域能力插件 -│ ├── auth/ # OAuth / Session / Passkey 认证插件 -│ ├── user/ # 用户资料 / 权限 / 角色插件 -│ ├── message_gateway/ # Bot 网关 / 渠道推送插件 -│ ├── risk_control/ # 访问控制 / IP 限流 / 安全风控插件 -│ └── admin/ # 系统管理台与监控面板插件 -│ -└── downstream/ # 【下游二开项目模板与脚手架】 - ├── README.md # 下游插件开发指南 - └── plugins/ # 下游自定义业务插件目录 - └── custom_example/ # 官方标准插件开发基准模板(含完整分层结构) -``` - -### 各层职责与禁止规则 (Guardrails): -1. **`core/`**: - - **职责**:纯抽象,提供 IoC、Context、EventBus 和 Lifecycle。 - - **严禁**:严禁 import 任何具体业务包,严禁 import `gin`、`gorm`、`asynq`。 -2. **`core/contracts/`**: - - **职责**:仅定义公开的 Go Interface 和公共 DTO。 - - **严禁**:严禁包含任何具体实现逻辑或 SQL 操作。 -3. **`plugins/` 与 `downstream/`**: - - **职责**:所有业务逻辑和驱动实现的归宿。遵循统一标准分层架构(Layered Architecture / MVC 变体)。 - - **统一分层架构与标准开发模板**: - - **唯一基准模板**:以 [`backend/downstream/plugins/custom_example`](file:///Users/ryan/Code/Go/Wavelet/backend/downstream/plugins/custom_example) 为全项目统一基准模板。 - - **物理子包隔离结构**:包含 `plugin.go`, `consts/`, `controller/`, `service/`, `dao/`, `model/` [含 `entity/`, `do/`], `migrations/` [含 `postgres/`, `sqlite/`]。 - - **命名与依赖禁令**:**严禁在根包平铺 `handlers_*`、`service_*`、`dao_*` 等前缀文件**,子包内文件直接以纯业务实体命名(如 `hello.go`、`user.go`)。严格约束 `controller -> service -> dao -> model` 单向依赖。 - - **严禁**:插件之间严禁跨包 import 内部私有代码,跨插件调用一律走 `contracts` 接口或 `EventBus`。 - ---- - -# 第三部分:框架核心提供给插件调用的公用能力矩阵 (Context Capability Matrix) - -每个插件在 `Apply(ctx *core.Context)` 时,都可以无缝调用微内核暴露的以下标准能力: - -| 扩展点方法 | 返回类型 | 功能说明 | 适用场景 | -| :--- | :--- | :--- | :--- | -| `ctx.Router()` | `RouterExtension` | 声明 HTTP 路由、前缀分组与挂载中间件 | 暴露 API 接口、Web 控制台 | -| `ctx.Task()` | `TaskExtension` | 注册 Asynq 异步任务消费处理器 | 耗时后台任务、异步消息发送 | -| `ctx.Schedule()` | `ScheduleExtension`| 注册 Cron 定时调度任务 | 定时报表统计、周期性清理 | -| `ctx.Migrations()` | `MigrationExtension`| 注册插件专属的 Goose SQL 迁移嵌入系统 | 自建数据表、版本升级 | -| `ctx.Events()` | `EventBus` | 强类型领域事件的发布与订阅 (Emit / On) | 跨插件完全解耦通知与状态同步 | -| `ctx.Settings()` | `SettingExtension` | 声明动态可配置项(支持热更新) | 业务参数配置、管理台可调节参数 | -| `ctx.DB()` | `*gorm.DB` | 获取全局受事务与 Trace 保护的 GORM 数据源 | 数据持久化 CRUD | -| `ctx.Cache()` | `CacheService` | 三层穿透缓存(RAM L1 + Redis L2 + PubSub 广播)| 高频读数据性能加速 | -| `ctx.DistLock()` | `DistLockService` | 基于 Redis 的工业级分布式锁 | 防并发超卖、防重复执行 | -| `ctx.Logger()` | `Logger` | 携带链路 TraceID 的结构化日志记录器 | 业务日志打印与审计 | -| `ctx.Storage()` | `StorageService` | 统一对象存储读写引擎 | 文件摄取、图片持久化 | -| `core.Provide[T]`| `void` | 向全局 IoC 容器注册本插件提供的强类型服务 | 暴露自身能力给其他插件消费 | -| `core.Inject[T]` | `(T, error)` | 从全局 IoC 容器中按类型获取服务实例 | 消费其他插件暴露的服务 | -| `core.Using[T]` | `error` | 响应式声明依赖,当服务就绪时执行回调 | 声明前置依赖关系 | - diff --git a/docs/superpowers/specs/2026-08-27-cordis-plugin-architecture-design.md b/docs/superpowers/specs/2026-08-27-cordis-plugin-architecture-design.md deleted file mode 100644 index ab6b30cb..00000000 --- a/docs/superpowers/specs/2026-08-27-cordis-plugin-architecture-design.md +++ /dev/null @@ -1,279 +0,0 @@ -# Wavelet Cordis 微内核与全插件化架构设计规范 - -- **创建日期**: 2026-08-27 -- **状态**: Approved Design -- **架构代号**: Cordis-Wavelet (Next 5-Year Foundation) - ---- - -## 1. 背景与目标 - -### 1.1 现状与痛点 -Wavelet 当前采用中心化显式装配架构(`internal/platform/bootstrap` 与 `internal/router`),业务逻辑集中在 `internal/apps/` 下。 -随着业务功能的快速拓展,现有架构暴露出以下瓶颈: -1. **模块高耦合**:新增功能需要横跨多个中心化目录(`apps/`、`router/`、`bootstrap/`、`migrator/`、`task/handlers/`)进行插桩,难以做到随插随用与物理隔离。 -2. **下游扩展困难**:二次开发项目无法在不修改核心源码的前提下灵活扩展或替换业务模块。 -3. **缺乏清晰的运行切面**:API、Worker、Scheduler 启动模式依赖手动条件判断,维护成本高。 - -### 1.2 改造核心目标 -1. **微内核 (Micro-Kernel)**:内核仅提供上下文总线(Context)、依赖注入(IoC)、生命周期状态机与扩展点协议,内核本身零具体业务依赖。 -2. **一切皆插件 (All-in-Plugins)**:数据库、缓存、日志、HTTP 服务、任务处理、认证鉴权、消息网关及业务能力全部以插件形式挂载在 Context 上。 -3. **下游一等公民支持**:下游项目通过声明式 `app.Use(&MyPlugin{})` 引入官方或自定义插件,编译为单一高性能二进制文件。 -4. **面向未来 5 年的分布式与微服务就绪 (Monolith-First, Microservice-Ready)**:基于强类型接口契约,单体模式下零开销内存调用,高并发下支持透明替换为 gRPC/RPC 客户端插件完成微服务拆分。 - ---- - -## 2. 核心架构模型 (Core Architecture) - -``` -+-----------------------------------------------------------------------------------+ -| 下游业务项目 (Downstream Application) | -| main.go: app.Use(&logger.Plugin{}).Use(&auth.Plugin{})... | -+-----------------------------------------------------------------------------------+ - │ - ▼ -+-----------------------------------------------------------------------------------+ -| Wavelet Core (微内核上下文总线) | -| - Context (服务树与扩展点总线) - Lifecycle Manager (生命周期编排) | -| - Service Hub (泛型 IoC 容器) - EventBus (强类型领域事件总线) | -+-----------------------------------------------------------------------------------+ - │ │ - ▼ 注册与驱动 ▼ 挂载能力 -+------------------------------------+ +-------------------------------------------+ -| 运行时驱动插件 (Driver Plugins) | | 业务领域插件 (Domain Plugins) | -| - driver-http (Gin Web 引擎) | | - plugin-auth (认证/Session/OAuth) | -| - driver-worker (Asynq 消费池) | | - plugin-user (用户资料/角色权限) | -| - driver-cron (Asynq 定时调度器) | | - plugin-msg-gateway (消息通道与推送) | -| - driver-database (GORM 数据源) | | - plugin-risk-control (访问风控与限流) | -| - driver-cache (RAM/Redis 缓存) | | - [下游自定义插件] (业务私有插件) | -+------------------------------------+ +-------------------------------------------+ -``` - ---- - -## 3. 微内核协议契约与设计规范 - -### 3.1 插件契约 (`core.Plugin`) -所有官方插件与下游自定义插件均实现统一的 `Plugin` 接口: - -```go -package core - -import "context" - -// Plugin 插件统一契约 -type Plugin interface { - // Name 插件唯一标识(如 "auth", "database", "message_gateway") - Name() string - // Apply 核心装载入口:通过 Context 提供服务、注册路由、声明任务与监听事件 - Apply(ctx *Context) error -} -``` - -### 3.2 运行时驱动契约 (`core.Driver`) -HTTP 服务、Worker 消费池、Cron 调度器不硬编码在内核中,而是作为标准 `Driver` 挂载: - -```go -package core - -type DriverType string - -const ( - DriverTypeHTTP DriverType = "http" - DriverTypeWorker DriverType = "worker" - DriverTypeScheduler DriverType = "schedule" -) - -// Driver 是具备事件循环或监听端口的运行时引擎 -type Driver interface { - Type() DriverType - Start(ctx context.Context) error - Stop(ctx context.Context) error -} -``` - -### 3.3 Context 统一服务总线与泛型注入 -```go -package core - -// Provide 向 Context 注册强类型服务实现 -func Provide[T any](ctx *Context, service T) - -// Inject 从 Context 获取已注册的服务 -func Inject[T any](ctx *Context) (T, error) - -// Using 声明式依赖注入(当且仅当依赖的服务全部就绪时激活回调) -func Using[T1 any](ctx *Context, fn func(s1 T1)) error -func Using2[T1, T2 any](ctx *Context, fn func(s1 T1, s2 T2)) error -``` - ---- - -## 4. 领域扩展点规范 (Domain Extension Points) - -微内核提供 6 大标准扩展点,供插件高内聚地声明自己的资源: - -### 4.1 HTTP 路由扩展 (`ctx.Router()`) -```go -type RouterExtension interface { - Group(relativePath string, handlers ...gin.HandlerFunc) *gin.RouterGroup - Use(middleware ...gin.HandlerFunc) -} -``` - -### 4.2 数据迁移扩展 (`ctx.Migrations()`) -每个插件通过 Go 内置 `embed.FS` 打包专属的 Goose SQL 文件,彻底消除单体大迁移目录的合并冲突: - -```go -type MigrationExtension interface { - // Register 注册插件专属的 SQL 迁移文件系统 - Register(pluginID string, fsys fs.FS, dir ...string) -} -``` - -**版本隔离机制**:所有插件共享一张 `w_schema_versions` 表,以 `plugin_id` 列区分。运行时引擎(`gooseEngine`)实现 `goosedb.Store` 接口,对该表执行 `plugin_id` 限定的 CRUD 操作,确保各插件的版本互不干扰。 - -```sql -w_schema_versions ( - plugin_id VARCHAR(64) NOT NULL, - version_id BIGINT NOT NULL, - applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (plugin_id, version_id) -) -``` - -**启动流程**: -1. `ApplyPlugins()` 阶段:各插件调用 `ctx.Migrations().Register("order", embedFS)` 收集迁移 -2. `RunMigrations()` 阶段:引擎遍历所有 `entries`,为每个插件创建 `goose.NewProvider(dialect, sqlDB, entry.FS, goose.WithStore(store))` -3. `provider.Up()` 查询 `w_schema_versions WHERE plugin_id = 'order'` 决定版本,执行增量迁移 - -### 4.3 异步任务与定时调度扩展 (`ctx.Task()` & `ctx.Schedule()`) -```go -type TaskExtension interface { - Register(taskType string, handler asynq.HandlerFunc) -} - -type ScheduleExtension interface { - RegisterCron(spec string, taskType string, payload any) -} -``` - -### 4.4 领域事件总线 (`ctx.Events()`) -用于跨插件完全解耦通信,单机模式走内存通道,集群模式无缝升级为 Redis Stream / NATS: -```go -type EventBus interface { - On(topic string, handler any) - Emit(topic string, payload any) error -} -``` - -### 4.5 动态系统设置扩展 (`ctx.Settings()`) -```go -type SettingExtension interface { - RegisterSchema(pluginID string, schema any) -} -``` - ---- - -## 5. 插件形态与目录布局规范 - -插件结构遵循 **“扁平 (Flat)、自包含 (Self-Contained)、就近组织 (Colocated)”** 原则,杜绝不必要的 DDD 样板代码。 - -### 5.1 官方插件目录结构 -```text -plugins/ -├── database/ # 数据库驱动插件 -│ ├── plugin.go # 注册 DBService 与连接池 -│ └── service.go -├── auth/ # 认证插件 -│ ├── plugin.go # 插件装载入口:ctx.Provide[AuthService] + 路由挂载 -│ ├── service.go # AuthService 接口实现 (登录/Token/Session) -│ ├── handlers.go # HTTP Controller -│ ├── models.go # GORM 实体定义 -│ └── migrations/ # 专属 Goose SQL 迁移 -│ └── 001_auth_init.sql -├── message_gateway/ # 消息网关插件 -│ ├── plugin.go # 路由挂载 + Worker 任务注册 -│ ├── channels.go # Telegram / QQ / Webhook 各渠道实现 -│ └── models.go -└── [下游自定义插件]/ # 下游业务方自研插件 - ├── plugin.go - └── models.go -``` - ---- - -## 6. 插件间引用关系与协同规范 - -为杜绝 Go 语言的 `import cycle not allowed` 错误并保持插件的独立可替换性,插件间交互严格遵循以下 3 大模式: - -1. **服务槽位与延迟绑定(用于跨插件直接调用)**: - * 双方互不 import 对方包,仅面向 `core/contracts` 暴露的 Interface 编程。 - * 运行时通过 `core.Inject[contracts.UserService](ctx)` 获取服务。 -2. **事件总线广播(用于通知与状态联动)**: - * 登录成功、密码修改、订单创建等事件统一通过 `ctx.Events().Emit()` 广播,下游自愿监听。 -3. **注册表扩展点模式(用于功能插件扩充主插件能力)**: - * 主插件向 Context 提供注册表(如 `OAuthProviderRegistry`),扩充插件在 `Apply` 中向注册表添加自己的 Provider 实现。 - ---- - -## 7. 运行切面与启动路径 (Runtime Profiles) - -CLI 命令仅作为**切面激活器 (Target Selector)**,业务插件无需感知当前的运行角色: - -``` -[CLI: wavelet api / worker / schedule / all] - ↓ -1. App Bootstrap: 加载所有已配置插件并构建 Context - ↓ -2. Apply Phase: 执行所有 plugin.Apply(ctx),收集路由、任务、调度与迁移 - └─ 各插件调用 ctx.Migrations().Register("auth", authMigrations) 等 - ↓ -3. Migration Engine: 遍历所有 entries,逐插件创建 Goose Provider 执行迁移 - └─ 每个插件使用独立的 sharedStore(pluginID),共享同一张 w_schema_versions 表 - └─ provider.Up() 检查 w_schema_versions WHERE plugin_id = 'auth' - └─ 未执行过 → 执行 00001_initial.sql → INSERT 版本记录 - └─ 已执行过 → 跳过 - ↓ -4. Profile Dispatch: - - "api": 激活 DriverTypeHTTP 驱动 (Gin.ListenAndServe) - - "worker": 激活 DriverTypeWorker 驱动 (Asynq.Run) - - "schedule": 激活 DriverTypeScheduler 驱动 (Asynq.Scheduler) - - "all": 激活所有 Driver 实例 (单体一键融合启动) - ↓ -5. Graceful Shutdown: 监听系统信号,逆序安全停机 -``` - ---- - -## 8. 面向未来 5 年的分布式与服务拆分演进 - -```mermaid -graph LR - subgraph Monolith ["阶段 1:单体插件化 (进程内零开销)"] - UserP["plugin-user"] -->|Go Interface 内存调用| AuthP["plugin-auth"] - end - - subgraph Distributed ["阶段 2:高并发微服务拆分 (透明代理替换)"] - UserP2["plugin-user"] -->|相同的 Go Interface| AuthClient["plugin-auth-client (gRPC 代理)"] - AuthClient -.->|gRPC / HTTP/2| RemoteAuth["独立 Auth 微服务集群"] - end -``` - -1. **接口不变性 (Contract Stability)**:所有跨模块调用走 Interface,微服务化拆分时只需引入 RPC 客户端插件替换原插件,调用方业务代码 **0 修改**。 -2. **分布式事件驱动**:进程内 EventBus 通过简单配置可无缝切换为 Redis Stream / NATS / Kafka。 -3. **独立数据分片**:每个插件表名自带命名空间(如 `w_auth_*`),且有独立 Migration,天然支持物理分库分表。 - ---- - -## 9. 渐进式改造实施路线图 - -1. **Phase 1: 微内核基础设施搭建 (`core/` & `core/contracts/`)** - - 实现 Context、泛型 IoC 容器、生命周期状态机与 6 大扩展点协议。 -2. **Phase 2: 运行时驱动插件下沉 (`plugins/driver_*`)** - - 将现有 Gin、Asynq Worker、Asynq Scheduler、GORM、Redis 封装为标准 Driver 插件。 -3. **Phase 3: 官方领域模块插件化拆分 (`plugins/domain_*`)** - - 依次将 `auth`、`user`、`message_gateway`、`risk_control`、`admin` 迁移为标准插件。 -4. **Phase 4: 下游工程脚手架与验证** - - 提供下游开发模板,编写示例自定义插件,端到端验证 API/Worker/Schedule 运行切面与测试覆盖。 diff --git a/docs/superpowers/specs/2026-08-28-cordis-architecture-alignment-design.md b/docs/superpowers/specs/2026-08-28-cordis-architecture-alignment-design.md deleted file mode 100644 index b772e045..00000000 --- a/docs/superpowers/specs/2026-08-28-cordis-architecture-alignment-design.md +++ /dev/null @@ -1,82 +0,0 @@ -# Cordis Architecture Alignment & Refactoring Design - -**Date**: 2026-08-28 -**Topic**: Cordis Meta-framework Alignment (Spatiotemporal Composability, Revertible Effects, Reactive Coeffects & Boundary Isolation) -**Status**: Approved - ---- - -## 1. Background & Objectives - -Wavelet adopts the **Cordis** micro-kernel paradigm (originating from Koishi and DeepSeek Harness) to achieve runtime composability and zero-side-effect lifecycle management. -According to the formal metatheory of Cordis (*A Programming Paradigm for Spatiotemporal Composability*), the runtime must satisfy two orthogonal requirements: -1. **Temporal Composability (时间可组合性)**: Every context mutation/registration must track an inverse operation (Revertible Effects) and automatically roll back in LIFO order upon unloading/disposing. -2. **Spatial Composability (空间可组合性)**: Components declare required coeffects/dependencies (`inject`); when dependencies become available or unavailable, the system reactively activates or deactivates components (Fiber state machine), guaranteeing **Confluence (合流)** regardless of registration order. -3. **Context as the Sole Surface & Defensive Isolation**: Eliminate cross-plugin private imports and global static singletons (`database.DB()`, global configs), strictly enforcing single-owner boundaries and `contracts` programming. - ---- - -## 2. Architecture & Detailed Design - -### 2.1 Revertible Effects & Scoped Extpoints (时间可组合性) - -- **`Context` Scoped Lifetime**: - Each plugin instance is mounted with a dedicated child context `pluginCtx := rootCtx.Fork()`. -- **Automatic Disposer Registration for Extpoints**: - When registrations occur through `pluginCtx`, inverse operations are automatically pushed to `pluginCtx`'s Disposer stack: - - **`Router`**: Registering a route returns a definition with an ID; `pluginCtx` records a disposer that calls `router.UnregisterByID(id)`. - - **`Events`**: `ctx.Events().On(...)` returns a `Disposer`; when called on a scoped context (or via `ctx.On(...)`), it binds to `pluginCtx.OnDispose`. - - **`Tasks`**: Registering an async task binds `tasks.Unregister(taskType)` to `pluginCtx.OnDispose`. - - **`Schedules`**: Registering a cron schedule binds `schedules.Unregister(cronName)` to `pluginCtx.OnDispose`. - - **`Settings`**: Registering setting schemas binds schema deregistration to `pluginCtx.OnDispose`. - - **`Container (Provide)`**: Providing a service type `T` binds `container.remove(T)` to `pluginCtx.OnDispose`. -- **LIFO Teardown Guarantee**: - Calling `pluginCtx.Dispose()` runs all registered disposers in reverse order (LIFO), cleanly revoking routes, event listeners, tasks, schedules, and service bindings without residual side effects. - ---- - -### 2.2 Reactive Coeffects & Fiber Lifecycle (空间可组合性) - -- **Dependency Declaration (`DependentPlugin`)**: - Plugins can optionally implement: - ```go - type DependentPlugin interface { - Plugin - Inject() []reflect.Type - } - ``` -- **Plugin Fiber State Machine**: - ``` - PENDING ──(All dependencies provided)──> LOADING ──(Apply succeeds)──> ACTIVE - ▲ │ - └─────────────(Dependency removed / Plugin unloaded)──────────────────────┘ - ``` - - **States**: `FiberPending`, `FiberLoading`, `FiberActive`, `FiberUnloading`, `FiberDisposed`. - - **Reconciler**: When `core.Provide[T]` registers a service or `core.App.Use` registers a plugin, the reconciler checks all pending fibers. Fibers with satisfied dependencies transition `Pending -> Loading -> Active`. - - **Confluence**: Plugin registration order (`app.Use(A, B)` vs `app.Use(B, A)`) produces the exact same final active state once all dependencies are satisfied. - ---- - -### 2.3 Boundary Defense & Single Owner Enforcement (架构防线) - -- **Eliminate Direct Global Invocations**: - - Refactor `plugins/domain/user/repository.go` and other domain repositories to avoid direct `import "Wavelet/plugins/infra/database"` and direct calls to `database.DB(ctx)`. - - Inject `contracts.DBService` via repository struct or retrieve via `ctx.DB()`. -- **Strict Package Separation**: - - `backend/core/`: Micro-kernel, context, container, fiber, events, scoped extpoints. - - `backend/core/contracts/`: Public interfaces and shared DTOs/events. - - `backend/plugins/infra/`: Infrastructure implementations providing contracts services. - - `backend/plugins/drivers/`: Runtime drivers (HTTP, Asynq Worker, Cron). - - `backend/plugins/domain/`: Domain business logic and single-owner tables. - - `backend/pkg/`: Stateless utilities and algorithm libraries. - ---- - -## 3. Verification Plan - -1. **Unit Tests for Core**: - - `core/fiber_test.go`: Test fiber state transitions, out-of-order registration confluence, and dynamic unloading. - - `core/context_test.go` & `core/extpoints/`: Test automatic scoped disposer tracking for routes, tasks, schedules, and event listeners. -2. **Refactoring Verification for Domain Plugins**: - - Run `go test ./backend/...` across all domain and infra packages. - - Run `make code-check` and verify zero lint regressions. diff --git a/docs/superpowers/specs/2026-08-28-cordis-architecture-refactor-design.md b/docs/superpowers/specs/2026-08-28-cordis-architecture-refactor-design.md deleted file mode 100644 index 7ac5e106..00000000 --- a/docs/superpowers/specs/2026-08-28-cordis-architecture-refactor-design.md +++ /dev/null @@ -1,84 +0,0 @@ -# Cordis 架构重构设计规格书 (Cordis Architecture Refactor Design) - -**日期**: 2026-08-28 -**目标**: 依据 Cordis 时空可组合性元框架(Spatiotemporal Composability)哲学,重构 Wavelet 后端包结构、包职责与插件边界,消除全局静态单例与跨插件私有实现依赖,实现真正的可逆副作用与契约化隔离。 - ---- - -## 1. 背景与核心设计原则 - -Cordis 是一个面向时空可组合性的元框架,核心在于: -1. **时间可组合性 (Temporal Composability / Revertible Effects)**:组件挂载到上下文时产生的任何副作用(数据库连接、Redis 客户端、路由、事件监听、定时任务)必须具备明确的逆操作,在卸载时按 LIFO(后进先出)干净撤销。 -2. **空间可组合性 (Spatial Composability / Reactive Coeffects)**:组件通过 `Inject` 声明依赖;无特权微内核,所有基础设施与业务均以平等插件形态存在;组件之间严格面向抽象服务契约(Contracts)编程,严禁跨包引用私有实现。 -3. **合流定理 (Confluence)**:任何插件的装载/卸载顺序,静止状态等同于从零静态装配,杜绝全局隐藏状态与启动顺序隐式假设。 - ---- - -## 2. 详细重构方案 - -### 2.1 微内核纯洁化 (`backend/core/`) - -#### 改造点: -1. **移除特权辅助方法**: - - 从 `backend/core/context.go` 中移除 `func (c *Context) DB() contracts.DBService` 与 `func (c *Context) Cache() contracts.CacheService`。 - - 所有服务消费方统一面向 `core.Inject[T](ctx)`、`core.MustInject[T](ctx)` 或 `core.Using[T](ctx, ...)`。 -2. **保持依赖注入纯粹性**: - - 内核仅保留:`Context`、`Container`、`Fiber`、`EventBus`、生命周期管理以及通用的扩展点挂载。 - ---- - -### 2.2 基础设施插件生命周期可逆化 (`backend/plugins/infra/`) - -#### 1. 数据库插件 (`plugins/infra/database`) -- **移除隐式副作用**: - - 删除 `postgres.go` 与 `sqlite.go` 中的 `func init() { ... }` 静态建连。 - - 删除包级导出的静态全局变量 `var db *gorm.DB` 以及全局 `DB(ctx)` / `SetDB()`。 -- **生命周期受控与可逆释放**: - - 在 `Plugin.Apply(ctx *core.Context)` 时根据配置建立数据库连接(GORM + underlying `*sql.DB`)。 - - 创建 `contracts.DBService` 实例并通过 `core.Provide[contracts.DBService](ctx, svc)` 注册。 - - 注册 `ctx.OnDispose` 逆操作,在插件卸载时调用 `sqlDB.Close()`。 - -#### 2. 缓存插件 (`plugins/infra/cache`) -- **移除隐式副作用**: - - 删除 `redis.go` 中的 `func init() { ... }` 静态建连。 - - 删除包级导出的全局变量 `var Redis redis.UniversalClient`。 -- **生命周期受控与可逆释放**: - - 在 `Plugin.Apply(ctx *core.Context)` 时初始化 Redis 客户端并构造 `contracts.CacheService`。 - - 通过 `core.Provide[contracts.CacheService](ctx, svc)` 注册。 - - 注册 `ctx.OnDispose` 逆操作,在插件卸载时调用 `client.Close()`。 - ---- - -### 2.3 业务领域插件防线隔离与依赖重构 (`backend/plugins/domain/`) - -#### 1. 消除跨插件私有 Import -- 遍历并重构以下 8 个 Domain 插件: - - `auth` - - `user` - - `admin` - - `cap` - - `message_gateway` - - `risk_control` - - `system` - - `upload` -- **规则**: - - 严禁任何 domain 插件 `import "Wavelet/plugins/infra/database"` 或 `import "Wavelet/plugins/infra/cache"`。 - - 严禁任何 domain 插件直接 import 另一个 domain 插件的具体实现包(如 `admin` 严禁 import `risk_control/logstore` 或 `storage/diskcache`)。 - - 各插件内部的 Repository / Service 统一通过 `core.Inject[contracts.DBService](ctx)` 或插件内部 scoped context 获取数据库连接。 - -#### 2. `admin` 插件解耦与全局变量清除 -- 移除 `admin/plugin.go` 中的包级变量(`globalUserSvc`, `globalAuthSvc`, `globalCoreCtx`)。 -- 将 `admin` 的日志查询、任务触发、缓存清理等管理接口改造为通过 `contracts` 或 `ctx.Tasks()` 访问,消除对 `risk_control`、`driver_asynq_worker` 等的私有依赖。 - ---- - -## 3. 验证与门禁标准 - -1. **编译与依赖检查**: - - 运行 `grep -r "Wavelet/plugins/infra/database" backend/plugins/domain/` 结果为空。 - - 运行 `grep -r "Wavelet/plugins/infra/cache" backend/plugins/domain/` 结果为空。 -2. **自动化测试**: - - 所有既有单元测试与集成测试(`go test ./...`)无回归,全部 PASS。 -3. **代码质量门禁**: - - `make code-check` 静态检查 0 告警通过。 - - `make format` 格式化通过。 diff --git a/docs/superpowers/specs/2026-08-28-cordis-plugin-layered-architecture-spec.md b/docs/superpowers/specs/2026-08-28-cordis-plugin-layered-architecture-spec.md deleted file mode 100644 index fcef8684..00000000 --- a/docs/superpowers/specs/2026-08-28-cordis-plugin-layered-architecture-spec.md +++ /dev/null @@ -1,128 +0,0 @@ -# Cordis 架构插件标准分层设计规范 (Plugin Layered Architecture Spec) - -- **文档状态**: 已敲定 (Approved) -- **版本**: v1.1.0 (2026-08-28) -- **适用范围**: Wavelet 官方插件 (`backend/plugins/`)、下游定制插件 (`downstream/custom_plugins/`) - ---- - -## 1. 架构总览与统一标准规范 (Architecture & Unified Standard Spec) - -在 Wavelet 的 Cordis 微内核架构中,系统通过 **微内核 (`core/`) + 服务契约 (`core/contracts/`) + 自包含插件 (`plugins/` 及 `downstream/plugins/`)** 实现高度解耦与单向依赖。 -所有插件统一以 [`backend/downstream/plugins/custom_example`](file:///Users/ryan/Code/Go/Wavelet/backend/downstream/plugins/custom_example) 为基准模板,严格遵循物理子包隔离的分层架构(`controller -> service -> dao -> model`)。 - ---- - -## 2. 统一标准分层目录结构 (以 `custom_example` 为基准) - -```text -backend/downstream/plugins/custom_example/ (或 backend/plugins/domain//) -├── plugin.go # [插件根入口] 实现 core.Plugin,装配各子包并向 Cordis 注册 -│ -├── consts/ # package consts:常量与模块内部错误码定义 -│ └── consts.go -│ -├── controller/ # package controller:HTTP API 接入层 (参数绑定、会话获取、统一信封响应) -│ └── hello/ # 业务分组/实体子包 -│ └── hello.go # 接口处理 Handler(直接以业务命名,禁止 controller_hello.go) -│ -├── service/ # package service:核心业务用例层 (业务用例、事务编排、事件发布) -│ └── hello.go # 业务用例实现(纯 Go 逻辑,禁止依赖 *gin.Context) -│ -├── dao/ # package dao:数据持久化访问层 DAL (GORM CRUD、SQL 转义防注入) -│ └── hello.go # 数据访问实现(直接以业务命名,禁止 dao_hello.go) -│ -├── model/ # package model:纯数据实体与传输对象 (零 Web/数据库框架依赖) -│ ├── entity/ # 数据库映射实体 (TableName() 必须带 w__ 前缀) -│ │ └── hello.go -│ └── do/ # 领域对象、请求 Request DTO 与响应 Response DTO -│ └── hello.go -│ -└── migrations/ # Goose SQL 独立迁移嵌入目录 (//go:embed) - ├── postgres/ # PostgreSQL 专属迁移 SQL - │ └── 20260901000001_init.sql - └── sqlite/ # SQLite 专属迁移 SQL - └── 20260901000001_init.sql -``` - -> ⚠️ **严禁规则**:严禁在插件根目录下平铺 `handlers_*.go`、`service_*.go`、`dao_*.go` 等前缀文件,子包内文件直接按业务实体命名。严格约束 `controller -> service -> dao -> model` 单向依赖。 -│ ├── auth.go # 认证业务逻辑(直接命名为 auth.go,禁止 service_auth.go) -│ ├── user.go # 用户业务逻辑(直接命名为 user.go,禁止 service_user.go) -│ ├── config.go # 配置业务逻辑(直接命名为 config.go,禁止 service_config.go) -│ └── logs.go # 日志业务逻辑(直接命名为 logs.go,禁止 service_logs.go) -│ -├── repository/ # package repository:数据访问持久化层 (DAL) -│ ├── repository.go # 仓储通用方法与工厂 -│ ├── user.go # 用户仓储实现(直接命名为 user.go,禁止 repository_user.go) -│ ├── config.go # 配置仓储实现(直接命名为 config.go,禁止 repository_config.go) -│ └── log.go # 日志仓储实现(直接命名为 log.go,禁止 repository_log.go) -│ -├── model/ # package model (或 models/):纯领域实体与传输对象 -│ ├── entity.go # 数据库映射实体 (TableName() 必须带 w__ 前缀) -│ ├── dto.go # 请求入参与响应出参 DTO -│ └── events.go # 插件内部/广播事件结构体定义 -│ -├── errs/ # package errs:错误常量与错误码定义 (或根目录 errs.go) -│ └── errs.go -│ -└── migrations/ # Goose SQL 独立迁移嵌入文件 (//go:embed) - └── 20260828000001_init_.sql -``` - -### 3.2 依赖方向约束 (Strict Dependency Flow) -```mermaid -graph TD - Plugin[plugin.go 入口] --> Handler[handler/ 接入层] - Plugin --> Service[service/ 业务层] - Plugin --> Repository[repository/ 仓储层] - Handler --> Service - Handler --> Model[model/ 实体与DTO] - Handler --> Errs[errs/ 错误常量] - Service --> Repository - Service --> Model - Service --> Errs - Repository --> Model -``` -* **单向依赖铁律**: - 1. `handler/` 依赖 `service/`、`model/`、`errs/`; - 2. `service/` 依赖 `repository/`、`model/`、`errs/`,**严禁 import gin**; - 3. `repository/` 依赖 `model/` 和数据库底层,**严禁反向依赖 service 或 handler**; - 4. `model/` 纯粹由 Go 结构体组成,**严禁依赖上层 handler/service/repository**。 - ---- - -## 4. 各层职责边界与编码守则 (Layer Responsibilities & Guardrails) - -### 4.1 Handler 层 (`handler/`) -1. **参数绑定**:使用 `c.ShouldBindJSON` 或 `c.ShouldBindQuery`。 -2. **上下文提取**:从 `*gin.Context` 提取登录态(如 `oauth.GetCurrentUser(c)`)。 -3. **调用下游**:调用 Service 方法,禁止直接调用 Repository 或编写 SQL。 -4. **统一信封响应**: - - 成功:`c.JSON(http.StatusOK, response.OK(data))` 或 `response.OKNil()`。 - - 失败:使用 `backend/pkg/response` 的 `Abort*` 系列函数(如 `AbortBadRequest`、`AbortUnauthorized`、`AbortNotFound`、`AbortInternal`)。 -5. **Swagger 注释**:每个导出 Handler 必须编写完整的 OpenAPI/Swagger 注解。 - -### 4.2 Service 层 (`service/`) -1. **纯 Go 逻辑**:第一参数必须为 `ctx context.Context`,返回 `(result, error)`。 -2. **禁止依赖 Web 框架**:严禁 import `github.com/gin-gonic/gin`,严禁接收 `*gin.Context`,严禁调用 `c.JSON`/`Abort*`。 -3. **事务编排**:涉及插件内多表原子操作时,通过 `ctx.DB().Transaction(...)` 编排。 -4. **事件驱动解耦**:跨插件业务通知与状态联动统一通过 `ctx.Events().Emit(...)` 广播领域事件,杜绝直接跨插件调用私有方法。 - -### 4.3 Repository 层 (`repository/`) -1. **GORM / SQL 操作**:统一接收 `context.Context`,通过 `db.WithContext(ctx)` 操作数据。 -2. **SQL LIKE 防注入**:所有含用户输入的模糊查询必须调用 `backend/pkg/util.EscapeLike` 并显式声明 `ESCAPE '\\'`。 -3. **表单一所有者原则**:仅操作本插件所属表(前缀 `w__*`),严禁越权 DML/DDL 其他插件所有表。 - -### 4.4 Model 层 (`model/` 或 `models/`) -1. **GORM 映射**:显式实现 `TableName() string` 返回带前缀表名。 -2. **零值对齐**:Go 结构体字段零值必须与数据库默认值匹配。 -3. **无物理外键**:禁止物理外键约束,显式建立单列/复合索引。 - -### 4.5 Plugin 入口 (`plugin.go`) -1. 实现 `core.Plugin` 接口(`Name() string` 与 `Apply(ctx *core.Context) error`)。 -2. 在 `Apply` 中完成: - - 依赖注入与解析(`core.Provide` / `core.Inject` / `ctx.Using`) - - 路由与中间件声明(`ctx.Router().Group(...)`) - - 异步与定时任务注册(`ctx.Task().Register` / `ctx.Schedule().RegisterCron`) - - 配置与设置声明(`ctx.Settings().Register` / `ctx.Config().Bind`) - - 数据库迁移注册(`ctx.Migrations().Register`) diff --git a/docs/superpowers/specs/2026-08-28-zero-redis-pluggable-architecture-design.md b/docs/superpowers/specs/2026-08-28-zero-redis-pluggable-architecture-design.md deleted file mode 100644 index b2fdfd7d..00000000 --- a/docs/superpowers/specs/2026-08-28-zero-redis-pluggable-architecture-design.md +++ /dev/null @@ -1,97 +0,0 @@ -# Zero-Redis Pluggable Architecture Design - -**Date**: 2026-08-28 -**Topic**: Decoupling Redis via Cordis Pluggable Infrastructure and In-Process Drivers (Zero-Redis Monolith Mode) -**Status**: Approved - ---- - -## 1. Background & Objectives - -Currently, the Wavelet platform has direct or indirect couplings with Redis across four areas: -1. **Cache Layer (`infra/cache`)**: Hardcoded initialization of Redis client and L2 cache lookup. -2. **Background Worker & Cron Drivers (`drivers/driver_asynq_*`)**: Asynq requires Redis as message queue and timer broker. -3. **Cross-Node Invalidation (Pub/Sub)**: Invalidation messages directly interact with Redis channels. -4. **Task Execution Log Stream**: Real-time worker output writes directly to Redis pipelines in `admin/repository.go`. - -**Goal**: -In accordance with Cordis's "Everything is a Plugin" and "Single Owner Principle", extract Redis into dedicated optional plugins and provide lightweight in-process equivalents (`cache_memory`, `driver_inproc_worker`, `driver_inproc_cron`) so that standalone monolith deployments, embedded scenarios, and local development can run with zero external Redis dependency. - ---- - -## 2. Architecture & Detailed Design - -### 2.1 Cache Infrastructure Split (`backend/plugins/infra/`) - -`contracts.CacheService` remains the sole contract for caching. Two alternative plugins implement this contract: - -1. **`plugins/infra/cache_memory` (Default for Monolith / Zero-Redis)**: - - Encapsulates `pkg/cache/ram` for fast in-process TTL caching. - - Cache invalidations emit `cache:invalidate` events via `ctx.Events()` locally. - - Provides `core.Provide[contracts.CacheService](ctx, memCacheSvc)`. -2. **`plugins/infra/cache_redis` (Distributed Cluster Mode)**: - - Provides full multi-tier caching: L1 Local RAM + L2 Remote Redis + Redis Pub/Sub invalidation. - - Implements `core.DependentPlugin` (declares dependencies on database / configuration). - - Provides `core.Provide[contracts.CacheService](ctx, redisCacheSvc)`. - ---- - -### 2.2 In-Process Worker & Scheduler Drivers (`backend/plugins/drivers/`) - -Domain plugins register tasks and schedules only against `ctx.Tasks()` and `ctx.Schedules()` extension points, completely oblivious to the underlying runner. - -1. **`plugins/drivers/driver_inproc_worker` (In-Process Worker Driver)**: - - Implements `core.Driver` with `Type() == core.DriverTypeWorker`. - - Maintains an in-memory buffered channel queue and worker goroutine pool (managed via `util.Go` with panic recovery). - - Supports task concurrency limits, exponential backoff retries, and context execution timeouts. -2. **`plugins/drivers/driver_inproc_cron` (In-Process Cron Scheduler Driver)**: - - Implements `core.Driver` with `Type() == core.DriverTypeScheduler`. - - Uses `robfig/cron/v3` to poll and trigger entries in `ctx.Schedules().Schedules()`. -3. **`plugins/drivers/driver_asynq_*` (Distributed Cluster Drivers)**: - - Retains Asynq worker and cron drivers for Redis-backed distributed workloads. - ---- - -### 2.3 Task Execution Log Stream & Event Bus Decoupling - -1. **Task Stream Logs**: - - Provide an in-memory `RingBuffer` (e.g. recent 500 lines per execution). - - When Redis is disabled, logs stream into the `RingBuffer` and flush to `w_task_executions` upon completion. -2. **System Config & Invalidation Broadcast**: - - In single-node mode, `ctx.Events()` in-process event bus handles all notifications immediately. - - In multi-node mode, `cache_redis` bridges events across instances via Redis Pub/Sub. - ---- - -### 2.4 Application Assembly (`backend/cmd/app.go`) - -In `cmd/app.go`, the application declaratively selects the plugin suite based on configuration: - -```go -if config.Config.Redis.Enabled { - app.Use( - cache_redis.New(), - driver_asynq_worker.New(), - driver_asynq_cron.New(), - ) -} else { - app.Use( - cache_memory.New(), - driver_inproc_worker.New(), - driver_inproc_cron.New(), - ) -} -``` - ---- - -## 3. Verification Plan - -1. **Unit Tests**: - - `plugins/infra/cache_memory/plugin_test.go`: Test in-memory cache operations, TTL expiry, and `contracts.CacheService` compliance. - - `plugins/drivers/driver_inproc_worker/plugin_test.go`: Test in-process task dispatch, concurrency, retry, and cancellation. - - `plugins/drivers/driver_inproc_cron/plugin_test.go`: Test in-process cron schedule execution. -2. **Integration Verification**: - - Verify that running the application with `config.Database.Enabled = false` and `config.Redis.Enabled = false` boots cleanly into `all`, `api`, `worker`, and `scheduler` profiles with zero connection errors. -3. **Quality Gate**: - - Run `go test ./...`, `make code-check`, and `make format`. 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 deleted file mode 100644 index c2965834..00000000 --- a/docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md +++ /dev/null @@ -1,364 +0,0 @@ -# Cordis 配置扩展点设计 (Config Extension Point) - -- **文档状态**: 已敲定 (Approved) -- **版本**: v1.0.0 (2026-08-29) -- **适用范围**: `backend/core/`(微内核)、`backend/plugins/`(自包含插件)、`backend/cmd/`(组合根)、`backend/pkg/`(无状态基础库) - ---- - -## 0. 背景与动机 - -`backend/pkg/config` 同时承担了三件事:viper 装载 `config.yaml`、环境变量覆盖、以及以全局单例 `config.Config` 暴露全量配置模型。它与架构文档对 `backend/pkg/` 的定位("Stateless utilities and algorithm libraries")冲突,并且带来两个结构性问题: - -1. **配置所有权倒挂**:任何包都能读到全量配置,因此 `cmd` 直接替 `cache` 插件判断 Redis 是否启用、`risk_control` 直接判断 `clickhouse.enabled`。配置的"读者"与"所有者"没有关系约束。 -2. **隐式全局状态**:`init()` 内完成文件搜索、解析与 `log.Fatalf`,并以 `isTest()` 猜测执行上下文来禁用数据库/Redis/ClickHouse;测试通过改写全局单例驱动生产代码路径。 - -本设计把"配置的读取框架"下沉为内核扩展点,把"读哪些字段"的所有权交给各插件自己声明,并一次性迁移全部 27 个消费文件(109 处引用),彻底删除全局单例。 - -`AGENTS.md` 与 `new-setting` skill 中早已写明插件应通过 `ctx.Config().Bind(...)` 绑定静态配置,但该 API 在代码中从未存在——本设计同时修正这一文档漂移。 - ---- - -## 1. 决策记录 - -| # | 决策 | 理由与取舍 | -| :--- | :--- | :--- | -| D1 | **预声明阶段 + 配置门禁** | 内核在 `Apply` 之前收集声明并求值门禁,使组合根不再跨插件读配置选实现。代价是给 Fiber 增加"被门禁跳过"语义。 | -| D2 | **混合读取形态:结构体 `Bind` + 泛型 `Get`** | `redis`/`database` 等 14+ 字段结构体整体消费,逐 key 声明不可读;`app.session_secret` 等单字段不值得为它绑一个结构体。 | -| D3 | **共享声明 + 内核冲突校验** | 配置是进程级只读事实,不存在数据表那种写竞争,因此允许读者各自声明同一 key;由内核强制"重复声明必须一致"兜底。放弃严格单所有权(需为若干配置值另造契约接口,且 `driver_http` 需 session store 连接参数是真实底层依赖)。 | -| D4 | **一次性全量迁移** | 不留双轨,架构一次到位;接受较大的 diff。 | -| D5 | **显式测试缝** | 删除 `isTest()` 魔法,测试通过 `core.WithConfigValues(...)` 注入。放弃"测试环境自动禁用中间件"的安全网,换取语义透明与可并行。 | -| D6 | **内核持抽象,viper 归 infra 适配器** | 微内核防线规定 `core/` 严禁 import 具体运行时依赖。`core` 只依赖 `ConfigSource` 接口,viper/yaml 装载放 `plugins/infra/config`。放弃"全放 core/config"(污染内核纯净性)与"完全插件化 + `contracts.ConfigService`"(门禁求值在 core,而 config 插件 `Apply` 尚未运行,存在鸡生蛋时序问题)。 | -| D7 | **顺带解耦 `pkg/idgen`** | 其 `init()` 读全局配置,导致 `pkg` 反向依赖配置单例。 | - ---- - -## 2. 分层与物理结构 - -```text -backend/core/extpoints/config.go # 配置引擎(仅 stdlib + reflect): - # ConfigSource 接口、声明注册、解析、冲突校验、脱敏 dump -backend/core/config.go # 泛型读取入口与 App 装配选项(Go 方法不支持类型参数) -backend/core/fiber.go # 新增 FiberSkipped 状态与门禁求值 -backend/core/types.go # 新增 ConfigExtension / ConfigBinding / ConfigView 别名 -backend/plugins/infra/config/ # viper + yaml 适配器,实现 core.ConfigSource(非 core.Plugin) -backend/cmd/ # 组合根:host 声明集 + app.Prepare() -backend/pkg/idgen/ # 移除 config 依赖,改为显式 Init(nodeID) -删除 backend/pkg/config/ # 全局单例 config.Config 一并消失 -``` - -职责边界: - -| 单元 | 做什么 | 不做什么 | -| :--- | :--- | :--- | -| `core/extpoints` 配置引擎 | 维护 key 注册表、按优先级解析、类型转换、冲突校验、脱敏输出 | 不知道任何具体 key 的名字,不读文件,不 import viper | -| `plugins/infra/config` | 定位 `config.yaml`(`CONFIG_PATH` → 向上查找)、解析成 raw map、代理 env 查询 | 不含 schema、不含业务字段语义 | -| 各插件 | 声明自己读哪些字段(tag 结构体)、声明门禁谓词 | 不读未声明的 key、不访问他插件的声明类型 | -| `cmd` | 声明 host 级 key、注入 `ConfigSource`、按已解析值初始化 logger/trace/banner | 不做 `if redis.enabled { ... }` 这类跨插件判断 | - -`plugins/infra/config` 不实现 `core.Plugin`,不出现在 `app.Use()` 列表里:它只向内核提供一个 `ConfigSource` 实例,没有服务、路由或任务可注册。它归 `plugins/infra/` 而非 `pkg/`,是因为它封装了具体运行时依赖(viper、文件系统)并持有装载状态,不符合 `pkg/` 的无状态定位。 - -`pkg/idgen` 解耦后,`backend/pkg/` 恢复"不依赖配置源"的无状态定位。 - ---- - -## 3. 核心类型与 API - -### 3.1 声明形态:带 tag 的结构体 - -唯一的批量作者形态是结构体 tag,一个字段同时表达 yaml 路径、env 覆盖名、默认值与敏感标记: - -```go -// plugins/infra/cache/redis_config.go —— redis 配置由 redis 插件自己声明 -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"` - MaintNotifications bool `config:"maint_notifications" env:"REDIS_MAINT_NOTIFICATIONS" default:"false"` - // ...pool/timeout 字段略 -} -``` - -支持的 tag:`config`(yaml 相对路径,必填)、`env`(覆盖用环境变量名)、`default`(字符串形式,缺省时视为未设置)、`autoEnable`(该 env 一旦存在即把本布尔字段置 true)、`secret`(dump 时脱敏)。 - -### 3.2 内核接口 - -```go -// ConfigSource 抽象了"原始值从哪来",由 infra 适配器实现,使内核不绑定 viper。 -type ConfigSource interface { - Lookup(path string) (any, bool) // config.yaml 中的点分路径 - LookupEnv(name string) (string, bool) - Describe() string // 用于日志,如 "config.yaml" 或 "" -} - -// ConfigBinding 把一个结构体绑定到某个 yaml 前缀上,是插件的声明单元。 -type ConfigBinding struct { - Prefix string // "redis";空串表示字段 key 即完整路径 - Target any // 指向带 tag 的结构体的指针 -} - -// ConfigView 是只读的已解析视图,供门禁与零散取值使用。 -type ConfigView interface { - 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 - Source(key string) string // "env" | "yaml" | "default",用于诊断 -} - -// ConfigExtension 是挂载在 Context 上的扩展点,根 Context 与所有 Fork 共享。 -type ConfigExtension interface { - ConfigView - Declare(pluginID string, bindings ...ConfigBinding) error - Bind(prefix string, target any) error - Entries() []ConfigEntry // 有效配置的脱敏视图 -} -``` - -`core` 侧导出别名与泛型入口(沿用仓库既有 `core.Provide[T]` / `core.Inject[T]` 风格): - -```go -func ConfigGet[T any](v extpoints.ConfigView, key string) (T, error) -``` - -### 3.3 插件侧用法 - -```go -// 批量绑定(Apply 内) -var cfg redisConfig -if err := ctx.Config().Bind("redis", &cfg); err != nil { - return err -} - -// 单字段读取:带 fallback 的访问器(门禁使用) -secret := ctx.Config().String("app.session_secret", "") - -// 单字段读取:需要区分"未设置"与"设置为零值"时用泛型入口 -rate, err := core.ConfigGet[float64](ctx.Config(), "otel.sampling_rate") -``` - -`ConfigEntry` 是 `Entries()` 返回的诊断单元,只含元数据与脱敏后的值: - -```go -type ConfigEntry struct { - Key string // "redis.password" - PluginID string // 首次声明者,用于冲突报错点名 - Env string - Source string // "env" | "yaml" | "default" - Value string // secret key 输出 "******" -} -``` - -`Bind` 的双重语义:若该 prefix 尚未声明,则按 `Target` 的 tag 自登记;若已声明,则是纯读取。登记时提供的 `env`/`default`/`secret` 元数据一律参与冲突校验(依 D3),因此自登记不会绕过校验。**只有需要早于 `Apply` 求值的插件才必须显式 `DeclareConfig()`。** - -### 3.4 门禁接口与 Fiber 跳过态 - -```go -// ConfigGatedPlugin 是可选接口:让内核在 Apply 之前决定插件是否激活。 -type ConfigGatedPlugin interface { - Plugin - DeclareConfig() []extpoints.ConfigBinding // 门禁所需 key 必须提前声明 - ConfigEnabled(v extpoints.ConfigView) bool -} -``` - -`FiberState` 新增 `FiberSkipped`。`App.reconcileLocked()` 在 `Load()` 前求值门禁:门禁为 false 的 Fiber 置 `FiberSkipped`,不计入依赖 satisfied 判定,也不参与 driver 启动。`App.Stop()` 对 skipped 与 active 一视同仁地按 LIFO 卸载其 scoped Context。 - -受门禁的插件对(现状仅三对,均为 Redis 存在与否的互斥实现): - -| 启用 | 跳过 | 门禁谓词 | -| :--- | :--- | :--- | -| `infra/cache` | `infra/cache_memory` | `redis.enabled` | -| `drivers/driver_asynq_worker` | `drivers/driver_inproc_worker` | `redis.enabled` | -| `drivers/driver_asynq_cron` | `drivers/driver_inproc_cron` | `redis.enabled` | - -### 3.5 组合根 - -```go -src := config.NewSource() // plugins/infra/config:仅定位与 raw 解析 -app := core.NewApp( - core.WithProfile(profile), - core.WithConfigSource(src), - core.WithConfigDecl(hostBinding...), // app.* / log.* / otel.* -) -app.Use( - infradb.New(), logger.New(), storage.New(), - cache.New(), cache_memory.New(), // 不再 if/else,门禁决定 - driver_asynq_worker.New(), driver_inproc_worker.New(), - driver_asynq_cron.New(), driver_inproc_cron.New(), - admin.New(), user.New(), auth.New(), /* ... */ - driver_http.New(), // addr 由插件自己声明读取 -) -if err := app.Prepare(); err != nil { return err } // 解析屏障 + 门禁求值 - -timeout, _ := app.Context().Config().Duration("app.graceful_shutdown_timeout", 30) -app.SetShutdownTimeout(timeout) -``` - -`WithShutdownTimeout(d)` 保留为显式覆盖入口(测试与非标准装配使用),生产路径改为 `Prepare()` 之后由已解析视图经 `SetShutdownTimeout` 设定。`driver_http.New(WithAddr(...))` 选项删除,addr 归 `driver_http` 在 `Apply` 内声明读取。 - ---- - -## 4. 解析语义与启动时序 - -### 4.1 单 key 优先级链 - -```text -1. 显式 env 命中 env:"DB_ENABLED" → 最高优先级 -2. autoEnable env 命中 autoEnable:"DB_HOST" → true(被 1 覆盖) -3. config.yaml 命中 config:"enabled" -4. default tag 兜底 -``` - -需要保留的既有特殊语义: - -- **标量 env 填充切片字段**:`REDIS_ADDR=redis:6379` → `redis.addrs = ["redis:6379"]`;`CLICKHOUSE_HOST` 同理。 -- **隐式启用**:`DB_HOST` → `database.enabled=true`、`REDIS_ADDR` → `redis.enabled=true`、`CLICKHOUSE_HOST` → `clickhouse.enabled=true`;显式 `*_ENABLED` 始终优先于隐式推导。 -- **同一 env 的双重角色**:`REDIS_ADDR` 既是 `redis.addrs` 的值来源,又是 `redis.enabled` 的 `autoEnable` 触发器。引擎按 key 独立解析、允许一个 env 名服务多个 key,实现时不可把它建模成"env → 单一 key"的一对一映射。 -- **时长字段**:`slow_threshold: 200ms` 解析为 `time.Duration`。 -- **文件定位**:`CONFIG_PATH` 优先;否则从工作目录向上最多 5 层查找 `config.yaml`(该文件位于仓库根,`backend/` 为其子目录)。 - -### 4.2 时序 - -```text -config.NewSource() # 读 yaml → raw map;零 schema 知识 - ↓ -core.NewApp(WithConfigSource) # 记录 host 声明 - ↓ -app.Use(...) # 遇 DeclareConfig() 立即登记 binding(叶子 key + env + default + secret) - ↓ -app.Prepare() # ① 冲突校验 ② 逐 key 解析 ③ 脱敏 dump ④ 门禁求值 → FiberSkipped - ↓ -app.Run() → Reconcile/Apply # 插件内 Bind/Get 读取已解析值 -``` - -`App.Start()` 在未显式调用 `Prepare()` 时幂等补做,防止遗漏。冲突校验规则:同一 key 的多份声明必须 `env` 名、`default`、`secret` 三项一致,否则 `Prepare()` 返回错误并点名两个声明者。 - -### 4.3 有意的行为变更 - -| # | 变更 | 现状 | 变更后 | -| :--- | :--- | :--- | :--- | -| C1 | `default` 生效条件 | `applyDefaults` 对零值二次回落(`session_age<=0` → 86400) | 仅当 env 与 yaml 均缺失时生效;`app.session_age<=0` 在 `Prepare()` 判为配置错误(fail fast 优于静默改写) | -| C2 | 测试上下文 | `isTest()` 自动禁用 DB/Redis/ClickHouse 并把 sqlite 指向 `:memory:` | 删除该魔法;测试用 `core.WithConfigValues(...)` 显式声明。未声明 `database.enabled` 时按 default `false` 落 sqlite 后备,其路径沿用 `postgres.go` 既有的 `./data/wavelet.db` 回落——需要内存库的用例必须显式注入 `database.sqlite_path = ":memory:"` | -| C3 | 配置 dump | `printConfig` 明文打印全量结构体,含 `DB_PASSWORD`、`APP_SESSION_SECRET` | 按 `secret:"true"` 脱敏后输出,并标注每个 key 的来源(env/yaml/default) | -| C4 | 队列默认值 | 硬编码在 `pkg/config` 的 `applyEnvOverrides` | 移入唯一消费者 `driver_asynq_worker` 的声明(`webhook`/`whitelist_only`/`default` 三级优先级不变) | -| C5 | 非法 env 值 | `envInt/envBool/envFloat64` 在 `strconv` 失败时静默丢弃 env 值、回落 yaml/default | `Prepare()` 返回 `ErrConfigType` 并点名 key 与非法值 | - -除此之外,解析结果与现状逐 key 等价(由 §7.1 第 4 条的对拍测试证明)。 - ---- - -## 5. 声明归属映射 - -| 声明方 | key 前缀 | 消费者(含跨插件读) | -| :--- | :--- | :--- | -| `cmd` host 声明集 | `app.{env,app_name,addr,node_id,graceful_shutdown_timeout}`、`log.*`、`otel.*` | `cmd/root.go`、`cmd/banner.go`、`core.App` | -| `plugins/infra/cache` | `redis.*`(含 `enabled` 门禁、`autoEnable: REDIS_ADDR`) | `infra/cache`、`driver_http`(session store)、`driver_asynq_worker`、`driver_asynq_cron` | -| `plugins/infra/database` | `database.*`、`clickhouse.*` | `infra/database`、`admin`、`risk_control` | -| `plugins/domain/auth` | `app.session_*`(cookie/secret/age/domain/secure/http_only) | `auth`、`cap`、`message_gateway`、`driver_http` | -| `plugins/drivers/driver_asynq_worker` | `worker.*`(并发、strict_priority、queues 默认值) | 自身 | -| 其余 | 按需就近声明 | — | - -跨插件读同一 key(如 `cap` 读 auth 声明的 `app.session_secret`)依 D3 走共享声明:`cap` 也声明该 key,三份元数据必须与 auth 一致,否则启动失败。 - -**Key 命名约定**:既有 infra key 保持顶层(`redis.*`、`database.*`),以兼容线上 `config.yaml`;新增插件的私有配置归 `plugins..*` 命名空间,与 `new-setting` skill 的描述对齐。 - -### 5.1 `pkg/idgen` 解耦 - -- 删除 `init()` 中对 `config.Config.App.NodeID` 的读取。 -- 新增 `idgen.Init(nodeID int64) error`,由 host 在 `Prepare()` 之后显式调用(值来自 host 声明的 `app.node_id`)。 -- 未初始化时 `NextUint64ID()` panic 并点名"未调用 idgen.Init",而非静默使用 nodeID=0 生成可能与集群冲突的 ID。 -- 11 个调用点的 `idgen.NextUint64ID()` 签名保持不变;依赖 ID 生成的测试需显式 `idgen.Init`。这是本次迁移唯一会触及既有测试文件之处。 - -### 5.2 明确不在范围内 - -本设计只改变配置的**来源与所有权**,不动这些既有全局变量:`cache.Redis`、`driver_asynq_worker.RedisOpt`/`AsynqClient`、`infra/database.db`。它们各自的收敛属于独立议题。 - ---- - -## 6. 错误处理 - -- `Prepare()` 以 `errors.Join` 聚合全部配置错误,哨兵错误:`ErrConfigConflict`(重复声明不一致)、`ErrConfigType`(env 值无法转为目标类型)、`ErrConfigInvalid`(值域校验失败,如 `session_age<=0`)、`ErrConfigNotResolved`(`Prepare()` 之前调用 `Bind`/`Get`,错误信息点名正确调用顺序)。 -- 所有配置错误经 `error` 返回,由 `cmd` 决定终止方式;`core` 与 `extpoints` 内不再有 `log.Fatalf`。 -- `config.yaml` 缺失不是错误(沿用"仅用 env"路径,记一条 info 日志);`CONFIG_PATH` 显式指定但读不到或解析失败 → 返回 error。 -- 门禁 `ConfigEnabled(v ConfigView) bool` 只读已解析值、用带 fallback 的访问器,配置错误已在 `Prepare()` 阶段暴露,因此门禁不引入新的错误源。 -- `Declare` 与 `Bind` 校验 `Target` 必须是非 nil 结构体指针,否则返回 error(不 panic)。 - ---- - -## 7. 测试与验收 - -### 7.1 测试分层 - -1. **引擎单测**(`core/extpoints`):内存 fake `ConfigSource`,表驱动覆盖优先级四档、标量 env→切片、`autoEnable` 与显式 env 的优先关系、冲突校验、脱敏 dump、`time.Duration` 与嵌套结构体 tag 解析、`Prepare()` 前访问的错误路径。 -2. **门禁单测**(`core`):互斥插件对恰好激活一个、被跳过插件不计入依赖 satisfied、`FiberSkipped` 参与 `Stop` 的 LIFO 卸载。 -3. **适配器单测**(`plugins/infra/config`):`t.TempDir()` 写 yaml + `t.Setenv`,禁止相对路径。 -4. **新旧对拍**:迁移期间保留一份临时对拍测试,用仓库现网 `config.yaml` 与 `.env` 逐 key 比较旧 `pkg/config` 与新引擎的输出,证明除 C1–C5 外完全等价;验证通过后随旧包一并删除。 -5. **迁移后插件测试**:改用 `core.WithConfigValues(...)` 显式注入;依赖 ID 生成的测试显式 `idgen.Init`。 - -### 7.2 验收标准 - -1. `backend/pkg/config` 不存在,`grep -rn "pkg/config\|config\.Config" backend/` 零命中。 -2. `core/` 无 viper import;`backend/pkg/` 内不出现任何配置源 import。 -3. `cmd/app.go` 中不存在跨插件配置判断,驱动选型完全由门禁产生。 -4. `.env`、`config.yaml`、docker-compose **零改动**即可启动,行为等价(除已登记的 C1–C5)。 -5. 同时挂载 `cache` 与 `cache_memory` 而仅激活其一——"预声明 + 门禁"的端到端可验证证据;两条路径(Redis 启用 → asynq;禁用 → inproc)各实跑一次。 -6. `make code-check`、`make format`、`go test ./backend/...` 全绿;`go run main.go all` 实跑通过,覆盖 banner、迁移与门禁。 -7. `AGENTS.md`、`new-setting` skill 与白皮书中 `ctx.Config().Bind(...)` 的签名与 key 命名约定更新为已实现的真实 API。 - -### 7.3 实施顺序建议 - -每阶段独立可验证,供实施计划拆分参考: - -| 阶段 | 内容 | 验证 | -| :--- | :--- | :--- | -| P1 | 配置引擎(`core/extpoints/config.go`)+ `plugins/infra/config` 适配器 + 新旧对拍测试 | `go test ./backend/core/...`;对拍输出等价性报告 | -| P2 | 门禁与 `FiberSkipped`、`App.Prepare()` 解析屏障 | `core` 门禁单测;现有测试全绿(此时旧单例仍在,未迁移) | -| P3 | 按 infra → drivers → domain → cmd 顺序迁移 27 个文件;`idgen.Init` 解耦 | 每层迁移后 `go build ./...` + 该层测试;最后实跑两条门禁路径 | -| P4 | 删除 `backend/pkg/config` 与对拍测试;更新 `AGENTS.md`/skill/白皮书 API | §7.2 全部验收项逐条复核 | - ---- - -## 附录 A:迁移清单 - -删除:`backend/pkg/config/{config.go,model.go,config_test.go}` - -新增:`backend/core/extpoints/config.go`、`backend/core/config.go`、`backend/plugins/infra/config/*`、各插件内 `_config.go` 声明文件 - -需改写的 27 个文件: - -| 分组 | 文件 | -| :--- | :--- | -| 组合根 | `cmd/app.go`、`cmd/root.go`、`cmd/banner.go`、`cmd/app_test.go`、`cmd/banner_test.go`、`cmd/redis_plug_test.go` | -| 基础库 | `pkg/idgen/snowflake.go`(连带 `pkg/idgen/snowflake_test.go`) | -| infra | `plugins/infra/cache/redis.go`、`plugins/infra/database/postgres.go`、`plugins/infra/database/clickhouse.go` | -| drivers | `plugins/drivers/driver_http/engine.go`、`plugins/drivers/driver_http/middlewares.go`、`plugins/drivers/driver_asynq_worker/utils.go`、`plugins/drivers/driver_asynq_worker/utils_test.go`、`plugins/drivers/driver_asynq_cron/plugin.go` | -| domain/admin | `plugins/domain/admin/handler/db.go`、`plugins/domain/admin/repository/db.go`、`plugins/domain/admin/service/db.go`、`plugins/domain/admin/service/status.go`、`plugins/domain/admin/service/log_switch.go` | -| domain/其他 | `plugins/domain/auth/session.go`、`plugins/domain/cap/service.go`、`plugins/domain/message_gateway/service/service.go`、`plugins/domain/system/plugin.go`、`plugins/domain/risk_control/middleware.go`、`plugins/domain/risk_control/middleware_test.go`、`plugins/domain/risk_control/logstore/provider.go` | - -> 注:`risk_control/middleware.go`、`cap/service.go`、`message_gateway/service/service.go` 等处以 `config.Config != nil` 做存在性判断的分支,在注入式配置模型下不再可能,迁移时一并消除。 - ---- - -## 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`、移除对拍夹具)由后续计划承接。