diff --git a/backend/tools/newmodule/main.go b/backend/tools/newmodule/main.go new file mode 100644 index 0000000000..3116d9125d --- /dev/null +++ b/backend/tools/newmodule/main.go @@ -0,0 +1,184 @@ +// Command newmodule 生成插件模块骨架(纯开发工具,不进生产二进制)。 +// +// 用法: +// +// go run ./tools/newmodule -id job.foo +// make new-module ID=job.foo +// +// 在 internal/modules/<模块名>/ 下生成模块文件与基于 plugintest 的测试文件 +// (模板从 internal/modules/hello 提炼:四个可选生命周期接口骨架、编译期 +// 断言、EnabledByDefault=false、示例私有配置与校验),并打印后续插装步骤。 +// +// 生成器不会自动修改 internal/modules/standard/imports.go—— +// 显式插装可 review 是设计原则,import 行由作者手工加入。 +package main + +import ( + "bytes" + "embed" + "errors" + "flag" + "fmt" + "go/format" + "go/token" + "io" + "os" + "path/filepath" + "text/template" + + "github.com/Wei-Shaw/sub2api/internal/plugin" +) + +// modulePath 是本仓库的 Go module 路径,用于生成插装 import 行。 +const modulePath = "github.com/Wei-Shaw/sub2api" + +// authorGuidePath 是模块作者指南的仓库相对路径(next steps 提示用)。 +const authorGuidePath = "docs/plugin-architecture/MODULE-AUTHOR-GUIDE.md" + +//go:embed templates/module.go.tmpl templates/module_test.go.tmpl +var templatesFS embed.FS + +// templateData 是模板渲染数据。 +type templateData struct { + // ID 是完整模块 ID,如 "job.foo"。 + ID string + // Package 是模块包名(ID 最后一段),如 "foo"。 + Package string +} + +func main() { + id := flag.String("id", "", "模块 ID(点分层级命名,如 job.foo)") + dir := flag.String("dir", ".", "backend 根目录(须包含 internal/modules)") + flag.Parse() + + if err := run(*id, *dir, os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "newmodule: %v\n", err) + os.Exit(1) + } +} + +// run 执行完整的生成流程:校验 ID 与包名 → 渲染模板 → 写盘 → 打印后续步骤。 +// root 是 backend 根目录(包含 internal/modules),out 接收 next steps 输出。 +func run(id, root string, out io.Writer) error { + if id == "" { + return errors.New(`missing module ID: usage "go run ./tools/newmodule -id job.foo" or "make new-module ID=job.foo"`) + } + if err := validateModuleID(id); err != nil { + return err + } + pkg := plugin.ModuleID(id).Name() + if err := validatePackageName(pkg); err != nil { + return err + } + + modulesDir := filepath.Join(root, "internal", "modules") + if info, err := os.Stat(modulesDir); err != nil || !info.IsDir() { + return fmt.Errorf("%s not found: run from the backend root (or pass -dir)", modulesDir) + } + targetDir := filepath.Join(modulesDir, pkg) + if _, err := os.Stat(targetDir); err == nil { + return fmt.Errorf("target directory %s already exists; refusing to overwrite", targetDir) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("stat %s: %w", targetDir, err) + } + + data := templateData{ID: id, Package: pkg} + moduleSrc, err := render("module.go.tmpl", data) + if err != nil { + return err + } + testSrc, err := render("module_test.go.tmpl", data) + if err != nil { + return err + } + + if err := os.Mkdir(targetDir, 0o755); err != nil { + return fmt.Errorf("create %s: %w", targetDir, err) + } + files := []struct { + name string + content []byte + }{ + {pkg + ".go", moduleSrc}, + {pkg + "_test.go", testSrc}, + } + for _, f := range files { + path := filepath.Join(targetDir, f.name) + if err := os.WriteFile(path, f.content, 0o644); err != nil { + // 写盘失败时回滚刚创建的目标目录,避免留下半成品。 + if rmErr := os.RemoveAll(targetDir); rmErr != nil { + return errors.Join(fmt.Errorf("write %s: %w", path, err), rmErr) + } + return fmt.Errorf("write %s: %w", path, err) + } + } + + printNextSteps(out, id, pkg) + return nil +} + +// validateModuleID 通过内核导出口径 plugin.ParseConfig 复用 ModuleID.validate() +// 的格式规则(见 internal/plugin/module.go):非空、点分、各段为非空的 +// [a-z0-9_-]+。不在生成器中复制规则,内核规则演进时自动跟随。 +func validateModuleID(id string) error { + if _, err := plugin.ParseConfig(map[string]map[string]any{id: {}}); err != nil { + return fmt.Errorf("invalid module ID %q (kernel rule, see internal/plugin/module.go ModuleID.validate): %w", id, err) + } + return nil +} + +// validatePackageName 校验模块名(ID 最后一段)可用作 Go 包名。 +// 内核 ID 规则允许 [a-z0-9_-],但包含 '-'、数字开头、Go 关键字与 "_" +// 都不是合法包名,必须在生成前拒绝。 +func validatePackageName(name string) error { + if name == "_" || !token.IsIdentifier(name) { + return fmt.Errorf("module name %q (the last ID segment, used as the Go package name) is not a valid package name: it must be a non-keyword Go identifier (no '-', no leading digit, not a Go keyword, not %q)", name, "_") + } + return nil +} + +// render 渲染指定模板并经 go/format 规范化,保证产物 gofmt 干净; +// 渲染结果无法通过 gofmt(模板自身腐化)时立即报错,不写盘。 +func render(name string, data templateData) ([]byte, error) { + tmpl, err := template.ParseFS(templatesFS, "templates/"+name) + if err != nil { + return nil, fmt.Errorf("parse template %s: %w", name, err) + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("execute template %s: %w", name, err) + } + src, err := format.Source(buf.Bytes()) + if err != nil { + return nil, fmt.Errorf("gofmt rendered %s (template is corrupted, fix tools/newmodule/templates): %w", name, err) + } + return src, nil +} + +// printNextSteps 打印生成结果与后续插装步骤(生成器不自动改 imports.go)。 +func printNextSteps(out io.Writer, id, pkg string) { + fmt.Fprintf(out, `module %[1]s generated: + internal/modules/%[2]s/%[2]s.go + internal/modules/%[2]s/%[2]s_test.go + +Next steps (the generator never edits imports.go -- explicit instrumentation keeps it reviewable): + + 1. 编译期插装:在 internal/modules/standard/imports.go 的 import 块中加入: + + // %[1]s:TODO 一句话描述模块用途(默认 disabled)。 + _ "%[3]s/internal/modules/%[2]s" + + 2. 配置启用(config.yaml;新模块默认 disabled,未显式 enabled 时零行为变更): + + modules: + %[1]s: + enabled: true + interval: 30s + + 3. 填充代码中的 TODO 后运行测试: + + go test -count=1 ./internal/modules/%[2]s/ + +生命周期契约与完整示例见 %[4]s(蓝本实现:internal/modules/hello/)。 +`, id, pkg, modulePath, authorGuidePath) +} diff --git a/backend/tools/newmodule/main_test.go b/backend/tools/newmodule/main_test.go new file mode 100644 index 0000000000..260fa7a2f6 --- /dev/null +++ b/backend/tools/newmodule/main_test.go @@ -0,0 +1,148 @@ +package main + +import ( + "bytes" + "go/format" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// newRoot 构造一个带 internal/modules 结构的临时 backend 根目录。 +func newRoot(t *testing.T) string { + t.Helper() + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "internal", "modules"), 0o755)) + return root +} + +// TestRunGeneratesGofmtCleanModule 验证生成产物:文件路径正确、gofmt 干净 +// (format.Source 幂等)、关键骨架内容齐全,且 next steps 含确切的插装 import 行。 +func TestRunGeneratesGofmtCleanModule(t *testing.T) { + root := newRoot(t) + var out bytes.Buffer + require.NoError(t, run("job.foo", root, &out)) + + moduleFile := filepath.Join(root, "internal", "modules", "foo", "foo.go") + testFile := filepath.Join(root, "internal", "modules", "foo", "foo_test.go") + + for _, path := range []string{moduleFile, testFile} { + content, err := os.ReadFile(path) + require.NoError(t, err, "generated file should exist: %s", path) + formatted, err := format.Source(content) + require.NoError(t, err, "generated file must be valid Go: %s", path) + require.Equal(t, string(formatted), string(content), "generated file must be gofmt-clean: %s", path) + require.Contains(t, string(content), "package foo") + } + + moduleSrc, err := os.ReadFile(moduleFile) + require.NoError(t, err) + require.Contains(t, string(moduleSrc), `const ID plugin.ModuleID = "job.foo"`) + require.Contains(t, string(moduleSrc), "EnabledByDefault: false") + require.Contains(t, string(moduleSrc), "plugin.RegisterModule(&Module{})") + // 四个可选生命周期接口的编译期断言齐全。 + for _, iface := range []string{"plugin.Provisioner", "plugin.Validator", "plugin.Starter", "plugin.Stopper"} { + require.Contains(t, string(moduleSrc), iface) + } + require.Contains(t, string(moduleSrc), "TODO", "module template should mark author fill-in points") + + testSrc, err := os.ReadFile(testFile) + require.NoError(t, err) + require.Contains(t, string(testSrc), "internal/plugin/plugintest", "test template must use plugintest fixtures") + require.Contains(t, string(testSrc), "plugintest.RunLifecycle") + + // next steps:确切 import 行、不自动插装的提示、配置示例与作者指南链接。 + nextSteps := out.String() + require.Contains(t, nextSteps, `_ "github.com/Wei-Shaw/sub2api/internal/modules/foo"`) + require.Contains(t, nextSteps, "internal/modules/standard/imports.go") + require.Contains(t, nextSteps, "enabled: true") + require.Contains(t, nextSteps, "docs/plugin-architecture/MODULE-AUTHOR-GUIDE.md") +} + +// TestRunRejectsInvalidModuleID 验证不符合内核规则的 ID 被拒绝, +// 错误信息引用内核规则来源,且不留下任何产物目录。 +func TestRunRejectsInvalidModuleID(t *testing.T) { + tests := []struct { + name string + id string + }{ + {"empty", ""}, + {"uppercase", "Job.Foo"}, + {"empty segment", "job..foo"}, + {"illegal char", "job.foo!"}, + {"trailing dot", "job.foo."}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := newRoot(t) + err := run(tt.id, root, &bytes.Buffer{}) + require.Error(t, err) + if tt.id != "" { + require.Contains(t, err.Error(), "internal/plugin/module.go", + "error should reference the kernel rule source") + } + entries, readErr := os.ReadDir(filepath.Join(root, "internal", "modules")) + require.NoError(t, readErr) + require.Empty(t, entries, "rejected ID must not leave artifacts") + }) + } +} + +// TestRunRejectsInvalidPackageName 验证 ID 符合内核规则但最后一段 +// 不是合法 Go 包名(关键字、含 '-'、数字开头、下划线)时被拒绝。 +func TestRunRejectsInvalidPackageName(t *testing.T) { + tests := []struct { + name string + id string + }{ + {"go keyword", "job.type"}, + {"contains dash", "job.foo-bar"}, + {"leading digit", "job.1foo"}, + {"blank identifier", "job._"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := newRoot(t) + err := run(tt.id, root, &bytes.Buffer{}) + require.Error(t, err) + require.Contains(t, err.Error(), "package name") + entries, readErr := os.ReadDir(filepath.Join(root, "internal", "modules")) + require.NoError(t, readErr) + require.Empty(t, entries, "rejected package name must not leave artifacts") + }) + } +} + +// TestRunRejectsExistingTargetDir 验证目标目录已存在时拒绝覆盖,且原内容不被触碰。 +func TestRunRejectsExistingTargetDir(t *testing.T) { + root := newRoot(t) + existing := filepath.Join(root, "internal", "modules", "foo") + require.NoError(t, os.MkdirAll(existing, 0o755)) + sentinel := filepath.Join(existing, "keep.go") + require.NoError(t, os.WriteFile(sentinel, []byte("package foo\n"), 0o644)) + + err := run("job.foo", root, &bytes.Buffer{}) + require.Error(t, err) + require.Contains(t, err.Error(), "already exists") + + content, readErr := os.ReadFile(sentinel) + require.NoError(t, readErr) + require.Equal(t, "package foo\n", string(content), "existing files must be untouched") +} + +// TestRunRequiresModulesDir 验证 root 下没有 internal/modules 时给出明确提示。 +func TestRunRequiresModulesDir(t *testing.T) { + err := run("job.foo", t.TempDir(), &bytes.Buffer{}) + require.Error(t, err) + require.Contains(t, err.Error(), "internal") + require.Contains(t, err.Error(), "backend root") +} + +// TestRunRequiresID 验证缺少 -id 参数时报用法错误。 +func TestRunRequiresID(t *testing.T) { + err := run("", newRoot(t), &bytes.Buffer{}) + require.Error(t, err) + require.Contains(t, err.Error(), "make new-module ID=job.foo") +} diff --git a/backend/tools/newmodule/templates/module.go.tmpl b/backend/tools/newmodule/templates/module.go.tmpl new file mode 100644 index 0000000000..ca78163367 --- /dev/null +++ b/backend/tools/newmodule/templates/module.go.tmpl @@ -0,0 +1,166 @@ +// Package {{.Package}} 实现插件模块 {{.ID}}。 +// +// TODO: 用 1-3 句话描述模块职责。 +// +// 本骨架由 tools/newmodule 生成(蓝本:internal/modules/hello),实现了 +// 全部四个可选生命周期接口(Provision / Validate / Start / Stop)。 +// 模块默认 disabled——仅在 `modules:` 配置中显式 enabled 后才会运行。 +package {{.Package}} + +import ( + "context" + "fmt" + "time" + + "go.uber.org/zap" + + "github.com/Wei-Shaw/sub2api/internal/plugin" +) + +// ID 是 {{.Package}} 模块的模块 ID。 +const ID plugin.ModuleID = "{{.ID}}" + +func init() { + plugin.RegisterModule(&Module{}) +} + +// Config 是 {{.Package}} 模块的私有配置 +// (`modules:` 子树中 "{{.ID}}" 项除 enabled 外的部分)。 +// +// TODO: 替换为模块真实的配置字段(同步更新 defaultConfig 与 Validate)。 +// 注意:全局配置经 Viper 加载时所有 key 会被静默小写化, +// mapstructure 标签必须全小写。 +type Config struct { + // Interval 是后台工作循环的执行间隔,必须大于 0。 + // TODO: 示例字段,按需替换或删除。 + Interval time.Duration `mapstructure:"interval"` +} + +// defaultConfig 返回 {{.Package}} 模块的默认配置(用户未配置的字段保持默认值)。 +func defaultConfig() Config { + return Config{ + Interval: 30 * time.Second, + } +} + +// Module 是 {{.ID}} 模块实例。零值即可用,依赖在 Provision 中装配; +// 每次 Runtime.Build 都会通过 ModuleInfo.New 创建全新实例, +// 模块不应依赖包级可变状态。 +type Module struct { + cfg Config + logger *zap.Logger + + cancel context.CancelFunc + done chan struct{} +} + +// 编译期断言:Module 实现 plugin.Module 与全部四个可选生命周期接口。 +// TODO: 用不到的可选接口请连同对应方法与断言一起删除(plugin.Module 必须保留)。 +var ( + _ plugin.Module = (*Module)(nil) + _ plugin.Provisioner = (*Module)(nil) + _ plugin.Validator = (*Module)(nil) + _ plugin.Starter = (*Module)(nil) + _ plugin.Stopper = (*Module)(nil) +) + +// ModuleInfo 返回模块注册信息。EnabledByDefault 必须为 false: +// 未在 `modules:` 中显式 enabled 时模块不实例化、无任何副作用, +// 保证引入新模块在缺省配置下零行为变更。 +func (*Module) ModuleInfo() plugin.ModuleInfo { + return plugin.ModuleInfo{ + ID: ID, + New: func() plugin.Module { return new(Module) }, + EnabledByDefault: false, + } +} + +// Provision 从宿主获取依赖,并在默认配置之上解码模块私有配置。 +// +// TODO: 按需从 host 获取其他能力(Logger / ConfigOf / DB / Redis)。 +// 模块依赖只能经 Host 获取,禁止依赖具体 Service。 +func (m *Module) Provision(host *plugin.Host) error { + m.cfg = defaultConfig() + if host != nil { + m.logger = host.Logger + if host.ConfigOf != nil { + if err := host.ConfigOf(ID, &m.cfg); err != nil { + return err + } + } + } + if m.logger == nil { + m.logger = zap.NewNop() + } + return nil +} + +// Validate 校验模块配置与状态,失败将中止启动(fail-fast)。 +// +// TODO: 补充模块真实配置的校验规则。 +func (m *Module) Validate() error { + if m.cfg.Interval <= 0 { + return fmt.Errorf("{{.Package}}: interval must be positive, got %s", m.cfg.Interval) + } + return nil +} + +// Start 启动模块的后台工作。 +// +// 契约:Start 必须快速返回——长任务在独立 goroutine 中运行(Runtime 在 +// 持锁状态下调用 Start/Stop,阻塞的实现会同时阻塞 Snapshot 与其他生命周期 +// 操作)。模块持有独立于启动 ctx 的生命周期上下文,由 Stop 负责取消, +// 因此模块的运行寿命不受启动流程上下文的影响。 +func (m *Module) Start(_ context.Context) error { + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + m.done = make(chan struct{}) + go m.run(ctx) + m.logger.Info("{{.Package}} module started", + zap.String("module", string(ID)), + zap.Duration("interval", m.cfg.Interval)) + return nil +} + +// run 是模块的后台工作循环,直到 ctx 被取消。 +// +// TODO: 替换为模块真实的后台逻辑;没有后台工作的模块可整体删除 +// Start / Stop / run(连同对应的编译期断言与字段)。 +func (m *Module) run(ctx context.Context) { + defer close(m.done) + ticker := time.NewTicker(m.cfg.Interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + // TODO: 在这里执行周期工作。 + m.logger.Debug("{{.Package}} tick", zap.String("module", string(ID))) + } + } +} + +// Stop 取消后台 goroutine 并等待其退出,尊重 ctx 的 deadline +// (与进程优雅关闭超时协同)。未 Start 过(或 Start 之前失败)时调用为 no-op。 +func (m *Module) Stop(ctx context.Context) error { + if m.cancel == nil { + return nil + } + m.cancel() + // 先非阻塞探测 worker 是否已退出:当传入的 ctx 已取消/超时而 worker 其实 + // 已干净退出时,select 双臂就绪的随机选取可能把成功停止误报为失败。 + select { + case <-m.done: + m.logger.Info("{{.Package}} module stopped", zap.String("module", string(ID))) + return nil + default: + } + select { + case <-m.done: + m.logger.Info("{{.Package}} module stopped", zap.String("module", string(ID))) + return nil + case <-ctx.Done(): + return fmt.Errorf("{{.Package}}: stop interrupted while waiting for worker exit: %w", ctx.Err()) + } +} diff --git a/backend/tools/newmodule/templates/module_test.go.tmpl b/backend/tools/newmodule/templates/module_test.go.tmpl new file mode 100644 index 0000000000..67c1324f79 --- /dev/null +++ b/backend/tools/newmodule/templates/module_test.go.tmpl @@ -0,0 +1,86 @@ +package {{.Package}} + +// 本测试文件由 tools/newmodule 生成,基于 plugintest 夹具覆盖注册校验、 +// 默认配置与 Runtime 生命周期冒烟。 +// TODO: 模块实现真实逻辑后请补充对应的行为测试 +// (更多夹具用法参照 internal/modules/hello/hello_test.go)。 + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/Wei-Shaw/sub2api/internal/plugin" + "github.com/Wei-Shaw/sub2api/internal/plugin/plugintest" +) + +// TestModuleRegisteredInDefaultRegistry 验证 init() 已向包级默认注册表 +// 注册 {{.ID}},且默认 disabled(EnabledByDefault=false)。 +func TestModuleRegisteredInDefaultRegistry(t *testing.T) { + info, ok := plugin.GetModule(ID) + require.True(t, ok, "{{.ID}} should self-register via init()") + require.Equal(t, ID, info.ID) + require.False(t, info.EnabledByDefault, "new modules must be disabled by default") + require.NotNil(t, info.New) + require.IsType(t, &Module{}, info.New()) +} + +// TestProvisionDefaults 验证未提供私有配置时 Provision 使用默认配置且可通过校验。 +func TestProvisionDefaults(t *testing.T) { + m := new(Module) + require.NoError(t, m.Provision(plugintest.NewHost(t))) + require.Equal(t, defaultConfig(), m.cfg) + require.NoError(t, m.Validate()) + + // nil host 防御路径:不 panic、使用默认配置。 + m2 := new(Module) + require.NoError(t, m2.Provision(nil)) + require.Equal(t, defaultConfig(), m2.cfg) + require.NoError(t, m2.Validate()) +} + +// TestValidateRejectsInvalidConfig 验证非法配置被 Validate 拒绝。 +// TODO: 配置字段变化后同步更新本用例。 +func TestValidateRejectsInvalidConfig(t *testing.T) { + m := &Module{cfg: Config{Interval: 0}} + err := m.Validate() + require.Error(t, err) + require.Contains(t, err.Error(), "interval must be positive") +} + +// TestRuntimeDefaultConfigIsNoop 验证缺省配置(modules: 子树为空)下, +// 模块保持 disabled:Build/Start/Stop 全程 no-op,无任何副作用。 +func TestRuntimeDefaultConfigIsNoop(t *testing.T) { + logOpt, logs := plugintest.WithObservedLogger() + rt := plugintest.RunLifecycle(t, &Module{}, plugintest.NewHost(t, logOpt), nil) + + require.NoError(t, rt.Stop(context.Background())) + + snap := rt.Snapshot() + require.Len(t, snap, 1) + require.Equal(t, ID, snap[0].ID) + require.False(t, snap[0].Enabled) + require.Equal(t, plugin.StateRegistered, snap[0].State) + require.Zero(t, logs.Len(), "disabled module must not produce any module logs") +} + +// TestRuntimeEnabledLifecycle 验证启用 {{.ID}} 后的完整生命周期: +// Build(Provision/Validate)→ Start(后台工作运行)→ Stop(优雅退出)。 +func TestRuntimeEnabledLifecycle(t *testing.T) { + logOpt, logs := plugintest.WithObservedLogger() + rt := plugintest.RunLifecycle(t, &Module{}, plugintest.NewHost(t, logOpt), map[string]map[string]any{ + "{{.ID}}": {"enabled": true, "interval": "5ms"}, + }) + + require.Eventually(t, func() bool { + return logs.FilterMessage("{{.Package}} tick").Len() >= 1 + }, 2*time.Second, 5*time.Millisecond, "background work should run after Start") + + require.NoError(t, rt.Stop(context.Background())) + snap := rt.Snapshot() + require.Len(t, snap, 1) + require.True(t, snap[0].Enabled) + require.Equal(t, plugin.StateStopped, snap[0].State) +} diff --git a/docs/plugin-architecture/MODULE-AUTHOR-GUIDE.md b/docs/plugin-architecture/MODULE-AUTHOR-GUIDE.md new file mode 100644 index 0000000000..c75ce73518 --- /dev/null +++ b/docs/plugin-architecture/MODULE-AUTHOR-GUIDE.md @@ -0,0 +1,707 @@ +# Sub2API 模块作者指南(Module Author Guide) + +> 本指南面向需要编写或迁移插件模块的开发者,内容以 `backend/internal/plugin/` 已落地代码为准。 +> 配套文档:[插件化改造 ROADMAP](../../.claude/plugin-refactor/ROADMAP.md)(命名空间规划与全程铁律)、 +> [回归不变量清单 INVARIANTS](../../.claude/plugin-refactor/INVARIANTS.md)(每 PR 必须保持的外部行为)。 + +## 1. 概述与设计理念 + +Sub2API 的插件系统是 **Caddy 式进程内模块系统**:模块与宿主编译进同一个二进制,没有动态加载。内核由四部分组成——**命名空间化模块注册表**(模块包在 `init()` 期自注册,编译期插装清单决定哪些模块被编译进来)、**`modules:` 配置子树**(每模块的 enabled 开关 + 私有配置)、**宿主能力面 Host**(模块可使用的全部宿主能力,只暴露 ports 级接口)、**生命周期驱动器 Runtime**(按稳定序驱动 Provision → Validate → Start → Stop)。注册表只是"目录",模块的实例化与生命周期统一由 Runtime 驱动。引入新模块在缺省配置下必须是零行为变更。 + +内核代码全部位于 `backend/internal/plugin/`: + +| 文件 | 职责 | +|---|---| +| `module.go` | `ModuleID` / `ModuleInfo` / `Module` 与 4 个可选生命周期接口 | +| `registry.go` | 注册表:`RegisterModule` / `GetModule` / `GetModulesInNamespace`,可实例化的 `Registry` | +| `host.go` | 宿主能力面 `Host`(Logger / ConfigOf / DB / Redis) | +| `config.go` | `modules:` 配置子树解析(`ParseConfig` / `Config.Of`) | +| `runtime.go` | 生命周期驱动器 `Runtime`(Build / Start / Stop / Snapshot) | +| `wire.go` | Wire 装配(ProvideModuleConfig / ProvideHost / ProvideModuleRuntime) | + +## 2. 模块结构与命名空间 + +### 2.1 ModuleID 规则 + +`ModuleID` 是模块的全局唯一标识,采用点分层级命名,形如 `job.hello`、`gateway.platform.anthropic`: + +- 最后一段为模块名(`Name()`),之前的部分为命名空间(`Namespace()`); +- 每一段只允许 `[a-z0-9_-]` 字符且不能为空; +- 单段 ID(如 `hello`)的命名空间为空字符串; +- `GetModulesInNamespace(ns)` 为**精确匹配**(非前缀匹配):`ns` 为 `gateway.platform` 时返回 `gateway.platform.anthropic` 等直接子模块,不包含 `gateway.platform.x.y`。 + +ID 格式非法(空段、非法字符)在注册时直接 panic,在配置解析时返回错误,均为 fail-fast。 + +### 2.2 命名空间规划 + +命名空间在 [ROADMAP](../../.claude/plugin-refactor/ROADMAP.md) 中一次定义、分阶段填充。新模块必须落入已规划的命名空间;需要新命名空间时先在 ROADMAP 登记: + +| 命名空间 | 宿主接口 | 迁移阶段 | 状态 | +|---|---|---|---| +| `job.*` | Starter/Stopper | Phase 1 示例 / Phase 2 可选 | **已实现**(示例模块 `job.hello`) | +| `payment.provider.*` | 现有 `payment.Provider` | Phase 2 | 规划中 | +| `gateway.hook.*` | pre/post-flight 管道钩子 | Phase 2 | 规划中 | +| `gateway.platform.*` | PlatformProvider | Phase 3 | 规划中 | +| `channel.*` / `notify.*` | 数据契约 / Notifier | Phase 4 | 规划中 | +| `plugin.bridge.*` | gRPC 进程外桥 | Phase 5(可选) | 规划中 | + +### 2.3 模块的最低要求 + +模块是一个实现 `plugin.Module` 接口的 struct,放在 `backend/internal/modules/<模块名>/` 包内: + +```go +// Module 是所有插件模块必须实现的最小接口。 +type Module interface { + // ModuleInfo 返回模块的注册信息。该方法必须无副作用且可在零值实例上调用。 + ModuleInfo() ModuleInfo +} +``` + +`ModuleInfo` 是注册表中的一条记录: + +```go +type ModuleInfo struct { + // ID 是模块的全局唯一标识。 + ID ModuleID + + // New 返回该模块的一个新实例。 + // 每次 Runtime.Build 都会通过 New 创建全新实例,模块不应依赖包级可变状态。 + New func() Module + + // EnabledByDefault 声明该模块在 `modules:` 配置未显式设置 enabled 时的默认启停状态。 + // 零值 false 意味着新模块默认不启用,保证引入新模块不会在未配置时改变现有行为。 + EnabledByDefault bool +} +``` + +要点: + +- **零值即可用**:`ModuleInfo()` 必须可在零值实例上调用且无副作用;依赖在 `Provision` 中装配; +- **默认 disabled**:除非有明确决策,新模块的 `EnabledByDefault` 应为 `false`(零值),未配置时模块不实例化、无任何副作用; +- **注册在 `init()` 期完成**,重复 ID、nil New、非法 ID 一律 panic(注册错误属于编译期插装错误,必须在进程启动最早期暴露): + +```go +const ID plugin.ModuleID = "job.hello" + +func init() { + plugin.RegisterModule(&Module{}) +} +``` + +建议在模块包中加编译期断言,确保实现的生命周期接口与预期一致: + +```go +var ( + _ plugin.Module = (*Module)(nil) + _ plugin.Provisioner = (*Module)(nil) + _ plugin.Validator = (*Module)(nil) + _ plugin.Starter = (*Module)(nil) + _ plugin.Stopper = (*Module)(nil) +) +``` + +## 3. 生命周期契约 + +除 `Module` 外,模块可按需实现四个**可选**生命周期接口: + +```go +type Provisioner interface{ Provision(host *Host) error } +type Validator interface{ Validate() error } +type Starter interface{ Start(ctx context.Context) error } +type Stopper interface{ Stop(ctx context.Context) error } +``` + +Runtime 按**注册表稳定序(ID 字典序)**驱动生命周期,宿主侧用法(`cmd/server/main.go` 已接好,模块作者无需关心): + +```go +rt := plugin.NewRuntime(host, cfg) +if err := rt.Build(); err != nil { ... } // 启动中止 +if err := rt.Start(ctx); err != nil { ... } // 已自动逆序回滚 +defer rt.Stop(shutdownCtx) // 接入既有 cleanup 序列 +``` + +### 3.1 各阶段语义 + +| 阶段 | 调用时机 | 失败后果 | +|---|---|---| +| `New()` | `Runtime.Build`,仅实例化 enabled 模块 | 返回 nil 实例 → Build 报错,启动中止 | +| `Provision(host)` | Build 阶段,实例化后。模块在此从 Host 获取所需能力(Logger / ConfigOf / DB / Redis)并完成自身初始化 | Build 报错,**启动中止**(fail-fast) | +| `Validate()` | Build 阶段,Provision 之后。校验模块配置与状态 | Build 报错,**启动中止** | +| `Start(ctx)` | `Runtime.Start`,HTTP server 启动前,按稳定序。启动后台工作(goroutine、监听等);未实现 Starter 的 enabled 模块直接视为 running | 见 3.2 逆序回滚 | +| `Stop(ctx)` | `Runtime.Stop`,进程优雅关闭时,按**稳定序的逆序** | 见 3.3 | + +`Build` 任一模块失败立即返回包含模块 ID 的错误,不会继续处理后续模块;已成功 Provision 的模块保持 provisioned 状态,由进程启动失败路径整体退出(`main` 中 `log.Fatalf`)。Runtime 为单次使用:Build/Start 各只能调用一次,不支持 Stop 后重新 Start。 + +### 3.2 Start 半途失败:逆序回滚 + +任一模块 `Start` 失败时,Runtime 对**已进入 running 的模块按逆序执行 Stop 回滚**(回滚沿用传入的 ctx;回滚失败仅记日志),然后返回包含失败模块 ID 的错误,进程启动中止。因此: + +- `Start` 失败的模块应自行清理 `Start` 内部已分配的资源(Runtime 不会对失败模块本身调用 Stop); +- `Stop` 必须容忍"Start 从未成功过"的状态(见 9.3 hello 的 no-op 处理)。 + +### 3.3 Stop 逆序与 ctx deadline + +- `Stop` 按稳定序的**逆序**执行:后启动的模块先停止; +- 单个模块 Stop 失败**只记日志并继续**关闭其余模块,最终返回 `errors.Join` 聚合的失败集合(与既有 cleanup 序列的容错纪律一致); +- ctx 原样传递给各模块 Stop,**模块必须自行尊重其 deadline**——宿主的 cleanup 整体超时为 10 秒,模块 Stop 与其他业务服务关闭并行执行,且先于 Redis/Ent 等基础设施关闭(模块停止时仍可写 DB/Redis)。 + +### 3.4 状态机 + +``` +registered ──Build──▶ provisioned ──Start──▶ running ──Stop──▶ stopped + │(disabled 模块停留在 registered) 任一阶段失败 ──▶ errored +``` + +- `registered`:已注册未实例化(disabled 模块始终处于该状态); +- `provisioned`:已实例化并通过 Provision/Validate,等待启动; +- `running`:已启动(未实现 Starter 的 enabled 模块在 Start 阶段也进入该状态); +- `stopped`:已正常停止; +- `errored`:在某个生命周期阶段失败,错误见 `ModuleStatus.Err`。 + +`Runtime.Snapshot()` 返回全部已注册模块(含 disabled)的状态快照,按 ID 字典序稳定排序,供可观测使用。 + +## 4. Host 能力面 + +`Provision` 注入的 `*plugin.Host` 是模块可使用的**全部**宿主能力,当前仅四项: + +```go +type Host struct { + // Logger 是宿主提供的结构化日志器。模块应通过它输出日志,而不是自建全局 logger。 + Logger *zap.Logger + + // ConfigOf 将 `modules:` 配置子树中 id 对应的 raw 私有配置解码到 out + // (out 必须是指向模块自定义配置 struct 的指针)。 + ConfigOf func(id ModuleID, out any) error + + // DB 是 Ent ORM 客户端(数据库访问能力)。 + DB *ent.Client + + // Redis 是 go-redis 客户端(缓存/队列能力)。 + Redis redis.UniversalClient +} +``` + +**铁律(host.go 边界纪律):** + +1. Host 只暴露 ports 级基础能力,**禁止暴露任何具体 Service**——模块依赖具体 Service 等于绕过插件边界,回到单体耦合; +2. **后续每新增一项 Host 能力,必须在[插件化 ROADMAP](../../.claude/plugin-refactor/ROADMAP.md) 决策记录中登记**,防止 Host 膨胀为上帝对象; +3. 模块内部依赖一律经 Host 获取,**不进入 Wire graph**(Wire 只负责装配 Host 与 Runtime,模块目录由注册表负责)。 + +防御性约定:`Provision` 应容忍 `host`、`host.Logger`、`host.ConfigOf` 为 nil(测试中常以部分填充的 Host 驱动模块),参考 hello 模块的处理(见第 9 节)。 + +## 5. 配置子树(`modules:`) + +### 5.1 用法 + +模块配置位于全局配置(`config.yaml`)的 `modules:` 子树,**顶层 key 是完整的带点模块 ID 字面量**(一个 key,不是嵌套层级): + +```yaml +modules: + job.hello: + enabled: true # 内核保留键:启停开关 + interval: 10s # 以下为模块私有配置,内核不解释 + greeting: "hello from sub2api" +``` + +每个模块项中只有 `enabled` 键由内核解释(接受 bool 或可解析为 bool 的字符串),其余键原样保留为模块私有配置,由 `Config.Of` 解码到模块自定义的配置 struct。 + +### 5.2 enabled 三态语义 + +| 配置情况 | 最终启停 | +|---|---| +| `enabled: true` | 启用 | +| `enabled: false` | 禁用 | +| 未显式配置(含整个模块项缺失、`modules:` 子树不存在、或写了空的 `enabled:` 即 YAML null) | 由注册时声明的 `ModuleInfo.EnabledByDefault` 决定 | + +缺省(`modules:` 子树不存在)时为空配置,所有模块按各自默认 enabled 值运行——对默认 disabled 的模块即"完全不存在",行为与未引入插件内核前一致。 + +### 5.3 私有配置解码 + +`Config.Of`(经 `host.ConfigOf` 暴露给模块)的解码行为与项目全局配置(Viper 默认解码器)保持一致: + +- `mapstructure` 标签、弱类型转换、字符串到 `time.Duration` / 逗号分隔切片的钩子; +- **模块未配置或私有配置为空时不修改 out**——模块应先填默认值再调用 `ConfigOf`(参考 hello 的 `defaultConfig()`); +- 类型不匹配等解码失败时返回包含模块 ID 的错误,模块应将其从 `Provision` 原样返回(启动中止)。 + +### 5.4 未知 ID 忽略 / 非法 ID fail-fast + +`ParseConfig` 的语义(在应用启动装配阶段执行,见 `plugin/wire.go` 的 `ProvideModuleConfig`): + +- **ID 格式非法**(如 `modules:` 下出现 `Job.Hello`、空段)或 **enabled 类型错误** → 返回错误,**应用启动 fail-fast**,配置笔误在启动时即暴露; +- **格式合法但未注册**的模块 ID(例如不同编译变体的配置共用)→ 允许存在,**被 Runtime 忽略**,不影响其他模块。 + +### 5.5 viper 含点 key 的注意事项 + +`modules:` 子树**不走 `viper.Unmarshal`**,而是在配置加载时通过 `viper.Get("modules")` 手工提取(见 `internal/config/config.go` 的 `Config.Modules` 字段与 `normalizeModulesSubtree`)——Unmarshal 基于 AllSettings 重建嵌套结构,会把含点的模块 ID key(`job.hello`)错误拆分为多级嵌套(`job: { hello: ... }`)。模块作者无需做任何事,但需注意: + +- 在 YAML 中模块 ID 必须写成**单个 key**(`job.hello:`),不要写成嵌套层级; +- 空模块项(如 `job.hello:` 后无内容)规整为空 map,语义是"提及但无私有配置"(enabled 仍走默认值); +- 全局配置的环境变量覆盖(`.` → `_` 替换)不适用于 `modules:` 子树下含点的模块 ID key,模块配置请写在配置文件中。 + +## 6. 插装清单(Instrumentation List) + +`backend/internal/modules/standard/imports.go` 是**唯一插装点**:所有需要编译进二进制的内置模块必须且只能在该文件中匿名 import。模块包在 `init()` 期向包级默认注册表自注册,`cmd/server/main.go` 匿名 import 本包即完成全部内置模块的插装。 + +```go +// Package standard 是插件模块的唯一插装清单(instrumentation list)。 +package standard + +import ( + // job.hello:示例模块(默认 disabled),验证插件链路连通性。 + _ "github.com/Wei-Shaw/sub2api/internal/modules/hello" +) +``` + +**新增一个模块 = 在此文件加一行 import**(带一行用途注释),使插装变更集中、可 review。不要在其他任何位置(包括测试外的业务代码)匿名 import 模块包。 + +## 7. 测试要求 + +### 7.1 用隔离 Registry,不污染包级默认注册表 + +涉及 Runtime 生命周期的测试**必须**在隔离注册表中驱动,避免依赖(或污染)包级默认注册表。默认入口是 `internal/plugin/plugintest` 夹具包——`RunLifecycle` / `BuildOnly` / `BuildExpectingError` 内部已用 `plugin.NewRegistry()` 构造隔离环境,一行驱动完整生命周期(API 速览与典型用法见 8.2)。 + +plugintest 未覆盖的复杂编排(Start 半途失败回滚、多模块顺序等)直接使用完全公开的内核 API: + +```go +reg := plugin.NewRegistry() +reg.RegisterModule(&Module{}) +rt := plugin.NewRuntimeWithRegistry(&plugin.Host{Logger: logger}, cfg, reg) + +require.NoError(t, rt.Build()) +require.NoError(t, rt.Start(context.Background())) +require.NoError(t, rt.Stop(context.Background())) +``` + +测内核语义本身(注册 panic、命名空间查询、生命周期顺序等)时,用 fake 模块(实现部分生命周期接口的最小 struct)注册进隔离 Registry,参考 `internal/plugin/registry_test.go`、`runtime_test.go` 中的写法。 + +### 7.2 模块必测清单 + +参照 `internal/modules/hello/hello_test.go`,新模块至少覆盖: + +1. **已注册且默认值正确**:`plugin.GetModule(ID)` 可查到,`EnabledByDefault` 符合预期; +2. **Provision 默认配置**:未提供私有配置时使用默认值并通过 Validate;nil host 不 panic; +3. **私有配置解码**:经 `host.ConfigOf` 解码(含 duration 字符串等弱类型转换);解码类型错误时 Provision 返回含模块 ID 的错误; +4. **Validate 拒绝非法配置**(表驱动); +5. **Start/Stop 行为**:启动后副作用可观测(如用 `zaptest/observer` 断言日志)、Stop 后 goroutine 优雅退出、未 Start 时 Stop 为 no-op; +6. **缺省配置零副作用**:`plugin.Config{}` 下模块保持 disabled,Build/Start/Stop 全程 no-op、Snapshot 状态为 `registered`、无任何模块日志。 + +### 7.3 回归 gate(每 PR 硬性要求) + +插件化改造全程零回归,模块相关 PR 必须通过 Phase-0 回归安全网(在 `backend/` 目录执行): + +```bash +make test-invariants # 不变量/特征化测试(详见 .claude/plugin-refactor/INVARIANTS.md) +./scripts/bench-baseline.sh compare # 热路径基准 vs 基线(allocs/op 严格、ns/op 容差 15%) +``` + +不变量清单见 [INVARIANTS.md](../../.claude/plugin-refactor/INVARIANTS.md),基准脚本说明见 [bench-baseline.sh](../../backend/scripts/bench-baseline.sh) 文件头注释。**默认配置(不启用任何模块)下,全部不变量必须绿、基准无回归。** 两条命令的预期输出、阈值语义与 Wire 重生成注意事项见 8.5。 + +## 8. 开发与调试工作流 + +本章把"从零开发并调试一个模块"串成可照做的闭环,每一步都给出实测过的命令与预期输出(实测环境:2026-06 主线)。除特别说明外,命令均在 `backend/` 目录下执行。 + +``` +make new-module(8.1 脚手架)→ plugintest 单测驱动开发(8.2) +→ imports.go 插装 + config.yaml 启用 + go run 本地运行(8.3) +→ 日志 / admin API / 前端页面观测(8.4) +→ make test-invariants + bench compare 提交前自检(8.5) +``` + +### 8.1 脚手架:生成模块骨架 + +```bash +make new-module ID=job.foo # 等价:go run ./tools/newmodule -id job.foo +``` + +预期输出(实测,全文): + +``` +module job.foo generated: + internal/modules/foo/foo.go + internal/modules/foo/foo_test.go + +Next steps (the generator never edits imports.go -- explicit instrumentation keeps it reviewable): + + 1. 编译期插装:在 internal/modules/standard/imports.go 的 import 块中加入: + + // job.foo:TODO 一句话描述模块用途(默认 disabled)。 + _ "github.com/Wei-Shaw/sub2api/internal/modules/foo" + + 2. 配置启用(config.yaml;新模块默认 disabled,未显式 enabled 时零行为变更): + + modules: + job.foo: + enabled: true + interval: 30s + + 3. 填充代码中的 TODO 后运行测试: + + go test -count=1 ./internal/modules/foo/ + +生命周期契约与完整示例见 docs/plugin-architecture/MODULE-AUTHOR-GUIDE.md(蓝本实现:internal/modules/hello/)。 +``` + +产物说明(模板从 hello 蓝本提炼,渲染时经 gofmt 校验,ID/包名非法直接报错、目标目录已存在拒绝覆盖): + +- `foo.go`:全部四个可选生命周期接口的骨架 + 编译期断言 + `EnabledByDefault: false` + 示例私有配置(`interval`)与校验,`TODO` 注释标出需要填充/删改的位置; +- `foo_test.go`:基于 plugintest 的 5 个用例(注册校验、默认配置、非法配置、缺省 no-op、启用生命周期),**开箱即过**: + +```bash +$ go test -count=1 ./internal/modules/foo/ +ok github.com/Wei-Shaw/sub2api/internal/modules/foo 0.017s +``` + +生成器**不会**自动修改 `standard/imports.go`——插装行按 next steps 提示手工加入(设计原则与唯一插装点纪律见第 6 节)。 + +### 8.2 单测驱动开发:plugintest 夹具 + +`internal/plugin/plugintest` 是模块测试的默认入口(**仅供 `_test.go` import**,进入生产依赖面在评审中一票否决)。API 速览: + +| 入口 | 用途 | +|---|---| +| `NewHost(t, ...Option)` | 一行构造 Host,默认零依赖可用:nop logger + 空 ConfigOf + nil DB/Redis | +| `WithConfig(raw)` | 把与 `modules:` 子树同形状的 raw 绑定为 ConfigOf,供直接调 `Provision` 的测试 | +| `WithObservedLogger()` | 返回 `(Option, *observer.ObservedLogs)`,debug 级别,用于断言日志 | +| `WithRedis(t)` | 附带 miniredis 支撑的 go-redis 客户端,生命周期挂 `t.Cleanup` 自动清理 | +| `RunLifecycle(t, m, host, raw)` | 隔离注册 → 解析 raw → Build → Start;`t.Cleanup` 自动 Stop(10s 上限)并断言无错 | +| `BuildOnly(...)` | 同 RunLifecycle 但停在 Build 之后,只验证 Provision/Validate | +| `BuildExpectingError(...)` | 期望 Build 失败,返回错误供断言内容(错误信息含模块 ID) | + +典型用法(均摘自 `internal/modules/hello/hello_test.go`)。 + +直接调 Provision 验证私有配置解码——`NewHost` + `WithConfig`: + +```go +host := plugintest.NewHost(t, plugintest.WithConfig(map[string]map[string]any{ + "job.hello": {"enabled": true, "interval": "15ms", "greeting": "hi"}, +})) + +m := new(Module) +require.NoError(t, m.Provision(host)) +require.Equal(t, 15*time.Millisecond, m.cfg.Interval) +require.Equal(t, "hi", m.cfg.Greeting) +``` + +完整生命周期 + 日志断言——`RunLifecycle` + `WithObservedLogger`: + +```go +logOpt, logs := plugintest.WithObservedLogger() +rt := plugintest.RunLifecycle(t, &Module{}, plugintest.NewHost(t, logOpt), map[string]map[string]any{ + "job.hello": {"enabled": true, "interval": "5ms"}, +}) + +require.Eventually(t, func() bool { + return logs.FilterMessage(defaultConfig().Greeting).Len() >= 1 +}, 2*time.Second, 5*time.Millisecond, "periodic debug log should appear after Start") + +require.NoError(t, rt.Stop(context.Background())) +snap := rt.Snapshot() +require.True(t, snap[0].Enabled) +require.Equal(t, plugin.StateStopped, snap[0].State) +``` + +非法配置应使 Build 失败(fail-fast)——`BuildExpectingError`: + +```go +err := plugintest.BuildExpectingError(t, &Module{}, plugintest.NewHost(t), map[string]map[string]any{ + "job.hello": {"enabled": true, "interval": "-1s"}, +}) +require.Contains(t, err.Error(), `validate module "job.hello"`) +require.Contains(t, err.Error(), "interval must be positive") +``` + +语义提醒: + +- `RunLifecycle` / `BuildOnly` 的 `raw` 是模块配置的**唯一来源**(会覆盖 host 上 `WithConfig` 设置的 ConfigOf),两者不要混用; +- raw 未写 `enabled: true` 且模块默认 disabled 时,Build/Start 全程 no-op,夹具会 `t.Logf` 提示(防"忘写 enabled"空跑:`module "job.foo" resolved to disabled under the given config ...`); +- Host 是纯数据结构,夹具未覆盖的少见场景(如注入 enttest 客户端)直接对字段赋值即可。 + +模块必测清单见 7.2;plugintest 未覆盖的复杂编排用内核 API(见 7.1)。 + +### 8.3 本地运行:在真实进程中启用模块 + +**依赖准备(PostgreSQL 15+ / Redis 7+)。** 调试模块推荐在宿主机直跑后端: + +- 本机已有 PG/Redis:`config.yaml` 的 `database:` / `redis:` 指向 `127.0.0.1` 即可(本地环境约定参照仓库根 `DEV_GUIDE.md`); +- 用 Docker 起依赖:`deploy/docker-compose.dev.yml` 内有 `postgres` / `redis` 服务,但**默认不映射宿主端口**(仅供同 compose 网络内的 sub2api 容器使用)。整栈在容器内运行(改模块代码需重新 build): + + ```bash + cd deploy && POSTGRES_PASSWORD=devpass docker compose -f docker-compose.dev.yml up -d --build + ``` + + 若坚持宿主跑后端 + Docker 跑依赖,需自行给 `postgres` / `redis` 服务加 `ports:` 映射(`5432:5432` / `6379:6379`)后再 `docker compose -f docker-compose.dev.yml up -d postgres redis`。 + +**配置。** 后端从工作目录查找 `config.yaml`(还会查 `./config`、`/etc/sub2api` 等;完整模板见 `deploy/config.example.yaml`),在 `backend/` 下运行即读取 `backend/config.yaml`。在其中追加 `modules:` 子树(顶层任意位置,语义见第 5 节): + +```yaml +modules: + job.hello: + enabled: true + interval: 10s +``` + +**启动与日志。** + +```bash +go run ./cmd/server +``` + +启动序列中、HTTP server 监听之前,可看到模块生命周期日志(实测摘录;`interval` 字段为毫秒数): + +``` +2026-06-11T20:01:31.765+0800 INFO hello/hello.go:111 hello module started {"service": "sub2api", "env": "production", "module": "job.hello", "interval": 10000} +2026-06-11T20:01:31.765+0800 INFO plugin/runtime.go:209 plugin module started {"service": "sub2api", "env": "production", "module": "job.hello"} +``` + +随后出现 `Server started on :`。注意 `plugin module provisioned` 与 hello 的周期 greeting 是 **Debug** 级别,默认 `log.level: info` 下不可见——要观察周期行为,在 `config.yaml` 中设 `log: { level: debug }`。模块配置非法时启动 fail-fast:进程以 `Failed to build plugin modules: ...`(错误含模块 ID)退出,符合 5.4 的语义。 + +**优雅关闭。** Ctrl+C(SIGINT/SIGTERM)后模块按逆序停止,与既有 cleanup 步骤交织输出(实测摘录): + +``` +2026-06-11T20:03:50.449+0800 INFO hello/hello.go:149 hello module stopped {"service": "sub2api", "env": "production", "module": "job.hello"} +2026-06-11T20:03:50.449+0800 INFO plugin/runtime.go:252 plugin module stopped {"service": "sub2api", "env": "production", "module": "job.hello"} +2026-06-11T20:03:50.449+0800 INFO stdlog runtime/asm_amd64.s:1771 [Cleanup] PluginModuleRuntime succeeded {"service": "sub2api", "env": "production", "legacy_stdlog": true} +``` + +### 8.4 三种观测方式 + +**1) 结构化日志。** 模块经 `Host.Logger` 输出的日志自带全局字段(`service` / `env`),按模块自身附加的 `module` 字段过滤即可。Runtime 侧固定输出:`plugin module started` / `plugin module stopped`(Info)、`plugin module provisioned`(Debug)、`plugin module stop failed`(Error),均含 `"module": ""` 字段。 + +**2) admin API(只读)。** 支持管理员 JWT 或系统设置中配置的 Admin API Key 两种鉴权(端口以 `server.port` 为准): + +```bash +curl -s http://127.0.0.1:8080/api/v1/admin/modules -H "x-api-key: " +# 或:curl -s http://127.0.0.1:8080/api/v1/admin/modules -H "Authorization: Bearer " +``` + +实测响应(job.hello 启用且运行中): + +```json +{ + "code": 0, + "message": "success", + "data": { + "modules": [ + { + "id": "job.hello", + "namespace": "job", + "name": "hello", + "enabled": true, + "state": "running", + "error": "" + } + ] + } +} +``` + +要点:返回**全部已注册模块**(disabled 模块也在列,`state` 为 `registered`),按 ID 字典序;`error` 文本已统一脱敏;全新部署需先完成管理员合规确认,否则该接口返回 423 `ADMIN_COMPLIANCE_ACK_REQUIRED`(用前端登录一次按引导确认即可)。 + +**3) 前端「插件模块」页。** + +```bash +cd frontend && pnpm install && pnpm run dev # http://127.0.0.1:3000 +``` + +dev server 默认把 `/api` 代理到 `http://localhost:8080`;后端端口不是 8080 时在 `frontend/.env` 设 `VITE_DEV_PROXY_TARGET=http://127.0.0.1:`。管理员登录后,经侧边栏「插件模块」进入 `/admin/modules`:只读列表展示模块 ID / 命名空间 / 名称 / 启用 / 状态 / 错误信息,状态以语义徽章着色(running=绿、errored=红、registered=灰、provisioned/stopped=主色),错误长文本截断 + 悬浮查看完整内容,右上角手动刷新。页面消费的就是上面的 admin API(实现:`frontend/src/views/admin/ModulesView.vue`)。 + +### 8.5 提交前自检 + +```bash +make test-invariants # 回归不变量/特征化测试(实测约 16s) +./scripts/bench-baseline.sh compare # 热路径基准 vs 基线(BENCH_COUNT=6 实测约 4 分钟) +``` + +`test-invariants` 只筛选 `Characterization|Invariant` 用例,多数包显示 `[no tests to run]` 属正常,关注每行是否全 `ok`、退出码为 0。 + +`compare` 先跑当前基准(有 `benchstat` 时附其报告),再对每个基准输出对比行并按阈值判定(实测样例): + +``` +BenchmarkGatewayForward_AnthropicNonStreamPassthrough ns/op 10164 -> 9882 (-2.8%) allocs/op 93.0 -> 93.0 ok +``` + +阈值语义(详见脚本头注释): + +- **allocs/op 严格**:均值增加超过 `ALLOC_TOLERANCE`(默认 0)即 FAIL; +- **ns/op 宽松**:劣化超过 `TIME_TOLERANCE_PCT`%(默认 15,吸收 runner 抖动)即 FAIL; +- **基线中的基准在本次运行缺失**(被删除/改名)→ FAIL(回归网已失效,须修复或重新 collect);新增基准只 warn、不参与对比; +- 基线文件不存在 → 退出码 2,提示先 `./scripts/bench-baseline.sh collect`(换机器/换 Go 版本后也要重新采集)。 + +**Wire 重生成注意。** 普通模块不进 Wire graph,无需此步;只有改了 Wire 装配(如 `internal/plugin/wire.go`、`cmd/server/wire.go`)才需要重生成。当前 `make generate` / `go generate ./cmd/server` 中无版本号的 wire 调用因 go.sum 缺 `github.com/google/subcommands` 条目而失败(预存在问题,实测报错 `missing go.sum entry for module providing package github.com/google/subcommands`),实测可用的调用方式: + +```bash +go run github.com/google/wire/cmd/wire@v0.7.0 ./cmd/server/ +``` + +**前端有改动时**,在仓库根执行 `make test-frontend`(lint + typecheck + 关键 vitest 集)。 + +### 8.6 常见坑 + +| 坑 | 现象 | 处置 | +|---|---|---| +| 忘加 imports.go | 单包 `go test` 全绿(同包测试必然触发 `init()` 注册),但真实进程里模块"不存在":无生命周期日志,Snapshot / admin API / 前端页面的列表里**根本没有这个模块**(配置中格式合法但未注册的 ID 被 Runtime 静默忽略,见 5.4) | 在 `standard/imports.go` 加匿名 import(见第 6 节) | +| `enabled:` 留空 | YAML null = 未显式配置 = 走 `EnabledByDefault`(默认 false),模块不会运行 | 启用必须写 `enabled: true`(三态语义见 5.2) | +| 含点 ID 的 viper 行为 | 模块 ID 在 YAML 里写成嵌套层级(`job:` 下挂 `hello:`)不被识别;环境变量覆盖对 `modules:` 子树不生效 | ID 写成单个 key、模块配置写在配置文件中(见 5.5)。改动 modules 配置解析本身时,注意该子树不走 `viper.Unmarshal`,而是 `internal/config` 的 `normalizeModulesSubtree` 手工提取 | +| `EnabledByDefault: true` | 模块未配置即运行——"引入新模块在缺省配置下零行为变更"的铁律被打破 | 保持 false;确需默认启用须先在 ROADMAP 登记决策(见 2.3) | +| Stop 不尊重 ctx deadline | 宿主 cleanup 整体超时 10 秒、模块 Stop 与其他服务关闭并行,悬挂的 Stop 拖死优雅关闭;plugintest 兜底 Stop 同样 10s 上限,悬挂表现为测试失败 | 等待 worker 退出必须同时 `select` 上 `ctx.Done()`(照抄 9.3 的 Stop 写法) | +| Start 里跑长任务 | Runtime 在持锁状态下调用 Start/Stop,阻塞的 Start 会卡住 Snapshot(admin API 与前端页面跟着挂起)和后续模块的启动 | Start 必须快速返回,长任务自起 goroutine;goroutine 不要挂在 Start 的 ctx 上,寿命由 Stop 终结(见 9.3) | +| mapstructure 标签含大写 | 全局配置经 Viper 加载时 key 被静默小写化,含大写的标签永远解码不到值 | `mapstructure` 标签全小写(脚手架模板注释已注明) | +| 前端单文件测试跑成全量 | `pnpm run test:run -- ` 的 `--` 透传会使 vitest 文件过滤失效 | 用 `pnpm exec vitest run `(如 `pnpm exec vitest run src/views/admin/__tests__/ModulesView.spec.ts`) | + +## 9. 完整示例:job.hello + +`backend/internal/modules/hello/hello.go` 是最小可运行的参考实现:实现全部四个可选生命周期接口,启动后按配置间隔周期性输出一条 debug 日志,默认 disabled。以下代码均摘自真实文件(有裁剪)。 + +### 9.1 注册 + +```go +// ID 是 hello 模块的模块 ID。 +const ID plugin.ModuleID = "job.hello" + +func init() { + plugin.RegisterModule(&Module{}) +} + +// Module 是 job.hello 模块实例。零值即可用,依赖在 Provision 中装配; +// 每次 Runtime.Build 都会通过 ModuleInfo.New 创建全新实例。 +type Module struct { + cfg Config + logger *zap.Logger + + cancel context.CancelFunc + done chan struct{} +} + +// ModuleInfo 返回模块注册信息。EnabledByDefault 为 false: +// 未在 `modules:` 中显式 enabled 时模块不实例化、无任何副作用。 +func (*Module) ModuleInfo() plugin.ModuleInfo { + return plugin.ModuleInfo{ + ID: ID, + New: func() plugin.Module { return new(Module) }, + EnabledByDefault: false, + } +} +``` + +插装:`internal/modules/standard/imports.go` 中已有 `_ "github.com/Wei-Shaw/sub2api/internal/modules/hello"` 一行。 + +### 9.2 配置(默认值 + 解码 + 校验) + +```go +// Config 是 hello 模块的私有配置 +// (`modules:` 子树中 "job.hello" 项除 enabled 外的部分)。 +type Config struct { + // Interval 是周期日志的输出间隔,必须大于 0。 + Interval time.Duration `mapstructure:"interval"` + + // Greeting 是周期日志输出的内容,不能为空。 + Greeting string `mapstructure:"greeting"` +} + +// defaultConfig 返回 hello 模块的默认配置(用户未配置的字段保持默认值)。 +func defaultConfig() Config { + return Config{ + Interval: 30 * time.Second, + Greeting: "hello from job.hello", + } +} + +// Provision 从宿主获取 logger,并在默认配置之上解码模块私有配置。 +func (m *Module) Provision(host *plugin.Host) error { + m.cfg = defaultConfig() + if host != nil { + m.logger = host.Logger + if host.ConfigOf != nil { + if err := host.ConfigOf(ID, &m.cfg); err != nil { + return err + } + } + } + if m.logger == nil { + m.logger = zap.NewNop() + } + return nil +} + +// Validate 校验模块配置:interval 必须大于 0,greeting 不能为空。 +func (m *Module) Validate() error { + if m.cfg.Interval <= 0 { + return fmt.Errorf("hello: interval must be positive, got %s", m.cfg.Interval) + } + if m.cfg.Greeting == "" { + return errors.New("hello: greeting must not be empty") + } + return nil +} +``` + +注意先填默认值再解码:`ConfigOf` 对未配置的模块不修改 out,默认值由模块自带。 + +### 9.3 启动与停止 + +```go +// Start 启动周期日志 goroutine。 +// +// 模块持有独立于启动 ctx 的生命周期上下文,由 Stop 负责取消, +// 因此模块的运行寿命不受启动流程上下文的影响。 +func (m *Module) Start(_ context.Context) error { + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + m.done = make(chan struct{}) + go m.run(ctx) + m.logger.Info("hello module started", + zap.String("module", string(ID)), + zap.Duration("interval", m.cfg.Interval)) + return nil +} + +// Stop 取消周期 goroutine 并等待其退出,尊重 ctx 的 deadline。 +// 未 Start 过(或 Start 之前失败)时调用为 no-op。 +func (m *Module) Stop(ctx context.Context) error { + if m.cancel == nil { + return nil + } + m.cancel() + select { + case <-m.done: + m.logger.Info("hello module stopped", zap.String("module", string(ID))) + return nil + case <-ctx.Done(): + return fmt.Errorf("hello: stop interrupted while waiting for worker exit: %w", ctx.Err()) + } +} +``` + +两个值得照抄的细节: + +- **后台 goroutine 不挂在 Start 的 ctx 上**——Start 的 ctx 只是启动流程上下文,模块寿命由 Stop 显式终结; +- **Stop 等待 worker 真正退出,且尊重 ctx deadline**——超时返回包装了 `ctx.Err()` 的错误,Runtime 会记日志并继续关闭其余模块。 + +### 9.4 启用与观测 + +启用(`config.yaml`): + +```yaml +modules: + job.hello: + enabled: true + interval: 10s +``` + +启动/关闭日志样例、`GET /api/v1/admin/modules`(实现见 `internal/handler/admin/module_handler.go`)与前端「插件模块」页等观测方式,统一见第 8 章(8.3 本地运行、8.4 三种观测方式);程序内观测用 `Runtime.Snapshot()`(语义见 3.4)。 + +### 9.5 新模块检查清单 + +- [ ] 模块包位于 `internal/modules/<模块名>/`,ID 落入 ROADMAP 已规划命名空间; +- [ ] `init()` 中 `plugin.RegisterModule`,零值可用、`ModuleInfo()` 无副作用; +- [ ] `EnabledByDefault: false`(除非有登记过的决策); +- [ ] 依赖只经 Host 获取,不依赖任何具体 Service、不进 Wire graph; +- [ ] 私有配置:默认值自带 + `ConfigOf` 解码 + `Validate` 校验; +- [ ] Stop 容忍未 Start、尊重 ctx deadline; +- [ ] `standard/imports.go` 加一行匿名 import; +- [ ] 测试覆盖第 7.2 节清单,使用隔离 Registry(默认经 plugintest,见 7.1 / 8.2); +- [ ] `make test-invariants` 与 `./scripts/bench-baseline.sh compare` 全绿(运行细节见 8.5)。 + +--- + +后续阶段的迁移计划与远期能力(试点模块、平台 Provider 模块化、模块自带前端页面、进程外桥等)以 [插件化 ROADMAP](../../.claude/plugin-refactor/ROADMAP.md) 为准,本指南只描述已落地的内核行为。 diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index 176498a287..7505f0e546 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -32,6 +32,7 @@ import adminPaymentAPI from './payment' import affiliatesAPI from './affiliates' import riskControlAPI from './riskControl' import adminComplianceAPI from './compliance' +import modulesAPI from './modules' /** * Unified admin API object for convenient access @@ -65,7 +66,8 @@ export const adminAPI = { payment: adminPaymentAPI, affiliates: affiliatesAPI, riskControl: riskControlAPI, - compliance: adminComplianceAPI + compliance: adminComplianceAPI, + modules: modulesAPI } export { @@ -97,7 +99,8 @@ export { adminPaymentAPI, affiliatesAPI, riskControlAPI, - adminComplianceAPI + adminComplianceAPI, + modulesAPI } export default adminAPI diff --git a/frontend/src/api/admin/modules.ts b/frontend/src/api/admin/modules.ts new file mode 100644 index 0000000000..d241cfe830 --- /dev/null +++ b/frontend/src/api/admin/modules.ts @@ -0,0 +1,17 @@ +/** + * Admin Modules API endpoints (plugin module observability, read-only) + */ + +import { apiClient } from '../client' +import type { Module } from '@/types' + +export async function list(): Promise<{ modules: Module[] }> { + const { data } = await apiClient.get<{ modules: Module[] }>('/admin/modules') + return data +} + +const modulesAPI = { + list +} + +export default modulesAPI diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 3d7f1604c7..21d92efdf7 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -643,6 +643,21 @@ const ChevronDownIcon = { ) } +const CubeIcon = { + render: () => + h( + 'svg', + { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, + [ + h('path', { + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + d: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4' + }) + ] + ) +} + // Public-settings flags go through the registry in utils/featureFlags.ts, // which handles the opt-in vs opt-out fallback when settings haven't loaded // yet. Admin-only flags (not in public settings) stay inline below. @@ -764,7 +779,8 @@ const adminNavItems = computed((): NavItem[] => { { path: '/admin/orders/plans', label: t('nav.paymentPlans'), icon: CreditCardIcon }, ], }, - { path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon } + { path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon }, + { path: '/admin/modules', label: t('nav.modules'), icon: CubeIcon, hideInSimpleMode: true } ] const visible = applyFeatureFlags(baseItems) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index f71d62a19e..2af5730052 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -416,6 +416,7 @@ export default { channelMonitor: 'Channel Monitor', channelStatus: 'Channel Status', riskControl: 'Risk Control', + modules: 'Modules', }, // Auth @@ -4511,6 +4512,34 @@ export default { deleteConfirm: 'Are you sure you want to delete this announcement? This action cannot be undone.' }, + // Plugin Modules + modules: { + title: 'Plugin Modules', + description: 'Observe registered plugin modules and their runtime state', + columns: { + id: 'Module ID', + namespace: 'Namespace', + name: 'Name', + enabled: 'Enabled', + state: 'State', + error: 'Error' + }, + enabledLabels: { + enabled: 'Enabled', + disabled: 'Disabled' + }, + stateLabels: { + registered: 'Registered', + provisioned: 'Provisioned', + running: 'Running', + stopped: 'Stopped', + errored: 'Errored' + }, + noModules: 'No modules registered', + noModulesDescription: 'No plugin modules are registered in this build', + failedToLoad: 'Failed to load modules' + }, + // Promo Codes promo: { title: 'Promo Code Management', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 996b38d6d9..6d9f943445 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -416,6 +416,7 @@ export default { channelMonitor: '渠道监控', channelStatus: '渠道状态', riskControl: '风控中心', + modules: '插件模块', }, // Auth @@ -4664,6 +4665,34 @@ export default { deleteConfirm: '确定要删除该公告吗?此操作无法撤销。' }, + // Plugin Modules + modules: { + title: '插件模块', + description: '查看已注册插件模块及其运行状态', + columns: { + id: '模块 ID', + namespace: '命名空间', + name: '名称', + enabled: '启用', + state: '状态', + error: '错误信息' + }, + enabledLabels: { + enabled: '已启用', + disabled: '未启用' + }, + stateLabels: { + registered: '已注册', + provisioned: '已装配', + running: '运行中', + stopped: '已停止', + errored: '异常' + }, + noModules: '暂无插件模块', + noModulesDescription: '当前构建中没有已注册的插件模块', + failedToLoad: '加载插件模块失败' + }, + // Promo Codes promo: { title: '优惠码管理', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index bcc5f42197..f4311363f1 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -549,6 +549,18 @@ const routes: RouteRecordRaw[] = [ descriptionKey: 'admin.settings.description' } }, + { + path: '/admin/modules', + name: 'AdminModules', + component: () => import('@/views/admin/ModulesView.vue'), + meta: { + requiresAuth: true, + requiresAdmin: true, + title: 'Plugin Modules', + titleKey: 'admin.modules.title', + descriptionKey: 'admin.modules.description' + } + }, { path: '/admin/risk-control', name: 'AdminRiskControl', diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts index 02ea149b36..2329ac5aaf 100644 --- a/frontend/src/stores/index.ts +++ b/frontend/src/stores/index.ts @@ -11,6 +11,7 @@ export { useOnboardingStore } from './onboarding' export { useAnnouncementStore } from './announcements' export { usePaymentStore } from './payment' export { useAdminComplianceStore } from './adminCompliance' +export { useModulesStore } from './modules' // Re-export types for convenience export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types' diff --git a/frontend/src/stores/modules.ts b/frontend/src/stores/modules.ts new file mode 100644 index 0000000000..a6f57881c2 --- /dev/null +++ b/frontend/src/stores/modules.ts @@ -0,0 +1,35 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { adminAPI } from '@/api/admin' +import type { Module } from '@/types' + +export const useModulesStore = defineStore('modules', () => { + // State + const modules = ref([]) + const loading = ref(false) + const error = ref(null) + + // Actions + async function fetchModules() { + loading.value = true + error.value = null + try { + const res = await adminAPI.modules.list() + modules.value = res.modules ?? [] + } catch (err: any) { + error.value = err?.response?.data?.message || err?.message || 'Failed to load modules' + throw err + } finally { + loading.value = false + } + } + + return { + // State + modules, + loading, + error, + // Actions + fetchModules, + } +}) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 3fa9e0f573..f78f756c5c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -361,6 +361,19 @@ export interface AnnouncementUserReadStatus { read_at?: string } +// ==================== Plugin Module Types ==================== + +export type ModuleState = 'registered' | 'provisioned' | 'running' | 'stopped' | 'errored' + +export interface Module { + id: string + namespace: string + name: string + enabled: boolean + state: ModuleState + error: string +} + // ==================== Proxy Node Types ==================== export interface ProxyNode { diff --git a/frontend/src/views/admin/ModulesView.vue b/frontend/src/views/admin/ModulesView.vue new file mode 100644 index 0000000000..42459a60f1 --- /dev/null +++ b/frontend/src/views/admin/ModulesView.vue @@ -0,0 +1,128 @@ + + + diff --git a/frontend/src/views/admin/__tests__/ModulesView.spec.ts b/frontend/src/views/admin/__tests__/ModulesView.spec.ts new file mode 100644 index 0000000000..d91837c842 --- /dev/null +++ b/frontend/src/views/admin/__tests__/ModulesView.spec.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import type { DOMWrapper, VueWrapper } from '@vue/test-utils' +import { createPinia } from 'pinia' + +import ModulesView from '../ModulesView.vue' +import type { Module } from '@/types' + +const { listModules, showError, showSuccess } = vi.hoisted(() => ({ + listModules: vi.fn(), + showError: vi.fn(), + showSuccess: vi.fn() +})) + +vi.mock('@/api/admin', () => ({ + adminAPI: { + modules: { + list: listModules + } + } +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ + showError, + showSuccess + }) +})) + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key + }) + } +}) + +const createModules = (): Module[] => [ + { + id: 'core.gateway', + namespace: 'core', + name: 'gateway', + enabled: true, + state: 'running', + error: '' + }, + { + id: 'demo.broken', + namespace: 'demo', + name: 'broken', + enabled: true, + state: 'errored', + error: 'provision failed: dependency missing' + } +] + +const AppLayoutStub = { template: '
' } +const TablePageLayoutStub = { + template: '
' +} +const DataTableStub = { + props: ['columns', 'data', 'loading'], + template: ` +
+
{{ columns.map(col => col.key).join(',') }}
+
loading
+
+ +
+ ` +} + +function mountView() { + return mount(ModulesView, { + global: { + plugins: [createPinia()], + stubs: { + AppLayout: AppLayoutStub, + TablePageLayout: TablePageLayoutStub, + DataTable: DataTableStub, + Icon: true + } + } + }) +} + +function findRefreshButton(wrapper: VueWrapper): DOMWrapper { + const button = wrapper + .findAll('button') + .find((item) => item.attributes('title') === 'common.refresh') + if (!button) { + throw new Error('refresh button not found') + } + return button +} + +describe('admin ModulesView', () => { + beforeEach(() => { + listModules.mockReset() + showError.mockReset() + showSuccess.mockReset() + + listModules.mockResolvedValue({ modules: createModules() }) + }) + + it('fetches modules on mount and renders the module list', async () => { + const wrapper = mountView() + await flushPromises() + + expect(listModules).toHaveBeenCalledTimes(1) + + const columns = wrapper.get('[data-test="columns"]').text() + expect(columns.split(',')).toEqual(['id', 'namespace', 'name', 'enabled', 'state', 'error']) + + const rows = wrapper.findAll('[data-test="row"]') + expect(rows).toHaveLength(2) + expect(wrapper.text()).toContain('core.gateway') + expect(wrapper.text()).toContain('demo.broken') + expect(showError).not.toHaveBeenCalled() + }) + + it('renders semantic state badges and error text', async () => { + const wrapper = mountView() + await flushPromises() + + expect(wrapper.find('.badge-success').text()).toBe('admin.modules.stateLabels.running') + expect(wrapper.find('.badge-danger').text()).toBe('admin.modules.stateLabels.errored') + expect(wrapper.text()).toContain('provision failed: dependency missing') + }) + + it('shows the loading state while the request is in flight', async () => { + let resolveList!: (value: { modules: Module[] }) => void + listModules.mockImplementation( + () => + new Promise<{ modules: Module[] }>((resolve) => { + resolveList = resolve + }) + ) + + const wrapper = mountView() + await flushPromises() + + expect(wrapper.find('[data-test="loading"]').exists()).toBe(true) + + resolveList({ modules: [] }) + await flushPromises() + + expect(wrapper.find('[data-test="loading"]').exists()).toBe(false) + }) + + it('renders the empty state when no modules are registered', async () => { + listModules.mockResolvedValue({ modules: [] }) + + const wrapper = mountView() + await flushPromises() + + expect(wrapper.find('[data-test="empty"]').exists()).toBe(true) + expect(wrapper.text()).toContain('admin.modules.noModules') + expect(wrapper.text()).toContain('admin.modules.noModulesDescription') + }) + + it('reloads modules when the refresh button is clicked', async () => { + const wrapper = mountView() + await flushPromises() + + expect(listModules).toHaveBeenCalledTimes(1) + + await findRefreshButton(wrapper).trigger('click') + await flushPromises() + + expect(listModules).toHaveBeenCalledTimes(2) + }) + + it('shows an error toast when loading fails', async () => { + listModules.mockRejectedValue(new Error('network down')) + + const wrapper = mountView() + await flushPromises() + + expect(showError).toHaveBeenCalledWith('admin.modules.failedToLoad') + expect(wrapper.find('[data-test="empty"]').exists()).toBe(true) + }) +})