From cb7ad4d35d5908ba416415d1af5454ec682fe3cc Mon Sep 17 00:00:00 2001 From: MengStack Dev Date: Sat, 3 Oct 2026 01:07:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(notification):=20M6=20=E7=A4=BA=E4=BE=8B?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=20+=20=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete 4-layer notification module as reference template: domain → infrastructure → application → interfaces. 10 unit tests covering all service methods with mock repository. --- internal/app/app.go | 4 + .../notification/application/service.go | 82 +++++ .../notification/application/service_test.go | 279 ++++++++++++++++++ .../notification/domain/notification.go | 50 ++++ .../modules/notification/domain/repository.go | 13 + .../notification/infrastructure/migrate.go | 11 + .../infrastructure/notification_repo.go | 71 +++++ .../notification/interfaces/handler.go | 124 ++++++++ .../modules/notification/interfaces/routes.go | 43 +++ 9 files changed, 677 insertions(+) create mode 100644 internal/modules/notification/application/service.go create mode 100644 internal/modules/notification/application/service_test.go create mode 100644 internal/modules/notification/domain/notification.go create mode 100644 internal/modules/notification/domain/repository.go create mode 100644 internal/modules/notification/infrastructure/migrate.go create mode 100644 internal/modules/notification/infrastructure/notification_repo.go create mode 100644 internal/modules/notification/interfaces/handler.go create mode 100644 internal/modules/notification/interfaces/routes.go diff --git a/internal/app/app.go b/internal/app/app.go index 9fbb562..063756d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -17,6 +17,7 @@ import ( orginterfaces "mengstack/internal/modules/org/interfaces" auditinterfaces "mengstack/internal/modules/audit/interfaces" settingsinterfaces "mengstack/internal/modules/settings/interfaces" + notificationinterfaces "mengstack/internal/modules/notification/interfaces" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" @@ -45,6 +46,7 @@ func newEngine( orgHandler *orginterfaces.Handler, auditHandler *auditinterfaces.Handler, settingsHandler *settingsinterfaces.Handler, + notificationHandler *notificationinterfaces.Handler, ) *gin.Engine { ginMode := "release" if cfg.Server.Mode == "debug" || cfg.Server.Mode == "dev" { @@ -68,6 +70,7 @@ func newEngine( orginterfaces.SetupRoutes(r, orgHandler, authMW) auditinterfaces.SetupRoutes(r, auditHandler, authMW) settingsinterfaces.SetupRoutes(r, settingsHandler, authMW) + notificationinterfaces.SetupRoutes(r, notificationHandler, authMW) return r } @@ -113,6 +116,7 @@ func NewApp() *fx.App { orginterfaces.Module, auditinterfaces.Module, settingsinterfaces.Module, + notificationinterfaces.Module, Module, ) } diff --git a/internal/modules/notification/application/service.go b/internal/modules/notification/application/service.go new file mode 100644 index 0000000..10fbf19 --- /dev/null +++ b/internal/modules/notification/application/service.go @@ -0,0 +1,82 @@ +package application + +import ( + "context" + "errors" + + "mengstack/internal/modules/notification/domain" + + "gorm.io/gorm" +) + +type Service struct { + repo domain.NotificationRepository +} + +func NewService(repo domain.NotificationRepository) *Service { + return &Service{repo: repo} +} + +func (s *Service) Create(ctx context.Context, tenantID string, req *domain.CreateNotificationRequest) (*domain.NotificationDTO, error) { + n := &domain.Notification{ + TenantID: tenantID, + UserID: req.UserID, + Title: req.Title, + Content: req.Content, + Type: req.Type, + } + if n.Type == "" { + n.Type = "info" + } + if err := s.repo.Create(ctx, n); err != nil { + return nil, err + } + dto := domain.ToNotificationDTO(n) + return &dto, nil +} + +func (s *Service) Get(ctx context.Context, tenantID string, id uint) (*domain.NotificationDTO, error) { + n, err := s.repo.FindByID(ctx, tenantID, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + dto := domain.ToNotificationDTO(n) + return &dto, nil +} + +func (s *Service) ListByUser(ctx context.Context, tenantID string, userID uint, page, pageSize int) ([]domain.NotificationDTO, int64, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + notifications, total, err := s.repo.FindByUser(ctx, tenantID, userID, page, pageSize) + if err != nil { + return nil, 0, err + } + result := make([]domain.NotificationDTO, len(notifications)) + for i, n := range notifications { + result[i] = domain.ToNotificationDTO(&n) + } + return result, total, nil +} + +func (s *Service) MarkAsRead(ctx context.Context, tenantID string, id uint) error { + return s.repo.MarkAsRead(ctx, tenantID, id) +} + +func (s *Service) MarkAllAsRead(ctx context.Context, tenantID string, userID uint) error { + return s.repo.MarkAllAsRead(ctx, tenantID, userID) +} + +func (s *Service) CountUnread(ctx context.Context, tenantID string, userID uint) (int64, error) { + return s.repo.CountUnread(ctx, tenantID, userID) +} + +func (s *Service) Delete(ctx context.Context, tenantID string, id uint) error { + return s.repo.Delete(ctx, tenantID, id) +} diff --git a/internal/modules/notification/application/service_test.go b/internal/modules/notification/application/service_test.go new file mode 100644 index 0000000..442ae93 --- /dev/null +++ b/internal/modules/notification/application/service_test.go @@ -0,0 +1,279 @@ +package application + +import ( + "context" + "testing" + + "mengstack/internal/modules/notification/domain" + + "gorm.io/gorm" +) + +type mockRepo struct { + notifications []domain.Notification + nextID uint +} + +func newMockRepo() *mockRepo { + return &mockRepo{nextID: 1} +} + +func (m *mockRepo) Create(_ context.Context, n *domain.Notification) error { + n.ID = m.nextID + m.nextID++ + m.notifications = append(m.notifications, *n) + return nil +} + +func (m *mockRepo) FindByID(_ context.Context, tenantID string, id uint) (*domain.Notification, error) { + for _, n := range m.notifications { + if n.ID == id && n.TenantID == tenantID { + return &n, nil + } + } + return nil, gorm.ErrRecordNotFound +} + +func (m *mockRepo) FindByUser(_ context.Context, tenantID string, userID uint, page, pageSize int) ([]domain.Notification, int64, error) { + var result []domain.Notification + for _, n := range m.notifications { + if n.TenantID == tenantID && n.UserID == userID { + result = append(result, n) + } + } + total := int64(len(result)) + start := (page - 1) * pageSize + if start >= len(result) { + return nil, total, nil + } + end := start + pageSize + if end > len(result) { + end = len(result) + } + return result[start:end], total, nil +} + +func (m *mockRepo) MarkAsRead(_ context.Context, tenantID string, id uint) error { + for i, n := range m.notifications { + if n.ID == id && n.TenantID == tenantID { + m.notifications[i].IsRead = true + return nil + } + } + return nil +} + +func (m *mockRepo) MarkAllAsRead(_ context.Context, tenantID string, userID uint) error { + for i, n := range m.notifications { + if n.TenantID == tenantID && n.UserID == userID { + m.notifications[i].IsRead = true + } + } + return nil +} + +func (m *mockRepo) CountUnread(_ context.Context, tenantID string, userID uint) (int64, error) { + var count int64 + for _, n := range m.notifications { + if n.TenantID == tenantID && n.UserID == userID && !n.IsRead { + count++ + } + } + return count, nil +} + +func (m *mockRepo) Delete(_ context.Context, tenantID string, id uint) error { + for i, n := range m.notifications { + if n.ID == id && n.TenantID == tenantID { + m.notifications = append(m.notifications[:i], m.notifications[i+1:]...) + return nil + } + } + return nil +} + +func TestCreate(t *testing.T) { + repo := newMockRepo() + svc := NewService(repo) + + req := &domain.CreateNotificationRequest{ + UserID: 1, + Title: "Test Notification", + Content: "Hello", + Type: "warning", + } + dto, err := svc.Create(context.Background(), "tenant-1", req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dto.ID != 1 { + t.Errorf("expected ID 1, got %d", dto.ID) + } + if dto.Title != "Test Notification" { + t.Errorf("expected title 'Test Notification', got %q", dto.Title) + } + if dto.Type != "warning" { + t.Errorf("expected type 'warning', got %q", dto.Type) + } +} + +func TestCreateDefaultType(t *testing.T) { + repo := newMockRepo() + svc := NewService(repo) + + req := &domain.CreateNotificationRequest{ + UserID: 1, + Title: "Test", + } + dto, err := svc.Create(context.Background(), "tenant-1", req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dto.Type != "info" { + t.Errorf("expected default type 'info', got %q", dto.Type) + } +} + +func TestGet(t *testing.T) { + repo := newMockRepo() + svc := NewService(repo) + + req := &domain.CreateNotificationRequest{UserID: 1, Title: "Test"} + svc.Create(context.Background(), "tenant-1", req) + + dto, err := svc.Get(context.Background(), "tenant-1", 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dto == nil { + t.Fatal("expected notification, got nil") + } + if dto.Title != "Test" { + t.Errorf("expected title 'Test', got %q", dto.Title) + } +} + +func TestGetNotFound(t *testing.T) { + repo := newMockRepo() + svc := NewService(repo) + + dto, err := svc.Get(context.Background(), "tenant-1", 999) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dto != nil { + t.Error("expected nil for non-existent notification") + } +} + +func TestListByUser(t *testing.T) { + repo := newMockRepo() + svc := NewService(repo) + + for i := 0; i < 5; i++ { + svc.Create(context.Background(), "tenant-1", &domain.CreateNotificationRequest{UserID: 1, Title: "Test"}) + } + svc.Create(context.Background(), "tenant-1", &domain.CreateNotificationRequest{UserID: 2, Title: "Other user"}) + + items, total, err := svc.ListByUser(context.Background(), "tenant-1", 1, 1, 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if total != 5 { + t.Errorf("expected total 5, got %d", total) + } + if len(items) != 5 { + t.Errorf("expected 5 items, got %d", len(items)) + } +} + +func TestListByUserPagination(t *testing.T) { + repo := newMockRepo() + svc := NewService(repo) + + for i := 0; i < 5; i++ { + svc.Create(context.Background(), "tenant-1", &domain.CreateNotificationRequest{UserID: 1, Title: "Test"}) + } + + items, total, err := svc.ListByUser(context.Background(), "tenant-1", 1, 1, 2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if total != 5 { + t.Errorf("expected total 5, got %d", total) + } + if len(items) != 2 { + t.Errorf("expected 2 items on page 1, got %d", len(items)) + } +} + +func TestMarkAsRead(t *testing.T) { + repo := newMockRepo() + svc := NewService(repo) + + svc.Create(context.Background(), "tenant-1", &domain.CreateNotificationRequest{UserID: 1, Title: "Test"}) + + err := svc.MarkAsRead(context.Background(), "tenant-1", 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + dto, _ := svc.Get(context.Background(), "tenant-1", 1) + if !dto.IsRead { + t.Error("expected notification to be marked as read") + } +} + +func TestMarkAllAsRead(t *testing.T) { + repo := newMockRepo() + svc := NewService(repo) + + for i := 0; i < 3; i++ { + svc.Create(context.Background(), "tenant-1", &domain.CreateNotificationRequest{UserID: 1, Title: "Test"}) + } + + err := svc.MarkAllAsRead(context.Background(), "tenant-1", 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + count, _ := svc.CountUnread(context.Background(), "tenant-1", 1) + if count != 0 { + t.Errorf("expected 0 unread, got %d", count) + } +} + +func TestCountUnread(t *testing.T) { + repo := newMockRepo() + svc := NewService(repo) + + for i := 0; i < 3; i++ { + svc.Create(context.Background(), "tenant-1", &domain.CreateNotificationRequest{UserID: 1, Title: "Test"}) + } + svc.MarkAsRead(context.Background(), "tenant-1", 1) + + count, err := svc.CountUnread(context.Background(), "tenant-1", 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if count != 2 { + t.Errorf("expected 2 unread, got %d", count) + } +} + +func TestDelete(t *testing.T) { + repo := newMockRepo() + svc := NewService(repo) + + svc.Create(context.Background(), "tenant-1", &domain.CreateNotificationRequest{UserID: 1, Title: "Test"}) + + err := svc.Delete(context.Background(), "tenant-1", 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + dto, _ := svc.Get(context.Background(), "tenant-1", 1) + if dto != nil { + t.Error("expected nil after delete") + } +} diff --git a/internal/modules/notification/domain/notification.go b/internal/modules/notification/domain/notification.go new file mode 100644 index 0000000..73f0fba --- /dev/null +++ b/internal/modules/notification/domain/notification.go @@ -0,0 +1,50 @@ +package domain + +import "time" + +type Notification struct { + ID uint `json:"id" gorm:"primaryKey"` + TenantID string `json:"tenant_id" gorm:"size:36;not null;index"` + UserID uint `json:"user_id" gorm:"not null;index"` + Title string `json:"title" gorm:"size:256;not null"` + Content string `json:"content" gorm:"type:text"` + Type string `json:"type" gorm:"size:32;default:info"` + IsRead bool `json:"is_read" gorm:"default:false"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (Notification) TableName() string { + return "notifications" +} + +type NotificationDTO struct { + ID uint `json:"id"` + TenantID string `json:"tenant_id"` + UserID uint `json:"user_id"` + Title string `json:"title"` + Content string `json:"content"` + Type string `json:"type"` + IsRead bool `json:"is_read"` + CreatedAt time.Time `json:"created_at"` +} + +func ToNotificationDTO(n *Notification) NotificationDTO { + return NotificationDTO{ + ID: n.ID, + TenantID: n.TenantID, + UserID: n.UserID, + Title: n.Title, + Content: n.Content, + Type: n.Type, + IsRead: n.IsRead, + CreatedAt: n.CreatedAt, + } +} + +type CreateNotificationRequest struct { + UserID uint `json:"user_id" binding:"required"` + Title string `json:"title" binding:"required"` + Content string `json:"content"` + Type string `json:"type"` +} diff --git a/internal/modules/notification/domain/repository.go b/internal/modules/notification/domain/repository.go new file mode 100644 index 0000000..40a1233 --- /dev/null +++ b/internal/modules/notification/domain/repository.go @@ -0,0 +1,13 @@ +package domain + +import "context" + +type NotificationRepository interface { + Create(ctx context.Context, notification *Notification) error + FindByID(ctx context.Context, tenantID string, id uint) (*Notification, error) + FindByUser(ctx context.Context, tenantID string, userID uint, page, pageSize int) ([]Notification, int64, error) + MarkAsRead(ctx context.Context, tenantID string, id uint) error + MarkAllAsRead(ctx context.Context, tenantID string, userID uint) error + CountUnread(ctx context.Context, tenantID string, userID uint) (int64, error) + Delete(ctx context.Context, tenantID string, id uint) error +} diff --git a/internal/modules/notification/infrastructure/migrate.go b/internal/modules/notification/infrastructure/migrate.go new file mode 100644 index 0000000..46aab9d --- /dev/null +++ b/internal/modules/notification/infrastructure/migrate.go @@ -0,0 +1,11 @@ +package infrastructure + +import ( + "mengstack/internal/modules/notification/domain" + + "gorm.io/gorm" +) + +func Migrate(db *gorm.DB) error { + return db.AutoMigrate(&domain.Notification{}) +} diff --git a/internal/modules/notification/infrastructure/notification_repo.go b/internal/modules/notification/infrastructure/notification_repo.go new file mode 100644 index 0000000..2c8a46a --- /dev/null +++ b/internal/modules/notification/infrastructure/notification_repo.go @@ -0,0 +1,71 @@ +package infrastructure + +import ( + "context" + + "mengstack/internal/modules/notification/domain" + + "gorm.io/gorm" +) + +type notificationRepo struct { + db *gorm.DB +} + +func NewNotificationRepository(db *gorm.DB) domain.NotificationRepository { + return ¬ificationRepo{db: db} +} + +func (r *notificationRepo) Create(ctx context.Context, n *domain.Notification) error { + return r.db.WithContext(ctx).Create(n).Error +} + +func (r *notificationRepo) FindByID(ctx context.Context, tenantID string, id uint) (*domain.Notification, error) { + var n domain.Notification + err := r.db.WithContext(ctx).Where("id = ? AND tenant_id = ?", id, tenantID).First(&n).Error + if err != nil { + return nil, err + } + return &n, nil +} + +func (r *notificationRepo) FindByUser(ctx context.Context, tenantID string, userID uint, page, pageSize int) ([]domain.Notification, int64, error) { + var notifications []domain.Notification + var total int64 + + query := r.db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID) + query.Model(&domain.Notification{}).Count(&total) + + offset := (page - 1) * pageSize + err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(¬ifications).Error + return notifications, total, err +} + +func (r *notificationRepo) MarkAsRead(ctx context.Context, tenantID string, id uint) error { + return r.db.WithContext(ctx). + Where("id = ? AND tenant_id = ?", id, tenantID). + Model(&domain.Notification{}). + Update("is_read", true).Error +} + +func (r *notificationRepo) MarkAllAsRead(ctx context.Context, tenantID string, userID uint) error { + return r.db.WithContext(ctx). + Where("tenant_id = ? AND user_id = ? AND is_read = ?", tenantID, userID, false). + Model(&domain.Notification{}). + Update("is_read", true).Error +} + +func (r *notificationRepo) CountUnread(ctx context.Context, tenantID string, userID uint) (int64, error) { + var count int64 + err := r.db.WithContext(ctx). + Where("tenant_id = ? AND user_id = ? AND is_read = ?", tenantID, userID, false). + Model(&domain.Notification{}). + Count(&count).Error + return count, err +} + +func (r *notificationRepo) Delete(ctx context.Context, tenantID string, id uint) error { + return r.db.WithContext(ctx). + Where("id = ? AND tenant_id = ?", id, tenantID). + Delete(&domain.Notification{}).Error +} diff --git a/internal/modules/notification/interfaces/handler.go b/internal/modules/notification/interfaces/handler.go new file mode 100644 index 0000000..5feeeb4 --- /dev/null +++ b/internal/modules/notification/interfaces/handler.go @@ -0,0 +1,124 @@ +package interfaces + +import ( + "net/http" + "strconv" + + "mengstack/internal/kernel/response" + "mengstack/internal/modules/notification/application" + "mengstack/internal/modules/notification/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) Create(c *gin.Context) { + var req domain.CreateNotificationRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, response.NewBadRequest("invalid request: "+err.Error())) + return + } + tenantID := c.GetString("tenant_id") + n, err := h.svc.Create(c.Request.Context(), tenantID, &req) + if err != nil { + response.HandleError(c, err) + return + } + response.Success(c, n) +} + +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") + n, err := h.svc.Get(c.Request.Context(), tenantID, uint(id)) + if err != nil { + response.HandleError(c, err) + return + } + if n == nil { + response.Fail(c, response.NewBadRequest("notification not found")) + return + } + response.Success(c, n) +} + +func (h *Handler) ListByUser(c *gin.Context) { + userID, err := strconv.ParseUint(c.Param("userId"), 10, 64) + if err != nil { + response.Fail(c, response.NewBadRequest("invalid user_id")) + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + tenantID := c.GetString("tenant_id") + notifications, total, err := h.svc.ListByUser(c.Request.Context(), tenantID, uint(userID), page, pageSize) + if err != nil { + response.HandleError(c, err) + return + } + response.Success(c, gin.H{ + "items": notifications, + "total": total, + "page": page, + }) +} + +func (h *Handler) MarkAsRead(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.MarkAsRead(c.Request.Context(), tenantID, uint(id)); err != nil { + response.HandleError(c, err) + return + } + response.Success(c, nil) +} + +func (h *Handler) MarkAllAsRead(c *gin.Context) { + userID := c.GetUint("user_id") + tenantID := c.GetString("tenant_id") + if err := h.svc.MarkAllAsRead(c.Request.Context(), tenantID, userID); err != nil { + response.HandleError(c, err) + return + } + response.Success(c, nil) +} + +func (h *Handler) CountUnread(c *gin.Context) { + userID := c.GetUint("user_id") + tenantID := c.GetString("tenant_id") + count, err := h.svc.CountUnread(c.Request.Context(), tenantID, userID) + if err != nil { + response.HandleError(c, err) + return + } + response.Success(c, gin.H{"count": count}) +} + +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 + } + c.JSON(http.StatusOK, gin.H{"code": 0, "message": "deleted"}) +} diff --git a/internal/modules/notification/interfaces/routes.go b/internal/modules/notification/interfaces/routes.go new file mode 100644 index 0000000..622255b --- /dev/null +++ b/internal/modules/notification/interfaces/routes.go @@ -0,0 +1,43 @@ +package interfaces + +import ( + "mengstack/internal/app/middleware" + "mengstack/internal/modules/notification/application" + "mengstack/internal/modules/notification/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, +) { + notifications := r.Group("/api/v1/notifications") + notifications.Use(authMW, middleware.MultiTenant()) + { + notifications.POST("", h.Create) + notifications.GET("/:id", h.Get) + notifications.DELETE("/:id", h.Delete) + notifications.PUT("/:id/read", h.MarkAsRead) + notifications.PUT("/read-all", h.MarkAllAsRead) + notifications.GET("/unread-count", h.CountUnread) + notifications.GET("/user/:userId", h.ListByUser) + } +} + +var Module = fx.Module("notification", + fx.Provide( + infrastructure.NewNotificationRepository, + application.NewService, + NewHandler, + ), + fx.Invoke(func(db *gorm.DB, log *zap.Logger) { + if err := infrastructure.Migrate(db); err != nil { + log.Fatal("notification migration failed", zap.Error(err)) + } + }), +)