Files
shaw 9be516448e feat(devkit): Phase-1.5 模块脚手架 + 前端模块可观测页 + 作者指南
- tools/newmodule + make new-module ID=job.foo:开箱即编译即测试的模块骨架
  (ID 校验复用内核规则、渲染必经 gofmt、失败原子回滚、显式插装提示)
- 前端 /admin/modules 只读页:列表/状态徽章/错误悬浮/中英 i18n + vitest
- docs/plugin-architecture/MODULE-AUTHOR-GUIDE.md:模块作者指南
  (生命周期契约/配置子树/插装清单/plugintest 用法/开发调试工作流闭环/常见坑)
2026-06-11 22:59:07 +08:00

185 lines
6.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}