diff --git a/internal/app/app.go b/internal/app/app.go index c04ccde..ee7720f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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, ) } diff --git a/internal/config/config.go b/internal/config/config.go index 4060859..cf605fd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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") diff --git a/internal/kernel/plugin/plugin.go b/internal/kernel/plugin/plugin.go new file mode 100644 index 0000000..2278f90 --- /dev/null +++ b/internal/kernel/plugin/plugin.go @@ -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// +// 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 +} diff --git a/internal/kernel/plugin/registry.go b/internal/kernel/plugin/registry.go new file mode 100644 index 0000000..25360e6 --- /dev/null +++ b/internal/kernel/plugin/registry.go @@ -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 +} diff --git a/internal/modules/example/application/service.go b/internal/modules/example/application/service.go new file mode 100644 index 0000000..a0b202e --- /dev/null +++ b/internal/modules/example/application/service.go @@ -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) +} diff --git a/internal/modules/example/domain/example.go b/internal/modules/example/domain/example.go new file mode 100644 index 0000000..34d8ee3 --- /dev/null +++ b/internal/modules/example/domain/example.go @@ -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 +} diff --git a/internal/modules/example/infrastructure/example_repo.go b/internal/modules/example/infrastructure/example_repo.go new file mode 100644 index 0000000..5e382a0 --- /dev/null +++ b/internal/modules/example/infrastructure/example_repo.go @@ -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{}) +} diff --git a/internal/modules/example/interfaces/handler.go b/internal/modules/example/interfaces/handler.go new file mode 100644 index 0000000..8bffcec --- /dev/null +++ b/internal/modules/example/interfaces/handler.go @@ -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) +} diff --git a/internal/modules/example/interfaces/plugin.go b/internal/modules/example/interfaces/plugin.go new file mode 100644 index 0000000..dc1d1c4 --- /dev/null +++ b/internal/modules/example/interfaces/plugin.go @@ -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)) + } + }), +)