feat(notification): add dashboard notification center

This commit is contained in:
mlogclub
2026-04-28 19:20:38 +08:00
parent f287727c59
commit 909854cd03
22 changed files with 1230 additions and 13 deletions
+1 -1
Submodule docs updated: a9099af47e...7504ff38a3
+2
View File
@@ -101,6 +101,7 @@ func addRouter(app *iris.Application) {
mvc.Configure(app.Party("/api/ws"), func(m *mvc.Application) {
m.Router.Get("/dashboard", middleware.AuthMiddleware, services.WsService.HandleDashboardWS)
m.Router.Get("/dashboard/notification", middleware.AuthMiddleware, services.WsService.HandleDashboardNotificationWS)
m.Router.Get("/open", services.WsService.HandleOpenWS)
})
@@ -116,6 +117,7 @@ func addRouter(app *iris.Application) {
m.Party("/tag").Handle(new(dashboard.TagController))
m.Party("/conversation").Handle(new(dashboard.ConversationController))
m.Party("/ticket").Handle(new(dashboard.TicketController))
m.Party("/notification").Handle(new(dashboard.NotificationController))
m.Party("/ticket-resolution-code").Handle(new(dashboard.TicketResolutionCodeController))
m.Party("/ticket-priority-config").Handle(new(dashboard.TicketPriorityConfigController))
m.Party("/quick-reply").Handle(new(dashboard.QuickReplyController))
+38
View File
@@ -0,0 +1,38 @@
package builders
import (
"cs-agent/internal/models"
"cs-agent/internal/pkg/dto/response"
"cs-agent/internal/pkg/utils"
)
func BuildNotification(item *models.Notification) *response.NotificationResponse {
if item == nil {
return nil
}
return &response.NotificationResponse{
ID: item.ID,
RecipientUserID: item.RecipientUserID,
Title: item.Title,
Content: item.Content,
NotificationType: item.NotificationType,
BizType: item.BizType,
BizID: item.BizID,
ActionURL: item.ActionURL,
ReadAt: utils.FormatTimePtr(item.ReadAt),
CreatedAt: utils.FormatTime(item.CreatedAt),
}
}
func BuildNotificationList(list []models.Notification) []response.NotificationResponse {
if len(list) == 0 {
return nil
}
results := make([]response.NotificationResponse, 0, len(list))
for i := range list {
if item := BuildNotification(&list[i]); item != nil {
results = append(results, *item)
}
}
return results
}
@@ -0,0 +1,82 @@
package dashboard
import (
"strings"
"cs-agent/internal/builders"
"cs-agent/internal/pkg/constants"
"cs-agent/internal/pkg/dto/request"
"cs-agent/internal/pkg/dto/response"
"cs-agent/internal/pkg/enums"
"cs-agent/internal/services"
"github.com/kataras/iris/v12"
"github.com/mlogclub/simple/web"
"github.com/mlogclub/simple/web/params"
)
type NotificationController struct {
Ctx iris.Context
}
func (c *NotificationController) AnyList() *web.JsonResult {
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionNotificationView)
if err != nil {
return web.JsonError(err)
}
cnd := params.NewPagedSqlCnd(c.Ctx,
params.QueryFilter{ParamName: "type", ColumnName: "notification_type"},
).Eq("recipient_user_id", operator.UserID).
Eq("status", enums.StatusOk).
Desc("id")
switch strings.TrimSpace(c.Ctx.URLParam("readStatus")) {
case "unread":
cnd.Where("read_at IS NULL")
case "read":
cnd.Where("read_at IS NOT NULL")
}
list, paging := services.NotificationService.FindPageByCnd(cnd)
return web.JsonData(&web.PageResult{
Results: builders.BuildNotificationList(list),
Page: paging,
})
}
func (c *NotificationController) GetUnread_count() *web.JsonResult {
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionNotificationView)
if err != nil {
return web.JsonError(err)
}
return web.JsonData(&response.NotificationUnreadCountResponse{
UnreadCount: services.NotificationService.CountUnread(operator.UserID),
})
}
func (c *NotificationController) PostMark_read() *web.JsonResult {
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionNotificationUpdate)
if err != nil {
return web.JsonError(err)
}
req := request.MarkNotificationReadRequest{}
if err := params.ReadJSON(c.Ctx, &req); err != nil {
return web.JsonError(err)
}
if err := services.NotificationService.MarkRead(req.ID, operator.UserID); err != nil {
return web.JsonError(err)
}
return web.JsonSuccess()
}
func (c *NotificationController) PostMark_all_read() *web.JsonResult {
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionNotificationUpdate)
if err != nil {
return web.JsonError(err)
}
if err := services.NotificationService.MarkAllRead(operator.UserID); err != nil {
return web.JsonError(err)
}
return web.JsonSuccess()
}
+16
View File
@@ -48,6 +48,7 @@ var Models = []any{
&TicketEventLog{},
&TicketSLARecord{},
&TicketRelation{},
&Notification{},
&AIAgent{},
&Channel{},
&AgentProfile{},
@@ -112,6 +113,21 @@ type TicketView struct {
AuditFields
}
// Notification 站内通知。
type Notification struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
RecipientUserID int64 `gorm:"type:bigint;not null;default:0;index"`
Title string `gorm:"type:varchar(255);not null;default:''"`
Content string `gorm:"type:text"`
NotificationType string `gorm:"type:varchar(50);not null;default:'';index"`
BizType string `gorm:"type:varchar(50);not null;default:'';index"`
BizID int64 `gorm:"type:bigint;not null;default:0;index"`
ActionURL string `gorm:"type:varchar(255);not null;default:''"`
ReadAt *time.Time `gorm:"type:datetime;index"`
Status enums.Status `gorm:"type:int;not null;default:0;index"`
CreatedAt time.Time `gorm:"type:datetime;not null;index"`
}
// AuditFields 定义涉及用户操作数据的统一审计字段。
// 该结构记录数据创建与更新的时间、操作者ID和操作者名称。
type AuditFields struct {
+9
View File
@@ -91,6 +91,10 @@ var (
PermissionTicketPriorityConfigUpdate = Permission{Name: "更新工单优先级", Code: "ticketPriorityConfig.update", Type: "api", GroupName: "ticketPriorityConfig", Method: "POST", APIPath: "/api/dashboard/ticket-priority-config/update", SortNo: 640}
PermissionTicketPriorityConfigDelete = Permission{Name: "删除工单优先级", Code: "ticketPriorityConfig.delete", Type: "api", GroupName: "ticketPriorityConfig", Method: "POST", APIPath: "/api/dashboard/ticket-priority-config/delete", SortNo: 650}
// 通知相关权限
PermissionNotificationView = Permission{Name: "查看通知", Code: "notification.view", Type: "api", GroupName: "notification", Method: "ANY", APIPath: "/api/dashboard/notification/list", SortNo: 680}
PermissionNotificationUpdate = Permission{Name: "更新通知", Code: "notification.update", Type: "api", GroupName: "notification", Method: "POST", APIPath: "/api/dashboard/notification/mark_read", SortNo: 690}
// 快捷回复相关权限
PermissionQuickReplyView = Permission{Name: "查看快捷回复", Code: "quickReply.view", Type: "api", GroupName: "quickReply", Method: "ANY", APIPath: "/api/dashboard/quick-reply/list", SortNo: 610}
PermissionQuickReplyCreate = Permission{Name: "创建快捷回复", Code: "quickReply.create", Type: "api", GroupName: "quickReply", Method: "POST", APIPath: "/api/dashboard/quick-reply/create", SortNo: 620}
@@ -227,6 +231,8 @@ var Permissions = []Permission{
PermissionTicketPriorityConfigCreate,
PermissionTicketPriorityConfigUpdate,
PermissionTicketPriorityConfigDelete,
PermissionNotificationView,
PermissionNotificationUpdate,
PermissionQuickReplyView,
PermissionQuickReplyCreate,
PermissionQuickReplyUpdate,
@@ -327,6 +333,7 @@ var RolePermissions = map[string][]Permission{
PermissionTicketView, PermissionTicketCreate, PermissionTicketUpdate, PermissionTicketAssign, PermissionTicketChangeStatus, PermissionTicketReply, PermissionTicketClose, PermissionTicketReopen,
PermissionTicketResolutionCodeView, PermissionTicketResolutionCodeCreate, PermissionTicketResolutionCodeUpdate, PermissionTicketResolutionCodeDelete,
PermissionTicketPriorityConfigView, PermissionTicketPriorityConfigCreate, PermissionTicketPriorityConfigUpdate, PermissionTicketPriorityConfigDelete,
PermissionNotificationView, PermissionNotificationUpdate,
PermissionQuickReplyView, PermissionQuickReplyCreate, PermissionQuickReplyUpdate, PermissionQuickReplyDelete,
PermissionTagView, PermissionTagCreate, PermissionTagUpdate, PermissionTagDelete,
PermissionCompanyView, PermissionCompanyCreate, PermissionCompanyUpdate, PermissionCompanyDelete,
@@ -349,6 +356,7 @@ var RolePermissions = map[string][]Permission{
PermissionTicketView, PermissionTicketCreate, PermissionTicketUpdate, PermissionTicketAssign, PermissionTicketChangeStatus, PermissionTicketReply, PermissionTicketClose, PermissionTicketReopen,
PermissionTicketResolutionCodeView, PermissionTicketResolutionCodeCreate, PermissionTicketResolutionCodeUpdate, PermissionTicketResolutionCodeDelete,
PermissionTicketPriorityConfigView, PermissionTicketPriorityConfigCreate, PermissionTicketPriorityConfigUpdate, PermissionTicketPriorityConfigDelete,
PermissionNotificationView, PermissionNotificationUpdate,
PermissionQuickReplyView, PermissionQuickReplyCreate, PermissionQuickReplyUpdate, PermissionQuickReplyDelete,
PermissionTagView, PermissionTagCreate, PermissionTagUpdate, PermissionTagDelete,
PermissionCompanyView,
@@ -369,6 +377,7 @@ var RolePermissions = map[string][]Permission{
PermissionConversationView,
PermissionTicketView, PermissionTicketCreate, PermissionTicketReply,
PermissionTicketResolutionCodeView, PermissionTicketPriorityConfigView,
PermissionNotificationView, PermissionNotificationUpdate,
PermissionQuickReplyView,
PermissionTagView,
PermissionCompanyView,
@@ -0,0 +1,15 @@
package request
type CreateNotificationRequest struct {
RecipientUserID int64 `json:"recipientUserId"`
Title string `json:"title"`
Content string `json:"content"`
NotificationType string `json:"notificationType"`
BizType string `json:"bizType"`
BizID int64 `json:"bizId"`
ActionURL string `json:"actionUrl"`
}
type MarkNotificationReadRequest struct {
ID int64 `json:"id"`
}
@@ -0,0 +1,18 @@
package response
type NotificationResponse struct {
ID int64 `json:"id"`
RecipientUserID int64 `json:"recipientUserId"`
Title string `json:"title"`
Content string `json:"content"`
NotificationType string `json:"notificationType"`
BizType string `json:"bizType"`
BizID int64 `json:"bizId"`
ActionURL string `json:"actionUrl"`
ReadAt string `json:"readAt,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
}
type NotificationUnreadCountResponse struct {
UnreadCount int64 `json:"unreadCount"`
}
+1
View File
@@ -250,6 +250,7 @@ const (
IMRealtimeEventConversationTransferred = "conversation.transferred"
IMRealtimeEventConversationClosed = "conversation.closed"
IMRealtimeEventConversationRead = "conversation.read"
IMRealtimeEventNotificationCreated = "notification.created"
)
const (
@@ -0,0 +1,67 @@
package repositories
import (
"time"
"cs-agent/internal/models"
"github.com/mlogclub/simple/sqls"
"github.com/mlogclub/simple/web/params"
"gorm.io/gorm"
)
var NotificationRepository = newNotificationRepository()
func newNotificationRepository() *notificationRepository {
return &notificationRepository{}
}
type notificationRepository struct {
}
func (r *notificationRepository) Get(db *gorm.DB, id int64) *models.Notification {
ret := &models.Notification{}
if err := db.First(ret, "id = ?", id).Error; err != nil {
return nil
}
return ret
}
func (r *notificationRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.Notification) {
cnd.Find(db, &list)
return
}
func (r *notificationRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.Notification, paging *sqls.Paging) {
return r.FindPageByCnd(db, &params.Cnd)
}
func (r *notificationRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.Notification, paging *sqls.Paging) {
cnd.Find(db, &list)
count := cnd.Count(db, &models.Notification{})
paging = &sqls.Paging{
Page: cnd.Paging.Page,
Limit: cnd.Paging.Limit,
Total: count,
}
return
}
func (r *notificationRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 {
return cnd.Count(db, &models.Notification{})
}
func (r *notificationRepository) Create(db *gorm.DB, item *models.Notification) error {
return db.Create(item).Error
}
func (r *notificationRepository) Updates(db *gorm.DB, id int64, columns map[string]any) error {
return db.Model(&models.Notification{}).Where("id = ?", id).Updates(columns).Error
}
func (r *notificationRepository) MarkAllRead(db *gorm.DB, userID int64, readAt time.Time) error {
return db.Model(&models.Notification{}).
Where("recipient_user_id = ? AND read_at IS NULL", userID).
Updates(map[string]any{"read_at": readAt}).Error
}
@@ -0,0 +1,84 @@
package event_handlers
import (
"context"
"fmt"
"log/slog"
"strings"
"cs-agent/internal/events"
"cs-agent/internal/pkg/dto/request"
"cs-agent/internal/pkg/eventbus"
"cs-agent/internal/services"
"github.com/mlogclub/simple/common/strs"
)
func init() {
eventbus.
Register[events.TicketAssignedEvent]().
Subscribe(handleTicketAssignedInAppNotification)
eventbus.
Register[events.ConversationAssignedEvent]().
Subscribe(handleConversationAssignedInAppNotification)
}
func handleTicketAssignedInAppNotification(ctx context.Context, event events.TicketAssignedEvent) error {
if event.TicketID <= 0 || event.ToUserID <= 0 {
return nil
}
ticket := services.TicketService.Get(event.TicketID)
if ticket == nil {
return nil
}
content := fmt.Sprintf("工单 %s 已指派给你", strs.DefaultIfBlank(ticket.TicketNo, fmt.Sprintf("#%d", ticket.ID)))
if title := strings.TrimSpace(ticket.Title); title != "" {
content = content + "\n" + title
}
if reason := strings.TrimSpace(event.Reason); reason != "" {
content = content + "\n指派原因: " + reason
}
_, err := services.NotificationService.CreateAndPush(request.CreateNotificationRequest{
RecipientUserID: event.ToUserID,
Title: "工单指派提醒",
Content: content,
NotificationType: "ticket_assigned",
BizType: "ticket",
BizID: ticket.ID,
ActionURL: fmt.Sprintf("/dashboard/tickets/%d", ticket.ID),
})
if err != nil {
slog.Error("create ticket assigned in-app notification failed", "error", err, "ticketId", event.TicketID, "toUserId", event.ToUserID)
}
return nil
}
func handleConversationAssignedInAppNotification(ctx context.Context, event events.ConversationAssignedEvent) error {
if event.ConversationID <= 0 || event.ToUserID <= 0 {
return nil
}
conversation := services.ConversationService.Get(event.ConversationID)
if conversation == nil {
return nil
}
content := fmt.Sprintf("会话 #%d 已分配给你", conversation.ID)
if summary := strings.TrimSpace(services.ConversationService.BuildConversationSummary(conversation)); summary != "" {
content = content + "\n" + summary
}
if reason := strings.TrimSpace(event.Reason); reason != "" {
content = content + "\n分配原因: " + reason
}
_, err := services.NotificationService.CreateAndPush(request.CreateNotificationRequest{
RecipientUserID: event.ToUserID,
Title: conversationAssignedNotifyTitle(event.AssignType),
Content: content,
NotificationType: "conversation_assigned",
BizType: "conversation",
BizID: conversation.ID,
ActionURL: fmt.Sprintf("/dashboard/conversations?conversationId=%d", conversation.ID),
})
if err != nil {
slog.Error("create conversation assigned in-app notification failed", "error", err, "conversationId", event.ConversationID, "toUserId", event.ToUserID)
}
return nil
}
@@ -0,0 +1,123 @@
package event_handlers
import (
"context"
"testing"
"time"
"cs-agent/internal/events"
"cs-agent/internal/models"
"cs-agent/internal/pkg/enums"
"cs-agent/internal/repositories"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestTicketAssignedInAppNotification(t *testing.T) {
setupNotificationEventHandlerTestDB(t)
ticket := &models.Ticket{
TicketNo: "TK202604280001",
Title: "退款处理",
Source: enums.TicketSourceManual,
Status: enums.TicketStatusOpen,
CurrentAssigneeID: 11,
AuditFields: models.AuditFields{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}
if err := repositories.TicketRepository.Create(sqls.DB(), ticket); err != nil {
t.Fatalf("create ticket error = %v", err)
}
if err := handleTicketAssignedInAppNotification(context.Background(), events.TicketAssignedEvent{
TicketID: ticket.ID,
FromUserID: 0,
ToUserID: 11,
OperatorID: 1,
Reason: "需要人工跟进",
}); err != nil {
t.Fatalf("handler error = %v", err)
}
list := repositories.NotificationRepository.Find(sqls.DB(), sqls.NewCnd().Eq("recipient_user_id", 11))
if len(list) != 1 {
t.Fatalf("expected 1 notification, got %d", len(list))
}
got := list[0]
if got.NotificationType != "ticket_assigned" || got.BizType != "ticket" || got.BizID != ticket.ID {
t.Fatalf("unexpected notification: %+v", got)
}
if got.ActionURL != "/dashboard/tickets/1" {
t.Fatalf("unexpected action url: %q", got.ActionURL)
}
}
func TestConversationAssignedInAppNotification(t *testing.T) {
setupNotificationEventHandlerTestDB(t)
conversation := &models.Conversation{
CustomerName: "张三",
Status: enums.IMConversationStatusActive,
CurrentAssigneeID: 22,
AuditFields: models.AuditFields{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}
if err := repositories.ConversationRepository.Create(sqls.DB(), conversation); err != nil {
t.Fatalf("create conversation error = %v", err)
}
if err := handleConversationAssignedInAppNotification(context.Background(), events.ConversationAssignedEvent{
ConversationID: conversation.ID,
FromUserID: 0,
ToUserID: 22,
OperatorID: 1,
Reason: "自动分配",
AssignType: events.ConversationAssignTypeAutoAssign,
}); err != nil {
t.Fatalf("handler error = %v", err)
}
list := repositories.NotificationRepository.Find(sqls.DB(), sqls.NewCnd().Eq("recipient_user_id", 22))
if len(list) != 1 {
t.Fatalf("expected 1 notification, got %d", len(list))
}
got := list[0]
if got.NotificationType != "conversation_assigned" || got.BizType != "conversation" || got.BizID != conversation.ID {
t.Fatalf("unexpected notification: %+v", got)
}
if got.ActionURL != "/dashboard/conversations?conversationId=1" {
t.Fatalf("unexpected action url: %q", got.ActionURL)
}
}
func setupNotificationEventHandlerTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: "t_",
SingularTable: true,
},
})
if err != nil {
t.Fatalf("open sqlite error = %v", err)
}
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
if err := db.AutoMigrate(&models.Notification{}, &models.Ticket{}, &models.Conversation{}); err != nil {
t.Fatalf("auto migrate error = %v", err)
}
sqls.SetDB(db)
return db
}
+105
View File
@@ -0,0 +1,105 @@
package services
import (
"strings"
"time"
"cs-agent/internal/models"
"cs-agent/internal/pkg/dto/request"
"cs-agent/internal/pkg/dto/response"
"cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/errorsx"
"cs-agent/internal/pkg/utils"
"cs-agent/internal/repositories"
"github.com/mlogclub/simple/sqls"
)
var NotificationService = newNotificationService()
func newNotificationService() *notificationService {
return &notificationService{}
}
type notificationService struct {
}
func (s *notificationService) Create(req request.CreateNotificationRequest) (*models.Notification, error) {
if req.RecipientUserID <= 0 {
return nil, errorsx.InvalidParam("接收人不能为空")
}
now := time.Now()
item := &models.Notification{
RecipientUserID: req.RecipientUserID,
Title: strings.TrimSpace(req.Title),
Content: strings.TrimSpace(req.Content),
NotificationType: strings.TrimSpace(req.NotificationType),
BizType: strings.TrimSpace(req.BizType),
BizID: req.BizID,
ActionURL: strings.TrimSpace(req.ActionURL),
Status: enums.StatusOk,
CreatedAt: now,
}
if err := repositories.NotificationRepository.Create(sqls.DB(), item); err != nil {
return nil, err
}
return item, nil
}
func (s *notificationService) CreateAndPush(req request.CreateNotificationRequest) (*models.Notification, error) {
item, err := s.Create(req)
if err != nil {
return nil, err
}
WsService.PublishNotificationCreated(item.RecipientUserID, response.NotificationResponse{
ID: item.ID,
RecipientUserID: item.RecipientUserID,
Title: item.Title,
Content: item.Content,
NotificationType: item.NotificationType,
BizType: item.BizType,
BizID: item.BizID,
ActionURL: item.ActionURL,
ReadAt: utils.FormatTimePtr(item.ReadAt),
CreatedAt: utils.FormatTime(item.CreatedAt),
})
return item, nil
}
func (s *notificationService) FindPageByCnd(cnd *sqls.Cnd) ([]models.Notification, *sqls.Paging) {
return repositories.NotificationRepository.FindPageByCnd(sqls.DB(), cnd)
}
func (s *notificationService) CountUnread(userID int64) int64 {
if userID <= 0 {
return 0
}
return repositories.NotificationRepository.Count(sqls.DB(), sqls.NewCnd().
Eq("recipient_user_id", userID).
Eq("status", enums.StatusOk).
Where("read_at IS NULL"))
}
func (s *notificationService) MarkRead(id int64, userID int64) error {
if id <= 0 {
return errorsx.InvalidParam("通知不存在")
}
item := repositories.NotificationRepository.Get(sqls.DB(), id)
if item == nil || item.RecipientUserID != userID {
return errorsx.InvalidParam("通知不存在")
}
if item.ReadAt != nil {
return nil
}
now := time.Now()
return repositories.NotificationRepository.Updates(sqls.DB(), id, map[string]any{
"read_at": now,
})
}
func (s *notificationService) MarkAllRead(userID int64) error {
if userID <= 0 {
return errorsx.InvalidParam("接收人不能为空")
}
return repositories.NotificationRepository.MarkAllRead(sqls.DB(), userID, time.Now())
}
@@ -0,0 +1,126 @@
package services_test
import (
"testing"
"cs-agent/internal/models"
"cs-agent/internal/pkg/dto/request"
"cs-agent/internal/services"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestNotificationServiceCreateAndUnreadCount(t *testing.T) {
setupNotificationTestDB(t)
item, err := services.NotificationService.Create(request.CreateNotificationRequest{
RecipientUserID: 101,
Title: "工单指派提醒",
Content: "工单 TK-1 已指派给你",
NotificationType: "ticket_assigned",
BizType: "ticket",
BizID: 1,
ActionURL: "/dashboard/tickets/1",
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if item.ID == 0 {
t.Fatalf("expected notification id to be assigned")
}
if item.RecipientUserID != 101 || item.ReadAt != nil {
t.Fatalf("unexpected notification: %+v", item)
}
if got := services.NotificationService.CountUnread(101); got != 1 {
t.Fatalf("expected unread count 1, got %d", got)
}
if got := services.NotificationService.CountUnread(102); got != 0 {
t.Fatalf("expected unread count 0 for another user, got %d", got)
}
}
func TestNotificationServiceMarkReadRequiresOwner(t *testing.T) {
setupNotificationTestDB(t)
item, err := services.NotificationService.Create(request.CreateNotificationRequest{
RecipientUserID: 201,
Title: "会话分配提醒",
Content: "会话 #9 已分配给你",
NotificationType: "conversation_assigned",
BizType: "conversation",
BizID: 9,
ActionURL: "/dashboard/conversations?conversationId=9",
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if err := services.NotificationService.MarkRead(item.ID, 202); err == nil {
t.Fatalf("expected foreign user mark read to fail")
}
if got := services.NotificationService.CountUnread(201); got != 1 {
t.Fatalf("expected notification to remain unread, got %d", got)
}
if err := services.NotificationService.MarkRead(item.ID, 201); err != nil {
t.Fatalf("MarkRead() owner error = %v", err)
}
if got := services.NotificationService.CountUnread(201); got != 0 {
t.Fatalf("expected unread count 0 after mark read, got %d", got)
}
}
func TestNotificationServiceMarkAllReadOnlyCurrentUser(t *testing.T) {
setupNotificationTestDB(t)
for _, userID := range []int64{301, 301, 302} {
if _, err := services.NotificationService.Create(request.CreateNotificationRequest{
RecipientUserID: userID,
Title: "工单指派提醒",
Content: "工单已指派给你",
NotificationType: "ticket_assigned",
BizType: "ticket",
BizID: userID,
ActionURL: "/dashboard/tickets/1",
}); err != nil {
t.Fatalf("Create() error = %v", err)
}
}
if err := services.NotificationService.MarkAllRead(301); err != nil {
t.Fatalf("MarkAllRead() error = %v", err)
}
if got := services.NotificationService.CountUnread(301); got != 0 {
t.Fatalf("expected user 301 unread count 0, got %d", got)
}
if got := services.NotificationService.CountUnread(302); got != 1 {
t.Fatalf("expected user 302 unread count 1, got %d", got)
}
}
func setupNotificationTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: "t_",
SingularTable: true,
},
})
if err != nil {
t.Fatalf("open sqlite error = %v", err)
}
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
if err := db.AutoMigrate(&models.Notification{}); err != nil {
t.Fatalf("auto migrate error = %v", err)
}
sqls.SetDB(db)
return db
}
+22 -2
View File
@@ -22,8 +22,9 @@ const (
)
const (
realtimeRoleUser = "user"
realtimeRoleAdmin = "admin"
realtimeRoleUser = "user"
realtimeRoleAdmin = "admin"
realtimeRoleNotification = "notification"
)
const (
@@ -31,6 +32,7 @@ const (
realtimeTopicGuestPrefix = "guest:"
realtimeTopicAdminPrefix = "admin:"
realtimeTopicConversationPrefix = "conversation:"
realtimeTopicNotificationPrefix = "notification:"
realtimeTopicAdminAll = "admin:all"
)
@@ -219,6 +221,24 @@ func (e RealtimeConversationChangedEvent) EventPayload() RealtimeEventPayload {
return e.Payload
}
type RealtimeNotificationCreatedPayload struct {
Notification response.NotificationResponse `json:"notification"`
}
func (RealtimeNotificationCreatedPayload) realtimeEventPayload() {}
type RealtimeNotificationCreatedEvent struct {
Payload RealtimeNotificationCreatedPayload
}
func (e RealtimeNotificationCreatedEvent) EventType() string {
return enums.IMRealtimeEventNotificationCreated
}
func (e RealtimeNotificationCreatedEvent) EventPayload() RealtimeEventPayload {
return e.Payload
}
type realtimeClientMessage struct {
Type string `json:"type"`
Topics []string `json:"topics,omitempty"`
+39
View File
@@ -54,6 +54,19 @@ func (s *wsService) HandleDashboardWS(ctx iris.Context) {
}
}
func (s *wsService) HandleDashboardNotificationWS(ctx iris.Context) {
principal := AuthService.GetAuthPrincipal(ctx)
if principal == nil {
_ = ctx.StopWithJSON(iris.StatusUnauthorized, web.JsonError(errorsx.Unauthorized("未登录或登录已过期")))
return
}
if err := s.upgradeConnection(ctx, principal, nil, realtimeRoleNotification); err != nil {
slog.Error("upgrade dashboard notification websocket failed", "error", err, "path", ctx.Path())
ctx.StopExecution()
return
}
}
func (s *wsService) HandleOpenWS(ctx iris.Context) {
channel := ChannelService.GetEnabledChannel(ctx)
if channel == nil {
@@ -420,6 +433,19 @@ func (s *wsService) PublishConversationChanged(conversation *models.Conversation
s.PublishToTopics(s.routeConversationTopics(conversation), event)
}
func (s *wsService) PublishNotificationCreated(userID int64, notification response.NotificationResponse) {
if userID <= 0 || notification.ID <= 0 {
return
}
topic := s.notificationTopic(userID)
event := s.newEvent(topic, RealtimeNotificationCreatedEvent{
Payload: RealtimeNotificationCreatedPayload{
Notification: notification,
},
})
s.PublishToTopic(topic, event)
}
func (s *wsService) PublishResyncRequired(topics []string, reason string) {
reason = strings.TrimSpace(reason)
if reason == "" {
@@ -531,6 +557,11 @@ func (s *wsService) defaultTopics(session *ClientSession) []string {
}
switch session.Role {
case realtimeRoleNotification:
if session.Principal == nil || session.Principal.UserID <= 0 {
return nil
}
return []string{s.notificationTopic(session.Principal.UserID)}
case realtimeRoleAdmin:
if session.Principal == nil || session.Principal.UserID <= 0 {
return []string{realtimeTopicAdminAll}
@@ -554,6 +585,10 @@ func (s *wsService) filterAllowedTopics(session *ClientSession, topics []string)
return nil
}
switch session.Role {
case realtimeRoleNotification:
if session.Principal == nil {
return nil
}
case realtimeRoleAdmin:
if session.Principal == nil {
return nil
@@ -645,6 +680,10 @@ func (s *wsService) adminTopic(userID int64) string {
return realtimeTopicAdminPrefix + strconv.FormatInt(userID, 10)
}
func (s *wsService) notificationTopic(userID int64) string {
return realtimeTopicNotificationPrefix + strconv.FormatInt(userID, 10)
}
func (s *wsService) conversationTopic(conversationID int64) string {
return realtimeTopicConversationPrefix + strconv.FormatInt(conversationID, 10)
}
+28
View File
@@ -0,0 +1,28 @@
package services
import (
"testing"
"cs-agent/internal/pkg/dto/response"
)
func TestWsNotificationTopic(t *testing.T) {
svc := newWsService()
if got := svc.notificationTopic(123); got != "notification:123" {
t.Fatalf("expected notification:123, got %q", got)
}
}
func TestWsNotificationCreatedEventType(t *testing.T) {
event := RealtimeNotificationCreatedEvent{
Payload: RealtimeNotificationCreatedPayload{
Notification: response.NotificationResponse{ID: 1},
},
}
if got := event.EventType(); got != "notification.created" {
t.Fatalf("expected notification.created, got %q", got)
}
if payload := event.EventPayload(); payload == nil {
t.Fatalf("expected payload")
}
}
+11 -8
View File
@@ -7,6 +7,7 @@ import { useEffect } from "react"
import { AppSidebar } from "@/components/app-sidebar"
import { useAuth } from "@/components/auth-provider"
import { NotificationProvider } from "@/components/notification-provider"
import { SiteHeader } from "@/components/site-header"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
@@ -57,15 +58,17 @@ export default function DashboardLayout({
} as CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
{children}
<NotificationProvider>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
{children}
</div>
</div>
</div>
</SidebarInset>
</SidebarInset>
</NotificationProvider>
</SidebarProvider>
)
}
+187
View File
@@ -0,0 +1,187 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { BellIcon, CheckCheckIcon, RefreshCwIcon } from "lucide-react"
import { toast } from "sonner"
import { ListPagination } from "@/components/list-pagination"
import { useNotifications } from "@/components/notification-provider"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
fetchNotifications,
markAllNotificationsRead,
markNotificationRead,
type NotificationItem,
type NotificationReadStatus,
} from "@/lib/api/notification"
import type { PageResult } from "@/lib/api/admin"
import { cn, formatDateTime } from "@/lib/utils"
const readStatusOptions: Array<{ value: NotificationReadStatus; label: string }> = [
{ value: "all", label: "全部" },
{ value: "unread", label: "未读" },
{ value: "read", label: "已读" },
]
export default function DashboardNotificationsPage() {
const router = useRouter()
const { refreshUnreadCount } = useNotifications()
const [readStatus, setReadStatus] = useState<NotificationReadStatus>("all")
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState(false)
const [result, setResult] = useState<PageResult<NotificationItem>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const loadData = useCallback(async () => {
setLoading(true)
try {
const data = await fetchNotifications({
page,
limit,
readStatus,
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载通知失败")
} finally {
setLoading(false)
}
}, [limit, page, readStatus])
useEffect(() => {
void loadData()
}, [loadData])
async function openNotification(item: NotificationItem) {
try {
if (!item.readAt) {
await markNotificationRead(item.id)
await refreshUnreadCount()
}
if (item.actionUrl) {
router.push(item.actionUrl)
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "打开通知失败")
}
}
async function handleMarkAllRead() {
setActionLoading(true)
try {
await markAllNotificationsRead()
await refreshUnreadCount()
await loadData()
toast.success("已全部标记为已读")
} catch (error) {
toast.error(error instanceof Error ? error.message : "全部已读失败")
} finally {
setActionLoading(false)
}
}
function handleStatusChange(nextStatus: NotificationReadStatus) {
setReadStatus(nextStatus)
setPage(1)
}
function handlePageChange(nextPage: number) {
if (nextPage < 1 || nextPage === page) {
return
}
setPage(nextPage)
}
function handleLimitChange(nextLimit: number) {
setLimit(nextLimit)
setPage(1)
}
return (
<div className="flex flex-col gap-4 p-4 md:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-semibold"></h1>
<p className="text-sm text-muted-foreground">
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={cn(loading && "animate-spin")} />
</Button>
<Button
variant="outline"
onClick={() => void handleMarkAllRead()}
disabled={actionLoading || result.page.total === 0}
>
<CheckCheckIcon />
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{readStatusOptions.map((option) => (
<Button
key={option.value}
variant={option.value === readStatus ? "default" : "outline"}
onClick={() => handleStatusChange(option.value)}
>
{option.label}
</Button>
))}
</div>
<div className="overflow-hidden rounded-lg border bg-background">
{result.results.length > 0 ? (
<div className="divide-y">
{result.results.map((item) => {
const unread = !item.readAt
return (
<button
key={item.id}
type="button"
onClick={() => void openNotification(item)}
className="grid w-full gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/60"
>
<div className="flex flex-wrap items-center gap-2">
<BellIcon className="size-4 text-muted-foreground" />
<span className="font-medium">{item.title || "通知"}</span>
{unread ? <Badge></Badge> : <Badge variant="outline"></Badge>}
<span className="ml-auto text-xs text-muted-foreground">
{formatDateTime(item.createdAt)}
</span>
</div>
<div className="whitespace-pre-line text-sm text-muted-foreground">
{item.content || "-"}
</div>
</button>
)
})}
</div>
) : (
<div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground">
{loading ? "正在加载通知" : "暂无通知"}
</div>
)}
</div>
<ListPagination
page={result.page.page}
limit={result.page.limit}
total={result.page.total}
loading={loading}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
/>
</div>
)
}
+16 -2
View File
@@ -5,6 +5,8 @@ import { useState } from "react"
import { useAuth } from "@/components/auth-provider"
import { ChangePasswordDialog } from "@/components/change-password-dialog"
import { useNotifications } from "@/components/notification-provider"
import { Badge } from "@/components/ui/badge"
import {
Avatar,
AvatarFallback,
@@ -42,7 +44,9 @@ export function NavUser({
}
}) {
const { signOut } = useAuth()
const { unreadCount } = useNotifications()
const { isMobile } = useSidebar()
const router = useRouter()
const [changePasswordOpen, setChangePasswordOpen] = useState(false)
const fallback = user.name.slice(0, 1).toUpperCase() || "U"
return (
@@ -99,9 +103,19 @@ export function NavUser({
<KeyRoundIcon />
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
router.push("/dashboard/notifications")
}}
className="gap-2"
>
<BellIcon />
<span className="flex-1"></span>
{unreadCount > 0 ? (
<Badge className="h-5 min-w-5 px-1.5">
{unreadCount > 99 ? "99+" : unreadCount}
</Badge>
) : null}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
+159
View File
@@ -0,0 +1,159 @@
"use client"
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import {
createNotificationWebSocketUrl,
fetchNotificationUnreadCount,
markNotificationRead,
type NotificationItem,
} from "@/lib/api/notification"
import { readSession } from "@/lib/auth"
import {
createRealtimeConnectionManager,
type RealtimeConnectionStatus,
} from "@/lib/realtime-connection"
type NotificationRealtimeEnvelope = {
eventId?: string
type?: string
data?: {
notification?: NotificationItem
}
}
type NotificationContextValue = {
unreadCount: number
realtimeStatus: RealtimeConnectionStatus
refreshUnreadCount: () => Promise<void>
markReadAndNavigate: (notification: NotificationItem) => Promise<void>
}
const NotificationContext = createContext<NotificationContextValue | null>(null)
export function NotificationProvider({ children }: { children: ReactNode }) {
const router = useRouter()
const [unreadCount, setUnreadCount] = useState(0)
const [realtimeStatus, setRealtimeStatus] =
useState<RealtimeConnectionStatus>("disconnected")
const currentUserIdRef = useRef(readSession()?.user.id ?? 0)
const refreshUnreadCount = useCallback(async () => {
const result = await fetchNotificationUnreadCount()
setUnreadCount(result.unreadCount)
}, [])
const markReadAndNavigate = useCallback(
async (notification: NotificationItem) => {
if (!notification.readAt) {
await markNotificationRead(notification.id)
setUnreadCount((current) => Math.max(0, current - 1))
}
if (notification.actionUrl) {
router.push(notification.actionUrl)
}
},
[router]
)
useEffect(() => {
currentUserIdRef.current = readSession()?.user.id ?? 0
void refreshUnreadCount().catch(() => {
setUnreadCount(0)
})
}, [refreshUnreadCount])
useEffect(() => {
const realtime = createRealtimeConnectionManager({
createSocket: () => new WebSocket(createNotificationWebSocketUrl()),
canReconnect: () => Boolean(readSession()?.accessToken),
onStatusChange: setRealtimeStatus,
onOpen: () => {
void refreshUnreadCount().catch(() => undefined)
},
onMessage: (event, socket) => {
try {
const envelope = JSON.parse(event.data) as NotificationRealtimeEnvelope
const eventType = envelope.type ?? ""
const eventId = envelope.eventId?.trim() ?? ""
if (
eventType === "" ||
eventType === "connected" ||
eventType === "pong" ||
eventType === "subscribed" ||
eventType === "unsubscribed"
) {
return
}
if (eventId && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: "ack", eventId }))
}
if (eventType !== "notification.created") {
return
}
const notification = envelope.data?.notification
if (!notification || notification.recipientUserId !== currentUserIdRef.current) {
return
}
setUnreadCount((current) => current + 1)
toast(notification.title || "新通知", {
description: notification.content,
action: {
label: "查看",
onClick: () => {
void markReadAndNavigate(notification).catch((error) => {
toast.error(error instanceof Error ? error.message : "打开通知失败")
})
},
},
})
} catch {
// ignore invalid realtime payload
}
},
onConnectError: (error) => {
toast.error(error instanceof Error ? error.message : "连接通知服务失败")
},
})
realtime.connect()
return () => {
realtime.disconnect()
}
}, [markReadAndNavigate, refreshUnreadCount])
const value = useMemo<NotificationContextValue>(
() => ({
unreadCount,
realtimeStatus,
refreshUnreadCount,
markReadAndNavigate,
}),
[markReadAndNavigate, realtimeStatus, refreshUnreadCount, unreadCount]
)
return (
<NotificationContext.Provider value={value}>
{children}
</NotificationContext.Provider>
)
}
export function useNotifications() {
const context = useContext(NotificationContext)
if (!context) {
throw new Error("useNotifications must be used within NotificationProvider")
}
return context
}
+81
View File
@@ -0,0 +1,81 @@
import { readSession } from "@/lib/auth"
import { request } from "@/lib/api/client"
import { createWebSocketBaseUrl } from "@/lib/api/websocket"
import type { PageResult } from "@/lib/api/admin"
export type NotificationReadStatus = "all" | "unread" | "read"
export type NotificationItem = {
id: number
recipientUserId: number
title: string
content: string
notificationType: string
bizType: string
bizId: number
actionUrl: string
readAt?: string
createdAt?: string
}
export type NotificationUnreadCount = {
unreadCount: number
}
export type NotificationListQuery = {
page?: number
limit?: number
readStatus?: NotificationReadStatus
type?: string
}
function toQueryString(query?: Record<string, string | number | undefined>) {
if (!query) {
return ""
}
const params = new URLSearchParams()
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === "") {
return
}
params.set(key, String(value))
})
const output = params.toString()
return output ? `?${output}` : ""
}
export function fetchNotifications(query?: NotificationListQuery) {
return request<PageResult<NotificationItem>>(
`/api/dashboard/notification/list${toQueryString(query)}`
)
}
export function fetchNotificationUnreadCount() {
return request<NotificationUnreadCount>("/api/dashboard/notification/unread_count")
}
export function markNotificationRead(id: number) {
return request<void>("/api/dashboard/notification/mark_read", {
method: "POST",
body: JSON.stringify({ id }),
})
}
export function markAllNotificationsRead() {
return request<void>("/api/dashboard/notification/mark_all_read", {
method: "POST",
})
}
export function createNotificationWebSocketUrl() {
const session = readSession()
if (!session?.accessToken) {
throw new Error("未登录或登录已过期")
}
const params = new URLSearchParams({
accessToken: session.accessToken,
})
return `${createWebSocketBaseUrl()}/api/ws/dashboard/notification?${params.toString()}`
}