diff --git a/docs b/docs index a9099af..7504ff3 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit a9099af47e278e96c7f39209e311d222a9d94ac8 +Subproject commit 7504ff38a3ebe3a3d6bde72402fc5b16ff8612a5 diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go index 7dbeaa4..ef04fd8 100644 --- a/internal/bootstrap/server.go +++ b/internal/bootstrap/server.go @@ -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)) diff --git a/internal/builders/notification_builder.go b/internal/builders/notification_builder.go new file mode 100644 index 0000000..f52cee2 --- /dev/null +++ b/internal/builders/notification_builder.go @@ -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 +} diff --git a/internal/controllers/dashboard/notification_controller.go b/internal/controllers/dashboard/notification_controller.go new file mode 100644 index 0000000..e20ebc7 --- /dev/null +++ b/internal/controllers/dashboard/notification_controller.go @@ -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() +} diff --git a/internal/models/models.go b/internal/models/models.go index 34fa96a..0dab0f2 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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 { diff --git a/internal/pkg/constants/auth.go b/internal/pkg/constants/auth.go index 35882f8..0e53636 100644 --- a/internal/pkg/constants/auth.go +++ b/internal/pkg/constants/auth.go @@ -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, diff --git a/internal/pkg/dto/request/notification_request.go b/internal/pkg/dto/request/notification_request.go new file mode 100644 index 0000000..398a01c --- /dev/null +++ b/internal/pkg/dto/request/notification_request.go @@ -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"` +} diff --git a/internal/pkg/dto/response/notification_response.go b/internal/pkg/dto/response/notification_response.go new file mode 100644 index 0000000..a6102ab --- /dev/null +++ b/internal/pkg/dto/response/notification_response.go @@ -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"` +} diff --git a/internal/pkg/enums/im.go b/internal/pkg/enums/im.go index 2cf7f86..994b3e5 100644 --- a/internal/pkg/enums/im.go +++ b/internal/pkg/enums/im.go @@ -250,6 +250,7 @@ const ( IMRealtimeEventConversationTransferred = "conversation.transferred" IMRealtimeEventConversationClosed = "conversation.closed" IMRealtimeEventConversationRead = "conversation.read" + IMRealtimeEventNotificationCreated = "notification.created" ) const ( diff --git a/internal/repositories/notification_repository.go b/internal/repositories/notification_repository.go new file mode 100644 index 0000000..4097d0a --- /dev/null +++ b/internal/repositories/notification_repository.go @@ -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 ¬ificationRepository{} +} + +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, ¶ms.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 +} diff --git a/internal/services/event_handlers/notification_event_handler.go b/internal/services/event_handlers/notification_event_handler.go new file mode 100644 index 0000000..243434e --- /dev/null +++ b/internal/services/event_handlers/notification_event_handler.go @@ -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 +} diff --git a/internal/services/event_handlers/notification_event_handler_test.go b/internal/services/event_handlers/notification_event_handler_test.go new file mode 100644 index 0000000..5a41f1e --- /dev/null +++ b/internal/services/event_handlers/notification_event_handler_test.go @@ -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 +} diff --git a/internal/services/notification_service.go b/internal/services/notification_service.go new file mode 100644 index 0000000..30c4300 --- /dev/null +++ b/internal/services/notification_service.go @@ -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 ¬ificationService{} +} + +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()) +} diff --git a/internal/services/notification_service_test.go b/internal/services/notification_service_test.go new file mode 100644 index 0000000..cc4ef6e --- /dev/null +++ b/internal/services/notification_service_test.go @@ -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 +} diff --git a/internal/services/ws_realtime_types.go b/internal/services/ws_realtime_types.go index 23ee588..c6e70ea 100644 --- a/internal/services/ws_realtime_types.go +++ b/internal/services/ws_realtime_types.go @@ -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"` diff --git a/internal/services/ws_service.go b/internal/services/ws_service.go index 529d2cb..d03ee4d 100644 --- a/internal/services/ws_service.go +++ b/internal/services/ws_service.go @@ -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) } diff --git a/internal/services/ws_service_test.go b/internal/services/ws_service_test.go new file mode 100644 index 0000000..b316343 --- /dev/null +++ b/internal/services/ws_service_test.go @@ -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") + } +} diff --git a/web/app/dashboard/layout.tsx b/web/app/dashboard/layout.tsx index d2ce9a4..5825fba 100644 --- a/web/app/dashboard/layout.tsx +++ b/web/app/dashboard/layout.tsx @@ -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 } > - - - -
-
- {children} + + + + +
+
+ {children} +
-
- + + ) } diff --git a/web/app/dashboard/notifications/page.tsx b/web/app/dashboard/notifications/page.tsx new file mode 100644 index 0000000..1ca754e --- /dev/null +++ b/web/app/dashboard/notifications/page.tsx @@ -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("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>({ + 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 ( +
+
+
+

通知中心

+

+ 查看工单、会话等业务流转提醒 +

+
+
+ + +
+
+ +
+ {readStatusOptions.map((option) => ( + + ))} +
+ +
+ {result.results.length > 0 ? ( +
+ {result.results.map((item) => { + const unread = !item.readAt + return ( + + ) + })} +
+ ) : ( +
+ {loading ? "正在加载通知" : "暂无通知"} +
+ )} +
+ + +
+ ) +} diff --git a/web/components/nav-user.tsx b/web/components/nav-user.tsx index dec43e8..801b9ac 100644 --- a/web/components/nav-user.tsx +++ b/web/components/nav-user.tsx @@ -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({ 修改密码 - + { + router.push("/dashboard/notifications") + }} + className="gap-2" + > - 通知中心 + 通知中心 + {unreadCount > 0 ? ( + + {unreadCount > 99 ? "99+" : unreadCount} + + ) : null} diff --git a/web/components/notification-provider.tsx b/web/components/notification-provider.tsx new file mode 100644 index 0000000..7ab2871 --- /dev/null +++ b/web/components/notification-provider.tsx @@ -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 + markReadAndNavigate: (notification: NotificationItem) => Promise +} + +const NotificationContext = createContext(null) + +export function NotificationProvider({ children }: { children: ReactNode }) { + const router = useRouter() + const [unreadCount, setUnreadCount] = useState(0) + const [realtimeStatus, setRealtimeStatus] = + useState("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( + () => ({ + unreadCount, + realtimeStatus, + refreshUnreadCount, + markReadAndNavigate, + }), + [markReadAndNavigate, realtimeStatus, refreshUnreadCount, unreadCount] + ) + + return ( + + {children} + + ) +} + +export function useNotifications() { + const context = useContext(NotificationContext) + if (!context) { + throw new Error("useNotifications must be used within NotificationProvider") + } + return context +} diff --git a/web/lib/api/notification.ts b/web/lib/api/notification.ts new file mode 100644 index 0000000..5299462 --- /dev/null +++ b/web/lib/api/notification.ts @@ -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) { + 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>( + `/api/dashboard/notification/list${toQueryString(query)}` + ) +} + +export function fetchNotificationUnreadCount() { + return request("/api/dashboard/notification/unread_count") +} + +export function markNotificationRead(id: number) { + return request("/api/dashboard/notification/mark_read", { + method: "POST", + body: JSON.stringify({ id }), + }) +} + +export function markAllNotificationsRead() { + return request("/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()}` +}