diff --git a/backend/internal/model/notification.go b/backend/internal/model/notification.go index aa5ecdd..bd5d2f7 100644 --- a/backend/internal/model/notification.go +++ b/backend/internal/model/notification.go @@ -17,3 +17,18 @@ type Notification struct { func (Notification) TableName() string { return "notifications" } + +type AdminNotification struct { + ID uint64 `gorm:"primaryKey" json:"id"` + AdminUserID uint64 `gorm:"not null;index" json:"admin_user_id"` + Type string `gorm:"size:32;not null" json:"type"` + Title string `gorm:"size:128;not null" json:"title"` + Content string `json:"content"` + IsRead bool `gorm:"not null;default:false" json:"is_read"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (AdminNotification) TableName() string { + return "admin_notifications" +} diff --git a/backend/internal/modules/adminnotification/dto.go b/backend/internal/modules/adminnotification/dto.go new file mode 100644 index 0000000..b4a2d9e --- /dev/null +++ b/backend/internal/modules/adminnotification/dto.go @@ -0,0 +1,32 @@ +package adminnotification + +import "time" + +type Query struct { + AdminUserID uint64 + OnlyUnread bool + Page int + PageSize int +} + +type PaginatedResult struct { + Items interface{} `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type NotificationDTO struct { + ID uint64 `json:"id"` + AdminUserID uint64 `json:"admin_user_id"` + Type string `json:"type"` + Title string `json:"title"` + Content string `json:"content"` + IsRead bool `json:"is_read"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UnreadCountDTO struct { + UnreadCount int64 `json:"unread_count"` +} diff --git a/backend/internal/modules/adminnotification/handler.go b/backend/internal/modules/adminnotification/handler.go new file mode 100644 index 0000000..a846e92 --- /dev/null +++ b/backend/internal/modules/adminnotification/handler.go @@ -0,0 +1,121 @@ +package adminnotification + +import ( + "errors" + "net/http" + "strconv" + + "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func parsePagination(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + +func (h *Handler) List(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + page, pageSize := parsePagination(c) + result, err := h.service.List(c.Request.Context(), Query{ + AdminUserID: adminID, + OnlyUnread: c.Query("read") == "unread", + Page: page, + PageSize: pageSize, + }) + if err != nil { + writeNotificationError(c, err) + return + } + response.OK(c, result) +} + +func (h *Handler) UnreadCount(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + result, err := h.service.UnreadCount(c.Request.Context(), adminID) + if err != nil { + writeNotificationError(c, err) + return + } + response.OK(c, result) +} + +func (h *Handler) MarkRead(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.BadRequest(c, "ID 不正确") + return + } + if err := h.service.MarkRead(c.Request.Context(), adminID, id); err != nil { + writeNotificationError(c, err) + return + } + response.OK(c, gin.H{"read": true}) +} + +func (h *Handler) MarkAllRead(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + count, err := h.service.MarkAllRead(c.Request.Context(), adminID) + if err != nil { + writeNotificationError(c, err) + return + } + response.OK(c, gin.H{"read_count": count}) +} + +func currentAdminID(c *gin.Context) (uint64, bool) { + value, ok := c.Get(middleware.ContextAdminID) + if !ok { + return 0, false + } + adminID, ok := value.(uint64) + return adminID, ok +} + +func writeNotificationError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrDependencyUnavailable): + response.ServiceUnavailable(c, "数据库未连接") + case errors.Is(err, ErrNotificationNotFound): + response.NotFound(c, "通知不存在") + default: + response.Error(c, http.StatusInternalServerError, "internal_error", "通知服务暂时不可用") + } +} diff --git a/backend/internal/modules/adminnotification/repository.go b/backend/internal/modules/adminnotification/repository.go new file mode 100644 index 0000000..69af8aa --- /dev/null +++ b/backend/internal/modules/adminnotification/repository.go @@ -0,0 +1,125 @@ +package adminnotification + +import ( + "context" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) List(ctx context.Context, query Query) (*PaginatedResult, error) { + db := r.db.WithContext(ctx).Model(&model.AdminNotification{}). + Where("admin_user_id = ?", query.AdminUserID) + countDB := r.db.WithContext(ctx).Model(&model.AdminNotification{}). + Where("admin_user_id = ?", query.AdminUserID) + if query.OnlyUnread { + db = db.Where("is_read = ?", false) + countDB = countDB.Where("is_read = ?", false) + } + + var total int64 + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + + offset := (query.Page - 1) * query.PageSize + var rows []model.AdminNotification + if err := db.Order("id DESC").Offset(offset).Limit(query.PageSize).Find(&rows).Error; err != nil { + return nil, err + } + items := make([]NotificationDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, toDTO(row)) + } + return &PaginatedResult{Items: items, Total: total, Page: query.Page, PageSize: query.PageSize}, nil +} + +func (r *Repository) UnreadCount(ctx context.Context, adminUserID uint64) (int64, error) { + var total int64 + err := r.db.WithContext(ctx).Model(&model.AdminNotification{}). + Where("admin_user_id = ? AND is_read = ?", adminUserID, false). + Count(&total).Error + return total, err +} + +func (r *Repository) MarkRead(ctx context.Context, adminUserID uint64, id uint64) error { + result := r.db.WithContext(ctx).Model(&model.AdminNotification{}). + Where("id = ? AND admin_user_id = ?", id, adminUserID). + Update("is_read", true) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + var total int64 + if err := r.db.WithContext(ctx).Model(&model.AdminNotification{}). + Where("id = ? AND admin_user_id = ?", id, adminUserID). + Count(&total).Error; err != nil { + return err + } + if total == 0 { + return ErrNotificationNotFound + } + } + return nil +} + +func (r *Repository) MarkAllRead(ctx context.Context, adminUserID uint64) (int64, error) { + result := r.db.WithContext(ctx).Model(&model.AdminNotification{}). + Where("admin_user_id = ? AND is_read = ?", adminUserID, false). + Update("is_read", true) + return result.RowsAffected, result.Error +} + +// Entry 描述一条待写入的管理员通知。 +type Entry struct { + AdminUserID uint64 + Type string + Title string + Content string +} + +// Append 在事务中批量写入管理员通知。 +// 典型用法:在业务事务内调用 adminnotification.Append(tx, entries...), +// 确保通知与业务数据在同一事务中提交或回滚。 +func Append(tx *gorm.DB, entries ...Entry) error { + for _, entry := range entries { + if entry.AdminUserID == 0 || entry.Title == "" { + continue + } + row := model.AdminNotification{ + AdminUserID: entry.AdminUserID, + Type: entry.Type, + Title: entry.Title, + Content: entry.Content, + } + if row.Type == "" { + row.Type = "system" + } + if err := tx.Create(&row).Error; err != nil { + return err + } + } + return nil +} + +func toDTO(row model.AdminNotification) NotificationDTO { + return NotificationDTO{ + ID: row.ID, + AdminUserID: row.AdminUserID, + Type: row.Type, + Title: row.Title, + Content: row.Content, + IsRead: row.IsRead, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} diff --git a/backend/internal/modules/adminnotification/repository_test.go b/backend/internal/modules/adminnotification/repository_test.go new file mode 100644 index 0000000..45a162a --- /dev/null +++ b/backend/internal/modules/adminnotification/repository_test.go @@ -0,0 +1,105 @@ +package adminnotification + +import ( + "context" + "errors" + "testing" + + "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/model" +) + +func TestRepositoryUnreadCountAndMarkRead(t *testing.T) { + db := database.NewTestDB() + if err := db.AutoMigrate(&model.AdminNotification{}); err != nil { + t.Fatalf("AutoMigrate() error = %v", err) + } + repo := NewRepository(db) + ctx := context.Background() + + if err := db.Create(&model.AdminNotification{ + AdminUserID: 7, + Type: "system", + Title: "库存预警", + Content: "二维码库存不足", + }).Error; err != nil { + t.Fatalf("Create() error = %v", err) + } + + count, err := repo.UnreadCount(ctx, 7) + if err != nil { + t.Fatalf("UnreadCount() error = %v", err) + } + if count != 1 { + t.Fatalf("UnreadCount() = %d, want 1", count) + } + + if err := repo.MarkRead(ctx, 7, 1); err != nil { + t.Fatalf("MarkRead() error = %v", err) + } + if err := repo.MarkRead(ctx, 7, 1); err != nil { + t.Fatalf("MarkRead() repeat error = %v", err) + } + + count, err = repo.UnreadCount(ctx, 7) + if err != nil { + t.Fatalf("UnreadCount() after read error = %v", err) + } + if count != 0 { + t.Fatalf("UnreadCount() after read = %d, want 0", count) + } +} + +func TestRepositoryMarkAllRead(t *testing.T) { + db := database.NewTestDB() + if err := db.AutoMigrate(&model.AdminNotification{}); err != nil { + t.Fatalf("AutoMigrate() error = %v", err) + } + repo := NewRepository(db) + ctx := context.Background() + + rows := []model.AdminNotification{ + {AdminUserID: 7, Type: "system", Title: "一", Content: "一"}, + {AdminUserID: 7, Type: "system", Title: "二", Content: "二"}, + {AdminUserID: 8, Type: "system", Title: "三", Content: "三"}, + } + if err := db.Create(&rows).Error; err != nil { + t.Fatalf("Create() error = %v", err) + } + + affected, err := repo.MarkAllRead(ctx, 7) + if err != nil { + t.Fatalf("MarkAllRead() error = %v", err) + } + if affected != 2 { + t.Fatalf("MarkAllRead() affected = %d, want 2", affected) + } + affected, err = repo.MarkAllRead(ctx, 7) + if err != nil { + t.Fatalf("MarkAllRead() repeat error = %v", err) + } + if affected != 0 { + t.Fatalf("MarkAllRead() repeat affected = %d, want 0", affected) + } + + count, err := repo.UnreadCount(ctx, 8) + if err != nil { + t.Fatalf("UnreadCount() error = %v", err) + } + if count != 1 { + t.Fatalf("other admin UnreadCount() = %d, want 1", count) + } +} + +func TestRepositoryMarkReadNotFound(t *testing.T) { + db := database.NewTestDB() + if err := db.AutoMigrate(&model.AdminNotification{}); err != nil { + t.Fatalf("AutoMigrate() error = %v", err) + } + repo := NewRepository(db) + + err := repo.MarkRead(context.Background(), 7, 99) + if !errors.Is(err, ErrNotificationNotFound) { + t.Fatalf("MarkRead() error = %v, want ErrNotificationNotFound", err) + } +} diff --git a/backend/internal/modules/adminnotification/service.go b/backend/internal/modules/adminnotification/service.go new file mode 100644 index 0000000..cbb7852 --- /dev/null +++ b/backend/internal/modules/adminnotification/service.go @@ -0,0 +1,49 @@ +package adminnotification + +import ( + "context" + "errors" +) + +var ErrDependencyUnavailable = errors.New("dependency unavailable") +var ErrNotificationNotFound = errors.New("admin notification not found") + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) List(ctx context.Context, query Query) (*PaginatedResult, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.List(ctx, query) +} + +func (s *Service) UnreadCount(ctx context.Context, adminUserID uint64) (*UnreadCountDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + count, err := s.repo.UnreadCount(ctx, adminUserID) + if err != nil { + return nil, err + } + return &UnreadCountDTO{UnreadCount: count}, nil +} + +func (s *Service) MarkRead(ctx context.Context, adminUserID uint64, id uint64) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.MarkRead(ctx, adminUserID, id) +} + +func (s *Service) MarkAllRead(ctx context.Context, adminUserID uint64) (int64, error) { + if s.repo == nil { + return 0, ErrDependencyUnavailable + } + return s.repo.MarkAllRead(ctx, adminUserID) +} diff --git a/backend/internal/modules/notification/dto.go b/backend/internal/modules/notification/dto.go index 620f36b..cedd4af 100644 --- a/backend/internal/modules/notification/dto.go +++ b/backend/internal/modules/notification/dto.go @@ -20,3 +20,7 @@ type PaginatedResult struct { Page int `json:"page"` PageSize int `json:"page_size"` } + +type UnreadCountDTO struct { + UnreadCount int64 `json:"unread_count"` +} diff --git a/backend/internal/modules/notification/handler.go b/backend/internal/modules/notification/handler.go index 17eb73e..bdc00bd 100644 --- a/backend/internal/modules/notification/handler.go +++ b/backend/internal/modules/notification/handler.go @@ -48,6 +48,34 @@ func (h *Handler) List(c *gin.Context) { response.OK(c, result) } +func (h *Handler) UnreadCount(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + result, err := h.service.UnreadCount(c.Request.Context(), userID) + if err != nil { + writeNotificationError(c, err) + return + } + response.OK(c, result) +} + +func (h *Handler) MarkAllRead(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + count, err := h.service.MarkAllRead(c.Request.Context(), userID) + if err != nil { + writeNotificationError(c, err) + return + } + response.OK(c, gin.H{"read_count": count}) +} + func (h *Handler) MarkRead(c *gin.Context) { userID, ok := currentUserID(c) if !ok { @@ -79,6 +107,8 @@ func writeNotificationError(c *gin.Context, err error) { switch { case errors.Is(err, ErrDependencyUnavailable): response.ServiceUnavailable(c, "数据库未连接") + case errors.Is(err, ErrNotificationNotFound): + response.NotFound(c, "通知不存在") default: response.ServiceUnavailable(c, "通知服务暂时不可用") } diff --git a/backend/internal/modules/notification/repository.go b/backend/internal/modules/notification/repository.go index 3629d6b..5b4cf99 100644 --- a/backend/internal/modules/notification/repository.go +++ b/backend/internal/modules/notification/repository.go @@ -45,9 +45,40 @@ func (r *Repository) List(ctx context.Context, userID uint64, page, pageSize int func (r *Repository) MarkRead(ctx context.Context, userID uint64, id uint64) error { now := time.Now() - return r.db.WithContext(ctx).Model(&model.Notification{}). + result := r.db.WithContext(ctx).Model(&model.Notification{}). Where("id = ? AND user_id = ?", id, userID). - Update("read_at", now).Error + Update("read_at", now) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + var total int64 + if err := r.db.WithContext(ctx).Model(&model.Notification{}). + Where("id = ? AND user_id = ?", id, userID). + Count(&total).Error; err != nil { + return err + } + if total == 0 { + return ErrNotificationNotFound + } + } + return nil +} + +func (r *Repository) UnreadCount(ctx context.Context, userID uint64) (int64, error) { + var total int64 + err := r.db.WithContext(ctx).Model(&model.Notification{}). + Where("user_id = ? AND read_at IS NULL", userID). + Count(&total).Error + return total, err +} + +func (r *Repository) MarkAllRead(ctx context.Context, userID uint64) (int64, error) { + now := time.Now() + result := r.db.WithContext(ctx).Model(&model.Notification{}). + Where("user_id = ? AND read_at IS NULL", userID). + Update("read_at", now) + return result.RowsAffected, result.Error } func Append(tx *gorm.DB, entries ...Entry) error { diff --git a/backend/internal/modules/notification/repository_test.go b/backend/internal/modules/notification/repository_test.go new file mode 100644 index 0000000..a509532 --- /dev/null +++ b/backend/internal/modules/notification/repository_test.go @@ -0,0 +1,65 @@ +package notification + +import ( + "context" + "errors" + "testing" + + "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/model" +) + +func TestRepositoryUnreadCountAndMarkRead(t *testing.T) { + db := database.NewTestDB() + if err := db.AutoMigrate(&model.Notification{}); err != nil { + t.Fatalf("AutoMigrate() error = %v", err) + } + repo := NewRepository(db) + ctx := context.Background() + + if err := db.Create(&model.Notification{ + UserID: 10, + Type: "order", + Title: "订单通知", + Content: "请处理订单", + }).Error; err != nil { + t.Fatalf("Create() error = %v", err) + } + + count, err := repo.UnreadCount(ctx, 10) + if err != nil { + t.Fatalf("UnreadCount() error = %v", err) + } + if count != 1 { + t.Fatalf("UnreadCount() = %d, want 1", count) + } + + if err := repo.MarkRead(ctx, 10, 1); err != nil { + t.Fatalf("MarkRead() error = %v", err) + } + // 重复标记应保持幂等,不应误报不存在。 + if err := repo.MarkRead(ctx, 10, 1); err != nil { + t.Fatalf("MarkRead() repeat error = %v", err) + } + + count, err = repo.UnreadCount(ctx, 10) + if err != nil { + t.Fatalf("UnreadCount() after read error = %v", err) + } + if count != 0 { + t.Fatalf("UnreadCount() after read = %d, want 0", count) + } +} + +func TestRepositoryMarkReadNotFound(t *testing.T) { + db := database.NewTestDB() + if err := db.AutoMigrate(&model.Notification{}); err != nil { + t.Fatalf("AutoMigrate() error = %v", err) + } + repo := NewRepository(db) + + err := repo.MarkRead(context.Background(), 10, 99) + if !errors.Is(err, ErrNotificationNotFound) { + t.Fatalf("MarkRead() error = %v, want ErrNotificationNotFound", err) + } +} diff --git a/backend/internal/modules/notification/service.go b/backend/internal/modules/notification/service.go index 620dbd5..089b6d8 100644 --- a/backend/internal/modules/notification/service.go +++ b/backend/internal/modules/notification/service.go @@ -6,6 +6,7 @@ import ( ) var ErrDependencyUnavailable = errors.New("dependency unavailable") +var ErrNotificationNotFound = errors.New("notification not found") type Service struct { repo *Repository @@ -28,3 +29,21 @@ func (s *Service) MarkRead(ctx context.Context, userID uint64, id uint64) error } return s.repo.MarkRead(ctx, userID, id) } + +func (s *Service) UnreadCount(ctx context.Context, userID uint64) (*UnreadCountDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + count, err := s.repo.UnreadCount(ctx, userID) + if err != nil { + return nil, err + } + return &UnreadCountDTO{UnreadCount: count}, nil +} + +func (s *Service) MarkAllRead(ctx context.Context, userID uint64) (int64, error) { + if s.repo == nil { + return 0, ErrDependencyUnavailable + } + return s.repo.MarkAllRead(ctx, userID) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index d1ee7fe..265f40f 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -13,6 +13,7 @@ import ( "hfb_sys/backend/internal/modules/admindashboard" "hfb_sys/backend/internal/modules/adminfinance" "hfb_sys/backend/internal/modules/adminmgr" + "hfb_sys/backend/internal/modules/adminnotification" "hfb_sys/backend/internal/modules/adminrole" "hfb_sys/backend/internal/modules/adminuser" "hfb_sys/backend/internal/modules/announcement" @@ -145,6 +146,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } adminAuditService := adminaudit.NewService(adminAuditRepo) adminAuditHandler := adminaudit.NewHandler(adminAuditService) + var adminNotificationRepo *adminnotification.Repository + if deps.DB != nil { + adminNotificationRepo = adminnotification.NewRepository(deps.DB) + } + adminNotificationService := adminnotification.NewService(adminNotificationRepo) + adminNotificationHandler := adminnotification.NewHandler(adminNotificationService) userHandler := user.NewHandler(userRepo) var realnameRepo *realname.Repository if deps.DB != nil { @@ -447,6 +454,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { notificationRoutes := api.Group("/notifications", requireAuth) { notificationRoutes.GET("", notificationHandler.List) + notificationRoutes.GET("/unread-count", notificationHandler.UnreadCount) + notificationRoutes.PUT("/read-all", notificationHandler.MarkAllRead) notificationRoutes.POST("/:id/read", notificationHandler.MarkRead) } @@ -542,6 +551,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/system-configs", requirePerm("system_config:view"), systemConfigHandler.List) adminRoutes.PUT("/system-configs/:key", requirePerm("system_config:update"), systemConfigHandler.Update) + adminRoutes.GET("/notifications", requirePerm("notification:view"), adminNotificationHandler.List) + adminRoutes.GET("/notifications/unread-count", requirePerm("notification:view"), adminNotificationHandler.UnreadCount) + adminRoutes.PUT("/notifications/read-all", requirePerm("notification:view"), adminNotificationHandler.MarkAllRead) + adminRoutes.POST("/notifications/:id/read", requirePerm("notification:view"), adminNotificationHandler.MarkRead) adminRoutes.GET("/audit-logs", requirePerm("audit_log:view"), adminAuditHandler.List) if chatHubHandler != nil { adminRoutes.GET("/chats/events", requirePerm("chat:view"), chatHubHandler.AdminEvents) diff --git a/backend/migrations/000013_admin_notification_permission.sql b/backend/migrations/000013_admin_notification_permission.sql new file mode 100644 index 0000000..c96c407 --- /dev/null +++ b/backend/migrations/000013_admin_notification_permission.sql @@ -0,0 +1,28 @@ +-- +goose Up +-- +goose StatementBegin + +INSERT INTO permissions (code, name, resource, action) VALUES +('notification:view', '查看通知', 'notification', 'view') +ON DUPLICATE KEY UPDATE + name = VALUES(name), + resource = VALUES(resource), + action = VALUES(action); + +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.code = 'notification:view' +WHERE r.code IN ('super_admin', 'cs', 'ops'); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DELETE rp FROM role_permissions rp +JOIN permissions p ON p.id = rp.permission_id +WHERE p.code = 'notification:view'; + +DELETE FROM permissions WHERE code = 'notification:view'; + +-- +goose StatementEnd diff --git a/frontend/public/e82f2d3efb1d7d4db945449e112ad414.txt b/frontend/public/e82f2d3efb1d7d4db945449e112ad414.txt new file mode 100644 index 0000000..71a76b4 --- /dev/null +++ b/frontend/public/e82f2d3efb1d7d4db945449e112ad414.txt @@ -0,0 +1 @@ +05b41285dbac2f6dd146495e46ef3e29360cb820 \ No newline at end of file diff --git a/frontend/src/features/admin/api/adminNotifications.ts b/frontend/src/features/admin/api/adminNotifications.ts new file mode 100644 index 0000000..e199627 --- /dev/null +++ b/frontend/src/features/admin/api/adminNotifications.ts @@ -0,0 +1,56 @@ +import { apiClient } from '@/shared/api/client' +import type { ApiResponse, PaginatedResult } from '@/shared/types/types' + +export type AdminNotificationType = 'system' | 'chat' | (string & {}) + +export interface AdminNotification { + id: number + admin_user_id: number + type: AdminNotificationType + title: string + content: string + is_read: boolean + created_at: string + updated_at: string +} + +export interface AdminNotificationQuery { + read?: 'unread' + page?: number + page_size?: number +} + +export async function fetchAdminNotifications(query: AdminNotificationQuery = {}) { + const params = Object.fromEntries( + Object.entries(query).filter(([, value]) => value !== '' && value !== undefined) + ) + const { data } = await apiClient.get>>( + '/admin/notifications', + { params } + ) + return data.data +} + +export async function fetchAdminNotificationUnreadCount() { + const { data } = await apiClient.get>( + '/admin/notifications/unread-count', + { + silent: true, + } + ) + return data.data.unread_count +} + +export async function markAdminNotificationRead(id: number) { + const { data } = await apiClient.post>( + `/admin/notifications/${id}/read` + ) + return data.data +} + +export async function markAllAdminNotificationsRead() { + const { data } = await apiClient.put>( + '/admin/notifications/read-all' + ) + return data.data +} diff --git a/frontend/src/features/admin/composables/useAdminNotificationUnreadCount.ts b/frontend/src/features/admin/composables/useAdminNotificationUnreadCount.ts new file mode 100644 index 0000000..9c88021 --- /dev/null +++ b/frontend/src/features/admin/composables/useAdminNotificationUnreadCount.ts @@ -0,0 +1,100 @@ +import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import type { RouteLocationNormalizedLoaded } from 'vue-router' + +import { fetchAdminNotificationUnreadCount } from '@/features/admin/api/adminNotifications' +import { useAdminSessionStore } from '@/stores/adminSession' + +export const adminNotificationUnreadChangedEvent = 'admin-notification-unread-changed' + +export function useAdminNotificationUnreadCount(route: RouteLocationNormalizedLoaded) { + const adminSession = useAdminSessionStore() + const unreadCount = ref(0) + let timer: number | null = null + let requestID = 0 + + const unreadLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value))) + + function stopPolling() { + if (timer) { + window.clearInterval(timer) + timer = null + } + } + + async function loadUnreadCount() { + if (!adminSession.hasSessionHint) { + unreadCount.value = 0 + return + } + + const currentID = ++requestID + try { + const count = await fetchAdminNotificationUnreadCount() + if (currentID === requestID) { + unreadCount.value = count + } + } catch { + // 后台角标静默失败,避免干扰当前操作。 + } + } + + function startPolling() { + stopPolling() + void loadUnreadCount() + timer = window.setInterval(loadUnreadCount, 30_000) + } + + function resumePollingIfVisible() { + if (document.hidden || !adminSession.hasSessionHint) return + startPolling() + } + + function handleVisibilityChange() { + if (document.hidden) { + stopPolling() + return + } + resumePollingIfVisible() + } + + function handleUnreadChanged() { + void loadUnreadCount() + } + + onMounted(() => { + window.addEventListener(adminNotificationUnreadChangedEvent, handleUnreadChanged) + document.addEventListener('visibilitychange', handleVisibilityChange) + resumePollingIfVisible() + }) + + onBeforeUnmount(() => { + stopPolling() + window.removeEventListener(adminNotificationUnreadChangedEvent, handleUnreadChanged) + document.removeEventListener('visibilitychange', handleVisibilityChange) + }) + + watch( + () => adminSession.hasSessionHint, + hasSession => { + if (hasSession) { + resumePollingIfVisible() + return + } + stopPolling() + unreadCount.value = 0 + } + ) + + watch( + () => route.fullPath, + () => { + void loadUnreadCount() + } + ) + + return { + unreadCount, + unreadLabel, + refreshUnreadCount: loadUnreadCount, + } +} diff --git a/frontend/src/features/admin/index.ts b/frontend/src/features/admin/index.ts index ad0ee95..1fc65fb 100644 --- a/frontend/src/features/admin/index.ts +++ b/frontend/src/features/admin/index.ts @@ -6,6 +6,7 @@ export * from './api/adminWallet' export * from './api/adminPayments' export * from './api/adminFinance' export * from './api/adminAudit' +export * from './api/adminNotifications' export * from './api/systemConfigs' export * from './api/supportGroups' export * from './composables/useAdminTable' diff --git a/frontend/src/features/admin/views/AdminNotificationsView.vue b/frontend/src/features/admin/views/AdminNotificationsView.vue new file mode 100644 index 0000000..d4a6bd5 --- /dev/null +++ b/frontend/src/features/admin/views/AdminNotificationsView.vue @@ -0,0 +1,194 @@ + + + + + diff --git a/frontend/src/features/auth/api/notifications.ts b/frontend/src/features/auth/api/notifications.ts index 5f4125d..9a03047 100644 --- a/frontend/src/features/auth/api/notifications.ts +++ b/frontend/src/features/auth/api/notifications.ts @@ -28,3 +28,20 @@ export async function markNotificationRead(id: number) { const { data } = await apiClient.post>(`/notifications/${id}/read`) return data.data } + +export async function fetchUnreadNotificationCount() { + const { data } = await apiClient.get>( + '/notifications/unread-count', + { + silent: true, + } + ) + return data.data.unread_count +} + +export async function markAllNotificationsRead() { + const { data } = await apiClient.put>( + '/notifications/read-all' + ) + return data.data +} diff --git a/frontend/src/features/auth/composables/useNotificationUnreadCount.ts b/frontend/src/features/auth/composables/useNotificationUnreadCount.ts new file mode 100644 index 0000000..1cb5878 --- /dev/null +++ b/frontend/src/features/auth/composables/useNotificationUnreadCount.ts @@ -0,0 +1,100 @@ +import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import type { RouteLocationNormalizedLoaded } from 'vue-router' + +import { fetchUnreadNotificationCount } from '@/features/auth/api/notifications' +import { useSessionStore } from '@/stores/session' + +export const notificationUnreadChangedEvent = 'notification-unread-changed' + +export function useNotificationUnreadCount(route: RouteLocationNormalizedLoaded) { + const session = useSessionStore() + const unreadCount = ref(0) + let timer: number | null = null + let requestID = 0 + + const unreadLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value))) + + function stopPolling() { + if (timer) { + window.clearInterval(timer) + timer = null + } + } + + async function loadUnreadCount() { + if (!session.isLoggedIn) { + unreadCount.value = 0 + return + } + + const currentID = ++requestID + try { + const count = await fetchUnreadNotificationCount() + if (currentID === requestID) { + unreadCount.value = count + } + } catch { + // 角标静默失败,避免影响主流程。 + } + } + + function startPolling() { + stopPolling() + void loadUnreadCount() + timer = window.setInterval(loadUnreadCount, 30_000) + } + + function resumePollingIfVisible() { + if (document.hidden || !session.isLoggedIn) return + startPolling() + } + + function handleVisibilityChange() { + if (document.hidden) { + stopPolling() + return + } + resumePollingIfVisible() + } + + function handleUnreadChanged() { + void loadUnreadCount() + } + + onMounted(() => { + window.addEventListener(notificationUnreadChangedEvent, handleUnreadChanged) + document.addEventListener('visibilitychange', handleVisibilityChange) + resumePollingIfVisible() + }) + + onBeforeUnmount(() => { + stopPolling() + window.removeEventListener(notificationUnreadChangedEvent, handleUnreadChanged) + document.removeEventListener('visibilitychange', handleVisibilityChange) + }) + + watch( + () => session.isLoggedIn, + loggedIn => { + if (loggedIn) { + resumePollingIfVisible() + return + } + stopPolling() + unreadCount.value = 0 + } + ) + + watch( + () => route.fullPath, + () => { + void loadUnreadCount() + } + ) + + return { + unreadCount, + unreadLabel, + refreshUnreadCount: loadUnreadCount, + } +} diff --git a/frontend/src/features/auth/index.ts b/frontend/src/features/auth/index.ts index f4343d0..31e6f0b 100644 --- a/frontend/src/features/auth/index.ts +++ b/frontend/src/features/auth/index.ts @@ -2,3 +2,4 @@ export * from './api/auth' export * from './api/realname' export * from './api/notifications' +export * from './composables/useNotificationUnreadCount' diff --git a/frontend/src/features/auth/views/MobileNotificationsView.vue b/frontend/src/features/auth/views/MobileNotificationsView.vue new file mode 100644 index 0000000..887705f --- /dev/null +++ b/frontend/src/features/auth/views/MobileNotificationsView.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/frontend/src/features/auth/views/MobileProfileView.vue b/frontend/src/features/auth/views/MobileProfileView.vue index 8a2f5d5..28ed941 100644 --- a/frontend/src/features/auth/views/MobileProfileView.vue +++ b/frontend/src/features/auth/views/MobileProfileView.vue @@ -5,6 +5,7 @@ import { useRouter, useRoute } from 'vue-router' import { useSessionStore } from '@/stores/session' import { showDialog, showToast } from 'vant' import MobileBottomNav from '@/components/MobileBottomNav.vue' +import { useNotificationUnreadCount } from '@/features/auth/composables/useNotificationUnreadCount' import { fetchWalletBalance, @@ -19,6 +20,8 @@ import { formatCent } from '@/shared/utils/money' const session = useSessionStore() const router = useRouter() const route = useRoute() +const { unreadCount: notificationUnreadCount, unreadLabel: notificationUnreadLabel } = + useNotificationUnreadCount(route) /** 数据项 */ const availableBalanceCent = ref(0) @@ -375,6 +378,13 @@ function resolveAvatarURL(url: string | undefined | null) {