feat(settings): M5 配置管理模块
DB-backed runtime settings with global/tenant scope, key-value store, type support (string/number/bool/json), tenant-level overrides with global fallback.
This commit is contained in:
parent
b13427e76c
commit
0eb6a96057
@ -16,6 +16,7 @@ import (
|
|||||||
rbacinterfaces "mengstack/internal/modules/rbac/interfaces"
|
rbacinterfaces "mengstack/internal/modules/rbac/interfaces"
|
||||||
orginterfaces "mengstack/internal/modules/org/interfaces"
|
orginterfaces "mengstack/internal/modules/org/interfaces"
|
||||||
auditinterfaces "mengstack/internal/modules/audit/interfaces"
|
auditinterfaces "mengstack/internal/modules/audit/interfaces"
|
||||||
|
settingsinterfaces "mengstack/internal/modules/settings/interfaces"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
@ -43,6 +44,7 @@ func newEngine(
|
|||||||
rbacHandler *rbacinterfaces.Handler,
|
rbacHandler *rbacinterfaces.Handler,
|
||||||
orgHandler *orginterfaces.Handler,
|
orgHandler *orginterfaces.Handler,
|
||||||
auditHandler *auditinterfaces.Handler,
|
auditHandler *auditinterfaces.Handler,
|
||||||
|
settingsHandler *settingsinterfaces.Handler,
|
||||||
) *gin.Engine {
|
) *gin.Engine {
|
||||||
ginMode := "release"
|
ginMode := "release"
|
||||||
if cfg.Server.Mode == "debug" || cfg.Server.Mode == "dev" {
|
if cfg.Server.Mode == "debug" || cfg.Server.Mode == "dev" {
|
||||||
@ -65,6 +67,7 @@ func newEngine(
|
|||||||
rbacinterfaces.SetupRoutes(r, rbacHandler, authMW)
|
rbacinterfaces.SetupRoutes(r, rbacHandler, authMW)
|
||||||
orginterfaces.SetupRoutes(r, orgHandler, authMW)
|
orginterfaces.SetupRoutes(r, orgHandler, authMW)
|
||||||
auditinterfaces.SetupRoutes(r, auditHandler, authMW)
|
auditinterfaces.SetupRoutes(r, auditHandler, authMW)
|
||||||
|
settingsinterfaces.SetupRoutes(r, settingsHandler, authMW)
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@ -109,6 +112,7 @@ func NewApp() *fx.App {
|
|||||||
rbacinterfaces.Module,
|
rbacinterfaces.Module,
|
||||||
orginterfaces.Module,
|
orginterfaces.Module,
|
||||||
auditinterfaces.Module,
|
auditinterfaces.Module,
|
||||||
|
settingsinterfaces.Module,
|
||||||
Module,
|
Module,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
100
internal/modules/settings/application/service.go
Normal file
100
internal/modules/settings/application/service.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
11
internal/modules/settings/domain/repository.go
Normal file
11
internal/modules/settings/domain/repository.go
Normal file
@ -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
|
||||||
|
}
|
||||||
48
internal/modules/settings/domain/setting.go
Normal file
48
internal/modules/settings/domain/setting.go
Normal file
@ -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"`
|
||||||
|
}
|
||||||
11
internal/modules/settings/infrastructure/migrate.go
Normal file
11
internal/modules/settings/infrastructure/migrate.go
Normal file
@ -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{})
|
||||||
|
}
|
||||||
72
internal/modules/settings/infrastructure/setting_repo.go
Normal file
72
internal/modules/settings/infrastructure/setting_repo.go
Normal file
@ -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
|
||||||
|
}
|
||||||
108
internal/modules/settings/interfaces/handler.go
Normal file
108
internal/modules/settings/interfaces/handler.go
Normal file
@ -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"})
|
||||||
|
}
|
||||||
44
internal/modules/settings/interfaces/routes.go
Normal file
44
internal/modules/settings/interfaces/routes.go
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user