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:
MengStack Dev 2026-10-03 01:02:53 +08:00
parent b13427e76c
commit 0eb6a96057
8 changed files with 398 additions and 0 deletions

View File

@ -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,
) )
} }

View 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)
}

View 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
}

View 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"`
}

View 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{})
}

View 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
}

View 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"})
}

View 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))
}
}),
)