feat: 插件接口预留 — Plugin 接口 + Registry + 示例插件
Some checks failed
CI / Build & Test (push) Failing after 40s

- kernel/plugin: 定义 Plugin 接口(Metadata/FxOption/SetupRoutes/Init)
- kernel/plugin: 全局 Registry(Register/All/InitAll/Reset)
- modules/example: 完整 4 层示例插件,实现 Plugin 接口
- config: 新增 PluginConfig(enabled 列表)
- app.go: 集成插件系统 — fx.Invoke 注册 + plugin.All() 路由 + InitAll 生命周期
This commit is contained in:
MengStack Dev 2026-10-03 02:07:46 +08:00
parent 9148d2f6da
commit 9bb2c447ec
9 changed files with 449 additions and 0 deletions

View File

@ -11,6 +11,7 @@ import (
"mengstack/internal/app/health" "mengstack/internal/app/health"
"mengstack/internal/app/middleware" "mengstack/internal/app/middleware"
"mengstack/internal/config" "mengstack/internal/config"
"mengstack/internal/kernel/plugin"
"mengstack/internal/logger" "mengstack/internal/logger"
authinterfaces "mengstack/internal/modules/auth/interfaces" authinterfaces "mengstack/internal/modules/auth/interfaces"
rbacinterfaces "mengstack/internal/modules/rbac/interfaces" rbacinterfaces "mengstack/internal/modules/rbac/interfaces"
@ -18,6 +19,7 @@ import (
auditinterfaces "mengstack/internal/modules/audit/interfaces" auditinterfaces "mengstack/internal/modules/audit/interfaces"
settingsinterfaces "mengstack/internal/modules/settings/interfaces" settingsinterfaces "mengstack/internal/modules/settings/interfaces"
notificationinterfaces "mengstack/internal/modules/notification/interfaces" notificationinterfaces "mengstack/internal/modules/notification/interfaces"
exampleinterfaces "mengstack/internal/modules/example/interfaces"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
@ -75,6 +77,10 @@ func newEngine(
settingsinterfaces.SetupRoutes(r, settingsHandler, authMW) settingsinterfaces.SetupRoutes(r, settingsHandler, authMW)
notificationinterfaces.SetupRoutes(r, notificationHandler, authMW) notificationinterfaces.SetupRoutes(r, notificationHandler, authMW)
for _, p := range plugin.All() {
p.SetupRoutes(r, authMW)
}
return r return r
} }
@ -91,6 +97,9 @@ func newServer(cfg *config.Config, engine *gin.Engine) *http.Server {
func registerLifecycle(lc fx.Lifecycle, srv *http.Server, log *zap.Logger) { func registerLifecycle(lc fx.Lifecycle, srv *http.Server, log *zap.Logger) {
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error { OnStart: func(ctx context.Context) error {
if err := plugin.InitAll(log); err != nil {
return err
}
log.Info("server starting", zap.String("addr", srv.Addr)) log.Info("server starting", zap.String("addr", srv.Addr))
go func() { go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
@ -122,6 +131,10 @@ func NewApp() *fx.App {
auditinterfaces.Module, auditinterfaces.Module,
settingsinterfaces.Module, settingsinterfaces.Module,
notificationinterfaces.Module, notificationinterfaces.Module,
exampleinterfaces.Module,
fx.Invoke(func(p *exampleinterfaces.ExamplePlugin) {
plugin.Register(p)
}),
Module, Module,
) )
} }

View File

@ -14,6 +14,7 @@ type Config struct {
Redis RedisConfig `mapstructure:"redis"` Redis RedisConfig `mapstructure:"redis"`
JWT JWTConfig `mapstructure:"jwt"` JWT JWTConfig `mapstructure:"jwt"`
Log LogConfig `mapstructure:"log"` Log LogConfig `mapstructure:"log"`
Plugin PluginConfig `mapstructure:"plugin"`
} }
type ServerConfig struct { type ServerConfig struct {
@ -48,6 +49,10 @@ type LogConfig struct {
Format string `mapstructure:"format"` Format string `mapstructure:"format"`
} }
type PluginConfig struct {
Enabled []string `mapstructure:"enabled"`
}
func Load() (*Config, error) { func Load() (*Config, error) {
v := viper.New() v := viper.New()
v.SetConfigName("config") v.SetConfigName("config")

View File

@ -0,0 +1,46 @@
// Package plugin defines the contract for MengStack plugins.
//
// A plugin is a self-contained extension that follows the same 4-layer
// architecture as built-in modules. Each plugin provides:
//
// - FxOption: dependency injection wiring (repositories, services, handlers)
// - SetupRoutes: HTTP route registration on the Gin engine
// - Init: optional startup hook for background workers, caches, etc.
//
// To create a plugin:
// 1. Create a package under internal/modules/<name>/
// 2. Follow the 4-layer pattern: domain → infrastructure → application → interfaces
// 3. Implement the Plugin interface
// 4. Register in internal/app/app.go's plugin list
package plugin
import (
"github.com/gin-gonic/gin"
"go.uber.org/fx"
)
// Metadata describes a plugin's identity.
type Metadata struct {
Name string
Version string
Description string
}
// Plugin is the contract every MengStack plugin must implement.
type Plugin interface {
// Metadata returns the plugin's identity.
Metadata() Metadata
// FxOption returns the fx.Module for dependency injection.
FxOption() fx.Option
// SetupRoutes registers HTTP routes on the Gin engine.
// authMW is the JWT authentication middleware.
SetupRoutes(engine *gin.Engine, authMW gin.HandlerFunc)
// Init is called after all dependencies are resolved and before
// the HTTP server starts. Use it for background workers, warm-up
// caches, or external service connections.
// Returning nil indicates success.
Init() error
}

View File

@ -0,0 +1,52 @@
package plugin
import (
"fmt"
"sync"
"go.uber.org/zap"
)
var (
registry []Plugin
mu sync.RWMutex
)
// Register adds a plugin to the global registry.
// Typically called in init() or during application bootstrap.
func Register(p Plugin) {
mu.Lock()
defer mu.Unlock()
registry = append(registry, p)
}
// All returns a copy of all registered plugins.
func All() []Plugin {
mu.RLock()
defer mu.RUnlock()
out := make([]Plugin, len(registry))
copy(out, registry)
return out
}
// InitAll initializes all registered plugins in order.
func InitAll(log *zap.Logger) error {
for _, p := range All() {
meta := p.Metadata()
log.Info("initializing plugin",
zap.String("name", meta.Name),
zap.String("version", meta.Version),
)
if err := p.Init(); err != nil {
return fmt.Errorf("plugin %s init failed: %w", meta.Name, err)
}
}
return nil
}
// Reset clears the registry. Used in tests only.
func Reset() {
mu.Lock()
defer mu.Unlock()
registry = nil
}

View File

@ -0,0 +1,58 @@
package application
import (
"context"
"mengstack/internal/kernel/model"
"mengstack/internal/modules/example/domain"
)
// Service contains the business logic for examples.
type Service struct {
repo domain.Repository
}
// NewService creates a new example service.
func NewService(repo domain.Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) Create(ctx context.Context, tenantID string, req domain.CreateExampleRequest) (*domain.ExampleDTO, error) {
e := &domain.Example{
BaseModel: model.BaseModel{TenantID: tenantID},
Name: req.Name,
Description: req.Description,
Status: 1,
ExpiresAt: req.ExpiresAt,
}
if err := s.repo.Create(ctx, e); err != nil {
return nil, err
}
dto := domain.ToDTO(e)
return &dto, nil
}
func (s *Service) Get(ctx context.Context, tenantID string, id uint) (*domain.ExampleDTO, error) {
e, err := s.repo.FindByID(ctx, tenantID, id)
if err != nil {
return nil, err
}
dto := domain.ToDTO(e)
return &dto, nil
}
func (s *Service) List(ctx context.Context, tenantID string) ([]domain.ExampleDTO, error) {
items, err := s.repo.FindAll(ctx, tenantID)
if err != nil {
return nil, err
}
dtos := make([]domain.ExampleDTO, len(items))
for i := range items {
dtos[i] = domain.ToDTO(&items[i])
}
return dtos, nil
}
func (s *Service) Delete(ctx context.Context, tenantID string, id uint) error {
return s.repo.Delete(ctx, tenantID, id)
}

View File

@ -0,0 +1,61 @@
package domain
import (
"context"
"time"
"mengstack/internal/kernel/model"
)
// Example is a sample entity demonstrating the plugin data model.
type Example struct {
model.BaseModel
Name string `json:"name" gorm:"type:varchar(255);not null"`
Description string `json:"description" gorm:"type:text"`
Status int `json:"status" gorm:"type:smallint;default:1"`
ExpiresAt time.Time `json:"expires_at"`
}
func (Example) TableName() string { return "examples" }
// ExampleDTO is the response projection.
type ExampleDTO struct {
ID uint `json:"id"`
TenantID string `json:"tenant_id"`
Name string `json:"name"`
Description string `json:"description"`
Status int `json:"status"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ToDTO converts an Example entity to its DTO.
func ToDTO(e *Example) ExampleDTO {
return ExampleDTO{
ID: e.ID,
TenantID: e.TenantID,
Name: e.Name,
Description: e.Description,
Status: e.Status,
ExpiresAt: e.ExpiresAt,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
}
// CreateExampleRequest is the input for creating an example.
type CreateExampleRequest struct {
Name string `json:"name" binding:"required,max=255"`
Description string `json:"description"`
ExpiresAt time.Time `json:"expires_at"`
}
// Repository defines the data access contract for examples.
type Repository interface {
Create(ctx context.Context, e *Example) error
FindByID(ctx context.Context, tenantID string, id uint) (*Example, error)
FindAll(ctx context.Context, tenantID string) ([]Example, error)
Update(ctx context.Context, tenantID string, e *Example) error
Delete(ctx context.Context, tenantID string, id uint) error
}

View File

@ -0,0 +1,60 @@
package infrastructure
import (
"context"
"mengstack/internal/modules/example/domain"
"gorm.io/gorm"
)
type exampleRepo struct {
db *gorm.DB
}
// NewRepository returns a domain.Repository backed by GORM.
func NewRepository(db *gorm.DB) domain.Repository {
return &exampleRepo{db: db}
}
func (r *exampleRepo) Create(ctx context.Context, e *domain.Example) error {
return r.db.WithContext(ctx).Create(e).Error
}
func (r *exampleRepo) FindByID(ctx context.Context, tenantID string, id uint) (*domain.Example, error) {
var e domain.Example
err := r.db.WithContext(ctx).Where("id = ? AND tenant_id = ?", id, tenantID).First(&e).Error
if err != nil {
return nil, err
}
return &e, nil
}
func (r *exampleRepo) FindAll(ctx context.Context, tenantID string) ([]domain.Example, error) {
var items []domain.Example
err := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID).Find(&items).Error
return items, err
}
func (r *exampleRepo) Update(ctx context.Context, tenantID string, e *domain.Example) error {
return r.db.WithContext(ctx).
Model(e).
Where("id = ? AND tenant_id = ?", e.ID, tenantID).
Updates(map[string]any{
"name": e.Name,
"description": e.Description,
"status": e.Status,
"expires_at": e.ExpiresAt,
}).Error
}
func (r *exampleRepo) Delete(ctx context.Context, tenantID string, id uint) error {
return r.db.WithContext(ctx).
Where("id = ? AND tenant_id = ?", id, tenantID).
Delete(&domain.Example{}).Error
}
// Migrate runs auto-migration for the example table.
func Migrate(db *gorm.DB) error {
return db.AutoMigrate(&domain.Example{})
}

View File

@ -0,0 +1,79 @@
package interfaces
import (
"strconv"
"mengstack/internal/kernel/response"
"mengstack/internal/modules/example/application"
"mengstack/internal/modules/example/domain"
"github.com/gin-gonic/gin"
)
// Handler handles HTTP requests for the example plugin.
type Handler struct {
svc *application.Service
}
// NewHandler creates a new example handler.
func NewHandler(svc *application.Service) *Handler {
return &Handler{svc: svc}
}
// Create handles POST /api/v1/examples
func (h *Handler) Create(c *gin.Context) {
var req domain.CreateExampleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, response.NewBadRequest("invalid request: "+err.Error()))
return
}
tenantID := c.GetString("tenant_id")
dto, err := h.svc.Create(c.Request.Context(), tenantID, req)
if err != nil {
response.HandleError(c, err)
return
}
response.Success(c, dto)
}
// Get handles GET /api/v1/examples/:id
func (h *Handler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, response.NewBadRequest("invalid id"))
return
}
tenantID := c.GetString("tenant_id")
dto, err := h.svc.Get(c.Request.Context(), tenantID, uint(id))
if err != nil {
response.HandleError(c, err)
return
}
response.Success(c, dto)
}
// List handles GET /api/v1/examples
func (h *Handler) List(c *gin.Context) {
tenantID := c.GetString("tenant_id")
dtos, err := h.svc.List(c.Request.Context(), tenantID)
if err != nil {
response.HandleError(c, err)
return
}
response.Success(c, dtos)
}
// Delete handles DELETE /api/v1/examples/:id
func (h *Handler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, response.NewBadRequest("invalid id"))
return
}
tenantID := c.GetString("tenant_id")
if err := h.svc.Delete(c.Request.Context(), tenantID, uint(id)); err != nil {
response.HandleError(c, err)
return
}
response.Success(c, nil)
}

View File

@ -0,0 +1,75 @@
package interfaces
import (
"mengstack/internal/app/middleware"
"mengstack/internal/kernel/plugin"
"mengstack/internal/modules/example/application"
"mengstack/internal/modules/example/infrastructure"
"github.com/gin-gonic/gin"
"go.uber.org/fx"
"go.uber.org/zap"
"gorm.io/gorm"
)
// ExamplePlugin implements plugin.Plugin.
type ExamplePlugin struct {
handler *Handler
}
// New creates the example plugin.
// Call this during application bootstrap to register the plugin.
func New(handler *Handler) *ExamplePlugin {
return &ExamplePlugin{handler: handler}
}
func (p *ExamplePlugin) Metadata() plugin.Metadata {
return plugin.Metadata{
Name: "example",
Version: "0.1.0",
Description: "示例插件 — 演示插件开发模式",
}
}
func (p *ExamplePlugin) FxOption() fx.Option {
return fx.Module("example-plugin",
fx.Provide(
infrastructure.NewRepository,
application.NewService,
NewHandler,
),
fx.Invoke(func(db *gorm.DB, log *zap.Logger) {
if err := infrastructure.Migrate(db); err != nil {
log.Fatal("example plugin migration failed", zap.Error(err))
}
}),
)
}
func (p *ExamplePlugin) SetupRoutes(engine *gin.Engine, authMW gin.HandlerFunc) {
g := engine.Group("/api/v1/examples", authMW, middleware.MultiTenant())
g.POST("", p.handler.Create)
g.GET("", p.handler.List)
g.GET("/:id", p.handler.Get)
g.DELETE("/:id", p.handler.Delete)
}
func (p *ExamplePlugin) Init() error {
return nil
}
// Module is the fx module for the example plugin.
// It provides all dependencies and the ExamplePlugin itself.
var Module = fx.Module("example-plugin",
fx.Provide(
infrastructure.NewRepository,
application.NewService,
NewHandler,
New,
),
fx.Invoke(func(db *gorm.DB, log *zap.Logger) {
if err := infrastructure.Migrate(db); err != nil {
log.Fatal("example plugin migration failed", zap.Error(err))
}
}),
)