diff --git a/internal/app/app.go b/internal/app/app.go index da387ad..e67870f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -14,6 +14,8 @@ import ( "mengstack/internal/logger" authinterfaces "mengstack/internal/modules/auth/interfaces" rbacinterfaces "mengstack/internal/modules/rbac/interfaces" + orginterfaces "mengstack/internal/modules/org/interfaces" + auditinterfaces "mengstack/internal/modules/audit/interfaces" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" @@ -39,6 +41,8 @@ func newEngine( authHandler *authinterfaces.Handler, authMW gin.HandlerFunc, rbacHandler *rbacinterfaces.Handler, + orgHandler *orginterfaces.Handler, + auditHandler *auditinterfaces.Handler, ) *gin.Engine { ginMode := "release" if cfg.Server.Mode == "debug" || cfg.Server.Mode == "dev" { @@ -59,6 +63,8 @@ func newEngine( authinterfaces.SetupRoutes(r, authHandler, authMW) rbacinterfaces.SetupRoutes(r, rbacHandler, authMW) + orginterfaces.SetupRoutes(r, orgHandler, authMW) + auditinterfaces.SetupRoutes(r, auditHandler, authMW) return r } @@ -101,6 +107,8 @@ func NewApp() *fx.App { cache.Module, authinterfaces.Module, rbacinterfaces.Module, + orginterfaces.Module, + auditinterfaces.Module, Module, ) } diff --git a/internal/modules/audit/application/service.go b/internal/modules/audit/application/service.go new file mode 100644 index 0000000..ad9ad05 --- /dev/null +++ b/internal/modules/audit/application/service.go @@ -0,0 +1,46 @@ +package application + +import ( + "context" + + "mengstack/internal/kernel/errors" + "mengstack/internal/modules/audit/domain" +) + +type Service struct { + repo domain.AuditRepository +} + +func NewService(repo domain.AuditRepository) *Service { + return &Service{repo: repo} +} + +func (s *Service) Log(ctx context.Context, tenantID string, userID uint, action, resource, resourceID, detail, ip string) error { + log := &domain.AuditLog{ + TenantID: tenantID, + UserID: userID, + Action: action, + Resource: resource, + ResourceID: resourceID, + Detail: detail, + IP: ip, + } + + if err := s.repo.Create(ctx, log); err != nil { + return errors.Wrap(err, "AUDIT_LOG_FAILED", "failed to write audit log", 500) + } + return nil +} + +func (s *Service) Query(ctx context.Context, query domain.AuditQuery) ([]domain.AuditLogDTO, int64, error) { + logs, total, err := s.repo.Find(ctx, query) + if err != nil { + return nil, 0, errors.Wrap(err, "QUERY_AUDIT_FAILED", "failed to query audit logs", 500) + } + + dtos := make([]domain.AuditLogDTO, len(logs)) + for i, l := range logs { + dtos[i] = domain.ToAuditLogDTO(&l) + } + return dtos, total, nil +} diff --git a/internal/modules/audit/domain/log.go b/internal/modules/audit/domain/log.go new file mode 100644 index 0000000..419dac7 --- /dev/null +++ b/internal/modules/audit/domain/log.go @@ -0,0 +1,54 @@ +package domain + +import "time" + +type AuditLog struct { + ID uint `json:"id" gorm:"primaryKey"` + TenantID string `json:"tenant_id" gorm:"type:uuid;index;not null"` + UserID uint `json:"user_id" gorm:"index;not null"` + Action string `json:"action" gorm:"size:64;not null;index"` + Resource string `json:"resource" gorm:"size:128;not null;index"` + ResourceID string `json:"resource_id" gorm:"size:128"` + Detail string `json:"detail" gorm:"type:text"` + IP string `json:"ip" gorm:"size:64"` + CreatedAt time.Time `json:"created_at" gorm:"index"` +} + +func (AuditLog) TableName() string { + return "audit_logs" +} + +type AuditLogDTO struct { + ID uint `json:"id"` + TenantID string `json:"tenant_id"` + UserID uint `json:"user_id"` + Action string `json:"action"` + Resource string `json:"resource"` + ResourceID string `json:"resource_id"` + Detail string `json:"detail"` + IP string `json:"ip"` + CreatedAt time.Time `json:"created_at"` +} + +func ToAuditLogDTO(l *AuditLog) AuditLogDTO { + return AuditLogDTO{ + ID: l.ID, + TenantID: l.TenantID, + UserID: l.UserID, + Action: l.Action, + Resource: l.Resource, + ResourceID: l.ResourceID, + Detail: l.Detail, + IP: l.IP, + CreatedAt: l.CreatedAt, + } +} + +type AuditQuery struct { + TenantID string + UserID uint + Action string + Resource string + Page int + PageSize int +} diff --git a/internal/modules/audit/domain/repository.go b/internal/modules/audit/domain/repository.go new file mode 100644 index 0000000..b0b10da --- /dev/null +++ b/internal/modules/audit/domain/repository.go @@ -0,0 +1,8 @@ +package domain + +import "context" + +type AuditRepository interface { + Create(ctx context.Context, log *AuditLog) error + Find(ctx context.Context, query AuditQuery) ([]AuditLog, int64, error) +} diff --git a/internal/modules/audit/infrastructure/audit_repo.go b/internal/modules/audit/infrastructure/audit_repo.go new file mode 100644 index 0000000..b0b8ec0 --- /dev/null +++ b/internal/modules/audit/infrastructure/audit_repo.go @@ -0,0 +1,61 @@ +package infrastructure + +import ( + "context" + + "mengstack/internal/modules/audit/domain" + + "gorm.io/gorm" +) + +type auditRepo struct { + db *gorm.DB +} + +func NewAuditRepository(db *gorm.DB) domain.AuditRepository { + return &auditRepo{db: db} +} + +func (r *auditRepo) Create(ctx context.Context, log *domain.AuditLog) error { + return r.db.WithContext(ctx).Create(log).Error +} + +func (r *auditRepo) Find(ctx context.Context, query domain.AuditQuery) ([]domain.AuditLog, int64, error) { + var logs []domain.AuditLog + var total int64 + + tx := r.db.WithContext(ctx).Model(&domain.AuditLog{}) + + if query.TenantID != "" { + tx = tx.Where("tenant_id = ?", query.TenantID) + } + if query.UserID > 0 { + tx = tx.Where("user_id = ?", query.UserID) + } + if query.Action != "" { + tx = tx.Where("action = ?", query.Action) + } + if query.Resource != "" { + tx = tx.Where("resource = ?", query.Resource) + } + + if err := tx.Count(&total).Error; err != nil { + return nil, 0, err + } + + page := query.Page + if page < 1 { + page = 1 + } + pageSize := query.PageSize + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + offset := (page - 1) * pageSize + + if err := tx.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&logs).Error; err != nil { + return nil, 0, err + } + + return logs, total, nil +} diff --git a/internal/modules/audit/infrastructure/migrate.go b/internal/modules/audit/infrastructure/migrate.go new file mode 100644 index 0000000..1ab0cc9 --- /dev/null +++ b/internal/modules/audit/infrastructure/migrate.go @@ -0,0 +1,13 @@ +package infrastructure + +import ( + "mengstack/internal/modules/audit/domain" + + "gorm.io/gorm" +) + +func Migrate(db *gorm.DB) error { + return db.AutoMigrate( + &domain.AuditLog{}, + ) +} diff --git a/internal/modules/audit/interfaces/handler.go b/internal/modules/audit/interfaces/handler.go new file mode 100644 index 0000000..82ae353 --- /dev/null +++ b/internal/modules/audit/interfaces/handler.go @@ -0,0 +1,53 @@ +package interfaces + +import ( + "strconv" + + "mengstack/internal/kernel/response" + "mengstack/internal/kernel/tenant" + "mengstack/internal/modules/audit/application" + auditdomain "mengstack/internal/modules/audit/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) ListLogs(c *gin.Context) { + tenantID, _ := tenant.FromContext(c.Request.Context()) + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + query := auditdomain.AuditQuery{ + TenantID: tenantID, + UserID: parseUintQuery(c, "user_id"), + Action: c.Query("action"), + Resource: c.Query("resource"), + Page: page, + PageSize: pageSize, + } + + dtos, total, err := h.svc.Query(c.Request.Context(), query) + if err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, gin.H{ + "items": dtos, + "total": total, + "page": page, + }) +} + +func parseUintQuery(c *gin.Context, key string) uint { + v, _ := strconv.ParseUint(c.DefaultQuery(key, "0"), 10, 64) + return uint(v) +} diff --git a/internal/modules/audit/interfaces/routes.go b/internal/modules/audit/interfaces/routes.go new file mode 100644 index 0000000..294985e --- /dev/null +++ b/internal/modules/audit/interfaces/routes.go @@ -0,0 +1,33 @@ +package interfaces + +import ( + "mengstack/internal/app/middleware" + "mengstack/internal/modules/audit/application" + auditinfra "mengstack/internal/modules/audit/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) { + audit := r.Group("/api/v1/audit") + audit.Use(authMW, middleware.MultiTenant()) + { + audit.GET("/logs", h.ListLogs) + } +} + +var Module = fx.Module("audit", + fx.Provide( + auditinfra.NewAuditRepository, + application.NewService, + NewHandler, + ), + fx.Invoke(func(db *gorm.DB, log *zap.Logger) { + if err := auditinfra.Migrate(db); err != nil { + log.Fatal("audit migration failed", zap.Error(err)) + } + }), +) diff --git a/internal/modules/org/application/service.go b/internal/modules/org/application/service.go new file mode 100644 index 0000000..2179b70 --- /dev/null +++ b/internal/modules/org/application/service.go @@ -0,0 +1,93 @@ +package application + +import ( + "context" + + "mengstack/internal/kernel/errors" + "mengstack/internal/modules/org/domain" +) + +type Service struct { + repo domain.TenantRepository +} + +func NewService(repo domain.TenantRepository) *Service { + return &Service{repo: repo} +} + +func (s *Service) CreateTenant(ctx context.Context, req domain.CreateTenantRequest) (domain.TenantDTO, error) { + if _, err := s.repo.FindBySlug(ctx, req.Slug); err == nil { + return domain.TenantDTO{}, errors.ErrConflict + } + + tenant := &domain.Tenant{ + Name: req.Name, + Slug: req.Slug, + } + + if err := s.repo.Create(ctx, tenant); err != nil { + return domain.TenantDTO{}, errors.Wrap(err, "CREATE_TENANT_FAILED", "failed to create tenant", 500) + } + + return domain.ToTenantDTO(tenant), nil +} + +func (s *Service) ListTenants(ctx context.Context) ([]domain.TenantDTO, error) { + tenants, err := s.repo.FindAll(ctx) + if err != nil { + return nil, errors.Wrap(err, "QUERY_TENANTS_FAILED", "failed to query tenants", 500) + } + + dtos := make([]domain.TenantDTO, len(tenants)) + for i, t := range tenants { + dtos[i] = domain.ToTenantDTO(&t) + } + return dtos, nil +} + +func (s *Service) GetTenant(ctx context.Context, id string) (domain.TenantDTO, error) { + tenant, err := s.repo.FindByID(ctx, id) + if err != nil { + return domain.TenantDTO{}, errors.ErrNotFound + } + + return domain.ToTenantDTO(tenant), nil +} + +func (s *Service) UpdateTenant(ctx context.Context, id string, req domain.UpdateTenantRequest) (domain.TenantDTO, error) { + tenant, err := s.repo.FindByID(ctx, id) + if err != nil { + return domain.TenantDTO{}, errors.ErrNotFound + } + + if req.Name != "" { + tenant.Name = req.Name + } + if req.Slug != "" { + if existing, err := s.repo.FindBySlug(ctx, req.Slug); err == nil && existing.ID != id { + return domain.TenantDTO{}, errors.ErrConflict + } + tenant.Slug = req.Slug + } + if req.Status != nil { + tenant.Status = *req.Status + } + + if err := s.repo.Update(ctx, tenant); err != nil { + return domain.TenantDTO{}, errors.Wrap(err, "UPDATE_TENANT_FAILED", "failed to update tenant", 500) + } + + return domain.ToTenantDTO(tenant), nil +} + +func (s *Service) DeleteTenant(ctx context.Context, id string) error { + if _, err := s.repo.FindByID(ctx, id); err != nil { + return errors.ErrNotFound + } + + if err := s.repo.Delete(ctx, id); err != nil { + return errors.Wrap(err, "DELETE_TENANT_FAILED", "failed to delete tenant", 500) + } + + return nil +} diff --git a/internal/modules/org/domain/repository.go b/internal/modules/org/domain/repository.go new file mode 100644 index 0000000..5564a62 --- /dev/null +++ b/internal/modules/org/domain/repository.go @@ -0,0 +1,12 @@ +package domain + +import "context" + +type TenantRepository interface { + Create(ctx context.Context, tenant *Tenant) error + FindByID(ctx context.Context, id string) (*Tenant, error) + FindBySlug(ctx context.Context, slug string) (*Tenant, error) + FindAll(ctx context.Context) ([]Tenant, error) + Update(ctx context.Context, tenant *Tenant) error + Delete(ctx context.Context, id string) error +} diff --git a/internal/modules/org/domain/tenant.go b/internal/modules/org/domain/tenant.go new file mode 100644 index 0000000..349927d --- /dev/null +++ b/internal/modules/org/domain/tenant.go @@ -0,0 +1,48 @@ +package domain + +import "time" + +type Tenant struct { + ID string `json:"id" gorm:"type:uuid;primaryKey;default:gen_random_uuid()"` + Name string `json:"name" gorm:"size:128;not null"` + Slug string `json:"slug" gorm:"uniqueIndex;size:64;not null"` + Status int `json:"status" gorm:"default:1"` + Settings string `json:"settings" gorm:"type:text"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (Tenant) TableName() string { + return "tenants" +} + +type TenantDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Status int `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func ToTenantDTO(t *Tenant) TenantDTO { + return TenantDTO{ + ID: t.ID, + Name: t.Name, + Slug: t.Slug, + Status: t.Status, + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + } +} + +type CreateTenantRequest struct { + Name string `json:"name" binding:"required"` + Slug string `json:"slug" binding:"required"` +} + +type UpdateTenantRequest struct { + Name string `json:"name"` + Slug string `json:"slug"` + Status *int `json:"status"` +} diff --git a/internal/modules/org/infrastructure/migrate.go b/internal/modules/org/infrastructure/migrate.go new file mode 100644 index 0000000..32f05de --- /dev/null +++ b/internal/modules/org/infrastructure/migrate.go @@ -0,0 +1,13 @@ +package infrastructure + +import ( + "mengstack/internal/modules/org/domain" + + "gorm.io/gorm" +) + +func Migrate(db *gorm.DB) error { + return db.AutoMigrate( + &domain.Tenant{}, + ) +} diff --git a/internal/modules/org/infrastructure/tenant_repo.go b/internal/modules/org/infrastructure/tenant_repo.go new file mode 100644 index 0000000..b17e333 --- /dev/null +++ b/internal/modules/org/infrastructure/tenant_repo.go @@ -0,0 +1,47 @@ +package infrastructure + +import ( + "context" + + "mengstack/internal/modules/org/domain" + + "gorm.io/gorm" +) + +type tenantRepo struct { + db *gorm.DB +} + +func NewTenantRepository(db *gorm.DB) domain.TenantRepository { + return &tenantRepo{db: db} +} + +func (r *tenantRepo) Create(ctx context.Context, tenant *domain.Tenant) error { + return r.db.WithContext(ctx).Create(tenant).Error +} + +func (r *tenantRepo) FindByID(ctx context.Context, id string) (*domain.Tenant, error) { + var tenant domain.Tenant + err := r.db.WithContext(ctx).Where("id = ?", id).First(&tenant).Error + return &tenant, err +} + +func (r *tenantRepo) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { + var tenant domain.Tenant + err := r.db.WithContext(ctx).Where("slug = ?", slug).First(&tenant).Error + return &tenant, err +} + +func (r *tenantRepo) FindAll(ctx context.Context) ([]domain.Tenant, error) { + var tenants []domain.Tenant + err := r.db.WithContext(ctx).Order("created_at DESC").Find(&tenants).Error + return tenants, err +} + +func (r *tenantRepo) Update(ctx context.Context, tenant *domain.Tenant) error { + return r.db.WithContext(ctx).Save(tenant).Error +} + +func (r *tenantRepo) Delete(ctx context.Context, id string) error { + return r.db.WithContext(ctx).Where("id = ?", id).Delete(&domain.Tenant{}).Error +} diff --git a/internal/modules/org/interfaces/handler.go b/internal/modules/org/interfaces/handler.go new file mode 100644 index 0000000..ffc8acd --- /dev/null +++ b/internal/modules/org/interfaces/handler.go @@ -0,0 +1,84 @@ +package interfaces + +import ( + "mengstack/internal/kernel/response" + "mengstack/internal/modules/org/application" + "mengstack/internal/modules/org/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) CreateTenant(c *gin.Context) { + var req domain.CreateTenantRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, response.NewBadRequest("invalid request body")) + return + } + + dto, err := h.svc.CreateTenant(c.Request.Context(), req) + if err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, dto) +} + +func (h *Handler) ListTenants(c *gin.Context) { + dtos, err := h.svc.ListTenants(c.Request.Context()) + if err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, dtos) +} + +func (h *Handler) GetTenant(c *gin.Context) { + id := c.Param("id") + + dto, err := h.svc.GetTenant(c.Request.Context(), id) + if err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, dto) +} + +func (h *Handler) UpdateTenant(c *gin.Context) { + id := c.Param("id") + + var req domain.UpdateTenantRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, response.NewBadRequest("invalid request body")) + return + } + + dto, err := h.svc.UpdateTenant(c.Request.Context(), id, req) + if err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, dto) +} + +func (h *Handler) DeleteTenant(c *gin.Context) { + id := c.Param("id") + + if err := h.svc.DeleteTenant(c.Request.Context(), id); err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, nil) +} diff --git a/internal/modules/org/interfaces/routes.go b/internal/modules/org/interfaces/routes.go new file mode 100644 index 0000000..371b466 --- /dev/null +++ b/internal/modules/org/interfaces/routes.go @@ -0,0 +1,37 @@ +package interfaces + +import ( + "mengstack/internal/app/middleware" + "mengstack/internal/modules/org/application" + "mengstack/internal/modules/org/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) { + org := r.Group("/api/v1/org") + org.Use(authMW, middleware.MultiTenant()) + { + org.POST("/tenants", h.CreateTenant) + org.GET("/tenants", h.ListTenants) + org.GET("/tenants/:id", h.GetTenant) + org.PUT("/tenants/:id", h.UpdateTenant) + org.DELETE("/tenants/:id", h.DeleteTenant) + } +} + +var Module = fx.Module("org", + fx.Provide( + infrastructure.NewTenantRepository, + application.NewService, + NewHandler, + ), + fx.Invoke(func(db *gorm.DB, log *zap.Logger) { + if err := infrastructure.Migrate(db); err != nil { + log.Fatal("org migration failed", zap.Error(err)) + } + }), +)