feat: 插件接口预留 — Plugin 接口 + Registry + 示例插件
Some checks failed
CI / Build & Test (push) Failing after 40s
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:
parent
9148d2f6da
commit
9bb2c447ec
@ -11,6 +11,7 @@ import (
|
||||
"mengstack/internal/app/health"
|
||||
"mengstack/internal/app/middleware"
|
||||
"mengstack/internal/config"
|
||||
"mengstack/internal/kernel/plugin"
|
||||
"mengstack/internal/logger"
|
||||
authinterfaces "mengstack/internal/modules/auth/interfaces"
|
||||
rbacinterfaces "mengstack/internal/modules/rbac/interfaces"
|
||||
@ -18,6 +19,7 @@ import (
|
||||
auditinterfaces "mengstack/internal/modules/audit/interfaces"
|
||||
settingsinterfaces "mengstack/internal/modules/settings/interfaces"
|
||||
notificationinterfaces "mengstack/internal/modules/notification/interfaces"
|
||||
exampleinterfaces "mengstack/internal/modules/example/interfaces"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
@ -75,6 +77,10 @@ func newEngine(
|
||||
settingsinterfaces.SetupRoutes(r, settingsHandler, authMW)
|
||||
notificationinterfaces.SetupRoutes(r, notificationHandler, authMW)
|
||||
|
||||
for _, p := range plugin.All() {
|
||||
p.SetupRoutes(r, authMW)
|
||||
}
|
||||
|
||||
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) {
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(ctx context.Context) error {
|
||||
if err := plugin.InitAll(log); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info("server starting", zap.String("addr", srv.Addr))
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
@ -122,6 +131,10 @@ func NewApp() *fx.App {
|
||||
auditinterfaces.Module,
|
||||
settingsinterfaces.Module,
|
||||
notificationinterfaces.Module,
|
||||
exampleinterfaces.Module,
|
||||
fx.Invoke(func(p *exampleinterfaces.ExamplePlugin) {
|
||||
plugin.Register(p)
|
||||
}),
|
||||
Module,
|
||||
)
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ type Config struct {
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
Plugin PluginConfig `mapstructure:"plugin"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@ -48,6 +49,10 @@ type LogConfig struct {
|
||||
Format string `mapstructure:"format"`
|
||||
}
|
||||
|
||||
type PluginConfig struct {
|
||||
Enabled []string `mapstructure:"enabled"`
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
v := viper.New()
|
||||
v.SetConfigName("config")
|
||||
|
||||
46
internal/kernel/plugin/plugin.go
Normal file
46
internal/kernel/plugin/plugin.go
Normal 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
|
||||
}
|
||||
52
internal/kernel/plugin/registry.go
Normal file
52
internal/kernel/plugin/registry.go
Normal 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
|
||||
}
|
||||
58
internal/modules/example/application/service.go
Normal file
58
internal/modules/example/application/service.go
Normal 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)
|
||||
}
|
||||
61
internal/modules/example/domain/example.go
Normal file
61
internal/modules/example/domain/example.go
Normal 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
|
||||
}
|
||||
60
internal/modules/example/infrastructure/example_repo.go
Normal file
60
internal/modules/example/infrastructure/example_repo.go
Normal 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{})
|
||||
}
|
||||
79
internal/modules/example/interfaces/handler.go
Normal file
79
internal/modules/example/interfaces/handler.go
Normal 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)
|
||||
}
|
||||
75
internal/modules/example/interfaces/plugin.go
Normal file
75
internal/modules/example/interfaces/plugin.go
Normal 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))
|
||||
}
|
||||
}),
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user