增加站内信未读提醒和后台通知中心+txt 校验

This commit is contained in:
yml2213
2026-06-19 15:24:15 +08:00
parent 8d5094a8d0
commit a4cdc3e806
30 changed files with 1836 additions and 53 deletions
+15
View File
@@ -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"
}
@@ -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"`
}
@@ -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", "通知服务暂时不可用")
}
}
@@ -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,
}
}
@@ -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)
}
}
@@ -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)
}
@@ -20,3 +20,7 @@ type PaginatedResult struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type UnreadCountDTO struct {
UnreadCount int64 `json:"unread_count"`
}
@@ -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, "通知服务暂时不可用")
}
@@ -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 {
@@ -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)
}
}
@@ -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)
}
+13
View File
@@ -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)