feat: 新增通知系统,支持通知列表、未读计数、标记已读、删除、创建和广播功能,并引入管理员中间件和用户仓库。

This commit is contained in:
2026-01-29 13:41:53 +08:00
parent 4a8a9afaf7
commit 0a6c9d7d43
7 changed files with 137 additions and 12 deletions

View File

@@ -13,6 +13,12 @@ ENVIRONMENT=production
# ============================================
DATA_DIR=./data
# ============================================
# 管理员密钥 (必填)
# 用于保护敏感的管理接口,如系统公告
# ============================================
ADMIN_SECRET_KEY=admin_secret_key_change_me_in_prod
# ============================================
# MySQL 数据库配置(必填)
# ============================================

View File

@@ -11,6 +11,7 @@ type Config struct {
// Server configuration
ServerPort string
Environment string
AdminSecretKey string
// MySQL Database configuration
DBHost string
@@ -75,6 +76,7 @@ func Load() *Config {
// Server
ServerPort: getEnv("SERVER_PORT", "2612"),
Environment: getEnv("ENVIRONMENT", "development"),
AdminSecretKey: getEnv("ADMIN_SECRET_KEY", "admin_secret"),
// Data directory
DataDir: getEnv("DATA_DIR", "./data"),

View File

@@ -205,6 +205,34 @@ func (h *NotificationHandler) CreateNotification(c *gin.Context) {
api.Created(c, notification)
}
// BroadcastNotification handles POST /api/v1/notifications/broadcast
// Sends a notification to all active users
func (h *NotificationHandler) BroadcastNotification(c *gin.Context) {
// TODO: Add strict admin authorization check
var input service.BroadcastNotificationInput
if err := c.ShouldBindJSON(&input); err != nil {
api.BadRequest(c, "Invalid request body: "+err.Error())
return
}
// Validate input
if input.Title == "" || input.Content == "" {
api.BadRequest(c, "Title and content are required")
return
}
if input.Type == "" {
input.Type = models.NotificationTypeSystem
}
if err := h.notificationService.BroadcastNotification(input); err != nil {
api.InternalError(c, "Failed to broadcast notification: "+err.Error())
return
}
api.Success(c, gin.H{"message": "Notification broadcast started"})
}
// RegisterUserRoutes registers notification routes for regular users
func (h *NotificationHandler) RegisterUserRoutes(rg *gin.RouterGroup) {
notifications := rg.Group("/notifications")
@@ -222,5 +250,6 @@ func (h *NotificationHandler) RegisterAdminRoutes(rg *gin.RouterGroup) {
notifications := rg.Group("/notifications")
{
notifications.POST("", h.CreateNotification)
notifications.POST("/broadcast", h.BroadcastNotification)
}
}

View File

@@ -0,0 +1,38 @@
package middleware
import (
"accounting-app/internal/config"
"accounting-app/pkg/api"
"github.com/gin-gonic/gin"
)
// AdminMiddleware provides admin authentication middleware
type AdminMiddleware struct {
cfg *config.Config
}
// NewAdminMiddleware creates a new AdminMiddleware
func NewAdminMiddleware(cfg *config.Config) *AdminMiddleware {
return &AdminMiddleware{
cfg: cfg,
}
}
// RequireAdminKey checks for the Admin-Secret-Key header
func (m *AdminMiddleware) RequireAdminKey() gin.HandlerFunc {
return func(c *gin.Context) {
key := c.GetHeader("X-Admin-Secret-Key")
if key == "" {
// Fallback to query param for easier testing
key = c.Query("admin_secret")
}
if key != m.cfg.AdminSecretKey {
api.Unauthorized(c, "Invalid or missing admin secret key")
return
}
c.Next()
}
}

View File

@@ -66,7 +66,6 @@ func (r *UserRepository) GetByEmail(email string) (*models.User, error) {
return &user, nil
}
// Update updates a user in the database
func (r *UserRepository) Update(user *models.User) error {
return r.db.Save(user).Error
@@ -163,3 +162,12 @@ func (r *UserRepository) EmailExists(email string) (bool, error) {
}
return count > 0, nil
}
// GetAllActiveUserIDs retrieves IDs of all active users
func (r *UserRepository) GetAllActiveUserIDs() ([]uint, error) {
var ids []uint
if err := r.db.Model(&models.User{}).Where("is_active = ?", true).Pluck("id", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}

View File

@@ -64,6 +64,7 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config)
}
authHandler := handler.NewAuthHandlerWithConfig(authService, gitHubOAuthService, giteeOAuthService, cfg)
authMiddleware := middleware.NewAuthMiddleware(authService)
adminMiddleware := middleware.NewAdminMiddleware(cfg)
// Initialize services
accountService := service.NewAccountService(accountRepo, db)
@@ -89,7 +90,7 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config)
reimbursementService := service.NewReimbursementService(db, transactionRepo, accountRepo)
refundService := service.NewRefundService(db, transactionRepo, accountRepo)
userSettingsService := service.NewUserSettingsService(userSettingsRepo)
notificationService := service.NewNotificationService(notificationRepo)
notificationService := service.NewNotificationService(notificationRepo, userRepo)
streakService := service.NewStreakService(streakRepo)
// Feature: financial-core-upgrade - Initialize new services
@@ -237,7 +238,13 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config)
// Feature: notification-center - Register notification routes
notificationHandler.RegisterUserRoutes(protected)
notificationHandler.RegisterAdminRoutes(v1)
// Admin routes
admin := v1.Group("")
admin.Use(adminMiddleware.RequireAdminKey())
{
notificationHandler.RegisterAdminRoutes(admin)
}
// Register report routes
protected.GET("/reports/summary", reportHandler.GetTransactionSummary)
@@ -341,6 +348,7 @@ func SetupWithRedis(db *gorm.DB, yunAPIClient *service.YunAPIClient, redisClient
}
authHandler := handler.NewAuthHandlerWithConfig(authService, gitHubOAuthService, giteeOAuthService, cfg)
authMiddleware := middleware.NewAuthMiddleware(authService)
adminMiddleware := middleware.NewAdminMiddleware(cfg)
// Initialize services
accountService := service.NewAccountService(accountRepo, db)
@@ -365,7 +373,7 @@ func SetupWithRedis(db *gorm.DB, yunAPIClient *service.YunAPIClient, redisClient
reimbursementService := service.NewReimbursementService(db, transactionRepo, accountRepo)
refundService := service.NewRefundService(db, transactionRepo, accountRepo)
userSettingsService := service.NewUserSettingsService(userSettingsRepo)
notificationService := service.NewNotificationService(notificationRepo)
notificationService := service.NewNotificationService(notificationRepo, userRepo)
streakService := service.NewStreakService(streakRepo)
// Feature: financial-core-upgrade - Initialize new services
@@ -544,7 +552,13 @@ func SetupWithRedis(db *gorm.DB, yunAPIClient *service.YunAPIClient, redisClient
// Register notification routes
notificationHandler.RegisterUserRoutes(protected)
notificationHandler.RegisterAdminRoutes(v1)
// Admin routes
admin := v1.Group("")
admin.Use(adminMiddleware.RequireAdminKey())
{
notificationHandler.RegisterAdminRoutes(admin)
}
// Feature: streak - Register streak routes
streakHandler.RegisterRoutes(v1)

View File

@@ -16,11 +16,12 @@ var (
// NotificationService handles business logic for notifications
type NotificationService struct {
repo *repository.NotificationRepository
userRepo *repository.UserRepository
}
// NewNotificationService creates a new NotificationService instance
func NewNotificationService(repo *repository.NotificationRepository) *NotificationService {
return &NotificationService{repo: repo}
func NewNotificationService(repo *repository.NotificationRepository, userRepo *repository.UserRepository) *NotificationService {
return &NotificationService{repo: repo, userRepo: userRepo}
}
// CreateNotificationInput represents input for creating a notification
@@ -158,3 +159,30 @@ func (s *NotificationService) SendSystemNotification(userID uint, title, content
Content: content,
})
}
// BroadcastNotificationInput represents input for broadcasting a notification
type BroadcastNotificationInput struct {
Type models.NotificationType `json:"type" binding:"required"`
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
}
// BroadcastNotification sends a notification to all active users
func (s *NotificationService) BroadcastNotification(input BroadcastNotificationInput) error {
userIDs, err := s.userRepo.GetAllActiveUserIDs()
if err != nil {
return fmt.Errorf("failed to get active user IDs: %w", err)
}
for _, userID := range userIDs {
// Ignore errors for individual users to ensure best-effort delivery
_, _ = s.CreateNotification(CreateNotificationInput{
UserID: userID,
Type: input.Type,
Title: input.Title,
Content: input.Content,
})
}
return nil
}