diff --git a/internal/app/app.go b/internal/app/app.go index e67870f..9fbb562 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -16,6 +16,7 @@ import ( rbacinterfaces "mengstack/internal/modules/rbac/interfaces" orginterfaces "mengstack/internal/modules/org/interfaces" auditinterfaces "mengstack/internal/modules/audit/interfaces" + settingsinterfaces "mengstack/internal/modules/settings/interfaces" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" @@ -43,6 +44,7 @@ func newEngine( rbacHandler *rbacinterfaces.Handler, orgHandler *orginterfaces.Handler, auditHandler *auditinterfaces.Handler, + settingsHandler *settingsinterfaces.Handler, ) *gin.Engine { ginMode := "release" if cfg.Server.Mode == "debug" || cfg.Server.Mode == "dev" { @@ -65,6 +67,7 @@ func newEngine( rbacinterfaces.SetupRoutes(r, rbacHandler, authMW) orginterfaces.SetupRoutes(r, orgHandler, authMW) auditinterfaces.SetupRoutes(r, auditHandler, authMW) + settingsinterfaces.SetupRoutes(r, settingsHandler, authMW) return r } @@ -109,6 +112,7 @@ func NewApp() *fx.App { rbacinterfaces.Module, orginterfaces.Module, auditinterfaces.Module, + settingsinterfaces.Module, Module, ) } diff --git a/internal/modules/settings/application/service.go b/internal/modules/settings/application/service.go new file mode 100644 index 0000000..14d3c39 --- /dev/null +++ b/internal/modules/settings/application/service.go @@ -0,0 +1,100 @@ +package application + +import ( + "context" + "errors" + + "mengstack/internal/modules/settings/domain" + + "gorm.io/gorm" +) + +type Service struct { + repo domain.SettingRepository +} + +func NewService(repo domain.SettingRepository) *Service { + return &Service{repo: repo} +} + +func (s *Service) GetGlobalSetting(ctx context.Context, key string) (*domain.SettingDTO, error) { + setting, err := s.repo.FindByKey(ctx, "", key) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + dto := domain.ToSettingDTO(setting) + return &dto, nil +} + +func (s *Service) UpdateGlobalSetting(ctx context.Context, key string, req *domain.UpdateSettingRequest, userID uint) error { + setting := &domain.Setting{ + TenantID: "", + Key: key, + Value: req.Value, + Type: req.Type, + Scope: "global", + UpdatedBy: userID, + } + if setting.Type == "" { + setting.Type = "string" + } + return s.repo.Upsert(ctx, setting) +} + +func (s *Service) ListGlobalSettings(ctx context.Context) ([]domain.SettingDTO, error) { + settings, err := s.repo.FindGlobal(ctx) + if err != nil { + return nil, err + } + result := make([]domain.SettingDTO, len(settings)) + for i, s := range settings { + result[i] = domain.ToSettingDTO(&s) + } + return result, nil +} + +func (s *Service) GetTenantSetting(ctx context.Context, tenantID, key string) (*domain.SettingDTO, error) { + setting, err := s.repo.FindByKey(ctx, tenantID, key) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return s.GetGlobalSetting(ctx, key) + } + return nil, err + } + dto := domain.ToSettingDTO(setting) + return &dto, nil +} + +func (s *Service) UpdateTenantSetting(ctx context.Context, tenantID, key string, req *domain.UpdateSettingRequest, userID uint) error { + setting := &domain.Setting{ + TenantID: tenantID, + Key: key, + Value: req.Value, + Type: req.Type, + Scope: "tenant", + UpdatedBy: userID, + } + if setting.Type == "" { + setting.Type = "string" + } + return s.repo.Upsert(ctx, setting) +} + +func (s *Service) ListTenantSettings(ctx context.Context, tenantID string) ([]domain.SettingDTO, error) { + settings, err := s.repo.FindByTenant(ctx, tenantID) + if err != nil { + return nil, err + } + result := make([]domain.SettingDTO, len(settings)) + for i, s := range settings { + result[i] = domain.ToSettingDTO(&s) + } + return result, nil +} + +func (s *Service) DeleteTenantSetting(ctx context.Context, tenantID, key string) error { + return s.repo.Delete(ctx, tenantID, key) +} diff --git a/internal/modules/settings/domain/repository.go b/internal/modules/settings/domain/repository.go new file mode 100644 index 0000000..467dce4 --- /dev/null +++ b/internal/modules/settings/domain/repository.go @@ -0,0 +1,11 @@ +package domain + +import "context" + +type SettingRepository interface { + Upsert(ctx context.Context, setting *Setting) error + FindByKey(ctx context.Context, tenantID, key string) (*Setting, error) + FindByTenant(ctx context.Context, tenantID string) ([]Setting, error) + FindGlobal(ctx context.Context) ([]Setting, error) + Delete(ctx context.Context, tenantID, key string) error +} diff --git a/internal/modules/settings/domain/setting.go b/internal/modules/settings/domain/setting.go new file mode 100644 index 0000000..62302c9 --- /dev/null +++ b/internal/modules/settings/domain/setting.go @@ -0,0 +1,48 @@ +package domain + +import "time" + +type Setting struct { + ID uint `json:"id" gorm:"primaryKey"` + TenantID string `json:"tenant_id" gorm:"type:uuid;index:idx_tenant_key,unique;default:''"` + Key string `json:"key" gorm:"size:128;not null;index:idx_tenant_key,unique"` + Value string `json:"value" gorm:"type:text"` + Type string `json:"type" gorm:"size:16;default:string"` + Scope string `json:"scope" gorm:"size:16;default:tenant"` + UpdatedBy uint `json:"updated_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (Setting) TableName() string { + return "settings" +} + +type SettingDTO struct { + ID uint `json:"id"` + TenantID string `json:"tenant_id"` + Key string `json:"key"` + Value string `json:"value"` + Type string `json:"type"` + Scope string `json:"scope"` + UpdatedBy uint `json:"updated_by"` + UpdatedAt time.Time `json:"updated_at"` +} + +func ToSettingDTO(s *Setting) SettingDTO { + return SettingDTO{ + ID: s.ID, + TenantID: s.TenantID, + Key: s.Key, + Value: s.Value, + Type: s.Type, + Scope: s.Scope, + UpdatedBy: s.UpdatedBy, + UpdatedAt: s.UpdatedAt, + } +} + +type UpdateSettingRequest struct { + Value string `json:"value" binding:"required"` + Type string `json:"type"` +} diff --git a/internal/modules/settings/infrastructure/migrate.go b/internal/modules/settings/infrastructure/migrate.go new file mode 100644 index 0000000..729f339 --- /dev/null +++ b/internal/modules/settings/infrastructure/migrate.go @@ -0,0 +1,11 @@ +package infrastructure + +import ( + "mengstack/internal/modules/settings/domain" + + "gorm.io/gorm" +) + +func Migrate(db *gorm.DB) error { + return db.AutoMigrate(&domain.Setting{}) +} diff --git a/internal/modules/settings/infrastructure/setting_repo.go b/internal/modules/settings/infrastructure/setting_repo.go new file mode 100644 index 0000000..1e25e66 --- /dev/null +++ b/internal/modules/settings/infrastructure/setting_repo.go @@ -0,0 +1,72 @@ +package infrastructure + +import ( + "context" + "errors" + + "mengstack/internal/modules/settings/domain" + + "gorm.io/gorm" +) + +type settingRepo struct { + db *gorm.DB +} + +func NewSettingRepository(db *gorm.DB) domain.SettingRepository { + return &settingRepo{db: db} +} + +func (r *settingRepo) Upsert(ctx context.Context, setting *domain.Setting) error { + var existing domain.Setting + err := r.db.WithContext(ctx). + Where("tenant_id = ? AND key = ?", setting.TenantID, setting.Key). + First(&existing).Error + + if errors.Is(err, gorm.ErrRecordNotFound) { + return r.db.WithContext(ctx).Create(setting).Error + } + if err != nil { + return err + } + + return r.db.WithContext(ctx). + Model(&existing). + Updates(map[string]interface{}{ + "value": setting.Value, + "type": setting.Type, + "updated_by": setting.UpdatedBy, + }).Error +} + +func (r *settingRepo) FindByKey(ctx context.Context, tenantID, key string) (*domain.Setting, error) { + var setting domain.Setting + err := r.db.WithContext(ctx). + Where("tenant_id = ? AND key = ?", tenantID, key). + First(&setting).Error + return &setting, err +} + +func (r *settingRepo) FindByTenant(ctx context.Context, tenantID string) ([]domain.Setting, error) { + var settings []domain.Setting + err := r.db.WithContext(ctx). + Where("tenant_id = ?", tenantID). + Order("key"). + Find(&settings).Error + return settings, err +} + +func (r *settingRepo) FindGlobal(ctx context.Context) ([]domain.Setting, error) { + var settings []domain.Setting + err := r.db.WithContext(ctx). + Where("tenant_id = ''"). + Order("key"). + Find(&settings).Error + return settings, err +} + +func (r *settingRepo) Delete(ctx context.Context, tenantID, key string) error { + return r.db.WithContext(ctx). + Where("tenant_id = ? AND key = ?", tenantID, key). + Delete(&domain.Setting{}).Error +} diff --git a/internal/modules/settings/interfaces/handler.go b/internal/modules/settings/interfaces/handler.go new file mode 100644 index 0000000..27e8910 --- /dev/null +++ b/internal/modules/settings/interfaces/handler.go @@ -0,0 +1,108 @@ +package interfaces + +import ( + "net/http" + + "mengstack/internal/kernel/response" + "mengstack/internal/modules/settings/application" + "mengstack/internal/modules/settings/domain" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + svc *application.Service +} + +func NewHandler(svc *application.Service) *Handler { + return &Handler{svc: svc} +} + +func (h *Handler) GetGlobalSetting(c *gin.Context) { + key := c.Param("key") + setting, err := h.svc.GetGlobalSetting(c.Request.Context(), key) + if err != nil { + response.HandleError(c, err) + return + } + if setting == nil { + response.Fail(c, response.NewBadRequest("setting not found")) + return + } + response.Success(c, setting) +} + +func (h *Handler) UpdateGlobalSetting(c *gin.Context) { + key := c.Param("key") + var req domain.UpdateSettingRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, response.NewBadRequest("invalid request: "+err.Error())) + return + } + userID := c.GetUint("user_id") + if err := h.svc.UpdateGlobalSetting(c.Request.Context(), key, &req, userID); err != nil { + response.HandleError(c, err) + return + } + response.Success(c, nil) +} + +func (h *Handler) ListGlobalSettings(c *gin.Context) { + settings, err := h.svc.ListGlobalSettings(c.Request.Context()) + if err != nil { + response.HandleError(c, err) + return + } + response.Success(c, settings) +} + +func (h *Handler) GetTenantSetting(c *gin.Context) { + key := c.Param("key") + tenantID := c.GetString("tenant_id") + setting, err := h.svc.GetTenantSetting(c.Request.Context(), tenantID, key) + if err != nil { + response.HandleError(c, err) + return + } + if setting == nil { + response.Fail(c, response.NewBadRequest("setting not found")) + return + } + response.Success(c, setting) +} + +func (h *Handler) UpdateTenantSetting(c *gin.Context) { + key := c.Param("key") + tenantID := c.GetString("tenant_id") + var req domain.UpdateSettingRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, response.NewBadRequest("invalid request: "+err.Error())) + return + } + userID := c.GetUint("user_id") + if err := h.svc.UpdateTenantSetting(c.Request.Context(), tenantID, key, &req, userID); err != nil { + response.HandleError(c, err) + return + } + response.Success(c, nil) +} + +func (h *Handler) ListTenantSettings(c *gin.Context) { + tenantID := c.GetString("tenant_id") + settings, err := h.svc.ListTenantSettings(c.Request.Context(), tenantID) + if err != nil { + response.HandleError(c, err) + return + } + response.Success(c, settings) +} + +func (h *Handler) DeleteTenantSetting(c *gin.Context) { + key := c.Param("key") + tenantID := c.GetString("tenant_id") + if err := h.svc.DeleteTenantSetting(c.Request.Context(), tenantID, key); err != nil { + response.HandleError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "message": "deleted"}) +} diff --git a/internal/modules/settings/interfaces/routes.go b/internal/modules/settings/interfaces/routes.go new file mode 100644 index 0000000..34e11eb --- /dev/null +++ b/internal/modules/settings/interfaces/routes.go @@ -0,0 +1,44 @@ +package interfaces + +import ( + "mengstack/internal/app/middleware" + "mengstack/internal/modules/settings/application" + "mengstack/internal/modules/settings/infrastructure" + + "github.com/gin-gonic/gin" + "go.uber.org/fx" + "go.uber.org/zap" + "gorm.io/gorm" +) + +func SetupRoutes( + r *gin.Engine, + h *Handler, + authMW gin.HandlerFunc, +) { + settings := r.Group("/api/v1/settings") + settings.Use(authMW, middleware.MultiTenant()) + { + settings.GET("", h.ListGlobalSettings) + settings.GET("/global/:key", h.GetGlobalSetting) + settings.PUT("/global/:key", h.UpdateGlobalSetting) + + settings.GET("/tenant", h.ListTenantSettings) + settings.GET("/tenant/:key", h.GetTenantSetting) + settings.PUT("/tenant/:key", h.UpdateTenantSetting) + settings.DELETE("/tenant/:key", h.DeleteTenantSetting) + } +} + +var Module = fx.Module("settings", + fx.Provide( + infrastructure.NewSettingRepository, + application.NewService, + NewHandler, + ), + fx.Invoke(func(db *gorm.DB, log *zap.Logger) { + if err := infrastructure.Migrate(db); err != nil { + log.Fatal("settings migration failed", zap.Error(err)) + } + }), +)