feat(plugin): Phase-1/1.5 进程内插件内核 + 开发套件后端

Caddy 式进程内模块系统(详见 docs/plugin-architecture/MODULE-AUTHOR-GUIDE.md):
- internal/plugin:命名空间注册表(init 注册/重复 panic)、Provision→Validate→Start→Stop
  生命周期(Start 半途逆序回滚、Stop 逆序+ctx deadline)、Host 四项 ports 能力面、
  modules: 配置子树(enabled 三态;viper 含点 key 手工提取)、Runtime 状态机
- internal/plugin/plugintest:模块测试夹具(NewHost Options + RunLifecycle)
- internal/modules:standard/imports.go 唯一插装清单 + job.hello 示例模块(默认 disabled)
- Wire/main 接入:Build+Start 失败 fail-fast(显式 Cleanup 防 leader 锁泄漏)、
  runtime.Stop 在 cleanup 并行组首位(先于 Redis/Ent)
- admin 可观测:GET /api/v1/admin/modules(只读,错误文本脱敏)
- 默认配置零行为变更(Phase-0 安全网 + bench allocs 零漂移验证)

含两轮对抗式审计修复(1×P1 启动失败泄漏 leader 锁 + 11×P2)与回归测试。
This commit is contained in:
shaw
2026-06-11 22:58:51 +08:00
parent f9fac7acbe
commit 8ba2fbd786
30 changed files with 2921 additions and 8 deletions
+16
View File
@@ -18,6 +18,7 @@ import (
_ "github.com/Wei-Shaw/sub2api/ent/runtime"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler"
_ "github.com/Wei-Shaw/sub2api/internal/modules/standard" // 插件模块插装清单(唯一插装点)
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/setup"
@@ -154,6 +155,21 @@ func runMainServer() {
}
defer app.Cleanup()
// 启动插件模块:Build(实例化 → Provision → Validate)+ Start(按稳定序)。
// 任一失败立即中止启动(fail-fast)。注意:log.Fatalf 经 os.Exit 退出,
// defer 的 app.Cleanup() 不会执行;而部分后台服务在 Wire 构造期已自启动并
// 持有 Redis leader 锁,必须先显式 Cleanup 再退出,避免锁滞留到 TTL 过期。
// ⚠ 不要把下面的 log.Fatalf 改成 return:那会让上面 defer 的 Cleanup 与
// 此处显式 Cleanup 双触发,而 Cleanup 各步骤并未统一保证幂等(审计 B-2)。
if err := app.Runtime.Build(); err != nil {
app.Cleanup()
log.Fatalf("Failed to build plugin modules: %v", err)
}
if err := app.Runtime.Start(context.Background()); err != nil {
app.Cleanup()
log.Fatalf("Failed to start plugin modules: %v", err)
}
// 启动服务器
go func() {
if err := app.Server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+18 -2
View File
@@ -14,6 +14,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/Wei-Shaw/sub2api/internal/plugin"
"github.com/Wei-Shaw/sub2api/internal/repository"
"github.com/Wei-Shaw/sub2api/internal/server"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
@@ -24,7 +25,10 @@ import (
)
type Application struct {
Server *http.Server
Server *http.Server
// Runtime 是插件模块生命周期驱动器:main 在 HTTP server 启动前
// 调用 Build + Start,关闭时由 Cleanup 序列执行 Stop。
Runtime *plugin.Runtime
Cleanup func()
}
@@ -43,6 +47,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
// Server layer ProviderSet
server.ProviderSet,
// Plugin kernel ProviderSet (Host + module Runtime)
plugin.ProviderSet,
// Privacy client factory for OpenAI training opt-out
providePrivacyClientFactory,
@@ -53,7 +60,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
provideCleanup,
// Application struct
wire.Struct(new(Application), "Server", "Cleanup"),
wire.Struct(new(Application), "Server", "Runtime", "Cleanup"),
)
return nil, nil
}
@@ -100,6 +107,7 @@ func provideCleanup(
paymentOrderExpiry *service.PaymentOrderExpiryService,
channelMonitorRunner *service.ChannelMonitorRunner,
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
moduleRuntime *plugin.Runtime,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -112,6 +120,14 @@ func provideCleanup(
// 应用层清理步骤可并行执行,基础设施资源(Redis/Ent)最后按顺序关闭。
parallelSteps := []cleanupStep{
// 插件模块 Stop 属于业务服务关闭组:模块停止时可能仍需写 DB/Redis,
// 必须先于基础设施(Redis/Ent)关闭执行。
{"PluginModuleRuntime", func() error {
if moduleRuntime == nil {
return nil
}
return moduleRuntime.Stop(ctx)
}},
{"OpsScheduledReportService", func() error {
if opsScheduledReport != nil {
opsScheduledReport.Stop()
+27 -5
View File
@@ -13,6 +13,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/Wei-Shaw/sub2api/internal/plugin"
"github.com/Wei-Shaw/sub2api/internal/repository"
"github.com/Wei-Shaw/sub2api/internal/server"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
@@ -27,6 +28,7 @@ import (
import (
_ "embed"
_ "github.com/Wei-Shaw/sub2api/ent/runtime"
_ "github.com/Wei-Shaw/sub2api/internal/modules/standard"
)
// Injectors from wire.go:
@@ -241,12 +243,20 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
affiliateHandler := admin.NewAffiliateHandler(affiliateService, adminService)
complianceHandler := admin.NewComplianceHandler(settingService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler, complianceHandler)
pluginConfig, err := plugin.ProvideModuleConfig(configConfig)
if err != nil {
return nil, err
}
host := plugin.ProvideHost(pluginConfig, client, redisClient)
runtime := plugin.ProvideModuleRuntime(host, pluginConfig)
moduleHandler := admin.NewModuleHandler(runtime)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler, complianceHandler, moduleHandler)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
chain := handler.ProvideGatewayHookChain(contentModerationService)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService)
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, configConfig)
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, chain, userMessageQueueService, configConfig, settingService)
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, chain, configConfig)
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
totpHandler := handler.NewTotpHandler(totpService)
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService)
@@ -273,9 +283,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db)
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, runtime)
application := &Application{
Server: httpServer,
Runtime: runtime,
Cleanup: v,
}
return application, nil
@@ -284,7 +295,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
// wire.go:
type Application struct {
Server *http.Server
Server *http.Server
// Runtime 是插件模块生命周期驱动器:main 在 HTTP server 启动前
// 调用 Build + Start,关闭时由 Cleanup 序列执行 Stop。
Runtime *plugin.Runtime
Cleanup func()
}
@@ -330,6 +344,7 @@ func provideCleanup(
paymentOrderExpiry *service.PaymentOrderExpiryService,
channelMonitorRunner *service.ChannelMonitorRunner,
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
moduleRuntime *plugin.Runtime,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -341,6 +356,13 @@ func provideCleanup(
}
parallelSteps := []cleanupStep{
{"PluginModuleRuntime", func() error {
if moduleRuntime == nil {
return nil
}
return moduleRuntime.Stop(ctx)
}},
{"OpsScheduledReportService", func() error {
if opsScheduledReport != nil {
opsScheduledReport.Stop()
+1
View File
@@ -80,6 +80,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil, // paymentOrderExpiry
nil, // channelMonitorRunner
nil, // quotaFlusher
nil, // moduleRuntime
)
require.NotPanics(t, func() {
+1 -1
View File
@@ -119,7 +119,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdelapenya/tlscert v0.2.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/mapstructure v1.5.0
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
+41
View File
@@ -93,6 +93,15 @@ type Config struct {
Gemini GeminiConfig `mapstructure:"gemini"`
Update UpdateConfig `mapstructure:"update"`
Idempotency IdempotencyConfig `mapstructure:"idempotency"`
// Modules 是插件模块配置子树(modules:)的原始内容。
// 顶层 key 为模块 ID 字面量(如 "job.hello"),value 为该模块的原始配置
// (含可选 enabled 键),语义解析由 internal/plugin 包负责。
//
// 注意:该字段不走 viper.Unmarshal(mapstructure:"-"),而是在 load() 中通过
// viper.Get 手工提取——Unmarshal 基于 AllSettings 重建嵌套结构,会把含点的
// 模块 ID key 错误拆分为多级嵌套。缺省(未配置)时为空 map,行为与现状完全等价。
Modules map[string]map[string]any `mapstructure:"-" yaml:"-"`
}
type LogConfig struct {
@@ -1397,6 +1406,11 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
if err := viper.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("unmarshal config error: %w", err)
}
modules, err := normalizeModulesSubtree(viper.Get("modules"))
if err != nil {
return nil, fmt.Errorf("parse modules config error: %w", err)
}
cfg.Modules = modules
if cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs == 0 {
cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs = 15000
}
@@ -1530,6 +1544,33 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
return &cfg, nil
}
// normalizeModulesSubtree 将 viper 读取的 modules: 原始子树规整为
// map[模块ID]map[配置键]值。raw 为 nil(未配置)时返回空 map(零行为影响);
// 子树或某个模块项不是映射时返回错误。空模块项(如 "job.hello:" 后无内容)
// 规整为空 map,表示"提及但无私有配置"。
func normalizeModulesSubtree(raw any) (map[string]map[string]any, error) {
result := make(map[string]map[string]any)
if raw == nil {
return result, nil
}
top, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("modules: expected a mapping, got %T", raw)
}
for key, value := range top {
if value == nil {
result[key] = map[string]any{}
continue
}
entry, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("modules.%s: expected a mapping, got %T", key, value)
}
result[key] = entry
}
return result, nil
}
func setDefaults() {
viper.SetDefault("run_mode", RunModeStandard)
@@ -0,0 +1,89 @@
package config
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
// TestLoadModulesSubtreeDefaultEmpty 验证不配置 modules: 子树时,
// cfg.Modules 为空(非 nil)map,解析结果与现状完全等价。
func TestLoadModulesSubtreeDefaultEmpty(t *testing.T) {
resetViperWithJWTSecret(t)
cfg, err := Load()
require.NoError(t, err)
require.NotNil(t, cfg.Modules)
require.Empty(t, cfg.Modules)
}
// TestLoadModulesSubtreeParsing 验证 modules: 子树解析:
// 含点的模块 ID 必须作为完整 key 保留(不被 viper 按 "." 拆分为多级嵌套)。
func TestLoadModulesSubtreeParsing(t *testing.T) {
resetViperWithJWTSecret(t)
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
yaml := `
modules:
job.hello:
enabled: true
greeting: "hi"
payment.provider.stripe:
enabled: false
job.bare:
`
require.NoError(t, os.WriteFile(configPath, []byte(yaml), 0o644))
t.Setenv("DATA_DIR", tempDir)
cfg, err := Load()
require.NoError(t, err)
require.Len(t, cfg.Modules, 3)
require.Equal(t, map[string]any{"enabled": true, "greeting": "hi"}, cfg.Modules["job.hello"])
require.Equal(t, map[string]any{"enabled": false}, cfg.Modules["payment.provider.stripe"])
// 空模块项规整为空 map(提及但无私有配置)。
require.NotNil(t, cfg.Modules["job.bare"])
require.Empty(t, cfg.Modules["job.bare"])
}
// TestLoadModulesSubtreeInvalidShape 验证 modules 子树形状非法时报错。
func TestLoadModulesSubtreeInvalidShape(t *testing.T) {
resetViperWithJWTSecret(t)
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
require.NoError(t, os.WriteFile(configPath, []byte("modules:\n job.hello: true\n"), 0o644))
t.Setenv("DATA_DIR", tempDir)
_, err := Load()
require.Error(t, err)
require.Contains(t, err.Error(), "modules")
}
// TestNormalizeModulesSubtree 直接覆盖规整函数的边界分支。
func TestNormalizeModulesSubtree(t *testing.T) {
t.Run("nil input", func(t *testing.T) {
out, err := normalizeModulesSubtree(nil)
require.NoError(t, err)
require.NotNil(t, out)
require.Empty(t, out)
})
t.Run("non-map top level", func(t *testing.T) {
_, err := normalizeModulesSubtree([]any{"x"})
require.Error(t, err)
})
t.Run("non-map entry", func(t *testing.T) {
_, err := normalizeModulesSubtree(map[string]any{"job.hello": 42})
require.Error(t, err)
require.Contains(t, err.Error(), "job.hello")
})
t.Run("nil entry becomes empty map", func(t *testing.T) {
out, err := normalizeModulesSubtree(map[string]any{"job.hello": nil})
require.NoError(t, err)
require.NotNil(t, out["job.hello"])
require.Empty(t, out["job.hello"])
})
}
@@ -0,0 +1,60 @@
package admin
import (
"sort"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/plugin"
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
"github.com/gin-gonic/gin"
)
// moduleStatusSource 提供插件模块状态快照能力(*plugin.Runtime 实现该接口,测试可注入桩)。
type moduleStatusSource interface {
Snapshot() []plugin.ModuleStatus
}
// ModuleHandler handles plugin module observability operations (read-only)
type ModuleHandler struct {
runtime moduleStatusSource
}
// NewModuleHandler creates a new ModuleHandler
func NewModuleHandler(runtime *plugin.Runtime) *ModuleHandler {
return &ModuleHandler{runtime: runtime}
}
// ModuleStatusItem 是单个插件模块状态的响应条目
type ModuleStatusItem struct {
ID string `json:"id"`
Namespace string `json:"namespace"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
State string `json:"state"`
Error string `json:"error"`
}
// List 返回全部已注册插件模块的状态清单(含未启用模块),按模块 ID 字典序排序
// GET /api/v1/admin/modules
func (h *ModuleHandler) List(c *gin.Context) {
statuses := h.runtime.Snapshot()
modules := make([]ModuleStatusItem, 0, len(statuses))
for _, status := range statuses {
errText := status.Err
if errText != "" {
// 错误文本可能包含敏感信息(如上游返回的凭据片段),输出前统一脱敏。
errText = logredact.RedactText(errText)
}
modules = append(modules, ModuleStatusItem{
ID: string(status.ID),
Namespace: status.ID.Namespace(),
Name: status.ID.Name(),
Enabled: status.Enabled,
State: string(status.State),
Error: errText,
})
}
sort.Slice(modules, func(i, j int) bool { return modules[i].ID < modules[j].ID })
response.Success(c, gin.H{"modules": modules})
}
@@ -0,0 +1,177 @@
package admin
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/plugin"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type moduleStatusSourceStub struct {
statuses []plugin.ModuleStatus
}
func (s *moduleStatusSourceStub) Snapshot() []plugin.ModuleStatus {
return s.statuses
}
type moduleListEnvelope struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Modules []struct {
ID string `json:"id"`
Namespace string `json:"namespace"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
State string `json:"state"`
Error string `json:"error"`
} `json:"modules"`
} `json:"data"`
}
func performModuleList(t *testing.T, source moduleStatusSource) (*httptest.ResponseRecorder, moduleListEnvelope) {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
handler := &ModuleHandler{runtime: source}
router.GET("/admin/modules", handler.List)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/modules", nil)
router.ServeHTTP(w, req)
var envelope moduleListEnvelope
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &envelope))
return w, envelope
}
func TestModuleHandlerListSerializesSnapshot(t *testing.T) {
stub := &moduleStatusSourceStub{statuses: []plugin.ModuleStatus{
{ID: "gateway.platform.anthropic", Enabled: true, State: plugin.StateRunning},
{ID: "job.hello", Enabled: false, State: plugin.StateRegistered},
}}
w, envelope := performModuleList(t, stub)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, 0, envelope.Code)
require.Equal(t, "success", envelope.Message)
require.Len(t, envelope.Data.Modules, 2)
first := envelope.Data.Modules[0]
require.Equal(t, "gateway.platform.anthropic", first.ID)
require.Equal(t, "gateway.platform", first.Namespace)
require.Equal(t, "anthropic", first.Name)
require.True(t, first.Enabled)
require.Equal(t, "running", first.State)
require.Empty(t, first.Error)
// 未启用模块也在列:state=registered、enabled=false
second := envelope.Data.Modules[1]
require.Equal(t, "job.hello", second.ID)
require.Equal(t, "job", second.Namespace)
require.Equal(t, "hello", second.Name)
require.False(t, second.Enabled)
require.Equal(t, "registered", second.State)
require.Empty(t, second.Error)
// snake_case 字段恒定输出(含空 error)
require.Contains(t, w.Body.String(), `"error":""`)
require.Contains(t, w.Body.String(), `"state":"registered"`)
}
func TestModuleHandlerListSortsByID(t *testing.T) {
stub := &moduleStatusSourceStub{statuses: []plugin.ModuleStatus{
{ID: "job.zeta", Enabled: true, State: plugin.StateRunning},
{ID: "gateway.alpha", Enabled: true, State: plugin.StateRunning},
{ID: "job.alpha", Enabled: false, State: plugin.StateRegistered},
}}
_, envelope := performModuleList(t, stub)
ids := make([]string, 0, len(envelope.Data.Modules))
for _, m := range envelope.Data.Modules {
ids = append(ids, m.ID)
}
require.Equal(t, []string{"gateway.alpha", "job.alpha", "job.zeta"}, ids)
}
func TestModuleHandlerListRedactsErrorText(t *testing.T) {
stub := &moduleStatusSourceStub{statuses: []plugin.ModuleStatus{
{ID: "job.bad", Enabled: true, State: plugin.StateErrored, Err: "provision failed: access_token=ya29.secret-value"},
}}
_, envelope := performModuleList(t, stub)
require.Len(t, envelope.Data.Modules, 1)
errText := envelope.Data.Modules[0].Error
require.Contains(t, errText, "provision failed")
require.NotContains(t, errText, "ya29.secret-value")
}
func TestModuleHandlerListEmptySnapshot(t *testing.T) {
w, envelope := performModuleList(t, &moduleStatusSourceStub{})
require.Equal(t, http.StatusOK, w.Code)
require.NotNil(t, envelope.Data.Modules)
require.Empty(t, envelope.Data.Modules)
require.Contains(t, w.Body.String(), `"modules":[]`)
}
type moduleHandlerFakeModule struct {
id plugin.ModuleID
enabledByDefault bool
}
func (m *moduleHandlerFakeModule) ModuleInfo() plugin.ModuleInfo {
id := m.id
enabled := m.enabledByDefault
return plugin.ModuleInfo{
ID: id,
New: func() plugin.Module { return &moduleHandlerFakeModule{id: id, enabledByDefault: enabled} },
EnabledByDefault: enabled,
}
}
// TestModuleHandlerListWithRuntimeSnapshot 用真实 Runtime(隔离注册表)验证
// 接口返回与 Runtime.Snapshot 一致:enabled 模块为 running,
// disabled 模块也在列且 state=registered、enabled=false。
func TestModuleHandlerListWithRuntimeSnapshot(t *testing.T) {
registry := plugin.NewRegistry()
registry.RegisterModule(&moduleHandlerFakeModule{id: "job.enabled", enabledByDefault: true})
registry.RegisterModule(&moduleHandlerFakeModule{id: "job.disabled", enabledByDefault: false})
runtime := plugin.NewRuntimeWithRegistry(nil, nil, registry)
require.NoError(t, runtime.Build())
require.NoError(t, runtime.Start(context.Background()))
t.Cleanup(func() { _ = runtime.Stop(context.Background()) })
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/admin/modules", NewModuleHandler(runtime).List)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/modules", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var envelope moduleListEnvelope
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &envelope))
require.Len(t, envelope.Data.Modules, 2)
disabled := envelope.Data.Modules[0]
require.Equal(t, "job.disabled", disabled.ID)
require.False(t, disabled.Enabled)
require.Equal(t, "registered", disabled.State)
enabled := envelope.Data.Modules[1]
require.Equal(t, "job.enabled", enabled.ID)
require.True(t, enabled.Enabled)
require.Equal(t, "running", enabled.State)
}
+1
View File
@@ -37,6 +37,7 @@ type AdminHandlers struct {
Payment *admin.PaymentHandler
Affiliate *admin.AffiliateHandler
Compliance *admin.ComplianceHandler
Module *admin.ModuleHandler
}
// Handlers contains all HTTP handlers
+4
View File
@@ -40,6 +40,7 @@ func ProvideAdminHandlers(
paymentHandler *admin.PaymentHandler,
affiliateHandler *admin.AffiliateHandler,
complianceHandler *admin.ComplianceHandler,
moduleHandler *admin.ModuleHandler,
) *AdminHandlers {
return &AdminHandlers{
Dashboard: dashboardHandler,
@@ -73,6 +74,7 @@ func ProvideAdminHandlers(
Payment: paymentHandler,
Affiliate: affiliateHandler,
Compliance: complianceHandler,
Module: moduleHandler,
}
}
@@ -147,6 +149,7 @@ var ProviderSet = wire.NewSet(
NewSubscriptionHandler,
NewAnnouncementHandler,
NewChannelMonitorUserHandler,
ProvideGatewayHookChain,
NewGatewayHandler,
NewOpenAIGatewayHandler,
NewTotpHandler,
@@ -187,6 +190,7 @@ var ProviderSet = wire.NewSet(
admin.NewPaymentHandler,
admin.NewAffiliateHandler,
admin.NewComplianceHandler,
admin.NewModuleHandler,
// AdminHandlers and Handlers constructors
ProvideAdminHandlers,
@@ -0,0 +1,34 @@
package hello
// Phase-1.5 对抗式审计(2026-06-11)B-1 回归测试:
// Stop 传入已取消的 ctx 时,若 worker 实际已干净退出,必须返回 nil
// (修复前 select 双臂就绪的随机选取可能把成功停止误报为失败)。
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/Wei-Shaw/sub2api/internal/plugin/plugintest"
)
func TestStopWithPreCancelledContextAfterWorkerExit(t *testing.T) {
m := new(Module)
require.NoError(t, m.Provision(plugintest.NewHost(t)))
require.NoError(t, m.Validate())
require.NoError(t, m.Start(context.Background()))
// 让 worker 先行退出并确认退出完成(直接驱动内部 cancel,规避计时竞态)。
m.cancel()
select {
case <-m.done:
case <-time.After(5 * time.Second):
t.Fatal("worker 未在期限内退出")
}
cancelled, cancel := context.WithCancel(context.Background())
cancel()
require.NoError(t, m.Stop(cancelled), "worker 已退出时,即便 ctx 已取消 Stop 也应返回 nil")
}
+154
View File
@@ -0,0 +1,154 @@
// Package hello 实现示例插件模块 job.hello。
//
// 该模块是插件内核的最小可运行示例:实现全部四个可选生命周期接口
// (Provision / Validate / Start / Stop),启动后按配置间隔周期性输出
// 一条 debug 日志。默认 disabled——仅在 `modules:` 配置中显式 enabled
// 后才会运行,用于验证插件链路连通性,并作为编写新模块的参考实现。
package hello
import (
"context"
"errors"
"fmt"
"time"
"go.uber.org/zap"
"github.com/Wei-Shaw/sub2api/internal/plugin"
)
// ID 是 hello 模块的模块 ID。
const ID plugin.ModuleID = "job.hello"
func init() {
plugin.RegisterModule(&Module{})
}
// 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",
}
}
// Module 是 job.hello 模块实例。零值即可用,依赖在 Provision 中装配;
// 每次 Runtime.Build 都会通过 ModuleInfo.New 创建全新实例。
type Module struct {
cfg Config
logger *zap.Logger
cancel context.CancelFunc
done chan struct{}
}
// 编译期断言:Module 实现 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 从宿主获取 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
}
// 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
}
// run 按配置间隔输出周期 debug 日志,直到 ctx 被取消。
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:
m.logger.Debug(m.cfg.Greeting, 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 双臂就绪的随机选取可能把成功停止误报为失败(审计 B-1)。
select {
case <-m.done:
m.logger.Info("hello module stopped", zap.String("module", string(ID)))
return nil
default:
}
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())
}
}
@@ -0,0 +1,178 @@
package hello
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() 已向包级默认注册表
// 注册 job.hello,且默认 disabled(EnabledByDefault=false)。
func TestModuleRegisteredInDefaultRegistry(t *testing.T) {
info, ok := plugin.GetModule(ID)
require.True(t, ok, "job.hello should self-register via init()")
require.Equal(t, ID, info.ID)
require.False(t, info.EnabledByDefault, "hello module 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())
}
// TestProvisionDecodesPrivateConfig 验证 Provision 通过 host.ConfigOf
// 解码模块私有配置(interval 时长字符串、greeting 覆盖)。
func TestProvisionDecodesPrivateConfig(t *testing.T) {
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)
require.NoError(t, m.Validate())
}
// TestProvisionConfigDecodeError 验证私有配置类型错误时 Provision 返回错误。
func TestProvisionConfigDecodeError(t *testing.T) {
host := plugintest.NewHost(t, plugintest.WithConfig(map[string]map[string]any{
"job.hello": {"interval": "not-a-duration"},
}))
m := new(Module)
err := m.Provision(host)
require.Error(t, err)
require.Contains(t, err.Error(), "job.hello")
}
// TestValidateRejectsInvalidConfig 验证非法配置被 Validate 拒绝。
func TestValidateRejectsInvalidConfig(t *testing.T) {
tests := []struct {
name string
cfg Config
wantErr string
}{
{"zero interval", Config{Interval: 0, Greeting: "hi"}, "interval must be positive"},
{"negative interval", Config{Interval: -time.Second, Greeting: "hi"}, "interval must be positive"},
{"empty greeting", Config{Interval: time.Second, Greeting: ""}, "greeting must not be empty"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &Module{cfg: tt.cfg}
err := m.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErr)
})
}
}
// TestStartPeriodicLogAndStop 验证 Start 后周期 debug 日志持续出现,
// Stop 后 goroutine 优雅退出。
func TestStartPeriodicLogAndStop(t *testing.T) {
logOpt, logs := plugintest.WithObservedLogger()
host := plugintest.NewHost(t, logOpt, plugintest.WithConfig(map[string]map[string]any{
"job.hello": {"interval": "5ms"},
}))
m := new(Module)
require.NoError(t, m.Provision(host))
require.NoError(t, m.Validate())
require.NoError(t, m.Start(context.Background()))
require.Eventually(t, func() bool {
return logs.FilterMessage(m.cfg.Greeting).Len() >= 2
}, 2*time.Second, 5*time.Millisecond, "periodic debug log should appear repeatedly")
require.NoError(t, m.Stop(context.Background()))
select {
case <-m.done:
// goroutine 已退出。
default:
t.Fatal("worker goroutine should have exited after Stop")
}
require.Equal(t, 1, logs.FilterMessage("hello module stopped").Len())
}
// TestStopWithoutStartIsNoop 验证未 Start 时 Stop 为 no-op。
func TestStopWithoutStartIsNoop(t *testing.T) {
m := new(Module)
require.NoError(t, m.Provision(plugintest.NewHost(t)))
require.NoError(t, m.Stop(context.Background()))
}
// TestRuntimeDefaultConfigIsNoop 验证缺省配置(modules: 子树为空)下,
// hello 模块保持 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")
}
// TestRuntimeEnabledHelloLifecycle 验证启用 job.hello 后完整生命周期:
// Build(Provision/Validate)→ Start(周期日志出现)→ Stop(优雅退出)。
func TestRuntimeEnabledHelloLifecycle(t *testing.T) {
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.Len(t, snap, 1)
require.True(t, snap[0].Enabled)
require.Equal(t, plugin.StateStopped, snap[0].State)
}
// TestRuntimeInvalidHelloConfigAbortsBuild 验证 hello 配置非法时
// Validate 失败导致 Build 返回错误(启动中止,fail-fast)。
func TestRuntimeInvalidHelloConfigAbortsBuild(t *testing.T) {
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")
}
// TestRuntimeIgnoresUnknownConfiguredModule 验证配置中出现格式合法但
// 未注册的模块 ID 时,Runtime 按内核语义忽略该项,不影响其他模块。
func TestRuntimeIgnoresUnknownConfiguredModule(t *testing.T) {
rt := plugintest.RunLifecycle(t, &Module{}, plugintest.NewHost(t), map[string]map[string]any{
"job.hello": {"enabled": true, "interval": "5ms"},
"job.unknown": {"enabled": true},
})
require.NoError(t, rt.Stop(context.Background()))
snap := rt.Snapshot()
require.Len(t, snap, 1, "snapshot should only contain registered modules")
require.Equal(t, ID, snap[0].ID)
}
@@ -0,0 +1,12 @@
// Package standard 是插件模块的唯一插装清单(instrumentation list)。
//
// 所有需要编译进二进制的内置模块必须且只能在本文件中匿名 import:
// 模块包在 init() 期向包级默认注册表自注册,cmd/server 匿名 import
// 本包即完成全部内置模块的插装。新增/移除模块只需修改本文件,
// 使插装变更集中、可 review。
package standard
import (
// job.hello:示例模块(默认 disabled),验证插件链路连通性。
_ "github.com/Wei-Shaw/sub2api/internal/modules/hello"
)
@@ -0,0 +1,59 @@
package plugin
// Phase-1.5 对抗式审计(2026-06-11)发现的 P2 问题回归测试:
// A-1: 失败的 Build 不得被静默重跑(会对已 Provision 实例不 Stop 即覆盖)
// C-1: enabled 空值(YAML null)按"未显式配置"处理,走模块默认值
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/require"
)
// TestRuntimeBuildFailureBlocksRetry 固化审计 A-1 修复:
// Build 失败后,二次 Build 与 Start 均被拒绝,且不重新驱动任何生命周期。
func TestRuntimeBuildFailureBlocksRetry(t *testing.T) {
log := &lifecycleLog{}
okMod := &fakeModule{id: "job.a", enabledByDefault: true, log: log}
badMod := &fakeModule{id: "job.b", enabledByDefault: true, log: log, provisionErr: errors.New("boom")}
rt := newTestRuntime(t, Config{}, okMod, badMod)
require.Error(t, rt.Build(), "首次 Build 应因 job.b Provision 失败而报错")
firstLog := log.snapshot()
err := rt.Build()
require.Error(t, err)
require.Contains(t, err.Error(), "cannot be rebuilt", "失败后的二次 Build 应被明确拒绝")
require.Equal(t, firstLog, log.snapshot(), "二次 Build 不得重新 Provision 任何模块")
require.Error(t, rt.Start(context.Background()), "Build 未成功时 Start 应被拒绝")
}
// TestRuntimeBuildSuccessThenRebuildStillRejected 确认成功路径的原有 guard 语义不变。
func TestRuntimeBuildSuccessThenRebuildStillRejected(t *testing.T) {
rt := newTestRuntime(t, Config{}, &plainModule{id: "job.ok"})
require.NoError(t, rt.Build())
err := rt.Build()
require.Error(t, err)
require.Contains(t, err.Error(), "already built")
}
// TestParseConfigEnabledNilTreatedAsUnset 固化审计 C-1 修复:
// `enabled:`(YAML 空值 → nil)等价于未配置,三态语义回落到模块默认值,
// 且不影响同条目下私有配置的解析。
func TestParseConfigEnabledNilTreatedAsUnset(t *testing.T) {
cfg, err := ParseConfig(map[string]map[string]any{
"job.hello": {"enabled": nil, "greeting": "hi"},
})
require.NoError(t, err)
mc, ok := cfg["job.hello"]
require.True(t, ok)
require.Nil(t, mc.Enabled, "enabled 空值应视为未显式配置")
require.Equal(t, "hi", mc.Raw["greeting"], "私有配置不受 enabled 空值影响")
require.True(t, cfg.enabledFor(ModuleInfo{ID: "job.hello", EnabledByDefault: true}))
require.False(t, cfg.enabledFor(ModuleInfo{ID: "job.hello", EnabledByDefault: false}))
}
+122
View File
@@ -0,0 +1,122 @@
package plugin
import (
"fmt"
"strconv"
"github.com/mitchellh/mapstructure"
)
// enabledKey 是每个模块配置项中由内核保留解释的键,其余键全部属于模块私有配置。
const enabledKey = "enabled"
// ModuleConfig 是单个模块在 `modules:` 配置子树中的配置项。
type ModuleConfig struct {
// Enabled 是模块的显式启停开关;nil 表示未显式配置,
// 由模块注册时声明的 ModuleInfo.EnabledByDefault 决定。
Enabled *bool
// Raw 是模块的私有配置(去掉 enabled 键之后的剩余部分),
// 由 Config.Of 解码到模块自定义的配置 struct,内核不解释其内容。
Raw map[string]any
}
// Config 是 `modules:` 配置子树的解析结果:模块 ID → 模块配置。
//
// 缺省(子树不存在)时为空 map,所有模块按各自默认 enabled 值运行,
// 行为与未引入插件内核前完全一致。
type Config map[ModuleID]ModuleConfig
// ParseConfig 将全局配置中 `modules:` 子树的原始映射解析为 Config。
//
// raw 的顶层 key 必须是合法的模块 ID 字面量(如 "job.hello",YAML 中带点的
// 完整 key),value 为该模块的配置映射;其中 enabled 键被内核解释为启停开关
// (接受 bool 或可解析为 bool 的字符串),其余键原样保留为模块私有配置。
//
// 允许出现"格式合法但未注册"的模块 ID(例如不同编译变体的配置共用),
// 这类配置项会被 Runtime 忽略;但 ID 格式非法或 enabled 类型错误会返回错误,
// 以便尽早暴露配置笔误。
//
// 注意:全局配置经 Viper 加载时,所有 key(含模块 ID 与私有配置键)会被
// 静默小写化——配置文件里写 "Job.Hello" 实际到达这里时已是 "job.hello"。
// 因此模块 ID 与私有配置 struct 的 mapstructure 标签必须全小写。
func ParseConfig(raw map[string]map[string]any) (Config, error) {
cfg := make(Config, len(raw))
for key, entry := range raw {
id := ModuleID(key)
if err := id.validate(); err != nil {
return nil, fmt.Errorf("plugin config: invalid module ID in modules config: %w", err)
}
mc := ModuleConfig{Raw: make(map[string]any, len(entry))}
for k, v := range entry {
if k == enabledKey {
// YAML 中写空的 `enabled:`(值为 null)按"未显式配置"处理,
// 走模块默认值——与 Enabled=nil 的三态语义一致(审计 C-1)。
if v == nil {
continue
}
b, err := toBool(v)
if err != nil {
return nil, fmt.Errorf("plugin config: module %q: invalid enabled value: %w", id, err)
}
mc.Enabled = &b
continue
}
mc.Raw[k] = v
}
cfg[id] = mc
}
return cfg, nil
}
// Of 将模块 id 的 raw 私有配置解码到 out(必须是指向配置 struct 的指针)。
//
// 解码行为与项目全局配置(Viper 默认解码器)保持一致:
// mapstructure 标签、弱类型转换、字符串到 time.Duration / 逗号分隔切片的钩子。
// 模块未配置或私有配置为空时不修改 out(模块应自带默认值并返回 nil);
// 类型不匹配等解码失败时返回包含模块 ID 的错误。
func (c Config) Of(id ModuleID, out any) error {
mc, ok := c[id]
if !ok || len(mc.Raw) == 0 {
return nil
}
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: out,
WeaklyTypedInput: true,
DecodeHook: mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
),
})
if err != nil {
return fmt.Errorf("plugin config: build decoder for module %q: %w", id, err)
}
if err := decoder.Decode(mc.Raw); err != nil {
return fmt.Errorf("plugin config: decode config for module %q: %w", id, err)
}
return nil
}
// enabledFor 解析模块的最终启停状态:显式配置优先,否则用注册时声明的默认值。
func (c Config) enabledFor(info ModuleInfo) bool {
if mc, ok := c[info.ID]; ok && mc.Enabled != nil {
return *mc.Enabled
}
return info.EnabledByDefault
}
// toBool 将 enabled 配置值弱类型转换为 bool(接受 bool 与 strconv.ParseBool 可解析的字符串)。
func toBool(v any) (bool, error) {
switch val := v.(type) {
case bool:
return val, nil
case string:
b, err := strconv.ParseBool(val)
if err != nil {
return false, fmt.Errorf("cannot parse %q as bool", val)
}
return b, nil
default:
return false, fmt.Errorf("expected bool, got %T", v)
}
}
+135
View File
@@ -0,0 +1,135 @@
package plugin
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestParseConfigEnabledTriState(t *testing.T) {
cfg, err := ParseConfig(map[string]map[string]any{
"job.on": {"enabled": true},
"job.off": {"enabled": false},
"job.unset": {"greeting": "hi"},
"job.strbool": {"enabled": "true"}, // 弱类型:字符串 bool(如来自环境变量)
})
require.NoError(t, err)
require.NotNil(t, cfg["job.on"].Enabled)
require.True(t, *cfg["job.on"].Enabled)
require.NotNil(t, cfg["job.off"].Enabled)
require.False(t, *cfg["job.off"].Enabled)
require.Nil(t, cfg["job.unset"].Enabled, "未显式配置 enabled 时必须为 nil(用模块默认值)")
require.NotNil(t, cfg["job.strbool"].Enabled)
require.True(t, *cfg["job.strbool"].Enabled)
}
func TestParseConfigSeparatesEnabledFromRaw(t *testing.T) {
cfg, err := ParseConfig(map[string]map[string]any{
"job.hello": {"enabled": true, "greeting": "hi", "interval": "5s"},
})
require.NoError(t, err)
mc := cfg["job.hello"]
require.Equal(t, map[string]any{"greeting": "hi", "interval": "5s"}, mc.Raw,
"enabled 键属于内核,不得混入模块私有配置")
}
func TestParseConfigErrors(t *testing.T) {
t.Run("invalid module ID", func(t *testing.T) {
_, err := ParseConfig(map[string]map[string]any{"Job.Hello": {}})
require.Error(t, err)
require.Contains(t, err.Error(), "Job.Hello")
})
t.Run("invalid enabled type", func(t *testing.T) {
_, err := ParseConfig(map[string]map[string]any{"job.hello": {"enabled": 123}})
require.Error(t, err)
require.Contains(t, err.Error(), "job.hello")
})
t.Run("invalid enabled string", func(t *testing.T) {
_, err := ParseConfig(map[string]map[string]any{"job.hello": {"enabled": "not-a-bool"}})
require.Error(t, err)
})
t.Run("unregistered but valid ID is allowed", func(t *testing.T) {
cfg, err := ParseConfig(map[string]map[string]any{"future.module": {"enabled": true}})
require.NoError(t, err)
require.Contains(t, cfg, ModuleID("future.module"))
})
}
type helloModuleConfig struct {
Greeting string `mapstructure:"greeting"`
Interval time.Duration `mapstructure:"interval"`
Targets []string `mapstructure:"targets"`
Limit int `mapstructure:"limit"`
}
func TestConfigOfDecodesRawIntoStruct(t *testing.T) {
cfg, err := ParseConfig(map[string]map[string]any{
"job.hello": {
"enabled": true,
"greeting": "hi",
"interval": "5s", // 字符串 → time.Duration(与 viper 默认钩子一致)
"targets": "a,b,c", // 逗号分隔 → 切片(与 viper 默认钩子一致)
"limit": "42", // 弱类型:字符串 → int
},
})
require.NoError(t, err)
var out helloModuleConfig
require.NoError(t, cfg.Of("job.hello", &out))
require.Equal(t, "hi", out.Greeting)
require.Equal(t, 5*time.Second, out.Interval)
require.Equal(t, []string{"a", "b", "c"}, out.Targets)
require.Equal(t, 42, out.Limit)
}
func TestConfigOfMissingModuleLeavesOutUntouched(t *testing.T) {
cfg := Config{}
out := helloModuleConfig{Greeting: "default-value"}
require.NoError(t, cfg.Of("job.absent", &out))
require.Equal(t, "default-value", out.Greeting, "模块未配置时不得修改 out(保留模块默认值)")
}
func TestConfigOfTypeErrorContainsModuleID(t *testing.T) {
cfg, err := ParseConfig(map[string]map[string]any{
"job.hello": {"limit": "not-an-int"},
})
require.NoError(t, err)
var out helloModuleConfig
err = cfg.Of("job.hello", &out)
require.Error(t, err)
require.Contains(t, err.Error(), "job.hello", "解码错误必须包含模块 ID")
}
func TestConfigEnabledFor(t *testing.T) {
on, off := true, false
cfg := Config{
"job.explicit-on": {Enabled: &on},
"job.explicit-off": {Enabled: &off},
"job.mentioned": {},
}
tests := []struct {
name string
info ModuleInfo
want bool
}{
{"explicit on overrides default off", ModuleInfo{ID: "job.explicit-on", EnabledByDefault: false}, true},
{"explicit off overrides default on", ModuleInfo{ID: "job.explicit-off", EnabledByDefault: true}, false},
{"mentioned without enabled uses default true", ModuleInfo{ID: "job.mentioned", EnabledByDefault: true}, true},
{"mentioned without enabled uses default false", ModuleInfo{ID: "job.mentioned", EnabledByDefault: false}, false},
{"absent uses default true", ModuleInfo{ID: "job.absent", EnabledByDefault: true}, true},
{"absent uses default false", ModuleInfo{ID: "job.absent", EnabledByDefault: false}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, cfg.enabledFor(tt.info))
})
}
}
+34
View File
@@ -0,0 +1,34 @@
package plugin
import (
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
ent "github.com/Wei-Shaw/sub2api/ent"
)
// Host 是模块可使用的宿主能力面(纯数据结构,由宿主装配后注入 Provision)。
//
// 边界纪律:Host 只暴露 ports 级基础能力,禁止暴露任何具体 Service;
// 本阶段仅 Logger / ConfigOf / DB / Redis 四项,后续每新增一项
// 必须在插件化 ROADMAP 决策记录中登记,防止 Host 膨胀为上帝对象。
type Host struct {
// Logger 是宿主提供的结构化日志器。模块应通过它输出日志,
// 而不是自建全局 logger。
Logger *zap.Logger
// ConfigOf 将 `modules:` 配置子树中 id 对应的 raw 私有配置解码到 out
// (out 必须是指向模块自定义配置 struct 的指针)。
// 模块未配置时不修改 out(模块应自带默认值);解码类型错误时返回错误。
//
// 该字段通常由宿主装配为 Config.Of;若装配时留空,
// NewRuntime 会默认绑定到 Runtime 自身持有的 Config。
ConfigOf func(id ModuleID, out any) error
// DB 是 Ent ORM 客户端(数据库访问能力)。
DB *ent.Client
// Redis 是 go-redis 客户端(缓存/队列能力)。
// 使用 UniversalClient 接口以兼容单机与集群部署形态。
Redis redis.UniversalClient
}
+133
View File
@@ -0,0 +1,133 @@
// Package plugin 实现 Caddy 式进程内插件内核。
//
// 内核由四部分组成:
// - 模块注册表(registry.go):命名空间化的模块目录,模块包在 init() 期通过
// RegisterModule 自注册,编译期插装清单(internal/modules/standard)决定哪些模块被编译进来;
// - 配置子树(config.go):来自全局配置 `modules:` 的每模块配置(enabled + raw 私有配置);
// - 宿主能力面(host.go):模块可使用的全部宿主能力,只暴露 ports 级接口;
// - 生命周期驱动器(runtime.go):按注册表稳定序实例化 enabled 模块并驱动
// Provision → Validate → Start → Stop(逆序)生命周期。
//
// 注册表只是"目录",模块的实例化与生命周期统一由 Runtime 驱动。
package plugin
import (
"context"
"fmt"
"strings"
)
// ModuleID 是模块的全局唯一标识,采用点分层级命名,
// 形如 "job.hello"、"gateway.platform.anthropic"。
//
// 最后一段为模块名(Name),之前的部分为命名空间(Namespace)。
// 每一段只允许 [a-z0-9_-] 字符且不能为空。
type ModuleID string
// Namespace 返回模块 ID 的命名空间部分(去掉最后一段)。
//
// 例如 "gateway.platform.anthropic" 的命名空间为 "gateway.platform";
// 单段 ID(如 "hello")的命名空间为空字符串。
func (id ModuleID) Namespace() string {
lastDot := strings.LastIndex(string(id), ".")
if lastDot < 0 {
return ""
}
return string(id)[:lastDot]
}
// Name 返回模块 ID 的最后一段,即模块名。
//
// 例如 "gateway.platform.anthropic" 的模块名为 "anthropic"。
func (id ModuleID) Name() string {
lastDot := strings.LastIndex(string(id), ".")
if lastDot < 0 {
return string(id)
}
return string(id)[lastDot+1:]
}
// validate 校验模块 ID 格式:非空、点分、各段为非空的 [a-z0-9_-]+。
// 校验规则有意保持宽松,不做长度等额外限制。
func (id ModuleID) validate() error {
if id == "" {
return fmt.Errorf("module ID is empty")
}
for _, segment := range strings.Split(string(id), ".") {
if segment == "" {
return fmt.Errorf("module ID %q contains an empty segment", id)
}
for _, r := range segment {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_' && r != '-' {
return fmt.Errorf("module ID %q contains invalid character %q (allowed: [a-z0-9_-] per segment)", id, r)
}
}
}
return nil
}
// ModuleInfo 描述一个模块的注册信息(模块目录中的一条记录)。
type ModuleInfo struct {
// ID 是模块的全局唯一标识,见 ModuleID。
ID ModuleID
// New 返回该模块的一个新实例。
// 每次 Runtime.Build 都会通过 New 创建全新实例,模块不应依赖包级可变状态。
New func() Module
// EnabledByDefault 声明该模块在 `modules:` 配置未显式设置 enabled 时的默认启停状态。
//
// 设计说明(默认 enabled 的声明方式):默认值由模块在注册时通过本字段自行声明,
// 而不是由内核统一约定。这样"缺省配置 = 内置模块全部按各自默认 enabled 值运行"
// 的语义完全由各模块的注册信息决定;零值 false 意味着新模块默认不启用,
// 保证引入新模块不会在未配置时改变现有行为(与 Phase-1 零行为变更要求一致)。
EnabledByDefault bool
}
// Module 是所有插件模块必须实现的最小接口。
//
// 模块通常是一个 struct,在所属包的 init() 中调用 RegisterModule 完成自注册。
// 除 Module 外,模块可按需实现以下可选生命周期接口:
// Provisioner、Validator、Starter、Stopper。
type Module interface {
// ModuleInfo 返回模块的注册信息。该方法必须无副作用且可在零值实例上调用。
ModuleInfo() ModuleInfo
}
// Provisioner 是可选生命周期接口:在模块实例化后、校验前装配依赖。
//
// Provision 在 Runtime.Build 阶段被调用,模块应在此从 Host 获取所需能力
// (Logger / ConfigOf / DB / Redis)并完成自身初始化。失败将中止启动。
type Provisioner interface {
Provision(host *Host) error
}
// Validator 是可选生命周期接口:在 Provision 之后校验模块配置与状态。
//
// Validate 在 Runtime.Build 阶段被调用,失败将中止启动。
type Validator interface {
Validate() error
}
// Starter 是可选生命周期接口:启动模块的后台工作(goroutine、监听等)。
//
// Start 在 Runtime.Start 阶段按注册表稳定序被调用,失败将中止启动并
// 触发对已启动模块的逆序 Stop 回滚。
//
// 契约:实现必须快速返回——长任务应自行启动 goroutine(参照 hello 模块)。
// Runtime 在持锁状态下调用 Start/Stop,阻塞的实现会同时阻塞 Snapshot
// (即 admin 模块可观测接口)与其他生命周期操作。
type Starter interface {
Start(ctx context.Context) error
}
// Stopper 是可选生命周期接口:停止模块并释放资源。
//
// Stop 在 Runtime.Stop 阶段按注册表稳定序的逆序被调用,
// 实现必须尊重 ctx 的 deadline(与进程优雅关闭超时协同)。
//
// 契约:与 Start 相同,实现必须快速返回(等待自身 goroutine 退出时
// 用 select 配合 ctx.Done(),参照 hello 模块),原因见 Starter 注释。
type Stopper interface {
Stop(ctx context.Context) error
}
+55
View File
@@ -0,0 +1,55 @@
package plugin
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestModuleIDNamespaceAndName(t *testing.T) {
tests := []struct {
id ModuleID
namespace string
name string
}{
{id: "job.hello", namespace: "job", name: "hello"},
{id: "gateway.platform.anthropic", namespace: "gateway.platform", name: "anthropic"},
{id: "hello", namespace: "", name: "hello"},
{id: "payment.provider.stripe-checkout", namespace: "payment.provider", name: "stripe-checkout"},
}
for _, tt := range tests {
t.Run(string(tt.id), func(t *testing.T) {
require.Equal(t, tt.namespace, tt.id.Namespace())
require.Equal(t, tt.name, tt.id.Name())
})
}
}
func TestModuleIDValidate(t *testing.T) {
valid := []ModuleID{
"hello",
"job.hello",
"gateway.platform.anthropic",
"a.b-c.d_e",
"ns.mod123",
}
for _, id := range valid {
require.NoError(t, id.validate(), "id %q should be valid", id)
}
invalid := []ModuleID{
"",
".",
".hello",
"hello.",
"job..hello",
"Job.hello", // 大写
"job.hello!", // 非法字符
"job. hello", // 空格
"job.héllo", // 非 ASCII
"job.hello.World", // 末段大写
}
for _, id := range invalid {
require.Error(t, id.validate(), "id %q should be invalid", id)
}
}
+103
View File
@@ -0,0 +1,103 @@
// Package plugintest 提供插件模块的测试夹具(test fixtures)。
//
// 本包是模块作者的测试入口:NewHost 一行构造可按需附带 miniredis、
// observed logger、配置子树的宿主能力面;RunLifecycle 一行驱动
// "隔离注册 → 解析配置 → Build → Start → t.Cleanup 自动 Stop" 的完整
// 生命周期。命名与用法风格对齐标准库 net/http/httptest。
//
// 使用约束:本包 import "testing",仅供 _test.go 文件 import,
// 禁止进入任何生产代码的依赖面。该约束以本声明 + 代码评审人工约定保证
// (未配置 depguard 规则;若被非 _test 文件 import,testing 包会被
// 连带编入生产二进制,评审时一票否决)。
//
// 夹具纪律:夹具自身绝不吞错——Build/Start/Stop 或配置解析失败一律经
// tb.Fatalf / tb.Errorf 报告,且错误信息包含被测模块 ID。
package plugintest
import (
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
"github.com/Wei-Shaw/sub2api/internal/plugin"
)
// Option 定制 NewHost 构造的 Host。Option 在 NewHost 内部被依次应用,
// tb 即传给 NewHost 的测试句柄;Option 内部的失败一律经 tb 报告(不吞错)。
type Option func(tb testing.TB, h *plugin.Host)
// NewHost 构造模块测试用的宿主能力面,默认零依赖即可使用:
// nop logger、空 ConfigOf(对任何模块 ID 都不报错、不修改 out)、
// nil DB、nil Redis。按需叠加 WithRedis / WithConfig / WithObservedLogger。
//
// Host 是纯数据结构:本包未覆盖的少见场景(如注入 enttest 客户端、
// 自定义 logger)可在 NewHost 返回后直接对字段赋值,无需新增 Option。
func NewHost(tb testing.TB, opts ...Option) *plugin.Host {
tb.Helper()
host := &plugin.Host{
Logger: zap.NewNop(),
ConfigOf: plugin.Config{}.Of,
}
for _, opt := range opts {
opt(tb, host)
}
return host
}
// WithRedis 为 Host 附带一个由 miniredis 支撑的 go-redis 客户端。
//
// miniredis 实例与 redis 客户端的生命周期均挂在 tb.Cleanup 上自动清理;
// tb 通常就是传给 NewHost 的同一个测试句柄。
func WithRedis(tb testing.TB) Option {
return func(_ testing.TB, h *plugin.Host) {
tb.Helper()
mr := miniredis.RunT(tb)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
tb.Cleanup(func() {
if err := rdb.Close(); err != nil {
tb.Errorf("plugintest: close redis client: %v", err)
}
})
h.Redis = rdb
}
}
// WithConfig 将 Host.ConfigOf 绑定为 raw(与全局配置 `modules:` 子树同形状)
// 的解析结果,供直接调用 Provision 等不经 Runtime 的测试读取模块私有配置。
// raw 解析失败(模块 ID 格式非法、enabled 类型错误)立即 tb.Fatal。
//
// 注意:经 RunLifecycle / BuildOnly 驱动生命周期时,模块配置以其 raw 参数
// 为唯一来源(会覆盖本 Option 设置的 ConfigOf),两者不要混用。
func WithConfig(raw map[string]map[string]any) Option {
return func(tb testing.TB, h *plugin.Host) {
tb.Helper()
cfg, err := plugin.ParseConfig(raw)
if err != nil {
tb.Fatalf("plugintest: parse modules config: %v", err)
}
h.ConfigOf = cfg.Of
}
}
// WithObservedLogger 将 Host.Logger 替换为 debug 级别的 zap observer logger,
// 并把可断言日志记录的句柄作为第二返回值一并返回:
//
// logOpt, logs := plugintest.WithObservedLogger()
// host := plugintest.NewHost(t, logOpt)
// // ... 驱动模块 ...
// require.Equal(t, 1, logs.FilterMessage("hello").Len())
//
// 设计说明:observer 句柄随 Option 直接返回,而非提供 Logs(host) 式的查表
// 函数——调用方一行解构即可拿到句柄,包内也无需维护 Host → observer 的
// 映射状态(与 PHASE_PLAN 草案中 Logs(t) 形态的差异已在任务交付说明登记)。
func WithObservedLogger() (Option, *observer.ObservedLogs) {
core, logs := observer.New(zapcore.DebugLevel)
logger := zap.New(core)
return func(_ testing.TB, h *plugin.Host) {
h.Logger = logger
}, logs
}
@@ -0,0 +1,318 @@
package plugintest_test
import (
"context"
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/Wei-Shaw/sub2api/internal/plugin"
"github.com/Wei-Shaw/sub2api/internal/plugin/plugintest"
)
// fixtureConfig 是 fakeModule 的私有配置,用于验证 raw 配置经 host.ConfigOf 抵达模块。
type fixtureConfig struct {
Greeting string `mapstructure:"greeting"`
}
// fakeModule 是自测用模块:New 返回自身以便直接断言生命周期调用计数,
// 可按需注入各阶段错误。生命周期由 Runtime 在测试 goroutine 上同步驱动
// (cleanup 与 t.Run 完成均有 happens-before 保证),计数无需加锁。
type fakeModule struct {
id plugin.ModuleID
enabledByDefault bool
provisionErr error
validateErr error
startErr error
stopErr error
provisions int
validates int
starts int
stops int
host *plugin.Host
cfg fixtureConfig
}
func (m *fakeModule) ModuleInfo() plugin.ModuleInfo {
return plugin.ModuleInfo{
ID: m.id,
EnabledByDefault: m.enabledByDefault,
New: func() plugin.Module { return m },
}
}
func (m *fakeModule) Provision(host *plugin.Host) error {
m.provisions++
m.host = host
if host != nil && host.ConfigOf != nil {
if err := host.ConfigOf(m.id, &m.cfg); err != nil {
return err
}
}
return m.provisionErr
}
func (m *fakeModule) Validate() error { m.validates++; return m.validateErr }
func (m *fakeModule) Start(context.Context) error { m.starts++; return m.startErr }
func (m *fakeModule) Stop(context.Context) error { m.stops++; return m.stopErr }
// errFatalCalled 是 recordingTB.Fatalf 用于模拟"立即中止"语义的 panic 哨兵。
var errFatalCalled = errors.New("recordingTB: Fatalf called")
// recordingTB 包装真实 *testing.T,捕获夹具的 Fatalf / Errorf / Logf / Cleanup
// 调用,用于验证夹具在错误路径上不吞错。Fatalf 记录信息后 panic(errFatalCalled)
// 模拟真实 t.Fatalf 的中止语义,由 expectFatal 捕获恢复。
type recordingTB struct {
testing.TB
fatals []string
errs []string
logLines []string
cleanups []func()
}
func (r *recordingTB) Helper() {}
func (r *recordingTB) Logf(format string, args ...any) {
r.logLines = append(r.logLines, fmt.Sprintf(format, args...))
}
func (r *recordingTB) Errorf(format string, args ...any) {
r.errs = append(r.errs, fmt.Sprintf(format, args...))
}
func (r *recordingTB) Fatalf(format string, args ...any) {
r.fatals = append(r.fatals, fmt.Sprintf(format, args...))
panic(errFatalCalled)
}
func (r *recordingTB) Cleanup(f func()) { r.cleanups = append(r.cleanups, f) }
// runCleanups 按注册逆序(与真实 testing 一致)执行捕获的 cleanup。
func (r *recordingTB) runCleanups() {
for i := len(r.cleanups) - 1; i >= 0; i-- {
r.cleanups[i]()
}
}
// expectFatal 执行 fn 并断言它经 recordingTB.Fatalf 中止,返回最后一条 fatal 信息。
func expectFatal(t *testing.T, rec *recordingTB, fn func()) (msg string) {
t.Helper()
defer func() {
if r := recover(); r != nil {
if r != errFatalCalled {
panic(r) // 非哨兵 panic 原样上抛
}
require.NotEmpty(t, rec.fatals)
msg = rec.fatals[len(rec.fatals)-1]
}
}()
fn()
t.Fatal("expected the fixture to call Fatalf, but it returned normally")
return ""
}
func TestNewHostDefaults(t *testing.T) {
host := plugintest.NewHost(t)
require.NotNil(t, host.Logger, "默认应提供 nop logger 而非 nil")
require.NotNil(t, host.ConfigOf, "默认应提供空 ConfigOf 而非 nil")
require.Nil(t, host.DB)
require.Nil(t, host.Redis)
// 空 ConfigOf:对任意模块 ID 不报错、不修改 out(模块默认值得以保留)。
cfg := fixtureConfig{Greeting: "default"}
require.NoError(t, host.ConfigOf("job.anything", &cfg))
require.Equal(t, "default", cfg.Greeting)
}
func TestWithRedis(t *testing.T) {
host := plugintest.NewHost(t, plugintest.WithRedis(t))
require.NotNil(t, host.Redis)
ctx := context.Background()
require.NoError(t, host.Redis.Set(ctx, "k", "v", 0).Err())
got, err := host.Redis.Get(ctx, "k").Result()
require.NoError(t, err)
require.Equal(t, "v", got)
}
func TestWithConfig(t *testing.T) {
host := plugintest.NewHost(t, plugintest.WithConfig(map[string]map[string]any{
"job.fixture": {"enabled": true, "greeting": "hi"},
}))
var cfg fixtureConfig
require.NoError(t, host.ConfigOf("job.fixture", &cfg))
require.Equal(t, "hi", cfg.Greeting, "私有配置应可经 ConfigOf 解码(enabled 键被内核剥离)")
}
func TestWithConfigParseFailureFatals(t *testing.T) {
rec := &recordingTB{TB: t}
msg := expectFatal(t, rec, func() {
plugintest.NewHost(rec, plugintest.WithConfig(map[string]map[string]any{
"INVALID ID": {},
}))
})
require.Contains(t, msg, "parse modules config")
}
func TestWithObservedLogger(t *testing.T) {
logOpt, logs := plugintest.WithObservedLogger()
host := plugintest.NewHost(t, logOpt)
host.Logger.Debug("observed-ping")
require.Equal(t, 1, logs.FilterMessage("observed-ping").Len())
}
func TestRunLifecycleSuccessAndAutoStop(t *testing.T) {
m := &fakeModule{id: "job.fixture"}
t.Run("lifecycle", func(t *testing.T) {
rt := plugintest.RunLifecycle(t, m, plugintest.NewHost(t), map[string]map[string]any{
"job.fixture": {"enabled": true, "greeting": "hi"},
})
require.Equal(t, 1, m.provisions)
require.Equal(t, 1, m.validates)
require.Equal(t, 1, m.starts)
require.Zero(t, m.stops)
require.Equal(t, "hi", m.cfg.Greeting, "raw 配置应经 host.ConfigOf 抵达模块")
require.NotNil(t, m.host)
snap := rt.Snapshot()
require.Len(t, snap, 1, "隔离注册表应只含被测模块")
require.Equal(t, plugin.StateRunning, snap[0].State)
})
require.Equal(t, 1, m.stops, "t.Cleanup 应自动 Stop 模块")
}
func TestRunLifecycleRawOverridesHostConfig(t *testing.T) {
m := &fakeModule{id: "job.fixture"}
host := plugintest.NewHost(t, plugintest.WithConfig(map[string]map[string]any{
"job.fixture": {"greeting": "from-with-config"},
}))
plugintest.RunLifecycle(t, m, host, map[string]map[string]any{
"job.fixture": {"enabled": true, "greeting": "from-raw"},
})
require.Equal(t, "from-raw", m.cfg.Greeting,
"RunLifecycle 的 raw 是配置唯一来源,应覆盖 WithConfig 的旧绑定")
}
func TestRunLifecycleNilHostUsesDefaultHost(t *testing.T) {
m := &fakeModule{id: "job.fixture"}
plugintest.RunLifecycle(t, m, nil, map[string]map[string]any{
"job.fixture": {"enabled": true},
})
require.NotNil(t, m.host, "nil host 应被替换为默认 Host")
require.NotNil(t, m.host.Logger)
require.NotNil(t, m.host.ConfigOf)
}
func TestRunLifecycleExplicitStopThenCleanupIsIdempotent(t *testing.T) {
m := &fakeModule{id: "job.fixture"}
t.Run("lifecycle", func(t *testing.T) {
rt := plugintest.RunLifecycle(t, m, nil, map[string]map[string]any{
"job.fixture": {"enabled": true},
})
require.NoError(t, rt.Stop(context.Background()))
require.Equal(t, 1, m.stops)
})
require.Equal(t, 1, m.stops, "测试已显式 Stop 时,cleanup 的二次 Stop 应为 no-op")
}
func TestRunLifecycleDisabledModuleIsNoop(t *testing.T) {
m := &fakeModule{id: "job.fixture"} // EnabledByDefault=false 且 raw 未显式 enabled
rt := plugintest.RunLifecycle(t, m, nil, nil)
require.Zero(t, m.provisions, "disabled 模块不应经历任何生命周期(语义与生产一致,不强制 enable)")
snap := rt.Snapshot()
require.Len(t, snap, 1)
require.False(t, snap[0].Enabled)
require.Equal(t, plugin.StateRegistered, snap[0].State)
}
func TestBuildOnlyDoesNotStart(t *testing.T) {
m := &fakeModule{id: "job.fixture"}
t.Run("build", func(t *testing.T) {
rt := plugintest.BuildOnly(t, m, nil, map[string]map[string]any{
"job.fixture": {"enabled": true},
})
require.Equal(t, 1, m.provisions)
require.Equal(t, 1, m.validates)
require.Zero(t, m.starts)
require.Equal(t, plugin.StateProvisioned, rt.Snapshot()[0].State)
})
require.Zero(t, m.stops, "未 Start 的模块在 cleanup 兜底 Stop 中不应被调用 Stop")
}
func TestBuildOnlyLogsDisabledHint(t *testing.T) {
rec := &recordingTB{TB: t}
m := &fakeModule{id: "job.fixture"}
plugintest.BuildOnly(rec, m, nil, nil)
require.NotEmpty(t, rec.logLines, "disabled 模块应输出提示日志")
require.Contains(t, rec.logLines[0], "disabled")
require.Contains(t, rec.logLines[0], `"job.fixture"`)
rec.runCleanups()
require.Empty(t, rec.errs)
}
func TestRunLifecycleBuildFailureFatalsWithModuleID(t *testing.T) {
rec := &recordingTB{TB: t}
m := &fakeModule{id: "job.fixture", enabledByDefault: true, provisionErr: errors.New("provision boom")}
msg := expectFatal(t, rec, func() {
plugintest.RunLifecycle(rec, m, nil, nil)
})
require.Contains(t, msg, `"job.fixture"`, "Build 失败信息必须包含模块 ID")
require.Contains(t, msg, "provision boom")
}
func TestRunLifecycleStartFailureFatalsWithModuleID(t *testing.T) {
rec := &recordingTB{TB: t}
m := &fakeModule{id: "job.fixture", enabledByDefault: true, startErr: errors.New("start boom")}
msg := expectFatal(t, rec, func() {
plugintest.RunLifecycle(rec, m, nil, nil)
})
require.Contains(t, msg, `"job.fixture"`, "Start 失败信息必须包含模块 ID")
require.Contains(t, msg, "start boom")
// Start 失败的模块未进入 running,cleanup 兜底 Stop 应为无错误的 no-op。
rec.runCleanups()
require.Empty(t, rec.errs)
require.Zero(t, m.stops)
}
func TestCleanupStopFailureReportedWithModuleID(t *testing.T) {
rec := &recordingTB{TB: t}
m := &fakeModule{id: "job.fixture", enabledByDefault: true, stopErr: errors.New("stop boom")}
plugintest.RunLifecycle(rec, m, nil, nil)
require.Empty(t, rec.fatals)
require.NotEmpty(t, rec.cleanups)
rec.runCleanups()
require.Len(t, rec.errs, 1, "cleanup Stop 失败必须经 Errorf 报告(不吞错)")
require.Contains(t, rec.errs[0], `"job.fixture"`)
require.Contains(t, rec.errs[0], "stop boom")
}
func TestBuildExpectingErrorReturnsBuildError(t *testing.T) {
m := &fakeModule{id: "job.fixture", enabledByDefault: true, validateErr: errors.New("validate boom")}
err := plugintest.BuildExpectingError(t, m, nil, nil)
require.Error(t, err)
require.Contains(t, err.Error(), `"job.fixture"`)
require.Contains(t, err.Error(), "validate boom")
}
func TestBuildExpectingErrorFatalsOnUnexpectedSuccess(t *testing.T) {
rec := &recordingTB{TB: t}
m := &fakeModule{id: "job.fixture", enabledByDefault: true}
msg := expectFatal(t, rec, func() {
_ = plugintest.BuildExpectingError(rec, m, nil, nil)
})
require.Contains(t, msg, "expected Build")
require.Contains(t, msg, `"job.fixture"`)
}
@@ -0,0 +1,120 @@
package plugintest
import (
"context"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/plugin"
)
// stopTimeout 是 t.Cleanup 自动 Stop 的超时上限:模块 Stop 悬挂时,
// 测试以包含模块 ID 的明确错误失败,而不是吊死整个 go test 进程。
const stopTimeout = 10 * time.Second
// RunLifecycle 驱动单个模块的完整生命周期:在隔离注册表中注册 m →
// 解析 raw 配置 → Build(Provision / Validate)→ Start。任一步失败立即
// tb.Fatal(错误信息含模块 ID)。测试结束时 t.Cleanup 自动 Stop 并断言无
// 错误;测试中途已显式 rt.Stop 时,清理阶段的二次 Stop 为 no-op
// (Runtime.Stop 只停 running 模块,幂等)。
//
// 语义说明:
// - raw 与全局配置 `modules:` 子树同形状,是模块配置的唯一来源
// (host 上已有的 ConfigOf——包括 WithConfig 设置的——会被重绑定为
// raw 的解析结果);nil 表示空配置;
// - enabled 语义与生产完全一致、不做任何强制:raw 未显式 enabled 且模块
// 默认 disabled 时,Build / Start 全程 no-op(此时会 tb.Logf 提示,
// 便于排查"忘了写 enabled: true");
// - host 为 nil 时等价于 NewHost(tb) 的默认 Host;
// - Runtime 经 ModuleInfo().New 创建实例驱动生命周期——除非模块的 New
// 返回自身(fake 模块常如此),传入的 m 仅用于注册,生命周期效果应
// 通过日志(WithObservedLogger)与返回 Runtime 的 Snapshot 断言,
// 而不是 m 的内部字段。
//
// 需要断言"Build 应失败"用 BuildExpectingError;更复杂的场景(Start 失败
// 回滚、多模块编排等)直接使用内核 API(plugin.NewRegistry +
// plugin.NewRuntimeWithRegistry),内核 API 保持完全公开。
func RunLifecycle(tb testing.TB, m plugin.Module, host *plugin.Host, raw map[string]map[string]any) *plugin.Runtime {
tb.Helper()
rt, id := buildOnly(tb, m, host, raw)
if err := rt.Start(context.Background()); err != nil {
tb.Fatalf("plugintest: start module %q: %v", id, err)
}
return rt
}
// BuildOnly 与 RunLifecycle 相同但停在 Build 之后(不 Start),用于只验证
// Provision / Validate 行为的测试。返回的 Runtime 可由测试自行 Start;
// 无论是否 Start,t.Cleanup 都会执行兜底 Stop(未启动时为 no-op)并断言无错误。
func BuildOnly(tb testing.TB, m plugin.Module, host *plugin.Host, raw map[string]map[string]any) *plugin.Runtime {
tb.Helper()
rt, _ := buildOnly(tb, m, host, raw)
return rt
}
// BuildExpectingError 与 BuildOnly 同样装配 Runtime,但期望 Build 失败:
// Build 意外成功时 tb.Fatal;失败时返回该错误供测试断言内容
// (内核保证 Build 错误信息包含失败模块 ID)。
//
// 设计说明:单独提供本变体而不是给 RunLifecycle 加 expectError 参数,
// 是为了让成功 / 失败两种期望在调用处一目了然,且失败路径不注册多余的
// cleanup(Build 失败后无 running 模块,无需 Stop)。raw 解析失败仍然
// tb.Fatal——配置字面量笔误属于测试自身缺陷,不属于"期望中的 Build 失败"。
// "Start 应失败"的场景本包不提供夹具(hello 与内核测试均无此样板复用需求,
// 避免为假设需求预铺 API),请直接使用内核 API。
func BuildExpectingError(tb testing.TB, m plugin.Module, host *plugin.Host, raw map[string]map[string]any) error {
tb.Helper()
rt, id := newRuntime(tb, m, host, raw)
err := rt.Build()
if err == nil {
tb.Fatalf("plugintest: expected Build of module %q to fail, but it succeeded", id)
}
return err
}
// buildOnly 是 RunLifecycle / BuildOnly 的共享实现:装配 Runtime、执行 Build、
// 输出 disabled 提示并注册 t.Cleanup 兜底 Stop。
func buildOnly(tb testing.TB, m plugin.Module, host *plugin.Host, raw map[string]map[string]any) (*plugin.Runtime, plugin.ModuleID) {
tb.Helper()
rt, id := newRuntime(tb, m, host, raw)
if err := rt.Build(); err != nil {
tb.Fatalf("plugintest: build module %q: %v", id, err)
}
for _, status := range rt.Snapshot() {
if status.ID == id && !status.Enabled {
tb.Logf("plugintest: module %q resolved to disabled under the given config; its lifecycle is a no-op (set \"enabled\": true in raw to run it)", id)
}
}
tb.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), stopTimeout)
defer cancel()
if err := rt.Stop(ctx); err != nil {
tb.Errorf("plugintest: stop module %q during cleanup: %v", id, err)
}
})
return rt, id
}
// newRuntime 完成各夹具入口共享的装配:隔离注册表注册 m、解析 raw 配置、
// 将 host.ConfigOf 重绑定为解析结果、创建 Runtime。
func newRuntime(tb testing.TB, m plugin.Module, host *plugin.Host, raw map[string]map[string]any) (*plugin.Runtime, plugin.ModuleID) {
tb.Helper()
if m == nil {
tb.Fatalf("plugintest: module under test is nil")
}
id := m.ModuleInfo().ID
cfg, err := plugin.ParseConfig(raw)
if err != nil {
tb.Fatalf("plugintest: parse modules config for module %q: %v", id, err)
}
if host == nil {
host = NewHost(tb)
}
// raw 是生命周期夹具的唯一配置来源:统一重绑定 ConfigOf,避免 host 上的
// 旧绑定(NewHost 默认空配置 / WithConfig)与 Runtime 实际持有的 cfg 不一致。
host.ConfigOf = cfg.Of
registry := plugin.NewRegistry()
registry.RegisterModule(m) // 注册非法(ID 格式等)时按内核语义 panic,测试直接失败
return plugin.NewRuntimeWithRegistry(host, cfg, registry), id
}
+113
View File
@@ -0,0 +1,113 @@
package plugin
import (
"fmt"
"sort"
"sync"
)
// Registry 是模块注册表:从 ModuleID 到 ModuleInfo 的并发安全目录。
//
// 注册表只负责"有哪些模块",不负责实例化与生命周期(那是 Runtime 的职责)。
// 常规代码使用包级默认注册表(RegisterModule 等包级函数);
// 测试可通过 NewRegistry 创建相互隔离的实例。
type Registry struct {
mu sync.RWMutex
modules map[ModuleID]ModuleInfo
}
// NewRegistry 创建一个空的、与包级默认注册表相互隔离的注册表实例。
func NewRegistry() *Registry {
return &Registry{modules: make(map[ModuleID]ModuleInfo)}
}
// RegisterModule 将模块注册进本注册表。
//
// 与包级 RegisterModule 相同的 panic 语义:模块为 nil、ID 为空、ID 格式非法、
// New 为 nil 或 ID 重复时 panic。注册错误属于编译期插装错误,应尽早暴露。
func (r *Registry) RegisterModule(m Module) {
if m == nil {
panic("plugin: RegisterModule called with nil module")
}
info := m.ModuleInfo()
if err := info.ID.validate(); err != nil {
panic(fmt.Sprintf("plugin: RegisterModule: invalid module ID: %v", err))
}
if info.New == nil {
panic(fmt.Sprintf("plugin: RegisterModule: module %q has a nil New function", info.ID))
}
r.mu.Lock()
defer r.mu.Unlock()
if _, dup := r.modules[info.ID]; dup {
panic(fmt.Sprintf("plugin: RegisterModule: module %q is already registered (duplicate registration; check the instrumentation list in internal/modules/standard)", info.ID))
}
r.modules[info.ID] = info
}
// GetModule 按 ID 查询模块注册信息,第二个返回值表示是否存在。
func (r *Registry) GetModule(id ModuleID) (ModuleInfo, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
info, ok := r.modules[id]
return info, ok
}
// GetModulesInNamespace 返回命名空间恰好为 ns 的全部模块,按 ID 字典序稳定排序。
//
// 匹配为精确匹配(非前缀匹配):ns 为 "gateway.platform" 时返回
// "gateway.platform.anthropic" 等直接子模块,但不包含 "gateway.platform.x.y"。
// ns 为空字符串时返回所有单段 ID 的顶层模块。
func (r *Registry) GetModulesInNamespace(ns string) []ModuleInfo {
r.mu.RLock()
defer r.mu.RUnlock()
var result []ModuleInfo
for id, info := range r.modules {
if id.Namespace() == ns {
result = append(result, info)
}
}
sortModuleInfos(result)
return result
}
// Modules 返回注册表中的全部模块,按 ID 字典序稳定排序。
// Runtime 以该顺序作为模块生命周期的确定性顺序。
func (r *Registry) Modules() []ModuleInfo {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]ModuleInfo, 0, len(r.modules))
for _, info := range r.modules {
result = append(result, info)
}
sortModuleInfos(result)
return result
}
// sortModuleInfos 按 ID 字典序原地排序,保证遍历顺序的确定性。
func sortModuleInfos(infos []ModuleInfo) {
sort.Slice(infos, func(i, j int) bool { return infos[i].ID < infos[j].ID })
}
// defaultRegistry 是包级默认注册表,模块包在 init() 期向其自注册。
var defaultRegistry = NewRegistry()
// RegisterModule 将模块注册进包级默认注册表,必须在 init() 期调用。
//
// 模块为 nil、ID 为空、ID 格式非法、New 为 nil 或 ID 重复时 panic:
// 注册错误属于编译期插装错误(插装清单 import 错误、模块 ID 冲突等),
// 必须在进程启动最早期暴露,而不是延迟到运行时。
func RegisterModule(m Module) {
defaultRegistry.RegisterModule(m)
}
// GetModule 按 ID 在包级默认注册表中查询模块注册信息。
func GetModule(id ModuleID) (ModuleInfo, bool) {
return defaultRegistry.GetModule(id)
}
// GetModulesInNamespace 在包级默认注册表中查询命名空间恰好为 ns 的全部模块,
// 按 ID 字典序稳定排序。匹配语义见 (*Registry).GetModulesInNamespace。
func GetModulesInNamespace(ns string) []ModuleInfo {
return defaultRegistry.GetModulesInNamespace(ns)
}
+176
View File
@@ -0,0 +1,176 @@
package plugin
import (
"fmt"
"sync"
"testing"
"github.com/stretchr/testify/require"
)
// stubModule 是注册表测试用的最小模块实现。
type stubModule struct {
info ModuleInfo
}
func (m *stubModule) ModuleInfo() ModuleInfo { return m.info }
// newStubModule 构造一个 ID 为 id 的最小可注册模块。
func newStubModule(id ModuleID) *stubModule {
m := &stubModule{}
m.info = ModuleInfo{
ID: id,
New: func() Module { return &stubModule{info: m.info} },
}
return m
}
func TestRegistryRegisterAndGet(t *testing.T) {
reg := NewRegistry()
reg.RegisterModule(newStubModule("job.hello"))
info, ok := reg.GetModule("job.hello")
require.True(t, ok)
require.Equal(t, ModuleID("job.hello"), info.ID)
require.NotNil(t, info.New)
_, ok = reg.GetModule("job.missing")
require.False(t, ok)
}
func TestRegistryDuplicateIDPanics(t *testing.T) {
reg := NewRegistry()
reg.RegisterModule(newStubModule("job.hello"))
require.PanicsWithValue(t,
`plugin: RegisterModule: module "job.hello" is already registered (duplicate registration; check the instrumentation list in internal/modules/standard)`,
func() { reg.RegisterModule(newStubModule("job.hello")) },
)
}
func TestRegistryInvalidRegistrationPanics(t *testing.T) {
reg := NewRegistry()
t.Run("nil module", func(t *testing.T) {
require.Panics(t, func() { reg.RegisterModule(nil) })
})
t.Run("empty ID", func(t *testing.T) {
require.Panics(t, func() { reg.RegisterModule(newStubModule("")) })
})
t.Run("invalid ID format", func(t *testing.T) {
// panic 信息必须包含非法 ID,便于排查插装清单。
defer func() {
r := recover()
require.NotNil(t, r)
require.Contains(t, fmt.Sprint(r), "Job.Hello")
}()
reg.RegisterModule(newStubModule("Job.Hello"))
})
t.Run("nil New func", func(t *testing.T) {
require.Panics(t, func() {
reg.RegisterModule(&stubModule{info: ModuleInfo{ID: "job.no-new"}})
})
})
}
func TestRegistryGetModulesInNamespace(t *testing.T) {
reg := NewRegistry()
// 故意乱序注册,验证返回结果按 ID 字典序稳定排序。
for _, id := range []ModuleID{
"job.zeta",
"job.alpha",
"job.middle",
"gateway.platform.anthropic",
"gateway.platform.openai",
"toplevel",
} {
reg.RegisterModule(newStubModule(id))
}
ids := func(infos []ModuleInfo) []ModuleID {
out := make([]ModuleID, 0, len(infos))
for _, info := range infos {
out = append(out, info.ID)
}
return out
}
require.Equal(t, []ModuleID{"job.alpha", "job.middle", "job.zeta"}, ids(reg.GetModulesInNamespace("job")))
// 精确匹配:ns "gateway" 不包含 "gateway.platform.*"。
require.Empty(t, reg.GetModulesInNamespace("gateway"))
require.Equal(t, []ModuleID{"gateway.platform.anthropic", "gateway.platform.openai"},
ids(reg.GetModulesInNamespace("gateway.platform")))
// 空命名空间返回单段顶层模块。
require.Equal(t, []ModuleID{"toplevel"}, ids(reg.GetModulesInNamespace("")))
// 多次调用结果稳定。
require.Equal(t, ids(reg.GetModulesInNamespace("job")), ids(reg.GetModulesInNamespace("job")))
}
func TestRegistryModulesSorted(t *testing.T) {
reg := NewRegistry()
for _, id := range []ModuleID{"c.mod", "a.mod", "b.mod"} {
reg.RegisterModule(newStubModule(id))
}
all := reg.Modules()
require.Len(t, all, 3)
require.Equal(t, ModuleID("a.mod"), all[0].ID)
require.Equal(t, ModuleID("b.mod"), all[1].ID)
require.Equal(t, ModuleID("c.mod"), all[2].ID)
}
func TestRegistryIsolation(t *testing.T) {
regA := NewRegistry()
regB := NewRegistry()
regA.RegisterModule(newStubModule("job.only-in-a"))
_, ok := regA.GetModule("job.only-in-a")
require.True(t, ok)
_, ok = regB.GetModule("job.only-in-a")
require.False(t, ok, "isolated registry must not see other registry's modules")
// 隔离实例也不影响包级默认注册表。
_, ok = GetModule("job.only-in-a")
require.False(t, ok)
}
func TestPackageLevelFunctionsDelegateToDefaultRegistry(t *testing.T) {
// 使用唯一 ID,避免与同进程其他测试冲突(包级默认注册表是进程级状态)。
const id = ModuleID("test_default_registry.probe")
RegisterModule(newStubModule(id))
info, ok := GetModule(id)
require.True(t, ok)
require.Equal(t, id, info.ID)
infos := GetModulesInNamespace("test_default_registry")
require.Len(t, infos, 1)
require.Equal(t, id, infos[0].ID)
}
func TestRegistryConcurrentRegistration(t *testing.T) {
reg := NewRegistry()
const n = 64
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func(i int) {
defer wg.Done()
reg.RegisterModule(newStubModule(ModuleID(fmt.Sprintf("concurrent.mod-%03d", i))))
}(i)
}
// 并发读不应与写竞争(-race 验证)。
for i := 0; i < 8; i++ {
go func() {
_ = reg.GetModulesInNamespace("concurrent")
_, _ = reg.GetModule("concurrent.mod-000")
}()
}
wg.Wait()
all := reg.Modules()
require.Len(t, all, n)
for i := 1; i < len(all); i++ {
require.Less(t, all[i-1].ID, all[i].ID, "Modules() must be sorted")
}
}
+291
View File
@@ -0,0 +1,291 @@
package plugin
import (
"context"
"errors"
"fmt"
"sync"
"go.uber.org/zap"
)
// State 是模块在 Runtime 生命周期中的状态。
type State string
// 模块生命周期状态。状态机:
//
// registered ──Build──▶ provisioned ──Start──▶ running ──Stop──▶ stopped
// │(disabled 模块停留在 registered) 任一阶段失败 ──▶ errored
const (
// StateRegistered 模块已注册但未实例化(disabled 模块始终处于该状态)。
StateRegistered State = "registered"
// StateProvisioned 模块已实例化并通过 Provision/Validate,等待启动。
StateProvisioned State = "provisioned"
// StateRunning 模块已启动(未实现 Starter 的 enabled 模块在 Start 阶段也进入该状态)。
StateRunning State = "running"
// StateStopped 模块已正常停止。
StateStopped State = "stopped"
// StateErrored 模块在某个生命周期阶段失败,错误见 ModuleStatus.Err。
StateErrored State = "errored"
)
// ModuleStatus 是单个模块的状态快照(admin 可观测用)。
type ModuleStatus struct {
// ID 模块 ID。
ID ModuleID
// Enabled 模块最终的启停状态(显式配置优先,否则为注册声明的默认值)。
Enabled bool
// State 模块当前生命周期状态。
State State
// Err 模块最近一次生命周期失败的错误信息,无错误时为空字符串。
Err string
}
// moduleRecord 是 Runtime 内部维护的单模块运行时记录。
type moduleRecord struct {
info ModuleInfo
enabled bool
instance Module
state State
err error
}
// Runtime 是模块生命周期驱动器:按注册表稳定序(ID 字典序)实例化 enabled
// 模块并驱动 Provision → Validate → Start → Stop(逆序)生命周期。
//
// 使用方式(单次使用,不支持 Stop 后重新 Start):
//
// rt := plugin.NewRuntime(host, cfg)
// if err := rt.Build(); err != nil { ... } // 启动中止
// if err := rt.Start(ctx); err != nil { ... } // 已自动逆序回滚
// defer rt.Stop(shutdownCtx) // 接入既有 cleanup 序列
//
// 所有方法并发安全。
type Runtime struct {
mu sync.Mutex
host *Host
config Config
registry *Registry
logger *zap.Logger
built bool
buildAttempted bool
started bool
records []*moduleRecord
}
// NewRuntime 创建基于包级默认注册表的 Runtime。
//
// host 为 nil 时使用空 Host;若 host.ConfigOf 未装配,则默认绑定为 cfg.Of,
// 使模块在 Provision 中总能读取到自己的私有配置。
func NewRuntime(host *Host, cfg Config) *Runtime {
return NewRuntimeWithRegistry(host, cfg, defaultRegistry)
}
// NewRuntimeWithRegistry 创建基于指定注册表的 Runtime(测试隔离用)。
// 其余语义与 NewRuntime 一致。
func NewRuntimeWithRegistry(host *Host, cfg Config, registry *Registry) *Runtime {
if host == nil {
host = &Host{}
}
if cfg == nil {
cfg = Config{}
}
if registry == nil {
registry = defaultRegistry
}
if host.ConfigOf == nil {
host.ConfigOf = cfg.Of
}
logger := host.Logger
if logger == nil {
logger = zap.NewNop()
}
return &Runtime{
host: host,
config: cfg,
registry: registry,
logger: logger,
}
}
// Build 按注册表稳定序实例化全部 enabled 模块并依次执行 Provision、Validate。
//
// 任一模块失败立即返回包含模块 ID 的错误(启动中止),不会继续处理后续模块;
// 已成功 Provision 的模块保持 provisioned 状态,由进程启动失败路径整体退出。
// Build 只能调用一次:无论成败,第二次调用一律拒绝——失败的 Build 不可重试
// (重试会对已 Provision 的旧实例不 Stop 即覆盖,造成资源泄漏),应整体重启进程。
func (r *Runtime) Build() error {
r.mu.Lock()
defer r.mu.Unlock()
if r.buildAttempted {
if r.built {
return errors.New("plugin: runtime already built")
}
return errors.New("plugin: previous build failed; runtime cannot be rebuilt, restart the process")
}
r.buildAttempted = true
// 先为全部已注册模块建立记录(含 disabled),保证失败后 Snapshot 仍是完整视图。
infos := r.registry.Modules() // 已按 ID 字典序稳定排序
r.records = make([]*moduleRecord, 0, len(infos))
for _, info := range infos {
r.records = append(r.records, &moduleRecord{
info: info,
enabled: r.config.enabledFor(info),
state: StateRegistered,
})
}
for _, rec := range r.records {
if !rec.enabled {
continue
}
info := rec.info
rec.instance = info.New()
if rec.instance == nil {
rec.err = errors.New("module New func returned nil")
rec.state = StateErrored
return fmt.Errorf("plugin: build module %q: New returned nil instance", info.ID)
}
if p, ok := rec.instance.(Provisioner); ok {
if err := p.Provision(r.host); err != nil {
rec.err = err
rec.state = StateErrored
return fmt.Errorf("plugin: provision module %q: %w", info.ID, err)
}
}
if v, ok := rec.instance.(Validator); ok {
if err := v.Validate(); err != nil {
rec.err = err
rec.state = StateErrored
return fmt.Errorf("plugin: validate module %q: %w", info.ID, err)
}
}
rec.state = StateProvisioned
r.logger.Debug("plugin module provisioned", zap.String("module", string(info.ID)))
}
r.built = true
return nil
}
// Start 按注册表稳定序启动全部 enabled 模块(对实现 Starter 的模块调用 Start,
// 未实现 Starter 的模块直接视为 running)。
//
// 任一模块启动失败时:对已进入 running 的模块按逆序执行 Stop 回滚
// (回滚沿用传入的 ctx)。返回的错误包含失败模块 ID;若回滚中某些模块的
// Stop 也失败,这些错误会一并 errors.Join 进返回值(同时记日志),
// 让调用方能看到哪些模块的资源未释放干净。
func (r *Runtime) Start(ctx context.Context) error {
r.mu.Lock()
defer r.mu.Unlock()
if !r.built {
return errors.New("plugin: runtime must be built before start")
}
if r.started {
return errors.New("plugin: runtime already started")
}
startedRecs := make([]*moduleRecord, 0, len(r.records))
for _, rec := range r.records {
if !rec.enabled || rec.state != StateProvisioned {
continue
}
if s, ok := rec.instance.(Starter); ok {
if err := s.Start(ctx); err != nil {
rec.err = err
rec.state = StateErrored
// 逆序回滚已启动模块;回滚失败并入返回错误(见方法注释)。
rollbackErrs := r.stopRecordsLocked(ctx, startedRecs)
startErr := fmt.Errorf("plugin: start module %q: %w", rec.info.ID, err)
if len(rollbackErrs) > 0 {
return errors.Join(append([]error{startErr}, rollbackErrs...)...)
}
return startErr
}
}
rec.state = StateRunning
startedRecs = append(startedRecs, rec)
r.logger.Info("plugin module started", zap.String("module", string(rec.info.ID)))
}
r.started = true
return nil
}
// Stop 按注册表稳定序的逆序停止全部 running 模块。
//
// 单个模块 Stop 失败只记日志并继续关闭其余模块(与既有 cleanup 序列的容错
// 纪律一致),最终返回 errors.Join 聚合的失败集合(全部成功时为 nil)。
// ctx 原样传递给各模块 Stop,模块必须自行尊重其 deadline。
func (r *Runtime) Stop(ctx context.Context) error {
r.mu.Lock()
defer r.mu.Unlock()
running := make([]*moduleRecord, 0, len(r.records))
for _, rec := range r.records {
if rec.state == StateRunning {
running = append(running, rec)
}
}
errs := r.stopRecordsLocked(ctx, running)
r.started = false
return errors.Join(errs...)
}
// stopRecordsLocked 逆序停止 recs 中的模块(recs 为正向顺序),返回失败集合。
// 调用方必须已持有 r.mu。
func (r *Runtime) stopRecordsLocked(ctx context.Context, recs []*moduleRecord) []error {
var errs []error
for i := len(recs) - 1; i >= 0; i-- {
rec := recs[i]
if s, ok := rec.instance.(Stopper); ok {
if err := s.Stop(ctx); err != nil {
rec.err = err
rec.state = StateErrored
r.logger.Error("plugin module stop failed",
zap.String("module", string(rec.info.ID)), zap.Error(err))
errs = append(errs, fmt.Errorf("plugin: stop module %q: %w", rec.info.ID, err))
continue
}
}
rec.state = StateStopped
r.logger.Info("plugin module stopped", zap.String("module", string(rec.info.ID)))
}
return errs
}
// Snapshot 返回全部已注册模块的状态快照,按 ID 字典序稳定排序。
//
// Build 之前调用时,基于注册表与配置即时计算(状态均为 registered);
// Build 之后返回 Runtime 维护的实际生命周期状态。
func (r *Runtime) Snapshot() []ModuleStatus {
r.mu.Lock()
defer r.mu.Unlock()
if r.records == nil {
infos := r.registry.Modules()
statuses := make([]ModuleStatus, 0, len(infos))
for _, info := range infos {
statuses = append(statuses, ModuleStatus{
ID: info.ID,
Enabled: r.config.enabledFor(info),
State: StateRegistered,
})
}
return statuses
}
statuses := make([]ModuleStatus, 0, len(r.records))
for _, rec := range r.records {
status := ModuleStatus{
ID: rec.info.ID,
Enabled: rec.enabled,
State: rec.state,
}
if rec.err != nil {
status.Err = rec.err.Error()
}
statuses = append(statuses, status)
}
return statuses
}
+389
View File
@@ -0,0 +1,389 @@
package plugin
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// lifecycleLog 记录全部 fake 模块的生命周期调用序列("模块ID:阶段")。
type lifecycleLog struct {
mu sync.Mutex
events []string
}
func (l *lifecycleLog) add(event string) {
l.mu.Lock()
defer l.mu.Unlock()
l.events = append(l.events, event)
}
func (l *lifecycleLog) snapshot() []string {
l.mu.Lock()
defer l.mu.Unlock()
return append([]string(nil), l.events...)
}
// fakeModule 是实现全部可选生命周期接口的测试模块,按配置注入各阶段错误。
type fakeModule struct {
id ModuleID
enabledByDefault bool
log *lifecycleLog
provisionErr error
validateErr error
startErr error
stopErr error
stopFn func(ctx context.Context) error // 非 nil 时覆盖默认 Stop 行为
host *Host // Provision 时记录,供断言
}
func (m *fakeModule) ModuleInfo() ModuleInfo {
return ModuleInfo{
ID: m.id,
EnabledByDefault: m.enabledByDefault,
New: func() Module { return m },
}
}
func (m *fakeModule) Provision(host *Host) error {
m.host = host
m.log.add(string(m.id) + ":provision")
return m.provisionErr
}
func (m *fakeModule) Validate() error {
m.log.add(string(m.id) + ":validate")
return m.validateErr
}
func (m *fakeModule) Start(ctx context.Context) error {
m.log.add(string(m.id) + ":start")
return m.startErr
}
func (m *fakeModule) Stop(ctx context.Context) error {
m.log.add(string(m.id) + ":stop")
if m.stopFn != nil {
return m.stopFn(ctx)
}
return m.stopErr
}
// plainModule 只实现 Module(无任何生命周期接口)。
type plainModule struct {
id ModuleID
}
func (m *plainModule) ModuleInfo() ModuleInfo {
return ModuleInfo{
ID: m.id,
EnabledByDefault: true,
New: func() Module { return m },
}
}
// newTestRuntime 构造隔离注册表 + Runtime。
func newTestRuntime(t *testing.T, cfg Config, mods ...Module) *Runtime {
t.Helper()
reg := NewRegistry()
for _, m := range mods {
reg.RegisterModule(m)
}
return NewRuntimeWithRegistry(&Host{}, cfg, reg)
}
func statusByID(t *testing.T, statuses []ModuleStatus, id ModuleID) ModuleStatus {
t.Helper()
for _, s := range statuses {
if s.ID == id {
return s
}
}
t.Fatalf("module %q not found in snapshot", id)
return ModuleStatus{}
}
func TestRuntimeBuildLifecycleOrderAndEnabledTriState(t *testing.T) {
log := &lifecycleLog{}
on, off := true, false
cfg := Config{
"job.c-default-off-enabled": {Enabled: &on},
"job.d-default-on-disabled": {Enabled: &off},
}
rt := newTestRuntime(t, cfg,
// 故意乱序注册,验证 Build 按 ID 字典序执行。
&fakeModule{id: "job.b-default-on", enabledByDefault: true, log: log},
&fakeModule{id: "job.a-default-on", enabledByDefault: true, log: log},
&fakeModule{id: "job.c-default-off-enabled", enabledByDefault: false, log: log},
&fakeModule{id: "job.d-default-on-disabled", enabledByDefault: true, log: log},
&fakeModule{id: "job.e-default-off", enabledByDefault: false, log: log},
)
require.NoError(t, rt.Build())
// enabled 三态:默认 on(a、b)、显式 enable 默认 off 的 c、显式 disable 默认 on 的 d、默认 off 的 e。
require.Equal(t, []string{
"job.a-default-on:provision", "job.a-default-on:validate",
"job.b-default-on:provision", "job.b-default-on:validate",
"job.c-default-off-enabled:provision", "job.c-default-off-enabled:validate",
}, log.snapshot())
statuses := rt.Snapshot()
require.Equal(t, StateProvisioned, statusByID(t, statuses, "job.a-default-on").State)
require.Equal(t, StateProvisioned, statusByID(t, statuses, "job.c-default-off-enabled").State)
require.False(t, statusByID(t, statuses, "job.d-default-on-disabled").Enabled)
require.Equal(t, StateRegistered, statusByID(t, statuses, "job.d-default-on-disabled").State)
require.False(t, statusByID(t, statuses, "job.e-default-off").Enabled)
require.Equal(t, StateRegistered, statusByID(t, statuses, "job.e-default-off").State)
}
func TestRuntimeBuildProvisionFailureAborts(t *testing.T) {
log := &lifecycleLog{}
provisionErr := errors.New("provision boom")
rt := newTestRuntime(t, Config{},
&fakeModule{id: "job.a", enabledByDefault: true, log: log},
&fakeModule{id: "job.b", enabledByDefault: true, log: log, provisionErr: provisionErr},
&fakeModule{id: "job.c", enabledByDefault: true, log: log},
)
err := rt.Build()
require.Error(t, err)
require.Contains(t, err.Error(), "job.b", "Build 错误必须包含失败模块 ID")
require.ErrorIs(t, err, provisionErr)
// 失败即中止:job.c 不会被 Provision;job.b 的 Validate 不会执行。
require.Equal(t, []string{
"job.a:provision", "job.a:validate",
"job.b:provision",
}, log.snapshot())
statuses := rt.Snapshot()
require.Equal(t, StateProvisioned, statusByID(t, statuses, "job.a").State)
bStatus := statusByID(t, statuses, "job.b")
require.Equal(t, StateErrored, bStatus.State)
require.Contains(t, bStatus.Err, "provision boom")
require.Equal(t, StateRegistered, statusByID(t, statuses, "job.c").State)
// Build 失败后 Start 必须被拒绝。
require.Error(t, rt.Start(context.Background()))
}
func TestRuntimeBuildValidateFailureAborts(t *testing.T) {
log := &lifecycleLog{}
rt := newTestRuntime(t, Config{},
&fakeModule{id: "job.a", enabledByDefault: true, log: log, validateErr: errors.New("validate boom")},
&fakeModule{id: "job.b", enabledByDefault: true, log: log},
)
err := rt.Build()
require.Error(t, err)
require.Contains(t, err.Error(), "job.a")
require.Equal(t, []string{"job.a:provision", "job.a:validate"}, log.snapshot())
require.Equal(t, StateErrored, statusByID(t, rt.Snapshot(), "job.a").State)
}
func TestRuntimeStartOrderAndStopReverseOrder(t *testing.T) {
log := &lifecycleLog{}
rt := newTestRuntime(t, Config{},
&fakeModule{id: "job.b", enabledByDefault: true, log: log},
&fakeModule{id: "job.a", enabledByDefault: true, log: log},
&fakeModule{id: "job.c", enabledByDefault: true, log: log},
)
require.NoError(t, rt.Build())
require.NoError(t, rt.Start(context.Background()))
for _, s := range rt.Snapshot() {
require.Equal(t, StateRunning, s.State)
}
require.NoError(t, rt.Stop(context.Background()))
require.Equal(t, []string{
"job.a:provision", "job.a:validate",
"job.b:provision", "job.b:validate",
"job.c:provision", "job.c:validate",
"job.a:start", "job.b:start", "job.c:start",
"job.c:stop", "job.b:stop", "job.a:stop", // 逆序关闭
}, log.snapshot())
for _, s := range rt.Snapshot() {
require.Equal(t, StateStopped, s.State)
}
}
func TestRuntimeStartHalfwayFailureRollsBackInReverse(t *testing.T) {
log := &lifecycleLog{}
startErr := errors.New("start boom")
rt := newTestRuntime(t, Config{},
&fakeModule{id: "job.a", enabledByDefault: true, log: log},
&fakeModule{id: "job.b", enabledByDefault: true, log: log},
&fakeModule{id: "job.c", enabledByDefault: true, log: log, startErr: startErr},
&fakeModule{id: "job.d", enabledByDefault: true, log: log},
)
require.NoError(t, rt.Build())
err := rt.Start(context.Background())
require.Error(t, err)
require.Contains(t, err.Error(), "job.c", "Start 错误必须包含失败模块 ID")
require.ErrorIs(t, err, startErr)
// a、b 已启动;c 启动失败;回滚必须按 b → a 逆序 Stop;d 不得被 Start。
require.Equal(t, []string{
"job.a:provision", "job.a:validate",
"job.b:provision", "job.b:validate",
"job.c:provision", "job.c:validate",
"job.d:provision", "job.d:validate",
"job.a:start", "job.b:start", "job.c:start",
"job.b:stop", "job.a:stop",
}, log.snapshot())
statuses := rt.Snapshot()
require.Equal(t, StateStopped, statusByID(t, statuses, "job.a").State)
require.Equal(t, StateStopped, statusByID(t, statuses, "job.b").State)
cStatus := statusByID(t, statuses, "job.c")
require.Equal(t, StateErrored, cStatus.State)
require.Contains(t, cStatus.Err, "start boom")
require.Equal(t, StateProvisioned, statusByID(t, statuses, "job.d").State)
}
func TestRuntimeStopToleratesSingleFailure(t *testing.T) {
log := &lifecycleLog{}
stopErr := errors.New("stop boom")
rt := newTestRuntime(t, Config{},
&fakeModule{id: "job.a", enabledByDefault: true, log: log},
&fakeModule{id: "job.b", enabledByDefault: true, log: log, stopErr: stopErr},
&fakeModule{id: "job.c", enabledByDefault: true, log: log},
)
require.NoError(t, rt.Build())
require.NoError(t, rt.Start(context.Background()))
err := rt.Stop(context.Background())
require.Error(t, err)
require.ErrorIs(t, err, stopErr)
require.Contains(t, err.Error(), "job.b")
// 单个失败不中断其余模块关闭:c → b(失败)→ a 全部被调用。
stops := log.snapshot()[9:]
require.Equal(t, []string{"job.c:stop", "job.b:stop", "job.a:stop"}, stops)
statuses := rt.Snapshot()
require.Equal(t, StateStopped, statusByID(t, statuses, "job.a").State)
require.Equal(t, StateErrored, statusByID(t, statuses, "job.b").State)
require.Contains(t, statusByID(t, statuses, "job.b").Err, "stop boom")
require.Equal(t, StateStopped, statusByID(t, statuses, "job.c").State)
}
func TestRuntimeStopRespectsContextDeadline(t *testing.T) {
log := &lifecycleLog{}
blocking := &fakeModule{
id: "job.blocking", enabledByDefault: true, log: log,
stopFn: func(ctx context.Context) error {
<-ctx.Done() // 模拟尊重 ctx deadline 的慢关闭
return ctx.Err()
},
}
rt := newTestRuntime(t, Config{}, blocking,
&fakeModule{id: "job.a", enabledByDefault: true, log: log},
)
require.NoError(t, rt.Build())
require.NoError(t, rt.Start(context.Background()))
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
begin := time.Now()
err := rt.Stop(ctx)
require.Less(t, time.Since(begin), 2*time.Second, "Stop 必须随 ctx deadline 返回,不得无限阻塞")
require.Error(t, err)
require.ErrorIs(t, err, context.DeadlineExceeded)
// 即使 blocking 超时失败,job.a 仍被关闭(容错继续)。
require.Equal(t, StateStopped, statusByID(t, rt.Snapshot(), "job.a").State)
}
func TestRuntimeModuleWithoutLifecycleInterfaces(t *testing.T) {
rt := newTestRuntime(t, Config{}, &plainModule{id: "job.plain"})
require.NoError(t, rt.Build())
require.NoError(t, rt.Start(context.Background()))
require.Equal(t, StateRunning, statusByID(t, rt.Snapshot(), "job.plain").State,
"未实现 Starter 的 enabled 模块在 Start 后视为 running")
require.NoError(t, rt.Stop(context.Background()))
require.Equal(t, StateStopped, statusByID(t, rt.Snapshot(), "job.plain").State)
}
func TestRuntimeGuards(t *testing.T) {
rt := newTestRuntime(t, Config{})
require.Error(t, rt.Start(context.Background()), "未 Build 不得 Start")
require.NoError(t, rt.Build())
require.Error(t, rt.Build(), "Build 只能调用一次")
require.NoError(t, rt.Start(context.Background()))
require.Error(t, rt.Start(context.Background()), "Start 只能调用一次")
}
func TestRuntimeSnapshotBeforeBuild(t *testing.T) {
log := &lifecycleLog{}
off := false
rt := newTestRuntime(t, Config{"job.b": {Enabled: &off}},
&fakeModule{id: "job.b", enabledByDefault: true, log: log},
&fakeModule{id: "job.a", enabledByDefault: true, log: log},
)
statuses := rt.Snapshot()
require.Len(t, statuses, 2)
require.Equal(t, ModuleID("job.a"), statuses[0].ID, "Snapshot 按 ID 字典序")
require.Equal(t, ModuleID("job.b"), statuses[1].ID)
for _, s := range statuses {
require.Equal(t, StateRegistered, s.State)
require.Empty(t, s.Err)
}
require.True(t, statuses[0].Enabled)
require.False(t, statuses[1].Enabled, "Build 前 Snapshot 也要正确解析 enabled 三态")
}
func TestRuntimeHostConfigOfDefaultBinding(t *testing.T) {
log := &lifecycleLog{}
cfg, err := ParseConfig(map[string]map[string]any{
"job.hello": {"enabled": true, "greeting": "from-config"},
})
require.NoError(t, err)
mod := &fakeModule{id: "job.hello", log: log}
rt := newTestRuntime(t, cfg, mod)
require.NoError(t, rt.Build())
// NewRuntime 未显式装配 ConfigOf 时默认绑定到自身 Config.Of。
require.NotNil(t, mod.host)
require.NotNil(t, mod.host.ConfigOf)
var out helloModuleConfig
require.NoError(t, mod.host.ConfigOf("job.hello", &out))
require.Equal(t, "from-config", out.Greeting)
}
// 审计 B2 修复的回归测试:Start 半途失败触发回滚时,若已启动模块的回滚 Stop
// 也失败,该失败必须并入 Start 的返回错误(而不是只记日志被吞掉)。
func TestRuntimeStartRollbackStopFailureJoinedIntoError(t *testing.T) {
log := &lifecycleLog{}
stopBoom := errors.New("stop-a-boom")
rt := newTestRuntime(t, Config{},
&fakeModule{id: "job.a", enabledByDefault: true, log: log, stopErr: stopBoom},
&fakeModule{id: "job.b", enabledByDefault: true, log: log, startErr: errors.New("start-b-boom")},
)
require.NoError(t, rt.Build())
err := rt.Start(context.Background())
require.Error(t, err)
// 启动失败与回滚失败都必须在返回错误中可见(errors.Join 可穿透)。
require.ErrorIs(t, err, stopBoom)
require.Contains(t, err.Error(), `start module "job.b"`)
require.Contains(t, err.Error(), `stop module "job.a"`)
statuses := rt.Snapshot()
require.Equal(t, StateErrored, statusByID(t, statuses, "job.a").State, "回滚失败的模块应为 errored")
require.Equal(t, StateErrored, statusByID(t, statuses, "job.b").State)
}
+52
View File
@@ -0,0 +1,52 @@
package plugin
import (
"github.com/google/wire"
"github.com/redis/go-redis/v9"
ent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
)
// ProviderSet 提供插件内核的依赖:modules: 配置子树、宿主能力面 Host
// 与模块生命周期驱动器 Runtime。
//
// 边界约定(见 Phase-1 计划):Wire 只负责装配 Host 与 Runtime,
// 模块目录由注册表(插装清单 internal/modules/standard)负责,
// 模块内部依赖一律经 Host 获取,不进入 Wire graph。
var ProviderSet = wire.NewSet(
ProvideModuleConfig,
ProvideHost,
ProvideModuleRuntime,
)
// ProvideModuleConfig 解析全局配置中 `modules:` 子树为插件模块配置。
//
// 解析失败(模块 ID 格式非法、enabled 类型错误)返回错误,
// 使应用启动 fail-fast,配置笔误在启动时即暴露。
func ProvideModuleConfig(cfg *config.Config) (Config, error) {
return ParseConfig(cfg.Modules)
}
// ProvideHost 装配模块宿主能力面:聚合全局结构化日志器、模块私有配置
// 解码能力(ConfigOf 绑定为 modules: 配置子树的 Config.Of)、
// Ent 客户端与 Redis 客户端。
//
// 日志器复用项目全局 logger(main 在依赖装配前已完成 logger.Init)。
func ProvideHost(moduleCfg Config, entClient *ent.Client, rdb *redis.Client) *Host {
return &Host{
Logger: logger.L(),
ConfigOf: moduleCfg.Of,
DB: entClient,
Redis: rdb,
}
}
// ProvideModuleRuntime 创建基于包级默认注册表的模块生命周期驱动器。
//
// 生命周期驱动由 main 负责:HTTP server 启动前 Build + Start,
// 关闭时 Stop 接入 cleanup 序列(先于 Redis/Ent 等基础设施关闭)。
func ProvideModuleRuntime(host *Host, moduleCfg Config) *Runtime {
return NewRuntime(host, moduleCfg)
}
+8
View File
@@ -103,9 +103,17 @@ func RegisterAdminRoutes(
// 邀请返利(专属用户管理)
registerAffiliateRoutes(admin, h)
// 插件模块可观测
registerModuleRoutes(admin, h)
}
}
// registerModuleRoutes 注册插件模块可观测的管理端路由(只读)
func registerModuleRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
admin.GET("/modules", h.Admin.Module.List)
}
func registerAdminComplianceRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
compliance := admin.Group("/compliance")
{