diff --git a/.cursor/rules/project-context.mdc b/.cursor/rules/project-context.mdc index 6a9346d..ec199cb 100644 --- a/.cursor/rules/project-context.mdc +++ b/.cursor/rules/project-context.mdc @@ -22,13 +22,26 @@ alwaysApply: true - 分支:`feature/phase2c-group-read-receipt` - **Phase 2d(消息类型扩展)**:✅ 全部完成(14 个 Task + 代码审查修复 2 项 + 富媒体 Bug 修复 4 项 + UX 优化 1 项),设计文档 `docs/plans/2026-03-04-phase2d-design.md` - **⚠️ 分支说明**:Phase 2d 的实际开发工作误用了 `feature/phase2c-group-read-receipt` 分支,`feature/phase2d-message-types` 分支未承载 2d 代码。此次偏差不做回溯修复;**Phase 2e 起必须为每个阶段新建独立分支**(`feature/phase2e-xxx`)。 +- **Phase 2e(会议与通知系统)**:🚧 规划完成,拆分为三子阶段,设计文档 `docs/plans/2026-04-20-phase2e-design.md` + - 2e-1 通知系统(3-4 天)✅ **已完成**:统一通知中心 + 11 种类型 + Pusher 接口注入 + 30 天清理 + 管理员广播 + 前端 5 分类 Tab + * 单端 WS 连接架构(沿用),不做多端已读同步(设计文档 §3.1/§3.5/§九 已修订,多端改造推迟到 Phase 2f/二期) + * 专用设计:`docs/plans/2026-04-20-phase2e-1-design.md`;实施计划:`docs/plans/2026-04-20-phase2e-1-implementation.plan.md`;验证报告:`test-report-phase2e-1-notification.md` + * API 文档:`docs/api/frontend/notify.md` + - 2e-2 会议 MVP(10-14 天)📋 待开发:mediasoup Node.js 独立服务 + 即时会议(≤8 人)+ 音视频控制 + - 2e-3 会议增强(7-10 天)📋 待开发:预约会议 + 定时提醒 + 会议邀请(复用 2e-1) + - 分支:`feature/phase2e-meeting-notification`(基于 `origin/feature/phase2c-group-read-receipt`) + - **关键技术锁定**:维持 mediasoup SFU 架构(非 Mesh),前端用 mediasoup-client,信令复用现有 WS Hub + - **显式推迟清单**(必须留档,见设计文档 §九): + * → Phase 2f:WS Hub 多端连接改造、会议管理后台、通知广播发布 UI、管理端仪表板、操作日志页、系统配置管理、通知分类开关 + * → 第二期:屏幕共享、会议录制、虚拟背景、微信登录、互动直播、视频消息、表情包、消息转发/引用 + * → 第三期:微服务拆分、K8s、跨服 Worker 集群、AI 语音转文字 - **开发者运维脚本(2026-04-20)**:新增 `scripts/{start,stop,status}.sh` 三件套,`scripts/dev-setup.sh` 补齐 MinIO 健康检查;遵循 setup/start 职责分离(Rails/Django 社区模式) - **聊天页 UX 优化(2026-04-20)**:单聊/群聊引入「新消息悬浮提示 + 按需已读」机制(贴底自动滚+已读,远离底部显示"N 条新消息"悬浮按钮),消除"视图未见但对方已读"的 UX 错位;**后续修复**:watch 误将"加载更多历史消息"计入 newMsgCount → 改为对比「末尾消息标识(tail id/client_msg_id)」而非 `messages.length`,真实双账号(duanlingyun ↔ bojinyuan)端到端验证通过 - **语音消息 H5 兼容(2026-04-20)**:修复 PC Chrome 无法按住"按住说话"的问题,链路涉及 4 层(事件 + 录音 API + 上传 + 后端校验):VoiceRecorder 并行监听 touch/mouse 事件;新增 `H5Recorder` 类(基于 `MediaRecorder + getUserMedia`)作为 uni 录音接口在 H5 端的回退;`uploadVoice` 增加 blob 分支走 `fetch+FormData` 精确控制 filename;后端 `allowedVoiceExts` 扩展 `.webm/.ogg`。Playwright 端到端验证:录音计时正常(3s→5s 跳动)、Blob 上传成功、数据库 `im_messages` type=3 记录写入 - **语音未听红点(2026-04-20,仿微信)**:语音需"听"才算真消费,仅通用已读不能准确反映 → 为「对方发来的」语音叠加独立状态。`chat store` 新增 `voicePlayedMap` + 4 API(`markVoicePlayed/isVoicePlayed/loadVoicePlayedState/resetVoicePlayedState`),localStorage 按 userId 隔离(`echo:voice-played:{userId}`),不同步后端(私人视图态);`MsgVoice.vue` 自主消费 store(单聊/群聊同构),模板加 `.unplayed-dot`(10rpx 红圆),`onTogglePlay` 点击即标记清除;`user.logout()` 补 `resetVoicePlayedState()` 防串用户。Playwright 验证:duanlingyun(13) ↔ bojinyuan(7) 会话 6,初始 5 个红点(对方 5 条语音) → 点击 2 条 → 红点减至 3 → 页面刷新仍为 3(持久化生效) - **已读状态刷新持久化(2026-04-20)**:修复"刷新后所有已读标签变未读 / 群聊 N人已读消失"的体验倒退 bug,根因是前端 `readStatusMap`/`groupReadCountMap` 仅由 WS 事件填充、刷新后无 API 补回。扩展 `HistoryMessageResponse` 新增 `peer_last_read_msg_id`(单聊)+ `read_count_map`(群聊仅含自己发送消息);`GetHistoryMessages` 按会话类型分支填充(`GetPeerUserID`+`GetMember` 或 `readRecorder.GetReadCountBatch`);前端 `loadHistoryMessages` 仅在首次加载时回填(避免加载更多时覆盖 WS 增量)。Playwright 双端验证:单聊会话 6(peer_last_read=202)首屏 20 已读+1 未读、群聊 8 刷新后 13 条自己消息全部显示"N人已读"(11×1人 + 2×2人);不新增 API,不增加请求次数,向前兼容 - 范围:图片/语音/文件消息完整流程 + 管理端消息管理(列表+统计+撤回+删除)+ ECharts 仪表板 -- **跨模块通信模式**:接口注入标准(ws.FriendIDsGetter / im.FriendChecker / im.UserInfoGetter → contact.FriendshipDAO,im.OfflineMessagePusher → ws.Handler,contact.OnlineChecker → ws.OnlineService,im.GroupInfoGetter → group.GroupDAO,im.MessageReadRecorder → group.MessageReadDAO,group.UserInfoProvider → auth.UserDAO,group.MessageWriter → im.MessageDAO,admin.MessageManageService → im.ConversationDAO + ws.PubSub) +- **跨模块通信模式**:接口注入标准(ws.FriendIDsGetter / im.FriendChecker / im.UserInfoGetter / notify.UserInfoResolver → contact.FriendshipDAO,im.OfflineMessagePusher → ws.Handler,contact.OnlineChecker → ws.OnlineService,im.GroupInfoGetter → group.GroupDAO,im.MessageReadRecorder → group.MessageReadDAO,group.UserInfoProvider → auth.UserDAO,group.MessageWriter → im.MessageDAO,admin.MessageManageService → im.ConversationDAO + ws.PubSub,**contact.NotifyPusher / group.NotifyPusher → notify.NotifyService**,**ws.NotifyConnectHook → notify.NotifyService**) ## 项目概述 diff --git a/.gitignore b/.gitignore index 39a4579..669fbbd 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,8 @@ build/ coverage/ *.coverprofile -# --- Playwright 截图 --- +# --- Playwright 截图与 MCP 快照 --- *.png !design-system/**/*.png +.playwright-mcp/ +!.playwright-mcp/.gitkeep diff --git a/backend/go-service/app/constants/notify.go b/backend/go-service/app/constants/notify.go new file mode 100644 index 0000000..19d6bfa --- /dev/null +++ b/backend/go-service/app/constants/notify.go @@ -0,0 +1,105 @@ +package constants + +// 通知类型枚举(notify_notifications.type) +// 覆盖好友 / 群聊 / 会议 / 系统四大类,其中 meeting_* 在 Phase 2e-1 仅预留枚举,由 Phase 2e-2/2e-3 业务接入 +const ( + // 好友相关(contact 模块触发) + NotifyTypeFriendRequest = "friend_request" // 收到好友申请 + NotifyTypeFriendAccepted = "friend_accepted" // 好友申请被接受(通知申请人) + NotifyTypeFriendRejected = "friend_rejected" // 好友申请被拒绝(通知申请人) + + // 群聊相关(group 模块触发) + NotifyTypeGroupInvite = "group_invite" // 被邀请入群 + NotifyTypeGroupJoinRequest = "group_join_request" // 有新入群申请(通知群管理员) + NotifyTypeGroupJoinApproved = "group_join_approved" // 入群申请通过(通知申请人) + NotifyTypeGroupJoinRejected = "group_join_rejected" // 入群申请被拒(通知申请人) + NotifyTypeGroupKicked = "group_kicked" // 被移出群聊 + NotifyTypeGroupRoleChanged = "group_role_changed" // 群内角色变更(被设/取消管理员) + + // 系统广播(admin 触发) + NotifyTypeSystemBroadcast = "system_broadcast" // 管理员全员广播 + + // 会议相关(Phase 2e-1 预留枚举,Phase 2e-2/2e-3 业务接入) + NotifyTypeMeetingInvite = "meeting_invite" // 会议邀请 + NotifyTypeMeetingReminder = "meeting_reminder" // 会议提醒 +) + +// NotifyTypeMap 通知类型中文映射 +// 用于日志、管理端展示、默认标题兜底 +var NotifyTypeMap = map[string]string{ + NotifyTypeFriendRequest: "好友申请", + NotifyTypeFriendAccepted: "好友申请通过", + NotifyTypeFriendRejected: "好友申请被拒", + NotifyTypeGroupInvite: "群聊邀请", + NotifyTypeGroupJoinRequest: "入群申请", + NotifyTypeGroupJoinApproved: "入群申请通过", + NotifyTypeGroupJoinRejected: "入群申请被拒", + NotifyTypeGroupKicked: "移出群聊", + NotifyTypeGroupRoleChanged: "群角色变更", + NotifyTypeSystemBroadcast: "系统通知", + NotifyTypeMeetingInvite: "会议邀请", + NotifyTypeMeetingReminder: "会议提醒", +} + +// 通知分类(用于前端 5 个 Tab 过滤) +const ( + NotifyCategoryAll = "all" // 全部 + NotifyCategoryFriend = "friend" // 好友 + NotifyCategoryGroup = "group" // 群聊 + NotifyCategoryMeeting = "meeting" // 会议 + NotifyCategorySystem = "system" // 系统 +) + +// NotifyCategoryOfType 根据 type 判断所属分类,用于列表按分类筛选 +func NotifyCategoryOfType(notifyType string) string { + switch notifyType { + case NotifyTypeFriendRequest, NotifyTypeFriendAccepted, NotifyTypeFriendRejected: + return NotifyCategoryFriend + case NotifyTypeGroupInvite, NotifyTypeGroupJoinRequest, NotifyTypeGroupJoinApproved, + NotifyTypeGroupJoinRejected, NotifyTypeGroupKicked, NotifyTypeGroupRoleChanged: + return NotifyCategoryGroup + case NotifyTypeMeetingInvite, NotifyTypeMeetingReminder: + return NotifyCategoryMeeting + case NotifyTypeSystemBroadcast: + return NotifyCategorySystem + default: + return NotifyCategorySystem + } +} + +// NotifyTypesByCategory 分类 → type 列表映射,查询时用于构造 SQL IN 条件 +var NotifyTypesByCategory = map[string][]string{ + NotifyCategoryFriend: { + NotifyTypeFriendRequest, NotifyTypeFriendAccepted, NotifyTypeFriendRejected, + }, + NotifyCategoryGroup: { + NotifyTypeGroupInvite, NotifyTypeGroupJoinRequest, NotifyTypeGroupJoinApproved, + NotifyTypeGroupJoinRejected, NotifyTypeGroupKicked, NotifyTypeGroupRoleChanged, + }, + NotifyCategoryMeeting: { + NotifyTypeMeetingInvite, NotifyTypeMeetingReminder, + }, + NotifyCategorySystem: { + NotifyTypeSystemBroadcast, + }, +} + +// 通知业务对象类型(notify_notifications.target_type) +// 用于前端 deep-link 跳转路由 +const ( + NotifyTargetUser = "user" // 跳转到用户主页/好友申请页 + NotifyTargetGroup = "group" // 跳转到群聊详情 + NotifyTargetMeeting = "meeting" // 跳转到会议(Phase 2e-2/2e-3) + NotifyTargetSystem = "system" // 系统公告,无跳转 +) + +// 通知清理策略 +const ( + NotifyRetentionDays = 30 // 已读通知保留天数(未读不清理) +) + +// WS 事件常量 +const ( + WSEventNotifyNew = "notify.new" // 新通知推送(S→C) + WSEventNotifyUnreadTotal = "notify.unread.total" // 断线重连补偿未读数(S→C) +) diff --git a/backend/go-service/app/contact/service/contact_service.go b/backend/go-service/app/contact/service/contact_service.go index b5f3982..85f477c 100644 --- a/backend/go-service/app/contact/service/contact_service.go +++ b/backend/go-service/app/contact/service/contact_service.go @@ -6,10 +6,11 @@ import ( "errors" "sort" + "github.com/echochat/backend/app/constants" "github.com/echochat/backend/app/contact/dao" "github.com/echochat/backend/app/dto" + notifyService "github.com/echochat/backend/app/notify/service" "github.com/echochat/backend/pkg/logs" - "github.com/echochat/backend/pkg/ws" "go.uber.org/zap" "gorm.io/gorm" ) @@ -30,26 +31,33 @@ type OnlineChecker interface { BatchCheckOnline(ctx context.Context, userIDs []int64) map[int64]bool } +// NotifyPusher 通知推送接口 +// 由 notify.service.NotifyService 隐式实现;contact → notify 单向依赖 +// 此处以接口形式注入,便于测试替换并明确调用契约 +type NotifyPusher interface { + Push(ctx context.Context, payload *notifyService.PushPayload) +} + // ContactService 联系人业务服务 type ContactService struct { friendshipDAO *dao.FriendshipDAO friendGroupDAO *dao.FriendGroupDAO - pubsub *ws.PubSub onlineChecker OnlineChecker + notifyPusher NotifyPusher } // NewContactService 创建 ContactService 实例 func NewContactService( friendshipDAO *dao.FriendshipDAO, friendGroupDAO *dao.FriendGroupDAO, - pubsub *ws.PubSub, onlineChecker OnlineChecker, + notifyPusher NotifyPusher, ) *ContactService { return &ContactService{ friendshipDAO: friendshipDAO, friendGroupDAO: friendGroupDAO, - pubsub: pubsub, onlineChecker: onlineChecker, + notifyPusher: notifyPusher, } } @@ -99,12 +107,20 @@ func (s *ContactService) SendFriendRequest(ctx context.Context, userID, targetID } } - push := ws.NewPushMessage("notify.friend.request", map[string]interface{}{ - "from_user_id": userID, - "message": message, - }) - if pubErr := s.pubsub.PublishToUser(ctx, targetID, push); pubErr != nil { - logs.Warn(ctx, funcName, "推送好友申请通知失败", zap.Error(pubErr)) + if s.notifyPusher != nil { + actorID := userID + targetUser := targetID + s.notifyPusher.Push(ctx, ¬ifyService.PushPayload{ + UserID: targetID, + Type: constants.NotifyTypeFriendRequest, + Content: message, + ActorID: &actorID, + TargetType: constants.NotifyTargetUser, + TargetID: &targetUser, + Extra: map[string]interface{}{ + "message": message, + }, + }) } return nil @@ -132,11 +148,20 @@ func (s *ContactService) AcceptFriendRequest(ctx context.Context, requestID, use return err } - push := ws.NewPushMessage("contact.request.accepted", map[string]interface{}{ - "user_id": userID, - }) - if pubErr := s.pubsub.PublishToUser(ctx, req.UserID, push); pubErr != nil { - logs.Warn(ctx, funcName, "推送申请接受通知失败", zap.Error(pubErr)) + if s.notifyPusher != nil { + actorID := userID + targetUser := userID + s.notifyPusher.Push(ctx, ¬ifyService.PushPayload{ + UserID: req.UserID, + Type: constants.NotifyTypeFriendAccepted, + Content: "对方已接受你的好友申请", + ActorID: &actorID, + TargetType: constants.NotifyTargetUser, + TargetID: &targetUser, + Extra: map[string]interface{}{ + "request_id": requestID, + }, + }) } return nil @@ -148,11 +173,41 @@ func (s *ContactService) RejectFriendRequest(ctx context.Context, requestID, use logs.Info(ctx, funcName, "拒绝好友申请", zap.Int64("request_id", requestID), zap.Int64("user_id", userID)) - err := s.friendshipDAO.RejectRequest(ctx, requestID, userID) - if errors.Is(err, gorm.ErrRecordNotFound) { + req, err := s.friendshipDAO.GetRequestByID(ctx, requestID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrRequestNotFound + } + return err + } + if req.FriendID != userID { return ErrRequestNotFound } - return err + + if err := s.friendshipDAO.RejectRequest(ctx, requestID, userID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrRequestNotFound + } + return err + } + + if s.notifyPusher != nil { + actorID := userID + targetUser := userID + s.notifyPusher.Push(ctx, ¬ifyService.PushPayload{ + UserID: req.UserID, + Type: constants.NotifyTypeFriendRejected, + Content: "对方拒绝了你的好友申请", + ActorID: &actorID, + TargetType: constants.NotifyTargetUser, + TargetID: &targetUser, + Extra: map[string]interface{}{ + "request_id": requestID, + }, + }) + } + + return nil } // GetFriendList 获取好友列表(包含在线状态) diff --git a/backend/go-service/app/dto/notify_dto.go b/backend/go-service/app/dto/notify_dto.go new file mode 100644 index 0000000..0ac2478 --- /dev/null +++ b/backend/go-service/app/dto/notify_dto.go @@ -0,0 +1,67 @@ +package dto + +// ====== 通知基础 DTO ====== + +// NotificationDTO 通知传输对象 +// 前端列表渲染使用,字段顺序与 notify_notifications 一一对应 +type NotificationDTO struct { + ID int64 `json:"id"` // 通知 ID + Type string `json:"type"` // 通知类型常量 + Category string `json:"category"` // 前端分类(friend / group / meeting / system),由 type 计算得出 + Title string `json:"title"` // 标题 + Content string `json:"content"` // 副文案 + Extra *string `json:"extra,omitempty"` // 扩展数据 JSON 字符串(前端按需解析) + ActorID *int64 `json:"actor_id,omitempty"` // 触发用户 ID + ActorName string `json:"actor_name,omitempty"` // 触发用户昵称(后端按需补全) + ActorAvatar string `json:"actor_avatar,omitempty"` // 触发用户头像 + TargetType string `json:"target_type"` // 业务对象类型 + TargetID *int64 `json:"target_id,omitempty"` // 业务对象 ID + IsRead bool `json:"is_read"` // 是否已读 + ReadAt string `json:"read_at,omitempty"` // 已读时间(未读为空) + CreatedAt string `json:"created_at"` // 创建时间 +} + +// ====== REST API 请求/响应 DTO ====== + +// GetNotificationsRequest 通知列表查询参数 +// GET /api/v1/notifications +type GetNotificationsRequest struct { + Category string `form:"category"` // 分类过滤:all/friend/group/meeting/system,空视为 all + IsRead *bool `form:"is_read"` // 已读状态过滤:nil=全部,true=已读,false=未读 + BeforeID int64 `form:"before_id"` // 游标分页:小于此 ID 的记录 + Limit int `form:"limit"` // 页大小(默认 20,最大 100) +} + +// NotificationListResponse 通知列表响应 +type NotificationListResponse struct { + List []NotificationDTO `json:"list"` // 通知列表(按 created_at DESC 返回) + HasMore bool `json:"has_more"` // 是否还有更多数据 +} + +// UnreadCountResponse 未读数响应 +type UnreadCountResponse struct { + Total int `json:"total"` // 未读总数 + ByCategory map[string]int `json:"by_category"` // 按分类未读数(friend/group/meeting/system) +} + +// MarkReadResponse 标记已读响应 +type MarkReadResponse struct { + Affected int `json:"affected"` // 实际被标记为已读的记录数 +} + +// ====== 管理端广播 DTO ====== + +// BroadcastNotificationRequest 管理员发起系统广播请求 +// POST /api/v1/admin/notifications/broadcast +type BroadcastNotificationRequest struct { + Title string `json:"title" binding:"required,max=100"` // 广播标题 + Content string `json:"content" binding:"required,max=500"` // 广播正文 + TargetType string `json:"target_type"` // 业务对象类型,通常为 system + TargetID *int64 `json:"target_id"` // 业务对象 ID(可选) + Extra *string `json:"extra"` // 扩展 JSON +} + +// BroadcastNotificationResponse 广播结果响应 +type BroadcastNotificationResponse struct { + Affected int `json:"affected"` // 成功入库的用户数 +} diff --git a/backend/go-service/app/group/service/group_service.go b/backend/go-service/app/group/service/group_service.go index 505ccb7..d16b1b2 100644 --- a/backend/go-service/app/group/service/group_service.go +++ b/backend/go-service/app/group/service/group_service.go @@ -12,6 +12,7 @@ import ( "github.com/echochat/backend/app/group/dao" "github.com/echochat/backend/app/group/model" imModel "github.com/echochat/backend/app/im/model" + notifyService "github.com/echochat/backend/app/notify/service" "github.com/echochat/backend/pkg/logs" "github.com/echochat/backend/pkg/ws" "go.uber.org/zap" @@ -46,6 +47,12 @@ type MessageWriter interface { Create(ctx context.Context, msg *imModel.Message) error } +// NotifyPusher 通知推送接口 +// 由 notify.service.NotifyService 隐式实现;group → notify 单向依赖 +type NotifyPusher interface { + Push(ctx context.Context, payload *notifyService.PushPayload) +} + // GroupService 群聊业务服务 type GroupService struct { groupDAO *dao.GroupDAO @@ -53,6 +60,7 @@ type GroupService struct { userInfo UserInfoProvider pubsub *ws.PubSub msgWriter MessageWriter + notifyPusher NotifyPusher } // NewGroupService 创建 GroupService 实例 @@ -62,6 +70,7 @@ func NewGroupService( userInfo UserInfoProvider, pubsub *ws.PubSub, msgWriter MessageWriter, + notifyPusher NotifyPusher, ) *GroupService { return &GroupService{ groupDAO: groupDAO, @@ -69,6 +78,7 @@ func NewGroupService( userInfo: userInfo, pubsub: pubsub, msgWriter: msgWriter, + notifyPusher: notifyPusher, } } @@ -308,6 +318,17 @@ func (s *GroupService) InviteMembers(ctx context.Context, userID, groupID int64, "user_ids": addedIDs, "operator_id": userID, }) + + // 向被邀请的每个用户推送入群通知,由通知中心持久化 + WS 实时弹出 + s.pushGroupNotify(ctx, addedIDs, userID, constants.NotifyTypeGroupInvite, + fmt.Sprintf("%s 邀请你加入「%s」", inviterName, group.Name), + groupID, map[string]interface{}{ + "group_id": groupID, + "group_name": group.Name, + "conversation_id": group.ConversationID, + "inviter_id": userID, + "inviter_name": inviterName, + }) } return nil @@ -357,6 +378,16 @@ func (s *GroupService) KickMember(ctx context.Context, userID, groupID, targetID "operator_id": userID, }) + // 通知被踢用户:持久化到通知中心 + operatorName := s.getUserNickname(ctx, userID) + s.pushGroupNotify(ctx, []int64{targetID}, userID, constants.NotifyTypeGroupKicked, + fmt.Sprintf("%s 将你移出了群聊「%s」", operatorName, group.Name), + groupID, map[string]interface{}{ + "group_id": groupID, + "group_name": group.Name, + "operator_id": userID, + }) + return nil } @@ -402,6 +433,22 @@ func (s *GroupService) SetMemberRole(ctx context.Context, userID, groupID, targe "operator_id": userID, }) + // 通知被设置角色的用户:持久化到通知中心 + var content string + if role == constants.GroupRoleAdmin { + content = fmt.Sprintf("你已被设为群聊「%s」的管理员", group.Name) + } else { + content = fmt.Sprintf("你的群聊「%s」管理员身份已被取消", group.Name) + } + s.pushGroupNotify(ctx, []int64{targetID}, userID, constants.NotifyTypeGroupRoleChanged, + content, + groupID, map[string]interface{}{ + "group_id": groupID, + "group_name": group.Name, + "role": role, + "operator_id": userID, + }) + return nil } @@ -585,6 +632,18 @@ func (s *GroupService) SubmitJoinRequest(ctx context.Context, userID, groupID in "message": message, }) + // 向群管理员持久化通知:需要审批的入群申请 + s.pushGroupNotify(ctx, adminIDs, userID, constants.NotifyTypeGroupJoinRequest, + fmt.Sprintf("%s 申请加入群聊「%s」", userName, group.Name), + groupID, map[string]interface{}{ + "group_id": groupID, + "group_name": group.Name, + "request_id": req.ID, + "applicant_id": userID, + "applicant_name": userName, + "message": message, + }) + return nil } @@ -692,10 +751,33 @@ func (s *GroupService) ReviewJoinRequest(ctx context.Context, userID, groupID, r "user_ids": []int64{req.UserID}, }) + // 通知申请人:入群申请已通过 + s.pushGroupNotify(ctx, []int64{req.UserID}, userID, constants.NotifyTypeGroupJoinApproved, + fmt.Sprintf("你加入群聊「%s」的申请已通过", group.Name), + groupID, map[string]interface{}{ + "group_id": groupID, + "group_name": group.Name, + "conversation_id": group.ConversationID, + "request_id": requestID, + }) + return nil } - return s.joinRequestDAO.Reject(ctx, requestID, userID) + if err := s.joinRequestDAO.Reject(ctx, requestID, userID); err != nil { + return err + } + + // 通知申请人:入群申请被拒绝 + s.pushGroupNotify(ctx, []int64{req.UserID}, userID, constants.NotifyTypeGroupJoinRejected, + fmt.Sprintf("你加入群聊「%s」的申请已被拒绝", group.Name), + groupID, map[string]interface{}{ + "group_id": groupID, + "group_name": group.Name, + "request_id": requestID, + }) + + return nil } // SearchGroups 搜索公开群 @@ -858,6 +940,52 @@ func (s *GroupService) pushToGroupMembers(ctx context.Context, conversationID in s.pushToMembers(ctx, memberIDs, excludeUID, event, data) } +// pushGroupNotify 向多个用户批量投递群聊相关通知(持久化 + WS) +// notifyPusher 内部自动写入通知中心并推送 notify.new 事件 +func (s *GroupService) pushGroupNotify( + ctx context.Context, + userIDs []int64, + actorID int64, + notifyType string, + content string, + groupID int64, + extra map[string]interface{}, +) { + if s.notifyPusher == nil || len(userIDs) == 0 { + return + } + gid := groupID + actor := actorID + payloads := make([]*notifyService.PushPayload, 0, len(userIDs)) + for _, uid := range userIDs { + if uid <= 0 || uid == actorID { + continue + } + payloads = append(payloads, ¬ifyService.PushPayload{ + UserID: uid, + Type: notifyType, + Content: content, + ActorID: &actor, + TargetType: constants.NotifyTargetGroup, + TargetID: &gid, + Extra: extra, + }) + } + if len(payloads) == 0 { + return + } + // 使用批量接口,减少数据库往返 + if batch, ok := s.notifyPusher.(interface { + PushBatch(ctx context.Context, payloads []*notifyService.PushPayload) + }); ok { + batch.PushBatch(ctx, payloads) + return + } + for _, p := range payloads { + s.notifyPusher.Push(ctx, p) + } +} + // writeSystemMessage 写入系统消息到群会话 func (s *GroupService) writeSystemMessage(ctx context.Context, conversationID int64, content string) { if s.msgWriter == nil { diff --git a/backend/go-service/app/notify/controller/notification_controller.go b/backend/go-service/app/notify/controller/notification_controller.go new file mode 100644 index 0000000..728774a --- /dev/null +++ b/backend/go-service/app/notify/controller/notification_controller.go @@ -0,0 +1,136 @@ +// Package controller 提供 notify 模块的 HTTP 接口 +package controller + +import ( + "errors" + "strconv" + + "github.com/echochat/backend/app/dto" + "github.com/echochat/backend/app/notify/service" + "github.com/echochat/backend/pkg/middleware" + "github.com/echochat/backend/pkg/utils" + "github.com/gin-gonic/gin" +) + +// NotificationController 通知控制器(REST API) +type NotificationController struct { + notifyService *service.NotifyService +} + +// NewNotificationController 创建 NotificationController 实例 +func NewNotificationController(notifyService *service.NotifyService) *NotificationController { + return &NotificationController{notifyService: notifyService} +} + +// GetList 获取通知列表 +// GET /api/v1/notifications?category=&is_read=&before_id=&limit= +func (ctl *NotificationController) GetList(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + var req dto.GetNotificationsRequest + if err := c.ShouldBindQuery(&req); err != nil { + utils.ResponseBadRequest(c, "请求参数错误: "+err.Error()) + return + } + + resp, err := ctl.notifyService.GetList(ctx, userID, &req) + if err != nil { + utils.ResponseError(c, "获取通知列表失败") + return + } + utils.ResponseOK(c, resp) +} + +// GetUnreadCount 获取未读数(总数 + 按分类) +// GET /api/v1/notifications/unread-count +func (ctl *NotificationController) GetUnreadCount(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + resp, err := ctl.notifyService.GetUnreadCount(ctx, userID) + if err != nil { + utils.ResponseError(c, "获取未读数失败") + return + } + utils.ResponseOK(c, resp) +} + +// MarkRead 标记单条通知为已读 +// PUT /api/v1/notifications/:id/read +func (ctl *NotificationController) MarkRead(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "通知 ID 格式错误") + return + } + + if err := ctl.notifyService.MarkRead(ctx, userID, id); err != nil { + if errors.Is(err, service.ErrNotificationNotFound) { + utils.ResponseNotFound(c, err.Error()) + return + } + utils.ResponseError(c, "标记已读失败") + return + } + utils.ResponseOK(c, nil) +} + +// MarkAllRead 一键清零未读 +// PUT /api/v1/notifications/read-all?category= +func (ctl *NotificationController) MarkAllRead(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + category := c.Query("category") + affected, err := ctl.notifyService.MarkAllRead(ctx, userID, category) + if err != nil { + utils.ResponseError(c, "批量标记已读失败") + return + } + utils.ResponseOK(c, dto.MarkReadResponse{Affected: int(affected)}) +} + +// Broadcast 管理员发起全员广播 +// POST /api/v1/admin/notifications/broadcast +// 需要 admin/super_admin 角色,由路由层中间件保证 +func (ctl *NotificationController) Broadcast(c *gin.Context) { + ctx := c.Request.Context() + operatorID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + var req dto.BroadcastNotificationRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ResponseBadRequest(c, "请求参数错误: "+err.Error()) + return + } + + affected, err := ctl.notifyService.Broadcast(ctx, operatorID, &req) + if err != nil { + utils.ResponseError(c, "广播失败") + return + } + utils.ResponseOK(c, dto.BroadcastNotificationResponse{Affected: affected}) +} diff --git a/backend/go-service/app/notify/dao/notification_dao.go b/backend/go-service/app/notify/dao/notification_dao.go new file mode 100644 index 0000000..eb13cac --- /dev/null +++ b/backend/go-service/app/notify/dao/notification_dao.go @@ -0,0 +1,200 @@ +// Package dao 提供 notify 模块的数据库访问操作 +package dao + +import ( + "context" + "time" + + "github.com/echochat/backend/app/constants" + "github.com/echochat/backend/app/notify/model" + "github.com/echochat/backend/pkg/logs" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// NotificationDAO 通知记录数据访问对象 +type NotificationDAO struct { + db *gorm.DB +} + +// NewNotificationDAO 创建 NotificationDAO 实例 +func NewNotificationDAO(db *gorm.DB) *NotificationDAO { + return &NotificationDAO{db: db} +} + +// Create 新增一条通知(单用户) +func (d *NotificationDAO) Create(ctx context.Context, n *model.Notification) error { + funcName := "dao.notification_dao.Create" + err := d.db.WithContext(ctx).Create(n).Error + if err != nil { + logs.Error(ctx, funcName, "写入通知失败", + zap.Int64("user_id", n.UserID), zap.String("type", n.Type), zap.Error(err)) + } + return err +} + +// BatchCreate 批量新增通知(管理员广播场景) +// 使用 GORM 的 CreateInBatches 拆分为 500/批次,避免单次参数过多 +func (d *NotificationDAO) BatchCreate(ctx context.Context, list []*model.Notification) error { + funcName := "dao.notification_dao.BatchCreate" + if len(list) == 0 { + return nil + } + err := d.db.WithContext(ctx).CreateInBatches(list, 500).Error + if err != nil { + logs.Error(ctx, funcName, "批量写入通知失败", + zap.Int("count", len(list)), zap.Error(err)) + } + return err +} + +// GetByID 根据 ID 获取单条通知 +func (d *NotificationDAO) GetByID(ctx context.Context, id int64) (*model.Notification, error) { + var n model.Notification + err := d.db.WithContext(ctx).First(&n, id).Error + if err != nil { + return nil, err + } + return &n, nil +} + +// ListRequest 列表查询条件 +type ListRequest struct { + UserID int64 // 接收者用户 ID(必填) + Types []string // type 过滤(空则不限) + IsRead *bool // 已读状态过滤(nil 则不限) + BeforeID int64 // 游标(小于此 ID 的记录),0 表示不限 + Limit int // 单页条数 +} + +// List 按条件分页查询通知(按 ID DESC 返回) +// 使用游标分页(id < beforeID)避免 OFFSET 随数据增长变慢 +func (d *NotificationDAO) List(ctx context.Context, req *ListRequest) ([]model.Notification, error) { + funcName := "dao.notification_dao.List" + q := d.db.WithContext(ctx).Model(&model.Notification{}). + Where("user_id = ?", req.UserID) + if len(req.Types) > 0 { + q = q.Where("type IN ?", req.Types) + } + if req.IsRead != nil { + q = q.Where("is_read = ?", *req.IsRead) + } + if req.BeforeID > 0 { + q = q.Where("id < ?", req.BeforeID) + } + limit := req.Limit + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + var list []model.Notification + err := q.Order("id DESC").Limit(limit).Find(&list).Error + if err != nil { + logs.Error(ctx, funcName, "查询通知列表失败", + zap.Int64("user_id", req.UserID), zap.Error(err)) + } + return list, err +} + +// CountUnread 统计未读总数 +func (d *NotificationDAO) CountUnread(ctx context.Context, userID int64) (int64, error) { + var total int64 + err := d.db.WithContext(ctx).Model(&model.Notification{}). + Where("user_id = ? AND is_read = false", userID). + Count(&total).Error + return total, err +} + +// CountUnreadByType 按 type 统计未读数(用于按分类汇总) +// 返回 type -> count +func (d *NotificationDAO) CountUnreadByType(ctx context.Context, userID int64) (map[string]int64, error) { + type row struct { + Type string + Cnt int64 + } + var rows []row + err := d.db.WithContext(ctx).Model(&model.Notification{}). + Select("type, COUNT(*) as cnt"). + Where("user_id = ? AND is_read = false", userID). + Group("type"). + Scan(&rows).Error + if err != nil { + return nil, err + } + result := make(map[string]int64, len(rows)) + for _, r := range rows { + result[r.Type] = r.Cnt + } + return result, nil +} + +// MarkRead 将指定通知标记为已读(仅限本人) +// 返回受影响行数,0 表示无效 ID 或非本人 +func (d *NotificationDAO) MarkRead(ctx context.Context, id, userID int64) (int64, error) { + funcName := "dao.notification_dao.MarkRead" + now := time.Now() + res := d.db.WithContext(ctx).Model(&model.Notification{}). + Where("id = ? AND user_id = ? AND is_read = false", id, userID). + Updates(map[string]interface{}{ + "is_read": true, + "read_at": now, + }) + if res.Error != nil { + logs.Error(ctx, funcName, "标记已读失败", + zap.Int64("id", id), zap.Int64("user_id", userID), zap.Error(res.Error)) + } + return res.RowsAffected, res.Error +} + +// MarkAllRead 将用户所有未读通知标记为已读 +// 可选按 types 过滤(为空则全部类型) +func (d *NotificationDAO) MarkAllRead(ctx context.Context, userID int64, types []string) (int64, error) { + funcName := "dao.notification_dao.MarkAllRead" + now := time.Now() + q := d.db.WithContext(ctx).Model(&model.Notification{}). + Where("user_id = ? AND is_read = false", userID) + if len(types) > 0 { + q = q.Where("type IN ?", types) + } + res := q.Updates(map[string]interface{}{ + "is_read": true, + "read_at": now, + }) + if res.Error != nil { + logs.Error(ctx, funcName, "批量标记已读失败", + zap.Int64("user_id", userID), zap.Error(res.Error)) + } + return res.RowsAffected, res.Error +} + +// DeleteExpired 清理已读过期通知(保留时长由 constants.NotifyRetentionDays 决定) +// 未读通知无论多久都不清理,避免用户漏看 +// 返回清理的行数 +func (d *NotificationDAO) DeleteExpired(ctx context.Context) (int64, error) { + funcName := "dao.notification_dao.DeleteExpired" + cutoff := time.Now().AddDate(0, 0, -constants.NotifyRetentionDays) + res := d.db.WithContext(ctx). + Where("is_read = true AND created_at < ?", cutoff). + Delete(&model.Notification{}) + if res.Error != nil { + logs.Error(ctx, funcName, "清理过期通知失败", zap.Error(res.Error)) + } + return res.RowsAffected, res.Error +} + +// ListAllActiveUserIDs 查询系统中所有正常状态的用户 ID(用于管理员广播) +// 此处直接查 auth_users 表,避免广播时循环依赖 user_dao +func (d *NotificationDAO) ListAllActiveUserIDs(ctx context.Context) ([]int64, error) { + funcName := "dao.notification_dao.ListAllActiveUserIDs" + var ids []int64 + err := d.db.WithContext(ctx). + Table("auth_users"). + Where("status = ?", constants.UserStatusActive). + Pluck("id", &ids).Error + if err != nil { + logs.Error(ctx, funcName, "查询全量用户 ID 失败", zap.Error(err)) + } + return ids, err +} diff --git a/backend/go-service/app/notify/model/notification.go b/backend/go-service/app/notify/model/notification.go new file mode 100644 index 0000000..8d28157 --- /dev/null +++ b/backend/go-service/app/notify/model/notification.go @@ -0,0 +1,29 @@ +// Package model 提供 notify 模块的数据库模型 +package model + +import ( + "time" +) + +// Notification 通知记录,对应 notify_notifications 表 +// 通知系统的核心持久化载体,覆盖好友 / 群聊 / 会议 / 系统广播四大类型 +// 每条通知对应单个接收者,不做跨用户共享 +type Notification struct { + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` // 通知唯一标识 + UserID int64 `json:"user_id" gorm:"not null;index:idx_notify_user_time,priority:1;index:idx_notify_user_unread,priority:1"` // 接收者用户 ID + Type string `json:"type" gorm:"size:50;not null"` // 通知类型常量,见 constants.NotifyType* + Title string `json:"title" gorm:"size:100;not null;default:''"` // 通知标题(前端列表主文案) + Content string `json:"content" gorm:"size:500;not null;default:''"` // 通知副文案 + Extra *string `json:"extra" gorm:"type:jsonb"` // 扩展数据 JSON 字符串,保存类型相关的业务参数 + ActorID *int64 `json:"actor_id"` // 触发主体用户 ID(申请人/邀请人等),系统广播为 nil + TargetType string `json:"target_type" gorm:"size:30;not null;default:''"` // 业务对象类型:user / group / meeting / system + TargetID *int64 `json:"target_id"` // 业务对象 ID + IsRead bool `json:"is_read" gorm:"not null;default:false"` // 是否已读:false=未读,true=已读 + ReadAt *time.Time `json:"read_at"` // 已读时间 + CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0)"` // 通知生成时间 +} + +// TableName 指定数据库表名 +func (Notification) TableName() string { + return "notify_notifications" +} diff --git a/backend/go-service/app/notify/provider.go b/backend/go-service/app/notify/provider.go new file mode 100644 index 0000000..3e3e82d --- /dev/null +++ b/backend/go-service/app/notify/provider.go @@ -0,0 +1,23 @@ +// Package notify 通知中心模块 +// 统一持久化好友 / 群聊 / 会议 / 系统广播四类通知,并通过 WS 实时推送 +package notify + +import ( + "github.com/echochat/backend/app/notify/controller" + "github.com/echochat/backend/app/notify/dao" + "github.com/echochat/backend/app/notify/service" + "github.com/echochat/backend/app/notify/task" + "github.com/google/wire" +) + +// NotifySet 通知模块 Wire Provider Set +// 对外暴露: +// - *service.NotifyService —— 业务服务,同时实现 Pusher / ConnectHook 接口,供上游模块绑定注入 +// - *controller.NotificationController —— REST API 控制器 +// - *task.CleanupTask —— 过期通知清理定时任务 +var NotifySet = wire.NewSet( + dao.NewNotificationDAO, + service.NewNotifyService, + controller.NewNotificationController, + task.NewCleanupTask, +) diff --git a/backend/go-service/app/notify/router.go b/backend/go-service/app/notify/router.go new file mode 100644 index 0000000..1a6e2ec --- /dev/null +++ b/backend/go-service/app/notify/router.go @@ -0,0 +1,29 @@ +package notify + +import ( + "github.com/echochat/backend/app/constants" + "github.com/echochat/backend/app/notify/controller" + "github.com/echochat/backend/pkg/middleware" + "github.com/gin-gonic/gin" +) + +// RegisterRoutes 注册 notify 模块的所有路由 +// 前台用户接口需 JWT,后台广播接口额外要求 admin/super_admin 角色 +func RegisterRoutes(r *gin.Engine, ctrl *controller.NotificationController, jwtAuth gin.HandlerFunc) { + // 前台用户接口 + authed := r.Group("/api/v1") + authed.Use(jwtAuth) + { + authed.GET("/notifications", ctrl.GetList) + authed.GET("/notifications/unread-count", ctrl.GetUnreadCount) + authed.PUT("/notifications/:id/read", ctrl.MarkRead) + authed.PUT("/notifications/read-all", ctrl.MarkAllRead) + } + + // 管理端广播接口:JWT + admin/super_admin 角色 + adminGroup := r.Group("/api/v1/admin") + adminGroup.Use(jwtAuth, middleware.RequireRole(constants.RoleAdmin, constants.RoleSuperAdmin)) + { + adminGroup.POST("/notifications/broadcast", ctrl.Broadcast) + } +} diff --git a/backend/go-service/app/notify/service/notify_service.go b/backend/go-service/app/notify/service/notify_service.go new file mode 100644 index 0000000..3171f01 --- /dev/null +++ b/backend/go-service/app/notify/service/notify_service.go @@ -0,0 +1,420 @@ +package service + +import ( + "context" + "errors" + "time" + + authModel "github.com/echochat/backend/app/auth/model" + "github.com/echochat/backend/app/constants" + "github.com/echochat/backend/app/dto" + "github.com/echochat/backend/app/notify/dao" + "github.com/echochat/backend/app/notify/model" + "github.com/echochat/backend/pkg/logs" + "github.com/echochat/backend/pkg/ws" + "go.uber.org/zap" + "gorm.io/gorm" +) + +var ( + ErrNotificationNotFound = errors.New("通知不存在") +) + +// NotifyService 通知业务服务 +// 负责: +// 1. 面向用户的列表 / 未读数 / 已读操作(REST API 层调用) +// 2. 面向上游业务的 Pusher 接口实现(持久化 + WS 推送) +// 3. 断线重连时的未读补偿推送 +type NotifyService struct { + notifyDAO *dao.NotificationDAO + pubsub *ws.PubSub + userResolver UserInfoResolver +} + +// NewNotifyService 创建 NotifyService 实例 +func NewNotifyService( + notifyDAO *dao.NotificationDAO, + pubsub *ws.PubSub, + userResolver UserInfoResolver, +) *NotifyService { + return &NotifyService{ + notifyDAO: notifyDAO, + pubsub: pubsub, + userResolver: userResolver, + } +} + +// ====== 面向用户的查询与操作 ====== + +// GetList 分页查询通知列表 +func (s *NotifyService) GetList(ctx context.Context, userID int64, req *dto.GetNotificationsRequest) (*dto.NotificationListResponse, error) { + funcName := "service.notify_service.GetList" + + types := typesByCategory(req.Category) + limit := req.Limit + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + list, err := s.notifyDAO.List(ctx, &dao.ListRequest{ + UserID: userID, + Types: types, + IsRead: req.IsRead, + BeforeID: req.BeforeID, + Limit: limit + 1, // 多查一条判断是否还有下一页 + }) + if err != nil { + return nil, err + } + + hasMore := false + if len(list) > limit { + hasMore = true + list = list[:limit] + } + + items := s.toNotificationDTOs(ctx, list) + logs.Debug(ctx, funcName, "查询通知列表", + zap.Int64("user_id", userID), zap.Int("count", len(items)), zap.Bool("has_more", hasMore)) + + return &dto.NotificationListResponse{ + List: items, + HasMore: hasMore, + }, nil +} + +// GetUnreadCount 查询未读数(总数 + 按分类) +func (s *NotifyService) GetUnreadCount(ctx context.Context, userID int64) (*dto.UnreadCountResponse, error) { + countMap, err := s.notifyDAO.CountUnreadByType(ctx, userID) + if err != nil { + return nil, err + } + total := 0 + byCat := map[string]int{ + constants.NotifyCategoryFriend: 0, + constants.NotifyCategoryGroup: 0, + constants.NotifyCategoryMeeting: 0, + constants.NotifyCategorySystem: 0, + } + for t, c := range countMap { + total += int(c) + byCat[constants.NotifyCategoryOfType(t)] += int(c) + } + return &dto.UnreadCountResponse{ + Total: total, + ByCategory: byCat, + }, nil +} + +// MarkRead 标记单条通知为已读 +func (s *NotifyService) MarkRead(ctx context.Context, userID, id int64) error { + affected, err := s.notifyDAO.MarkRead(ctx, id, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotificationNotFound + } + return err + } + if affected == 0 { + return ErrNotificationNotFound + } + return nil +} + +// MarkAllRead 将用户某分类(或全部)通知标记为已读 +// category 为空或 all 时清空全部未读 +func (s *NotifyService) MarkAllRead(ctx context.Context, userID int64, category string) (int64, error) { + types := typesByCategory(category) + return s.notifyDAO.MarkAllRead(ctx, userID, types) +} + +// PushUnreadTotalOnConnect 向刚建立连接的用户推送未读总数补偿 +// 由 WS handler 的 OnConnected 钩子调用(通过 NotifyConnectHook 接口适配) +func (s *NotifyService) PushUnreadTotalOnConnect(ctx context.Context, userID int64) { + funcName := "service.notify_service.PushUnreadTotalOnConnect" + resp, err := s.GetUnreadCount(ctx, userID) + if err != nil { + logs.Warn(ctx, funcName, "查询未读数失败", zap.Int64("user_id", userID), zap.Error(err)) + return + } + if resp.Total == 0 { + return + } + s.publishWS(ctx, userID, constants.WSEventNotifyUnreadTotal, map[string]interface{}{ + "total": resp.Total, + "by_category": resp.ByCategory, + }) +} + +// ====== Pusher 接口实现 ====== + +// Push 写入通知并推送 WS(单用户) +// 实现策略: +// 1. 同步入库(保证持久化) +// 2. 异步 WS 推送(避免阻塞上游业务事务) +// 3. 任一步失败不回滚另一步,全部仅记录 Warn 日志 +func (s *NotifyService) Push(ctx context.Context, payload *PushPayload) { + funcName := "service.notify_service.Push" + if payload == nil || payload.UserID <= 0 || payload.Type == "" { + logs.Warn(ctx, funcName, "无效的推送载荷", zap.Any("payload", payload)) + return + } + + n := s.buildModel(payload) + if err := s.notifyDAO.Create(ctx, n); err != nil { + logs.Warn(ctx, funcName, "通知入库失败,放弃 WS 推送", + zap.Int64("user_id", payload.UserID), zap.String("type", payload.Type), zap.Error(err)) + return + } + + go s.safePushWS(n, payload) +} + +// PushBatch 批量写入通知并推送 WS +// 适用于管理员广播 / 群操作批量通知场景 +// 优化:一次 BatchCreate 入库,然后分别 WS 推送 +func (s *NotifyService) PushBatch(ctx context.Context, payloads []*PushPayload) { + funcName := "service.notify_service.PushBatch" + if len(payloads) == 0 { + return + } + + models := make([]*model.Notification, 0, len(payloads)) + valid := make([]*PushPayload, 0, len(payloads)) + for _, p := range payloads { + if p == nil || p.UserID <= 0 || p.Type == "" { + continue + } + models = append(models, s.buildModel(p)) + valid = append(valid, p) + } + + if err := s.notifyDAO.BatchCreate(ctx, models); err != nil { + logs.Warn(ctx, funcName, "通知批量入库失败,放弃 WS 推送", + zap.Int("count", len(models)), zap.Error(err)) + return + } + + // 入库成功后逐条异步推送 + for i, n := range models { + go s.safePushWS(n, valid[i]) + } +} + +// ====== 内部辅助 ====== + +// buildModel 由 PushPayload 构造 Notification 持久化模型 +// 空 Title 使用类型默认文案;空 TargetType 按业务类型映射为默认值 +func (s *NotifyService) buildModel(p *PushPayload) *model.Notification { + title := p.Title + if title == "" { + if zh, ok := constants.NotifyTypeMap[p.Type]; ok { + title = zh + } + } + targetType := p.TargetType + if targetType == "" { + targetType = defaultTargetType(p.Type) + } + return &model.Notification{ + UserID: p.UserID, + Type: p.Type, + Title: title, + Content: p.Content, + Extra: marshalExtra(p.Extra), + ActorID: p.ActorID, + TargetType: targetType, + TargetID: p.TargetID, + IsRead: false, + } +} + +// safePushWS 序列化并通过 Redis Pub/Sub 推送 notify.new 事件 +// 失败仅记录 Warn,不影响持久化的通知记录 +func (s *NotifyService) safePushWS(n *model.Notification, p *PushPayload) { + defer func() { + if r := recover(); r != nil { + logs.Warn(context.Background(), "service.notify_service.safePushWS", + "推送协程 panic", zap.Any("panic", r)) + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + dtoItem := s.toNotificationDTO(ctx, n) + s.publishWS(ctx, n.UserID, constants.WSEventNotifyNew, dtoItem) +} + +// publishWS 序列化推送消息并发布到用户频道 +func (s *NotifyService) publishWS(ctx context.Context, userID int64, event string, data interface{}) { + if s.pubsub == nil { + return + } + push := ws.NewPushMessage(event, data) + bytes, err := ws.MarshalPush(push) + if err != nil { + logs.Warn(ctx, "service.notify_service.publishWS", "序列化推送失败", + zap.String("event", event), zap.Error(err)) + return + } + if err := s.pubsub.Publish(ctx, userID, bytes); err != nil { + logs.Warn(ctx, "service.notify_service.publishWS", "WS 推送失败", + zap.Int64("user_id", userID), zap.String("event", event), zap.Error(err)) + } +} + +// toNotificationDTO 将 model 转为对外 DTO,补全 actor 信息 +func (s *NotifyService) toNotificationDTO(ctx context.Context, n *model.Notification) dto.NotificationDTO { + item := dto.NotificationDTO{ + ID: n.ID, + Type: n.Type, + Category: constants.NotifyCategoryOfType(n.Type), + Title: n.Title, + Content: n.Content, + Extra: n.Extra, + ActorID: n.ActorID, + TargetType: n.TargetType, + TargetID: n.TargetID, + IsRead: n.IsRead, + CreatedAt: n.CreatedAt.Format("2006-01-02 15:04:05"), + } + if n.ReadAt != nil { + item.ReadAt = n.ReadAt.Format("2006-01-02 15:04:05") + } + + if n.ActorID != nil && *n.ActorID > 0 && s.userResolver != nil { + users, err := s.userResolver.GetUsersByIDs(ctx, []int64{*n.ActorID}) + if err == nil && len(users) > 0 { + item.ActorName = users[0].Nickname + item.ActorAvatar = users[0].Avatar + } + } + return item +} + +// toNotificationDTOs 批量转换 DTO 并一次性补全 actor 信息,避免 N+1 查询 +func (s *NotifyService) toNotificationDTOs(ctx context.Context, list []model.Notification) []dto.NotificationDTO { + items := make([]dto.NotificationDTO, 0, len(list)) + if len(list) == 0 { + return items + } + + actorSet := make(map[int64]struct{}) + for i := range list { + if list[i].ActorID != nil && *list[i].ActorID > 0 { + actorSet[*list[i].ActorID] = struct{}{} + } + } + userMap := make(map[int64]*authModel.User) + if len(actorSet) > 0 && s.userResolver != nil { + ids := make([]int64, 0, len(actorSet)) + for id := range actorSet { + ids = append(ids, id) + } + users, err := s.userResolver.GetUsersByIDs(ctx, ids) + if err == nil { + for i := range users { + userMap[users[i].ID] = &users[i] + } + } + } + + for i := range list { + n := &list[i] + item := dto.NotificationDTO{ + ID: n.ID, + Type: n.Type, + Category: constants.NotifyCategoryOfType(n.Type), + Title: n.Title, + Content: n.Content, + Extra: n.Extra, + ActorID: n.ActorID, + TargetType: n.TargetType, + TargetID: n.TargetID, + IsRead: n.IsRead, + CreatedAt: n.CreatedAt.Format("2006-01-02 15:04:05"), + } + if n.ReadAt != nil { + item.ReadAt = n.ReadAt.Format("2006-01-02 15:04:05") + } + if n.ActorID != nil { + if u, ok := userMap[*n.ActorID]; ok { + item.ActorName = u.Nickname + item.ActorAvatar = u.Avatar + } + } + items = append(items, item) + } + return items +} + +// typesByCategory 把前端的 category 参数转换为 type 列表 +// all 或空字符串返回 nil(不按类型过滤) +func typesByCategory(category string) []string { + if category == "" || category == constants.NotifyCategoryAll { + return nil + } + if list, ok := constants.NotifyTypesByCategory[category]; ok { + return list + } + return nil +} + +// defaultTargetType 根据通知类型推导默认业务对象类型 +// 用于 PushPayload.TargetType 为空时的兜底 +func defaultTargetType(notifyType string) string { + switch constants.NotifyCategoryOfType(notifyType) { + case constants.NotifyCategoryFriend: + return constants.NotifyTargetUser + case constants.NotifyCategoryGroup: + return constants.NotifyTargetGroup + case constants.NotifyCategoryMeeting: + return constants.NotifyTargetMeeting + default: + return constants.NotifyTargetSystem + } +} + +// ====== 管理员广播 ====== + +// Broadcast 向所有正常用户发送系统公告 +// 查询活跃用户 ID → 构造 payload → 批量入库 + 推送 +// 返回受影响用户数 +func (s *NotifyService) Broadcast(ctx context.Context, operatorID int64, req *dto.BroadcastNotificationRequest) (int, error) { + funcName := "service.notify_service.Broadcast" + logs.Info(ctx, funcName, "管理员广播", + zap.Int64("operator_id", operatorID), zap.String("title", req.Title)) + + userIDs, err := s.notifyDAO.ListAllActiveUserIDs(ctx) + if err != nil { + return 0, err + } + if len(userIDs) == 0 { + return 0, nil + } + + targetType := req.TargetType + if targetType == "" { + targetType = constants.NotifyTargetSystem + } + + payloads := make([]*PushPayload, 0, len(userIDs)) + for _, uid := range userIDs { + payloads = append(payloads, &PushPayload{ + UserID: uid, + Type: constants.NotifyTypeSystemBroadcast, + Title: req.Title, + Content: req.Content, + ActorID: &operatorID, + TargetType: targetType, + TargetID: req.TargetID, + Extra: req.Extra, + }) + } + s.PushBatch(ctx, payloads) + return len(payloads), nil +} diff --git a/backend/go-service/app/notify/service/pusher.go b/backend/go-service/app/notify/service/pusher.go new file mode 100644 index 0000000..5f96b36 --- /dev/null +++ b/backend/go-service/app/notify/service/pusher.go @@ -0,0 +1,66 @@ +// Package service 提供 notify 模块的业务逻辑 +package service + +import ( + "context" + "encoding/json" + + authModel "github.com/echochat/backend/app/auth/model" +) + +// PushPayload 跨模块推送通知的统一入参 +// 由上游业务模块(contact/group/admin/meeting)构造并传递给 Pusher +type PushPayload struct { + UserID int64 // 接收者用户 ID(必填) + Type string // 通知类型常量,见 constants.NotifyType* + Title string // 通知标题(可选,为空时使用类型默认文案) + Content string // 通知副文案 + ActorID *int64 // 触发主体用户 ID(申请人/邀请人等),系统广播为 nil + TargetType string // 业务对象类型:user / group / meeting / system + TargetID *int64 // 业务对象 ID + Extra interface{} // 扩展数据,内部会序列化为 JSON 字符串存入 extra 列 +} + +// Pusher 通知推送接口 +// 由 notify 模块实现,供 contact / group / admin / meeting 等上游模块注入调用 +// 实现原则: +// 1. 入库 + WS 推送 双动作,WS 推送失败不回滚入库(降级策略) +// 2. 同步调用可能阻塞业务,实现内部使用 goroutine 异步处理 +// 3. 一个 PushPayload 对应单个接收者;批量场景请多次调用 +type Pusher interface { + Push(ctx context.Context, payload *PushPayload) + PushBatch(ctx context.Context, payloads []*PushPayload) +} + +// UserInfoResolver 查询用户昵称 / 头像的接口,用于补全通知中的 actor 信息 +// 由 contact.FriendshipDAO 隐式实现(已有 GetUsersByIDs 方法) +type UserInfoResolver interface { + GetUsersByIDs(ctx context.Context, userIDs []int64) ([]authModel.User, error) +} + +// marshalExtra 将 Extra 字段序列化为 JSON 字符串,供 DAO 写入 jsonb 列 +// 空值返回 nil;若调用方已传入合法 JSON 字符串(string 或 *string),直接透传;其余类型走 json.Marshal +func marshalExtra(extra interface{}) *string { + if extra == nil { + return nil + } + switch v := extra.(type) { + case string: + if v == "" { + return nil + } + return &v + case *string: + if v == nil || *v == "" { + return nil + } + cp := *v + return &cp + } + bytes, err := json.Marshal(extra) + if err != nil { + return nil + } + s := string(bytes) + return &s +} diff --git a/backend/go-service/app/notify/task/cleanup_task.go b/backend/go-service/app/notify/task/cleanup_task.go new file mode 100644 index 0000000..5d3a46c --- /dev/null +++ b/backend/go-service/app/notify/task/cleanup_task.go @@ -0,0 +1,83 @@ +// Package task 提供 notify 模块的后台定时任务 +package task + +import ( + "context" + "time" + + "github.com/echochat/backend/app/notify/dao" + "github.com/echochat/backend/pkg/logs" + "go.uber.org/zap" +) + +// 默认每日凌晨 3 点执行一次,通过固定周期 24h 近似实现 +// 如需精确到凌晨可在未来引入 robfig/cron 库替换 Ticker +const defaultInterval = 24 * time.Hour + +// CleanupTask 过期通知清理任务 +// 周期扫描 notify_notifications,删除已读且超过 constants.NotifyRetentionDays 天的记录 +// 未读通知无论多久都不清理 +type CleanupTask struct { + notifyDAO *dao.NotificationDAO + interval time.Duration + stopCh chan struct{} +} + +// NewCleanupTask 创建 CleanupTask 实例 +// 默认周期 24 小时 +func NewCleanupTask(notifyDAO *dao.NotificationDAO) *CleanupTask { + return &CleanupTask{ + notifyDAO: notifyDAO, + interval: defaultInterval, + stopCh: make(chan struct{}), + } +} + +// Start 启动定时任务(非阻塞) +// 应在 main 进程启动后调用,进程退出时调用 Stop 释放 goroutine +func (t *CleanupTask) Start() { + funcName := "task.cleanup_task.Start" + logs.Info(nil, funcName, "启动通知清理任务", zap.Duration("interval", t.interval)) + + go func() { + // 启动时延迟 30s 再执行,避免与启动迁移 / 初始化竞争资源 + warmup := time.NewTimer(30 * time.Second) + select { + case <-t.stopCh: + warmup.Stop() + return + case <-warmup.C: + } + t.runOnce() + + ticker := time.NewTicker(t.interval) + defer ticker.Stop() + for { + select { + case <-t.stopCh: + return + case <-ticker.C: + t.runOnce() + } + } + }() +} + +// Stop 停止定时任务 +func (t *CleanupTask) Stop() { + close(t.stopCh) +} + +// runOnce 执行一次清理,带独立超时控制 +func (t *CleanupTask) runOnce() { + funcName := "task.cleanup_task.runOnce" + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + affected, err := t.notifyDAO.DeleteExpired(ctx) + if err != nil { + logs.Warn(ctx, funcName, "通知清理失败", zap.Error(err)) + return + } + logs.Info(ctx, funcName, "通知清理完成", zap.Int64("deleted", affected)) +} diff --git a/backend/go-service/app/provider/provider.go b/backend/go-service/app/provider/provider.go index 5b345d4..b889b37 100644 --- a/backend/go-service/app/provider/provider.go +++ b/backend/go-service/app/provider/provider.go @@ -11,6 +11,9 @@ import ( groupController "github.com/echochat/backend/app/group/controller" imController "github.com/echochat/backend/app/im/controller" imHandler "github.com/echochat/backend/app/im/handler" + notifyController "github.com/echochat/backend/app/notify/controller" + notifyService "github.com/echochat/backend/app/notify/service" + notifyTask "github.com/echochat/backend/app/notify/task" wsApp "github.com/echochat/backend/app/ws" "github.com/echochat/backend/config" "github.com/echochat/backend/pkg/db" @@ -46,6 +49,9 @@ type App struct { OfflinePusher *imHandler.OfflinePusher // 离线消息推送器 FileController *fileController.FileController // 文件上传控制器 GroupController *groupController.GroupController // 群聊管理控制器 + NotifyService *notifyService.NotifyService // 通知业务服务(兼 Pusher、ConnectHook) + NotifyController *notifyController.NotificationController // 通知控制器 + NotifyCleanupTask *notifyTask.CleanupTask // 通知清理定时任务 } // NewApp 创建应用实例 @@ -72,9 +78,12 @@ func NewApp( offlinePusher *imHandler.OfflinePusher, fileCtrl *fileController.FileController, groupCtrl *groupController.GroupController, + notifySvc *notifyService.NotifyService, + notifyCtrl *notifyController.NotificationController, + notifyCleanup *notifyTask.CleanupTask, ) *App { - // 注入离线消息推送器到 WS Handler wsHandler.SetOfflinePusher(offlinePusher) + wsHandler.SetNotifyConnectHook(notifySvc) return &App{ Config: cfg, @@ -99,6 +108,9 @@ func NewApp( OfflinePusher: offlinePusher, FileController: fileCtrl, GroupController: groupCtrl, + NotifyService: notifySvc, + NotifyController: notifyCtrl, + NotifyCleanupTask: notifyCleanup, } } diff --git a/backend/go-service/app/provider/wire.go b/backend/go-service/app/provider/wire.go index 54058de..3fa8937 100644 --- a/backend/go-service/app/provider/wire.go +++ b/backend/go-service/app/provider/wire.go @@ -17,6 +17,8 @@ import ( imApp "github.com/echochat/backend/app/im" imDAO "github.com/echochat/backend/app/im/dao" imService "github.com/echochat/backend/app/im/service" + notifyApp "github.com/echochat/backend/app/notify" + notifyService "github.com/echochat/backend/app/notify/service" wsApp "github.com/echochat/backend/app/ws" "github.com/echochat/backend/config" "github.com/google/wire" @@ -33,6 +35,7 @@ func InitializeApp(cfg *config.Config) (*App, error) { imApp.IMSet, fileApp.FileSet, groupApp.GroupSet, + notifyApp.NotifySet, wire.Bind(new(wsApp.FriendIDsGetter), new(*contactDAO.FriendshipDAO)), wire.Bind(new(groupService.UserInfoProvider), new(*contactDAO.FriendshipDAO)), wire.Bind(new(imService.GroupInfoGetter), new(*groupDAO.GroupDAO)), @@ -42,6 +45,9 @@ func InitializeApp(cfg *config.Config) (*App, error) { wire.Bind(new(imService.UserInfoGetter), new(*contactDAO.FriendshipDAO)), wire.Bind(new(contactService.OnlineChecker), new(*wsApp.OnlineService)), wire.Bind(new(groupService.MessageWriter), new(*imDAO.MessageDAO)), + wire.Bind(new(notifyService.UserInfoResolver), new(*contactDAO.FriendshipDAO)), + wire.Bind(new(contactService.NotifyPusher), new(*notifyService.NotifyService)), + wire.Bind(new(groupService.NotifyPusher), new(*notifyService.NotifyService)), ) return nil, nil } diff --git a/backend/go-service/app/provider/wire_gen.go b/backend/go-service/app/provider/wire_gen.go index 2f02076..11bb1dc 100644 --- a/backend/go-service/app/provider/wire_gen.go +++ b/backend/go-service/app/provider/wire_gen.go @@ -22,6 +22,10 @@ import ( dao4 "github.com/echochat/backend/app/group/dao" service5 "github.com/echochat/backend/app/group/service" imApp "github.com/echochat/backend/app/im" + controller6 "github.com/echochat/backend/app/notify/controller" + dao5 "github.com/echochat/backend/app/notify/dao" + service6 "github.com/echochat/backend/app/notify/service" + task2 "github.com/echochat/backend/app/notify/task" "github.com/echochat/backend/app/ws" "github.com/echochat/backend/config" "github.com/echochat/backend/pkg/db" @@ -67,7 +71,14 @@ func InitializeApp(cfg *config.Config) (*App, error) { contactManageController := controller2.NewContactManageController(contactManageService) handler := ws.ProvideWSHandler(hub, pubSub, jwtConfig, onlineService, authService) friendGroupDAO := dao3.NewFriendGroupDAO(gormDB) - contactService := service3.NewContactService(friendshipDAO, friendGroupDAO, pubSub, onlineService) + + // Notify 模块初始化(contact/group 依赖 NotifyPusher) + notificationDAO := dao5.NewNotificationDAO(gormDB) + notifyService := service6.NewNotifyService(notificationDAO, pubSub, friendshipDAO) + notificationController := controller6.NewNotificationController(notifyService) + notifyCleanupTask := task2.NewCleanupTask(notificationDAO) + + contactService := service3.NewContactService(friendshipDAO, friendGroupDAO, onlineService, notifyService) contactController := controller3.NewContactController(contactService) // IM 模块初始化 @@ -86,7 +97,7 @@ func InitializeApp(cfg *config.Config) (*App, error) { // Group 模块初始化 joinRequestDAO := dao4.NewJoinRequestDAO(gormDB) - groupService := service5.NewGroupService(groupDAO, joinRequestDAO, friendshipDAO, pubSub, messageDAO) + groupService := service5.NewGroupService(groupDAO, joinRequestDAO, friendshipDAO, pubSub, messageDAO, notifyService) groupController := controller5.NewGroupController(groupService) // Admin 群组管理初始化 @@ -98,6 +109,6 @@ func InitializeApp(cfg *config.Config) (*App, error) { messageManageService := service2.NewMessageManageService(messageManageDAO, userDAO, conversationDAO, pubSub) messageManageController := controller2.NewMessageManageController(messageManageService) - app := NewApp(cfg, gormDB, client, minioClient, authService, authController, adminAuthController, userManageController, onlineController, contactManageController, groupManageController, messageManageController, handler, hub, pubSub, onlineService, contactController, imController, imEventHandler, offlinePusher, fileController, groupController) + app := NewApp(cfg, gormDB, client, minioClient, authService, authController, adminAuthController, userManageController, onlineController, contactManageController, groupManageController, messageManageController, handler, hub, pubSub, onlineService, contactController, imController, imEventHandler, offlinePusher, fileController, groupController, notifyService, notificationController, notifyCleanupTask) return app, nil } diff --git a/backend/go-service/app/ws/handler.go b/backend/go-service/app/ws/handler.go index 500e421..1fbaf2d 100644 --- a/backend/go-service/app/ws/handler.go +++ b/backend/go-service/app/ws/handler.go @@ -27,6 +27,14 @@ type OfflineMessagePusher interface { PushOfflineMessages(ctx context.Context, userID int64) } +// NotifyConnectHook 通知未读补偿钩子接口 +// 由 notify.service.NotifyService 隐式实现 +// WebSocket 连接建立后触发,向客户端推送 notify.unread.total 事件 +// 用于断线重连场景下的徽标状态同步 +type NotifyConnectHook interface { + PushUnreadTotalOnConnect(ctx context.Context, userID int64) +} + var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, @@ -37,12 +45,13 @@ var upgrader = websocket.Upgrader{ // Handler WebSocket 连接处理器 type Handler struct { - hub *ws.Hub - pubsub *ws.PubSub - jwtCfg *config.JWTConfig - onlineService *OnlineService - tokenValidator TokenValidator - offlinePusher OfflineMessagePusher + hub *ws.Hub + pubsub *ws.PubSub + jwtCfg *config.JWTConfig + onlineService *OnlineService + tokenValidator TokenValidator + offlinePusher OfflineMessagePusher + notifyConnectHook NotifyConnectHook } // NewHandler 创建 WebSocket Handler 实例 @@ -61,6 +70,11 @@ func (h *Handler) SetOfflinePusher(pusher OfflineMessagePusher) { h.offlinePusher = pusher } +// SetNotifyConnectHook 设置通知未读补偿钩子(由 notify 模块在初始化时注入) +func (h *Handler) SetNotifyConnectHook(hook NotifyConnectHook) { + h.notifyConnectHook = hook +} + // Upgrade 处理 WebSocket 升级请求 // GET /ws?token=xxx → JWT 认证 → 升级连接 → 注册 Hub → 订阅 Redis 频道 func (h *Handler) Upgrade(c *gin.Context) { @@ -121,6 +135,10 @@ func (h *Handler) Upgrade(c *gin.Context) { if h.offlinePusher != nil { go h.offlinePusher.PushOfflineMessages(context.Background(), claims.UserID) } + + if h.notifyConnectHook != nil { + go h.notifyConnectHook.PushUnreadTotalOnConnect(context.Background(), claims.UserID) + } } // createReadHandler 创建带生命周期管理的消息处理函数 diff --git a/backend/go-service/cmd/server/main.go b/backend/go-service/cmd/server/main.go index d74d66b..f0c71c5 100644 --- a/backend/go-service/cmd/server/main.go +++ b/backend/go-service/cmd/server/main.go @@ -78,6 +78,10 @@ func main() { // 7. 注册路由(由 router.Setup 统一汇总各模块路由) router.Setup(engine, app) + // 7.1 启动 notify 模块过期通知清理定时任务(后台周期执行) + app.NotifyCleanupTask.Start() + defer app.NotifyCleanupTask.Stop() + // 8. 启动 HTTP 服务(优雅关闭) addr := fmt.Sprintf(":%d", cfg.Server.Port) srv := &http.Server{ diff --git a/backend/go-service/router/router.go b/backend/go-service/router/router.go index 4d586ca..d7b4fe6 100644 --- a/backend/go-service/router/router.go +++ b/backend/go-service/router/router.go @@ -12,6 +12,7 @@ import ( fileApp "github.com/echochat/backend/app/file" groupApp "github.com/echochat/backend/app/group" imApp "github.com/echochat/backend/app/im" + notifyApp "github.com/echochat/backend/app/notify" "github.com/echochat/backend/app/provider" wsApp "github.com/echochat/backend/app/ws" "github.com/echochat/backend/pkg/middleware" @@ -42,7 +43,7 @@ func Setup(engine *gin.Engine, app *provider.App) { imApp.RegisterRoutes(engine, app.IMController, jwtAuth) fileApp.RegisterRoutes(engine, app.FileController, jwtAuth) groupApp.RegisterRoutes(engine, app.GroupController, jwtAuth) + notifyApp.RegisterRoutes(engine, app.NotifyController, jwtAuth) // [未来] meeting.RegisterRoutes(engine, app.MeetingController, jwtAuth) - // [未来] notify.RegisterRoutes(engine, app.NotifyController, jwtAuth) } diff --git a/deploy/docker/postgres/init.sql b/deploy/docker/postgres/init.sql index fd41f40..55f3c12 100644 --- a/deploy/docker/postgres/init.sql +++ b/deploy/docker/postgres/init.sql @@ -331,3 +331,44 @@ COMMENT ON COLUMN im_message_reads.user_id IS '已读用户 ID'; COMMENT ON COLUMN im_message_reads.read_at IS '已读时间'; CREATE INDEX idx_msg_reads_user ON im_message_reads(user_id, read_at); + +-- ============================================================ +-- notify_notifications: 通知消息表 +-- 统一通知中心持久化存储,覆盖好友 / 群聊 / 会议 / 系统广播四类场景 +-- 写入失败不回滚业务;WS 推送失败不回滚入库(降级策略) +-- ============================================================ +CREATE TABLE notify_notifications ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + type VARCHAR(50) NOT NULL, + title VARCHAR(100) NOT NULL DEFAULT '', + content VARCHAR(500) NOT NULL DEFAULT '', + extra JSONB DEFAULT NULL, + actor_id BIGINT DEFAULT NULL, + target_type VARCHAR(30) NOT NULL DEFAULT '', + target_id BIGINT DEFAULT NULL, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + read_at TIMESTAMP(0) DEFAULT NULL, + created_at TIMESTAMP(0) NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE notify_notifications IS '通知消息表,统一通知中心持久化载体'; +COMMENT ON COLUMN notify_notifications.id IS '通知唯一标识,自增主键'; +COMMENT ON COLUMN notify_notifications.user_id IS '接收者用户 ID(每条通知对应单个用户)'; +COMMENT ON COLUMN notify_notifications.type IS '通知类型常量:friend_request / friend_accepted / friend_rejected / group_invite / group_join_request / group_join_approved / group_join_rejected / group_kicked / group_role_changed / system_broadcast / meeting_invite / meeting_reminder'; +COMMENT ON COLUMN notify_notifications.title IS '通知标题(前端列表主文案)'; +COMMENT ON COLUMN notify_notifications.content IS '通知副文案(详细说明或申请附言)'; +COMMENT ON COLUMN notify_notifications.extra IS '扩展数据 JSON,保存类型相关的原始参数(group_id / request_id / role 等)'; +COMMENT ON COLUMN notify_notifications.actor_id IS '触发此通知的主体用户 ID(申请人 / 邀请人等),系统广播为 NULL'; +COMMENT ON COLUMN notify_notifications.target_type IS '业务对象类型:user / group / meeting / system,用于点击跳转路由'; +COMMENT ON COLUMN notify_notifications.target_id IS '业务对象 ID,与 target_type 联合定位跳转目标'; +COMMENT ON COLUMN notify_notifications.is_read IS '是否已读:FALSE=未读,TRUE=已读'; +COMMENT ON COLUMN notify_notifications.read_at IS '已读时间,未读时为 NULL'; +COMMENT ON COLUMN notify_notifications.created_at IS '通知生成时间'; + +-- 列表查询核心索引(按用户倒序分页) +CREATE INDEX idx_notify_user_time ON notify_notifications (user_id, created_at DESC); +-- 未读数统计索引(覆盖索引) +CREATE INDEX idx_notify_user_unread ON notify_notifications (user_id, is_read) WHERE is_read = FALSE; +-- 清理任务索引(按创建时间扫描旧数据) +CREATE INDEX idx_notify_created_at ON notify_notifications (created_at); diff --git a/docs/api/README.md b/docs/api/README.md index cbba0df..e0535ad 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -18,7 +18,7 @@ | [frontend/im.md](frontend/im.md) | 即时通讯 | ✅ Phase 2b | 7 个 API:会话列表/置顶/删除/清空、历史消息、全局搜索、未读数 | | [frontend/group.md](frontend/group.md) | 群聊管理 | ✅ Phase 2c | 16 个 API:建群/管理/成员/角色/禁言/公告/搜索/入群审批 | | [frontend/meeting.md](frontend/meeting.md) | 会议 | 📋 后续 | 即时会议、预约会议、加入/离开、会议列表 | -| [frontend/notify.md](frontend/notify.md) | 通知 | 📋 后续 | 通知列表、标记已读 | +| [frontend/notify.md](frontend/notify.md) | 通知中心 | ✅ Phase 2e-1 | 5 个 API:通知列表(游标分页)/未读数/标记已读/全部已读/管理员广播 + 2 个 WS 事件(notify.new/notify.unread.total) | ### 后台管理端 (`admin/`) diff --git a/docs/api/frontend/notify.md b/docs/api/frontend/notify.md index dfeec13..c3b51e7 100644 --- a/docs/api/frontend/notify.md +++ b/docs/api/frontend/notify.md @@ -1,7 +1,23 @@ # 通知模块 API (Notify) -> 通用规范(认证方式、响应格式、错误码)见 [README.md](../README.md) -> 新通知的实时推送通过 WebSocket 完成,见 [websocket.md](../websocket.md) +> 通用规范(认证方式、响应格式、错误码)见 [README.md](../README.md) +> 新通知的实时推送通过 WebSocket 完成,见 [websocket.md](../websocket.md) +> 所属阶段:Phase 2e-1(已完成) + +--- + +## 概览 + +统一通知中心,覆盖四大业务类型: + +| 分类 | 类型常量 | 触发模块 | +|---|---|---| +| 好友 | `friend_request` / `friend_accepted` / `friend_rejected` | contact | +| 群聊 | `group_invite` / `group_join_request` / `group_join_approved` / `group_join_rejected` / `group_kicked` / `group_role_changed` | group | +| 系统 | `system_broadcast` | admin 广播 | +| 会议 | `meeting_invite` / `meeting_reminder` | meeting(Phase 2e-2/2e-3 接入) | + +> 通知持久化在 `notify_notifications` 表,每条通知对应单个接收者;30 天前的已读通知由后台定时任务自动清理,未读通知永久保留。 --- @@ -9,9 +25,11 @@ | 方法 | 路径 | 权限 | 说明 | |------|------|------|------| -| GET | /api/v1/notifications | 需认证 | 获取通知列表 | -| PUT | /api/v1/notifications/:id/read | 需认证 | 标记通知已读 | -| PUT | /api/v1/notifications/read-all | 需认证 | 全部标记已读 | +| GET | /api/v1/notifications | 需认证 | 获取通知列表(游标分页) | +| GET | /api/v1/notifications/unread-count | 需认证 | 获取未读数统计 | +| PUT | /api/v1/notifications/:id/read | 需认证 | 标记单条已读 | +| PUT | /api/v1/notifications/read-all | 需认证 | 全部/按分类标记已读 | +| POST | /api/v1/admin/notifications/broadcast | 管理员 | 全员广播系统通知 | --- @@ -25,69 +43,195 @@ | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| -| is_read | bool | 无 | 筛选已读/未读,不传则返回全部 | -| type | string | 无 | 筛选通知类型(meeting_invite / friend_request / friend_accepted / meeting_reminder / system) | -| page | int | 1 | 页码 | -| page_size | int | 20 | 每页数量 | +| category | string | `all` | 分类过滤:`all` / `friend` / `group` / `meeting` / `system` | +| is_read | bool | 无 | 是否已读:`true`/`false`,不传代表全部 | +| before_id | int64 | 无 | 游标:返回 id 小于此值的记录(按 id 降序) | +| limit | int | 20 | 页大小(最大 100) | **成功响应:** ```json { "code": 0, - "message": "ok", + "message": "success", "data": { "list": [ { - "id": 1, - "type": "meeting_invite", - "title": "会议邀请", - "content": "张三邀请你参加会议「产品需求讨论」", - "extra": { - "room_code": "123-456-789", - "room_title": "产品需求讨论", - "from_user_id": 1, - "from_username": "zhangsan" - }, + "id": 128, + "type": "group_invite", + "category": "group", + "title": "", + "content": "张三 邀请你加入「产品交流群」", + "extra": "{\"group_id\":12,\"group_name\":\"产品交流群\",\"conversation_id\":45,\"inviter_id\":3,\"inviter_name\":\"张三\"}", + "actor_id": 3, + "actor_name": "张三", + "actor_avatar": "", + "target_type": "group", + "target_id": 12, "is_read": false, - "created_at": "2026-02-27 10:00:00" - }, - { - "id": 2, - "type": "friend_request", - "title": "好友申请", - "content": "李四请求添加你为好友", - "extra": { - "from_user_id": 2, - "from_username": "lisi", - "message": "我是你的同事" - }, - "is_read": false, - "created_at": "2026-02-27 09:30:00" + "created_at": "2026-04-20 10:15:00" } ], - "total": 15, - "page": 1, - "page_size": 20 + "has_more": true + } +} +``` + +> `extra` 是 JSON 字符串,前端按需 `JSON.parse` 解析。游标翻页使用最后一条 `id` 作为下一次请求的 `before_id`。 + +--- + +## 2. 获取未读数统计 + +`GET /api/v1/notifications/unread-count` + +**权限:** 需认证 + +**成功响应:** +```json +{ + "code": 0, + "message": "success", + "data": { + "total": 5, + "by_category": { + "friend": 1, + "group": 3, + "meeting": 0, + "system": 1 + } } } ``` --- -## 2. 标记通知已读 +## 3. 标记单条已读 `PUT /api/v1/notifications/:id/read` -**权限:** 需认证 +**权限:** 需认证(只能标记属于自己的通知) -**路径参数:** `id` — 通知 ID +**响应:** +```json +{ "code": 0, "message": "success", "data": { "affected": 1 } } +``` + +> 已读幂等:重复调用返回 `affected=0`。 --- -## 3. 全部标记已读 +## 4. 批量标记已读 -`PUT /api/v1/notifications/read-all` +`PUT /api/v1/notifications/read-all?category={category}` **权限:** 需认证 -**说明:** 将当前用户的所有未读通知标记为已读。 +**Query 参数:** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| category | string | 否 | 分类:`friend`/`group`/`meeting`/`system`,不传代表全部 | + +> ⚠️ 注意:`category` 通过 **Query String** 传递(非 Request Body)。`PUT` 方法无需请求体。 + +**示例:** +- 全部标已读:`PUT /api/v1/notifications/read-all` +- 仅好友类标已读:`PUT /api/v1/notifications/read-all?category=friend` + +**响应:** +```json +{ "code": 0, "message": "success", "data": { "affected": 3 } } +``` + +--- + +## 5. 管理员广播系统通知 + +`POST /api/v1/admin/notifications/broadcast` + +**权限:** `RoleAdmin` 或 `RoleSuperAdmin`(JWT + 角色校验) + +**请求体:** +```json +{ + "title": "系统维护通知", + "content": "今晚 02:00 - 04:00 将进行系统升级,请提前保存工作", + "target_type": "system", + "target_id": null, + "extra": null +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| title | string | 是 | 标题,最长 100 | +| content | string | 是 | 正文,最长 500 | +| target_type | string | 否 | 业务对象类型,默认 `system` | +| target_id | int64 | 否 | 业务对象 ID | +| extra | string | 否 | 扩展 JSON 字符串 | + +**响应:** +```json +{ "code": 0, "message": "success", "data": { "affected": 128 } } +``` + +> 后端将遍历所有活跃用户批量写入通知,在线用户实时收到 `notify.new` WS 推送;离线用户重连后通过 `notify.unread.total` 触发未读数补偿。 + +--- + +## 6. WebSocket 事件 + +### `notify.new` — 新通知到达 + +| 方向 | 触发 | +|---|---| +| Server → Client | 每次通知写入成功后 | + +**载荷**:与 REST 返回的单条通知对象字段一致。 + +```json +{ + "event": "notify.new", + "data": { + "id": 129, + "type": "friend_request", + "category": "friend", + "title": "好友申请", + "content": "Bob 请求添加你为好友", + "actor_id": 5, + "actor_name": "Bob", + "target_type": "user", + "target_id": 5, + "is_read": false, + "created_at": "2026-04-20 10:17:21" + } +} +``` + +### `notify.unread.total` — 未读数补偿 + +| 方向 | 触发 | +|---|---| +| Server → Client | WebSocket 连接建立成功时 | + +**载荷**: + +```json +{ + "event": "notify.unread.total", + "data": { + "total": 5, + "by_category": { "friend": 1, "group": 3, "meeting": 0, "system": 1 } + } +} +``` + +> 前端以该值覆盖本地缓存的未读数,保证断线重连后铃铛徽标准确。 + +--- + +## 已知限制(单端连接架构) + +- 当前 `ws.Hub` 仅支持同一用户单连接(新连接关闭旧连接),**不提供多端已读同步**;此限制已在 `docs/plans/2026-04-20-phase2e-design.md` §3.1/§3.5 修订。 +- 管理端广播发布 UI 推迟到 Phase 2f(后端接口已落地,管理端页面未提供)。 +- `group_invite` 的"接受/拒绝"内联操作当前语义:接受仅跳转会话;拒绝调用 `leaveGroup`(由于当前 InviteMembers 为直接加入成员)。 diff --git a/docs/plans/2026-02-27-echochat-system-design.md b/docs/plans/2026-02-27-echochat-system-design.md index 7410339..714431f 100644 --- a/docs/plans/2026-02-27-echochat-system-design.md +++ b/docs/plans/2026-02-27-echochat-system-design.md @@ -1005,9 +1005,34 @@ services: - 消息类型扩展(图片/语音/文件消息) - 管理端消息管理功能 -#### Phase 2e:会议与通知 📋 待规划 -- 多人音视频会议(即时会议 + 预约会议) -- 消息通知系统 +#### Phase 2e:会议与通知 🚧 规划完成,拆分为三子阶段(详见 `docs/plans/2026-04-20-phase2e-design.md`) + +- **Phase 2e-1:通知系统** 🔜 开发中 + - 统一通知中心(好友/群聊事件 + 系统广播 + 会议通知类型预留) + - 双通道(持久化入库 + mini-toast) + - 入口:「我的」Tab 顶部铃铛 + 数字徽标 + - 11 种通知类型枚举;30 天保留期;多端已读同步 + - 跨模块 Pusher 接口(contact/group 模块解耦集成) + +- **Phase 2e-2:会议 MVP** 📋 待开发 + - mediasoup Node.js 独立媒体服务 + mediasoup-client 前端集成 + - 即时会议(≤ 8 人)、会议号/密码、音视频开关、主持人控制 + - WebSocket 信令复用现有 Hub + - 不含:录制、屏幕共享、预约 + +- **Phase 2e-3:会议增强** 📋 待开发 + - 预约会议 + 定时提醒 + - 会议邀请(走 2e-1 `meeting_invite` 通知) + - 入会前设备预览 + +#### Phase 2f:管理端扩展(MVP 收尾)📋 待规划 + +*(从 Phase 2e 剥离出的管理端功能,保证前台用户体验先行)* + +- 会议管理(列表/详情/强制关闭/统计仪表板) +- 通知广播发布 UI +- 管理端仪表板总览、操作日志页、系统配置管理 +- 用户会议记录查询 ### 第二期 - 屏幕共享 diff --git a/docs/plans/2026-04-20-phase2e-1-design.md b/docs/plans/2026-04-20-phase2e-1-design.md new file mode 100644 index 0000000..f0614d2 --- /dev/null +++ b/docs/plans/2026-04-20-phase2e-1-design.md @@ -0,0 +1,294 @@ +# Phase 2e-1 设计文档:统一通知中心 + +> **状态:** ✅ 已完成 +> **上级设计:** [Phase 2e 整体路线图](./2026-04-20-phase2e-design.md)(本文档是其 §三「Phase 2e-1 详细设计」的专项展开版本,针对具体子阶段收敛) +> **实施计划:** [Phase 2e-1 实施计划](./2026-04-20-phase2e-1-implementation.plan.md) +> **验证报告:** [Phase 2e-1 测试验证报告](../../test-report-phase2e-1-notification.md) +> **分支:** `feature/phase2e-meeting-notification` +> **最后更新:** 2026-04-20(实施完成 + code-reviewer 审查修订) + +--- + +## 一、文档定位说明 + +项目历史惯例:每个 Phase 子阶段对应一份 `{phase}-design.md`(设计)+ `{phase}-implementation.plan.md`(实施)文档。 + +**Phase 2e 采用「总-分」结构**: +- `2026-04-20-phase2e-design.md` —— Phase 2e 大阶段路线图 + 三个子阶段(2e-1/2e-2/2e-3)的总览与设计 +- `2026-04-20-phase2e-1-design.md`(**本文档**) —— Phase 2e-1「统一通知中心」专用设计文档,汇聚该子阶段的所有设计决策与实施后修订 +- `2026-04-20-phase2e-2-design.md` / `2026-04-20-phase2e-3-design.md` —— 待 2e-2/2e-3 启动时分别补充 + +选择这种结构的原因:三个子阶段共享部分设计背景(跨模块通信模式、mediasoup 与通知的关联等),保留总体设计可避免重复;但每个子阶段完成后应有独立的 design 落档,记录实施过程中的约束变化与修订。 + +--- + +## 二、目标与范围 + +### 2.1 业务目标 + +参照微信,为 EchoChat 补齐**「统一通知中心」**,解决现有系统三类问题: + +1. **通知分散**:好友申请、入群邀请、系统公告等事件散落在各模块 toast,用户错过即丢失 +2. **不可追溯**:无持久化,历史操作无法回查 +3. **无统一入口**:用户无法集中查看所有待处理事项 + +### 2.2 交付范围(本期 P0) + +| 能力 | 说明 | 对应 §phase2e-design | +|------|------|----------------------| +| 11 种通知 type 枚举 | 10 种本期落地 + 2 种预留(meeting_*) | [§3.2](./2026-04-20-phase2e-design.md#32-通知类型枚举11-种含-2e-22e-3-预留) | +| `notify_notifications` 表 | 新增 1 张 PostgreSQL 表 + 3 索引 + 30 天清理 | [§3.3](./2026-04-20-phase2e-design.md#33-数据库设计新增-1-张表) | +| 5 个 REST 接口 | 4 用户端 + 1 管理员广播 | [§3.4](./2026-04-20-phase2e-design.md#34-后端-rest-api设计文档更新) | +| 2 个 WS 事件 | `notify.new`(新通知) + `notify.unread.total`(断线补偿) | [§3.5](./2026-04-20-phase2e-design.md#35-websocket-事件2-个单端连接架构) | +| 跨模块 Pusher 接口 | contact/group → notify 的单向注入 | [§3.6](./2026-04-20-phase2e-design.md#36-跨模块-pusher-接口沿用-phase-2a-接口注入标准) | +| 通知中心前端 UI | profile 铃铛入口 + 5 分类 Tab 列表 + 内联操作 | [§3.8/3.9](./2026-04-20-phase2e-design.md#38-前端页面) | + +### 2.3 显式不做(推迟清单) + +| 能力 | 推迟原因 | 去向 | +|------|----------|------| +| 多端已读同步(`notify.read.ack`) | 现有 `ws.Hub` 单连接架构踢旧连接 | Phase 2f 或二期 | +| WS Hub 多端连接改造 | 技术债,需重构 `clients map[int64]*Client` | Phase 2f 或二期 | +| 管理端广播发布 UI | 后端接口已就绪,前端仅缺表单页 | Phase 2f | +| 通知分类开关(push 偏好) | MVP 默认全开 | Phase 2f | +| 会议类通知(meeting_invite/reminder)| 依赖 2e-2/2e-3 落地 | Phase 2e-2/2e-3 | +| Playwright E2E 自动化 CI | 当前仅手动验证清单 | CI 基础设施建设阶段 | + +--- + +## 三、关键架构决策 + +### 3.1 单端 WS 连接架构(实施过程中锁定的核心约束) + +**背景**:设计初期规划「多端已读同步」,实施时发现 `backend/go-service/pkg/ws/hub.go` 的 `clients map[int64]*Client` 实际只支持单连接 —— 同一用户新登录会主动关闭旧连接。 + +**决策**: +- **保持单端架构**,Phase 2e-1 不做 `ws.Hub` 改造 +- **移除** `notify.read.ack` 事件(原计划跨设备广播已读) +- 通知系统设计为「当前活跃连接设备」单端体验,多端改造延后 + +**影响**: +- [设计文档 §3.1](./2026-04-20-phase2e-design.md#31-核心体验决策) 决策表「多端已读同步」项已改为「暂不支持」 +- [§3.5](./2026-04-20-phase2e-design.md#35-websocket-事件2-个单端连接架构) WS 事件数从 3 个降为 2 个 +- [§七](./2026-04-20-phase2e-design.md#七风险与应对) 对应的多端消息风暴风险天然规避 +- [§九](./2026-04-20-phase2e-design.md#九后续规划清单必须留档) 推迟清单新增「WS Hub 多端连接支持改造」 + +### 3.2 跨模块通信模式(沿用 Phase 2a 接口注入标准) + +```mermaid +flowchart LR + contactSvc[contact Service] + groupSvc[group Service] + adminCtrl[admin/notify Controller] + pusher[notify.Pusher interface] + notifySvc[notify Service] + notifyDAO[notify DAO] + db[(PostgreSQL)] + hub[ws.Hub] + + contactSvc -->|Wire 注入| pusher + groupSvc -->|Wire 注入| pusher + adminCtrl -->|Wire 注入| pusher + pusher --> notifySvc + notifySvc --> notifyDAO + notifyDAO --> db + notifySvc -->|SendToUser| hub +``` + +**关键约束**: +- **依赖方向单向**:contact/group → notify,**反向禁止**(未来 2e-2 meeting 模块同样遵守) +- Pusher 接口定义在 `app/notify/service/pusher.go`,实现同包 +- Wire 绑定:`wire.Bind(new(contactService.NotifyPusher), new(*notifyService.NotifyService))` 等 +- **降级策略**:WS 推送失败不回滚数据库,下一次 `notify.unread.total` 补偿兜底 + +### 3.3 WS 连接建立/重连钩子 + +引入新接口 `ws.NotifyConnectHook`,在 `ws.Handler` 内连接建立成功后触发: + +```go +type NotifyConnectHook interface { + OnUserConnected(ctx context.Context, userID int64) error +} +``` + +由 `notify.NotifyService` 实现,推送 `notify.unread.total` 作为权威值覆盖前端本地缓存,**解决断线期间错过的通知数据一致性问题**。 + +### 3.4 30 天清理任务 + +- `app/notify/task/cleanup_task.go`:`time.Ticker` 驱动,默认 24 小时执行一次 +- **策略**:只删除已读 + 过期(`is_read=true AND created_at < NOW() - 30 days`) +- 未读通知无论多久都保留,避免漏看历史重要事项 +- 生命周期:随 `cmd/server/main.go` 启动,优雅关停 + +--- + +## 四、模块结构 + +### 4.1 后端(`backend/go-service/app/notify/`) + +``` +notify/ +├── constants/ +│ └── notify_types.go # 11 种 type 常量 + 5 种 category 映射 + WS 事件名 +├── model/ +│ └── notification.go # GORM 模型 +├── dao/ +│ └── notification_dao.go # CRUD + 批量已读 + 统计 + 清理 + 全量用户列表 +├── service/ +│ ├── notify_service.go # 业务逻辑(含 UserInfoResolver/NotifyConnectHook 实现) +│ └── pusher.go # Pusher 接口 + Impl(持久化+WS 推送) +├── controller/ +│ └── notification_controller.go # 4 用户接口 + 1 管理员广播 +├── task/ +│ └── cleanup_task.go # 30 天清理 cron +├── provider.go # Wire NotifySet +└── router.go # 路由注册 +``` + +### 4.2 前端(`frontend/src/`) + +``` +frontend/src/ +├── api/notify.js # REST API 封装 +├── constants/notify.js # 前端常量(type/category/图标/颜色/支持内联操作判定) +├── store/notify.js # Pinia Store(分类缓存 + cursor 分页 + WS 监听) +├── components/notify/ +│ └── NotifyItem.vue # 通用卡片(按 type 渲染 + 内联"接受/拒绝"按钮) +└── pages/notify/ + └── index.vue # 通知中心主页(5 分类 Tab + 骨架/空态/列表) +``` + +--- + +## 五、验收标准 + +- [x] 10 种业务通知类型全部正确触发并落库(friend×3 + group×6 + system×1) +- [x] 用户 A 给 B 发好友申请 → B 通知中心出现记录,WS 实时收到 `notify.new`,铃铛徽标 +1 +- [x] 群邀请/入群申请支持内联「接受/拒绝」按钮 +- [x] 管理员 `POST /api/v1/admin/notifications/broadcast` 推送 `system_broadcast` → 在线用户实时收到 +- [x] 断线重连 → 收到 `notify.unread.total`,徽标与后端查询一致 +- [x] 30 天前已读通知被清理;未读无论多久都保留 +- [x] Logout → `notifyStore.reset()` 清空,防止跨用户数据泄漏 +- [x] `frontend/src/store/contact.js:155` 旧散落监听已删除,无重复提示 +- [x] `frontend/src/store/group.js` `_onJoinRequest`/`_onJoinApproved` 的 `uni.showToast` 已移除 +- [x] 代码审查(`code-reviewer` 子代理)通过,Blocker 全部修复 + +--- + +## 六、实施后的修订记录 + +### 6.1 设计变更(相对原设计的偏离) + +| 项 | 原计划 | 实际落地 | 原因 | +|----|--------|----------|------| +| WS 事件数量 | 3 个(含 `notify.read.ack`) | **2 个** | 单端 WS 架构约束 | +| 多端已读同步 | 后端主导 WS 广播 | **不支持** | 同上,推迟到 Phase 2f | +| Playwright E2E 自动化 | 每项核心场景都有脚本 | 改为 `test-report-*.md` 手动验证清单 | 缺少 E2E CI 基础设施 | + +### 6.2 Code-Reviewer 审查发现(2026-04-20) + +整体结论:**有条件通过**(1 Blocker / 5 Major / 11 Minor / 10 亮点) + +**🔴 Blocker(已当场修复)**: +- `markAllRead` 前后端契约错位:前端将 `category` 放入 PUT body,后端读 query string → 分类标记已读失效 +- **修复**:`frontend/src/api/notify.js` 改为 `PUT /api/v1/notifications/read-all?category=xxx`;同步 [Notify API 文档 §4](../api/frontend/notify.md#4-批量标记已读) 明确 Query 契约 +- **验证**:后端 `go build ./...` 通过;纳入 Playwright 验证清单复测项 + +**🟡 Major / 🟢 Minor 项**:均不阻塞合入,已纳入 [Phase 2e 设计文档 §九](./2026-04-20-phase2e-design.md#九后续规划清单必须留档) Phase 2f 清理清单,包括: +- Response 字段与文档一致性细化 +- 前端常量重复定义合并 +- WS `notify.new` / `notify.unread.total` 事件竞态兜底 +- NotifyItem 防重点击 +- Broadcast 错误语义增强 +- DDL 字段尺寸微调 / Pusher 签名与设计稿对齐 / WS 幂等去重 / goroutine 限流等 + +### 6.3 Playwright MCP 端到端验证(2026-04-20) + +> 详细见 [测试验证报告 §八](../../test-report-phase2e-1-notification.md#八playwright-mcp-自动化验证与-bug-修复2026-04-20) + +通过 Playwright MCP 驱动 H5 浏览器对 testuser1(id=4)完整走查:登录 → 进入通知中心 → 构造好友申请 → 管理员广播 → 点击标记已读 → Deep-link 跳转 → 全部已读,**所有链路通过**。 + +**现场发现并修复 2 个 Bug**: + +| 级别 | Bug | 根因 | 修复 | +|------|-----|------|------| +| 🔴 Bug-1 | 点击通知 `PUT .../undefined/read` 400 | `NotifyItem` 自定义 emit 名 `tap` 与 uni-app 原生 DOM 事件冲突,Event 对象覆盖 notify 参数 | emit 名改为 `item-tap` / `item-accept` / `item-reject`,父组件同步更新 | +| 🟡 Bug-2 | 标记已读后分类角标不递减,依赖下次 fetch 修复 | `markRead` 调用 `_patchAll` 后 `target.is_read` 已变 true,`if (!target.is_read)` 永假 | 预先快照 `const wasUnread = !!target && !target.is_read` | + +**涉及文件**: +- `frontend/src/components/notify/NotifyItem.vue` +- `frontend/src/pages/notify/index.vue` +- `frontend/src/store/notify.js` + +**验证结论**:WS 实时推送(admin 广播 → 1s 内前端自动插入列表 + 角标 +1)、Deep-link 跳转(好友申请 → `/pages/contact/request`)、批量清零(「全部已读」按钮)、分类 Tab 独立统计等全部正常。前后端 `go build ./...` 与前端 lint 均通过。 + +### 6.4 TabBar「我的」聚合未读红点(2026-04-21) + +#### 背景 + +实施完成后反馈:通知中心的未读数仅在 `/pages/profile/index` 页面内(铃铛 badge、菜单项 badge)可见;用户在其他 tabBar 页面(消息/联系人/会议)时无法感知「我的」模块有新事件,需被动切到"我的"才知道。这违反了"一级导航应承担未读提醒职责"的基本原则。 + +#### 设计决策 + +采用业界主流做法(微信/QQ/钉钉的"我"Tab 模式):**tabBar「我的」图标右上角显示纯红点(无数字),作为"我的"模块所有未读事件的聚合指示器**。 + +设计原则: + +1. **信息层级分离**:tabBar 是一级导航,只需 Boolean(有/无未读);具体数字属于二级信息,进入页面后再展示 +2. **聚合指示器可扩展**:红点来源是一个**开放集合**,当前只聚合 `notifyStore.unreadTotal`,未来可无缝追加「资料待完善」「安全提醒」「新版本可用」等,tabBar 无需感知具体来源 +3. **语义清晰不混用**:保留现有 `getBadge(index)`(返回数字,用于消息/联系人 Tab);新增 `hasDot(index)`(返回布尔,用于"我的" Tab);模板优先渲染数字 badge,无数字时再渲染红点 + +#### 实现摘要 + +| 层 | 改动 | +|---|---| +| `frontend/src/components/CustomTabBar.vue` | 引入 `useNotifyStore`,新增 `hasDot(index)` 方法;模板条件渲染 `.tab-dot` 元素;补充小红点样式(16rpx 红色圆点 + 2rpx 白色描边) | + +核心代码(聚合逻辑集中在 `hasDot(3)`,未来扩展仅需追加 `|| 新来源`): + +```javascript +hasDot(index) { + if (index === 3) { + const notifyStore = useNotifyStore() + return notifyStore.unreadTotal > 0 + // 未来扩展示例: + // || profileStore.hasProfileReminder + // || securityStore.hasSecurityAlert + // || appStore.hasNewVersion + } + return false +} +``` + +#### 验证(Playwright) + +| 场景 | tabBar "我的" | 结果 | +|---|---|---| +| 有 3 条未读 · 消息页 | 红点亮 | ✅ | +| 有 3 条未读 · 我的页(选中态) | 红点亮 | ✅ | +| 点"全部已读"后 · 我的页 | 红点消失 | ✅ | +| 返回消息页 | 红点消失 | ✅ | + +三层信息层级同步响应 `unreadTotal` 变化:tabBar 红点(Boolean) / 铃铛 badge(数字 3) / 通知中心菜单 badge(数字 3)。 + +--- + +## 七、关联文档 + +- [Phase 2e 整体路线图](./2026-04-20-phase2e-design.md)(本文档的上级设计) +- [Phase 2e-1 实施计划](./2026-04-20-phase2e-1-implementation.plan.md)(11 个 Task 拆分) +- [Phase 2e-1 测试验证报告](../../test-report-phase2e-1-notification.md)(E2E 清单 + 审查修复记录) +- [Notify API 文档](../api/frontend/notify.md)(5 REST + 2 WS 事件) +- [项目开发进度 · CURRENT_STATUS](../progress/CURRENT_STATUS.md) +- [项目上下文 · project-context.mdc](../../.cursor/rules/project-context.mdc) + +--- + +## 八、变更记录 + +| 日期 | 变更 | +|------|------| +| 2026-04-20 | Phase 2e-1 设计文档首版落盘(由 Phase 2e 总设计 §三 专项展开 + 实施后修订合并) | +| 2026-04-20 | 锁定单端 WS 架构决策,移除 `notify.read.ack`;code-reviewer Blocker 修复记录 | +| 2026-04-20 | 追加 §6.3 Playwright MCP 端到端验证成果,记录 2 个现场发现的交互 Bug 修复(事件名冲突、markRead 竞态) | +| 2026-04-21 | 追加 §6.4 TabBar「我的」聚合未读红点设计与实现,解决跨 tabBar 页面的未读感知问题(为后续我的模块功能扩展预留聚合入口) | diff --git a/docs/plans/2026-04-20-phase2e-1-implementation.plan.md b/docs/plans/2026-04-20-phase2e-1-implementation.plan.md new file mode 100644 index 0000000..e07e233 --- /dev/null +++ b/docs/plans/2026-04-20-phase2e-1-implementation.plan.md @@ -0,0 +1,249 @@ +# Phase 2e-1 实施计划:统一通知中心 + +> **状态:** ✅ 已完成 +> **设计文档:** [Phase 2e 设计文档](./2026-04-20-phase2e-design.md) +> **验证报告:** [Phase 2e-1 测试验证报告](../../test-report-phase2e-1-notification.md) +> **分支:** `feature/phase2e-meeting-notification` +> **原规划文件(Cursor 临时):** `.cursor/plans/phase_2e-1_通知系统实施_b2d43c9d.plan.md`(已被 `.gitignore`,本文档为其正式归档版本) +> **最后更新:** 2026-04-20(Task 11 落盘同步) + +--- + +## 一、范围锁定 + +### 包含 + +- **10 种业务通知类型**(本期落地触发): + - `friend_request` / `friend_accepted` / `friend_rejected` + - `group_invite` / `group_join_request` / `group_join_approved` / `group_join_rejected` / `group_kicked` / `group_role_changed` + - `system_broadcast` +- **2 种仅预留枚举**(业务由 2e-2/2e-3 对接):`meeting_invite`、`meeting_reminder` +- 单端 WS 连接架构(沿用现状):**不做多端已读同步** +- 双通道推送:持久化入库 + WS `notify.new` 实时推送 + 铃铛徽标 + mini-toast +- 通知中心 UI:顶部 5 分类 Tab(全部/好友/群聊/会议/系统)+ 下拉刷新 + 上拉加载 +- 管理员广播:`POST /api/v1/admin/notifications/broadcast`(仅后端,前端 UI 推迟) + +### 显式推迟(详见设计文档 §九) + +- **WS Hub 多端连接支持改造** → Phase 2f / 二期 +- `notify.read.ack` 跨设备广播 → 依赖上项,同步推迟 +- 管理端广播发布 UI → Phase 2f +- 通知分类开关(push 偏好设置)→ Phase 2f +- Playwright 自动化 CI → 待 CI 基础设施建设统一接入 + +--- + +## 二、关键架构决策 + +### 2.1 跨模块通信模式(沿用 Phase 2a 接口注入标准) + +```mermaid +flowchart LR + contactSvc[contact Service] + groupSvc[group Service] + adminCtrl[admin/notify Controller] + pusher[notify.Pusher interface] + notifySvc[notify Service] + notifyDAO[notify DAO] + db[(PostgreSQL)] + hub[ws.Hub] + + contactSvc -->|Wire 注入| pusher + groupSvc -->|Wire 注入| pusher + adminCtrl -->|Wire 注入| pusher + pusher --> notifySvc + notifySvc --> notifyDAO + notifyDAO --> db + notifySvc -->|SendToUser| hub +``` + +- `notify.Pusher` 接口定义在 `app/notify/service/pusher.go`,实现同包 +- contact/group 通过 Wire 绑定 `NotifyPusher` 字段(interface 类型) +- **单向依赖**:contact/group → notify,**反向禁止**(Phase 2e-2 meeting 模块同样遵守) +- **降级策略**:WS 推送失败不回滚入库,下一次 `notify.unread.total` 补偿兜底 + +### 2.2 单端 WS 推送(2 个事件) + +| 事件 | 方向 | 触发时机 | Payload | +|------|------|----------|---------| +| `notify.new` | S→C | Pusher 入库成功后立即推送 | 完整通知对象 | +| `notify.unread.total` | S→C | 连接建立/重连后钩子触发 | `{ total, by_category }` 权威值 | + +**不引入** `notify.read.ack`(WS Hub 单连接架构已天然规避多端同步风暴)。 + +### 2.3 数据库(新增 1 张表) + +`notify_notifications` 表:`id / user_id / type / title / content / extra(JSONB) / actor_id / target_type / target_id / is_read / read_at / created_at` + +索引:`(user_id, is_read, created_at DESC)`、`(user_id, type, created_at DESC)`、`created_at`(配合 30 天清理)。 + +--- + +## 三、文件与变更清单 + +### 3.1 后端新增(`backend/go-service/`) + +| 文件 | 作用 | +|------|------| +| `app/notify/constants/notify_types.go` | 11 种 type 常量 + 5 种 category 映射 + WS 事件名 | +| `app/notify/model/notification.go` | GORM 模型 | +| `app/notify/dao/notification_dao.go` | CRUD + 批量已读 + 未读统计 + 清理 + 全量用户列表 | +| `app/notify/service/notify_service.go` | 业务逻辑(创建/列表/标已读/广播) | +| `app/notify/service/pusher.go` | `Pusher` 接口 + Impl(持久化 + WS 推送) | +| `app/notify/controller/notification_controller.go` | 4 用户接口 + 1 管理员广播接口 | +| `app/notify/router.go` | 路由注册 | +| `app/notify/provider.go` | Wire `NotifySet` | +| `app/notify/task/cleanup_task.go` | 30 天清理 cron(默认每日) | +| `app/constants/notify.go` | 跨模块共享常量(type / category / WS event) | +| `app/dto/notify_dto.go` | 请求 / 响应 / 广播 DTO | + +### 3.2 后端改造 + +| 文件 | 改动 | +|------|------| +| `app/provider/provider.go` | `App` 结构体新增 `NotificationController` / `NotifyCleanupTask` / `NotifyPusher`;`NewApp` 签名扩展 | +| `app/provider/wire.go` | 注册 `NotifySet`;绑定 `contactService.NotifyPusher` / `groupService.NotifyPusher` / `notifyService.UserInfoResolver` / `ws.NotifyConnectHook` | +| `app/provider/wire_gen.go` | 同步 Wire 生成产物 | +| `app/contact/service/contact_service.go` | **3 处** Pusher.Push:SendFriendRequest / AcceptFriendRequest / RejectFriendRequest | +| `app/group/service/group_service.go` | **6 处** Pusher.Push:InviteMembers / RequestJoin / ApproveJoin / RejectJoin / KickMember / ChangeRole | +| `app/ws/handler.go` | 连接建立/重连时触发 `NotifyConnectHook.OnUserConnected` → 推送 `notify.unread.total` | +| `cmd/server/main.go` | 启动 `app.NotifyCleanupTask.Start()` + `defer Stop()` | +| `router/router.go` | `notifyApp.RegisterRoutes(engine, app.NotifyController, jwtAuth)` | +| `deploy/docker/postgres/init.sql` | 追加 `notify_notifications` DDL + 3 个索引 | + +### 3.3 前端新增(`frontend/src/`) + +| 文件 | 作用 | +|------|------| +| `api/notify.js` | REST API 封装(getNotifications / getUnreadCount / markRead / markAllRead) | +| `constants/notify.js` | 前端常量(与后端 type/category 对齐 + 图标/颜色/支持内联操作判定) | +| `store/notify.js` | Pinia Store(分类缓存 + cursor 分页 + WS 监听 + reset) | +| `components/notify/NotifyItem.vue` | 通用卡片(按 type 渲染 + 内联"接受/拒绝"按钮) | +| `pages/notify/index.vue` | 通知中心页(5 分类 Tab + 骨架/空态/列表) | + +### 3.4 前端改造 + +| 文件 | 改动 | +|------|------| +| `pages.json` | 注册 `pages/notify/index`(custom navigationStyle) | +| `App.vue` | 全局 WS 初始化时调用 `useNotifyStore().initWsListeners() + fetchUnreadCount()` | +| `pages/auth/login.vue` | 登录成功后同上初始化 | +| `pages/profile/index.vue` | 顶部铃铛图标 + 徽标 + 「通知中心」菜单项 + `onShow` 刷新未读 | +| `store/user.js` | `logout()` 内调用 `notifyStore.reset()`,防止跨用户数据泄漏 | +| `store/contact.js` | 删除原 `notify.friend.request` 散落监听(由 notify store 接管) | +| `store/group.js` | 清理 `_onJoinRequest` / `_onJoinApproved` 中的 `uni.showToast`(统一到 notify store) | + +### 3.5 文档 + +| 文件 | 操作 | +|------|------| +| `docs/plans/2026-04-20-phase2e-design.md` | §3.1/§3.5/§八/§九 修订「单端 WS 架构」约束 | +| `docs/plans/2026-04-20-phase2e-1-implementation.plan.md` | **本文档**(正式版归档) | +| `docs/api/frontend/notify.md` | 新增 notify API 文档(5 REST + 2 WS 事件) | +| `docs/api/README.md` | 导航更新 | +| `docs/progress/CURRENT_STATUS.md` | 进度同步 | +| `.cursor/rules/project-context.mdc` | 跨模块通信模式 + 推迟清单更新 | +| `test-report-phase2e-1-notification.md` | E2E 验证报告 + Task 11 审查修复记录 | + +--- + +## 四、Task 拆分(11 个 Task,按依赖分层) + +| # | Task | 依赖 | 交付产物 | +|---|------|------|----------| +| **Task 0** | 基础设施:DDL + constants + DTO + model | — | 数据库表、常量、DTO、GORM 模型 | +| **Task 1** | Notify 模块骨架:DAO + Service + Pusher 接口 + Wire | T0 | 完整模块雏形,可独立编译 | +| **Task 2** | REST API:4 用户接口 + 1 管理员广播 | T1 | `/api/v1/notifications/*` + `/api/v1/admin/notifications/broadcast` | +| **Task 3** | WS 事件:`notify.new` + `notify.unread.total` 断线补偿 | T1 | Pusher 内部推送 + `ws.Handler` 钩子 | +| **Task 4** | contact 模块 Pusher 集成(3 类好友事件) | T1 | 申请/接受/拒绝 → 3 处 Push | +| **Task 5** | group 模块 Pusher 集成(6 类群聊事件) | T1 | 邀请/申请/批准/拒绝/踢人/角色变更 → 6 处 Push | +| **Task 6** | 30 天清理定时任务 | T1 | `CleanupTask` + 启动/停止集成 | +| **Task 7** | 前端 Pinia Store + API 封装 + WS 监听 | T2+T3 | `store/notify.js` + `api/notify.js` + 2 事件监听 | +| **Task 8** | 通知中心页 + NotifyItem 组件 + 5 分类 Tab | T7 | `pages/notify/index.vue` + 组件 | +| **Task 9** | profile 入口集成 + 冗余代码清理 | T8 | 铃铛徽标 + `contact.js:155` / `group.js:292` 清理 | +| **Task 10** | 端到端验证(4 类场景 + 视觉回归) | T4+T5+T9 | `test-report-phase2e-1-notification.md` | +| **Task 11** | 设计文档修订 + `code-reviewer` 审查 + 所有文档同步 | T10 | 设计文档 §3.1/§3.5/§八/§九 修订 + 审查报告 | + +### 依赖关系图 + +```mermaid +graph TD + T0[Task 0 基础设施] --> T1[Task 1 Notify 骨架] + T1 --> T2[Task 2 REST API] + T1 --> T3[Task 3 WS 事件] + T1 --> T4[Task 4 contact 集成] + T1 --> T5[Task 5 group 集成] + T1 --> T6[Task 6 清理任务] + T2 --> T7[Task 7 前端 Store] + T3 --> T7 + T7 --> T8[Task 8 通知中心页] + T8 --> T9[Task 9 入口集成] + T4 --> T10[Task 10 E2E 验证] + T5 --> T10 + T9 --> T10 + T10 --> T11[Task 11 文档同步 + 审查] +``` + +--- + +## 五、端到端验收标准 + +- [x] 用户 A 给 B 发好友申请 → B「我的」铃铛徽标 +1,通知中心出现记录,WS 实时收到 `notify.new` +- [x] B 点击通知 → 跳转好友申请页;处理后通知自动标已读 → 徽标 -1 +- [x] 张三邀请李四入群 → 李四收到卡片内联「接受/拒绝」,操作后群状态与通知状态一致 +- [x] 管理员 `POST /api/v1/admin/notifications/broadcast` → 所有在线用户实时收到 `system_broadcast` +- [x] 断线重连:前端收到 `notify.unread.total`,徽标与后端 API 查询一致 +- [x] 30 天前已读通知被清理任务删除;未读通知无论多久都不被清理 +- [x] `frontend/src/store/contact.js:155` 旧处理已删除,无重复提示 +- [x] Logout → `notifyStore.reset()` 清空,防止跨用户数据泄漏 +- [x] ~~多端已读同步~~ → **架构限制不支持**,设计文档已修订 + +--- + +## 六、风险与应对 + +| 风险 | 等级 | 应对 | 实际结果 | +|------|------|------|----------| +| Pusher 同步调用阻塞业务 | 中 | WS 推送失败仅 warn 日志,不阻塞业务主流程 | ✅ 已落实 | +| contact/group 现有业务逻辑受影响 | 中 | 每个调用点保留原路径,Pusher 仅旁路调用 | ✅ 无回归 | +| Wire 生成器循环依赖 | 高 | 严格单向 contact/group → notify,interface 注入 | ✅ 无循环 | +| 前端 toast 与现有 `uni.showToast` 冲突 | 中 | 统一由 notify store 触发,其他模块不再直接调用 | ✅ 已清理 `contact.js:155` / `group.js:292` | +| 管理端广播权限 | 高 | `middleware.RequireRole(admin/super_admin)` | ✅ 已校验 | +| 代码审查漏网 | 中 | Task 11 调用 `code-reviewer` 子代理 | ✅ 发现 1 Blocker 已修复 | + +--- + +## 七、实施后的连带动作 + +1. ✅ 修订 `docs/plans/2026-04-20-phase2e-design.md`: + - §3.1 决策表「多端已读同步」改为「暂不支持(WS Hub 单端架构)」 + - §3.5 WS 事件表移除 `notify.read.ack` + - §八验收标准全部标记完成并注明限制 + - §九推迟清单新增「WS Hub 多端连接支持改造」+「Playwright E2E CI」 + - §十新增 2026-04-20 实施完成变更记录 +2. ✅ `code-reviewer` 子代理审查(结论:有条件通过) + - 🔴 Blocker:`markAllRead` 前端 body / 后端 query 契约错位 → 已修复(`frontend/src/api/notify.js` 改走 Query) + - 🟡 Major × 5 / 🟢 Minor × 11:不阻塞合入,纳入 Phase 2f 清理清单 +3. ✅ 所有规划 + 实施 + 验证文档在同一分支(`feature/phase2e-meeting-notification`)提交 +4. ✅ `feature/phase2e-meeting-notification` 分支准备就绪,下一步可 PR 合并至主干或继续 Phase 2e-2 + +--- + +## 八、工期回顾 + +| 子任务 | 预估 | 实际 | 备注 | +|--------|------|------|------| +| 后端(Task 0-6) | 2 人日 | ~2 人日 | Wire 绑定与 ws.Handler 钩子略超预期 | +| 前端(Task 7-9) | 1.5 人日 | ~1.5 人日 | 分类缓存 + WS 去重实现顺利 | +| E2E + 审查 + 文档(Task 10-11) | 0.5 人日 | ~1 人日 | 代码审查发现 Blocker 增加修复耗时 | +| **合计** | **4 人日** | **~4.5 人日** | 与设计文档预估基本一致 | + +--- + +## 九、关联文档 + +- [Phase 2e 总体设计](./2026-04-20-phase2e-design.md) +- [Phase 2e-1 测试验证报告](../../test-report-phase2e-1-notification.md) +- [Notify API 文档](../api/frontend/notify.md) +- [项目开发进度 · CURRENT_STATUS](../progress/CURRENT_STATUS.md) +- [项目上下文 · project-context.mdc](../../.cursor/rules/project-context.mdc) diff --git a/docs/plans/2026-04-20-phase2e-design.md b/docs/plans/2026-04-20-phase2e-design.md new file mode 100644 index 0000000..079e152 --- /dev/null +++ b/docs/plans/2026-04-20-phase2e-design.md @@ -0,0 +1,358 @@ +# Phase 2e 设计文档:会议与通知系统 + +> **状态:** 🚧 进行中(2e-1 ✅ 已完成,2e-2/2e-3 📋 待开发) +> **分支:** `feature/phase2e-meeting-notification`(基于 `origin/feature/phase2c-group-read-receipt`) +> **前置依赖:** Phase 2a(联系人 + WS)、Phase 2b(即时通讯)、Phase 2c(群聊+已读)、Phase 2d(消息类型扩展)全部完成 +> **最后更新:** 2026-04-20(2e-1 完成 + 单端架构说明同步) + +--- + +## 一、设计目标 + +基于 Phase 2a-2d 建立的 WebSocket + 消息 + 联系人 + 群聊基础设施,实现 MVP 第一期收官的两大核心能力: + +1. **统一通知系统**:消除散落在好友/群聊/会议各模块的通知死角,提供"提醒 + 历史"双通道 +2. **多人音视频会议**:基于 mediasoup SFU 架构,支持即时会议(MVP)→ 预约会议 + 邀请(增强) + +**核心交付物(按子阶段):** +- **Phase 2e-1 通知系统**(3-4 人日):统一通知中心 + 11 种通知类型预留 + 跨模块 Pusher 接口 +- **Phase 2e-2 会议 MVP**(10-14 人日):mediasoup Node 媒体服务 + 即时会议 + 基础音视频控制(≤8 人) +- **Phase 2e-3 会议增强**(7-10 人日):预约会议 + 会议邀请 + 会议提醒 + +**不包含(明确推迟):** 见 [§九 后续规划清单](#九后续规划清单必须留档) + +--- + +## 二、阶段拆分与路线图 + +``` +┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ +│ Phase 2e-1 │ │ Phase 2e-2 │ │ Phase 2e-3 │ +│ 通知系统 │─▶│ 会议 MVP │─▶│ 会议增强 │ +│ 3-4 人日 │ │ 10-14 人日 │ │ 7-10 人日 │ +│ │ │ │ │ │ +│ ✓ 好友/群聊事件通知 │ │ ✓ mediasoup Node │ │ ✓ 预约会议+定时提醒 │ +│ ✓ 系统广播(后端) │ │ ✓ 即时会议(≤8人) │ │ ✓ 会议邀请(复用 2e-1│ +│ ✓ meeting_invite / │ │ ✓ 音视频+主持人控制 │ │ 通知类型) │ +│ meeting_reminder │ │ ✓ 会议号/密码 │ │ ✓ 入会前预览 │ +│ 类型预留 │ │ │ │ │ +└──────────────────────┘ └──────────────────────┘ └──────────────────────┘ +``` + +**拆分理由:** +- **2e-1 风险最低、收益最高**:纯业务逻辑扩展,复用 Phase 2a-2c 的 WS 基础设施;能立即解决"好友申请无感知"等体验死角 +- **2e-2 是风险核心**:引入全新技术栈(Node.js + mediasoup + WebRTC),需独立周期聚焦 +- **2e-3 是收尾增强**:依赖 2e-1(邀请/提醒走通知通道)和 2e-2(会议能运行),做在最后 + +--- + +## 三、Phase 2e-1 详细设计(通知系统) + +### 3.1 需求决策记录 + +| 决策项 | 选择 | 理由 | +|---|---|---| +| 推送样式 | 双通道(持久化入库 + 底部 mini-toast) | 不遗漏 + 不打扰,参照微信 | +| 主入口位置 | 「我的」Tab 顶部铃铛图标 + 数字徽标 | 不占用底部 Tab 位(已满 4 个) | +| 列表组织 | 顶部 Tab 分类:全部 / 好友 / 群聊 / 会议 / 系统 | 结构清晰,便于筛选 | +| 保留期限 | 30 天(每日定时任务清理已读通知) | 平衡存储与体验 | +| 历史追溯 | 不追溯,上线当天起新事件才入通知中心 | 无数据迁移风险 | +| 多端已读同步 | **暂不支持**(WS Hub 单端连接架构,同一用户仅最新连接有效) | 架构约束,已推迟到 Phase 2f/二期 | +| 点击行为 | Deep-link 按类型分发;群/会议邀请支持内联操作按钮 | 减少跳转步骤 | +| Toast 点击 | 无交互(避免误触),仅 2 秒自动消失 | 防止误操作 | +| 通知分类开关 | 不做(统一打开) | 简化 MVP,有需要再加 | + +### 3.2 通知类型枚举(11 种,含 2e-2/2e-3 预留) + +| type | 触发场景 | 触发模块 | 落地 Phase | Deep-Link 跳转 | +|---|---|---|---|---| +| `friend_request` | 收到好友申请 | contact | 2e-1 | `pages/contact/request` | +| `friend_accepted` | 好友申请被接受 | contact | 2e-1 | `pages/contact/detail?id=` | +| `friend_rejected` | 好友申请被拒绝 | contact | 2e-1 | 无跳转(告知型) | +| `group_invite` | 被邀请加入群聊 | group | 2e-1 | **内联接受/拒绝** | +| `group_join_request` | 收到入群申请(群管理员)| group | 2e-1 | `pages/group/join-requests?groupId=` | +| `group_join_approved` | 入群申请被批准 | group | 2e-1 | 直接进入群会话 | +| `group_join_rejected` | 入群申请被拒绝 | group | 2e-1 | 无跳转(告知型) | +| `group_kicked` | 被踢出群聊 | group | 2e-1 | 无跳转(告知型) | +| `group_role_changed` | 被设/撤管理员、群主转让 | group | 2e-1 | 群详情页 | +| `system_broadcast` | 系统广播 | notify(admin 触发)| 2e-1 仅后端 API | 通知详情页 | +| `meeting_invite` | 会议邀请 | meeting | **2e-2 对接** | **内联加入/稍后** | +| `meeting_reminder` | 预约会议开始前 N 分钟 | meeting | **2e-3 对接** | **内联加入** | + +### 3.3 数据库设计(新增 1 张表) + +```sql +CREATE TABLE notify_notifications ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + type VARCHAR(40) NOT NULL, + title VARCHAR(200) NOT NULL, + content TEXT, + extra JSONB, + actor_id BIGINT, + target_type VARCHAR(40), + target_id BIGINT, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + read_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_notify_user_unread ON notify_notifications(user_id, is_read, created_at DESC); +CREATE INDEX idx_notify_user_type ON notify_notifications(user_id, type, created_at DESC); +``` + +**字段说明:** +- `extra`:按类型存附加信息,例如 group_invite 存 `{group_id, group_name, group_avatar, inviter_name}`、meeting_invite 存 `{room_code, room_title, host_name, scheduled_at}` +- `actor_id`:触发者(如好友申请发起人) +- `target_type` + `target_id`:关联对象(friend_request / group / meeting / join_request) + +**清理任务**(每日 02:00 执行): +```sql +DELETE FROM notify_notifications WHERE created_at < NOW() - INTERVAL '30 days' AND is_read = true; +``` + +### 3.4 后端 API(4 个) + +``` +GET /api/v1/notifications 列表(query: type, is_read, before_id, limit) +GET /api/v1/notifications/unread-count 未读数统计(按 type 分组) +PUT /api/v1/notifications/:id/read 标记单条已读 +PUT /api/v1/notifications/read-all 全部已读(可按 type 过滤) +``` + +**响应示例** — `GET /api/v1/notifications?limit=20`: +```json +{ + "code": 0, "message": "success", + "data": { + "list": [ + { + "id": 1001, "type": "group_invite", + "title": "张三邀请你加入群聊「产品团队」", + "content": "", + "extra": { "group_id": 5, "group_name": "产品团队", "inviter_name": "张三" }, + "actor_id": 7, "target_type": "group", "target_id": 5, + "is_read": false, "created_at": "2026-04-20T10:00:00Z" + } + ], + "has_more": false + } +} +``` + +### 3.5 WebSocket 事件(2 个,**单端连接架构**) + +| 事件 | 方向 | Payload | 说明 | +|---|---|---|---| +| `notify.new` | S→C | 完整通知对象 | 新通知到达,前端入 store + 更新徽标 | +| `notify.unread.total` | S→C | `{ total: 10, by_category: { friend: 3, group: 5, ... } }` | 连接建立/断线重连时补偿推送(权威值覆盖本地) | + +> **说明**:由于现有 `ws.Hub` 为单端连接(同一用户仅保留最新连接),**不再设计** `notify.read.ack` 跨设备广播。多端已读同步作为独立技术债推迟到 Phase 2f(详见 §九)。 + +### 3.6 跨模块 Pusher 接口(沿用 Phase 2a 接口注入标准) + +```go +// app/notify/service/pusher.go +type Pusher interface { + Push(ctx context.Context, userID int64, req *PushRequest) error +} + +type PushRequest struct { + Type string // 通知类型 + Title string + Content string + Extra map[string]interface{} + ActorID int64 + TargetType string + TargetID int64 +} +``` + +**使用方示例**(contact 模块好友申请): +```go +// contact/service/contact_service.go +s.notifyPusher.Push(ctx, receiverID, ¬ify.PushRequest{ + Type: "friend_request", + Title: fmt.Sprintf("%s 请求添加你为好友", applicant.Nickname), + Content: applicationMessage, + ActorID: applicantID, + TargetType: "friend_request", + TargetID: requestID, +}) +``` + +### 3.7 模块结构 + +``` +backend/go-service/app/notify/ +├── constants/notify_types.go # 11 种 type 枚举常量 +├── model/notification.go # GORM 模型 +├── dao/notification_dao.go # CRUD +├── service/ +│ ├── notify_service.go # 业务逻辑:创建/标已读/清理 +│ └── pusher.go # Pusher 接口 + Impl(持久化 + WS 推送) +├── controller/notification_controller.go # HTTP 接口 +└── provider/wire.go # Wire 依赖注入 + +backend/go-service/app/dto/notify_dto.go # DTO +``` + +### 3.8 前端页面 + +| 路径 | 变更 | 说明 | +|---|---|---| +| `store/notify.js` | **新建** | Pinia Store:state/fetch/markRead/initWs | +| `pages/notify/index.vue` | **新建** | 列表页 + 顶部 5 个分类 Tab + 下拉刷新 + 上拉加载 | +| `pages/profile/index.vue` | 修改 | 顶部添加「🔔 通知」入口 + 数字徽标 | +| `components/notify/NotifyItem.vue` | **新建** | 通知卡片(按类型渲染不同 UI,支持内联操作按钮) | + +### 3.9 Pinia Store 设计要点 + +```javascript +// frontend/src/store/notify.js +export const useNotifyStore = defineStore('notify', () => { + const notifications = ref([]) // 当前已加载的通知列表 + const unreadCount = ref(0) // 总未读数(TabBar 徽标) + const unreadByType = ref({}) // 分类型未读数 + const hasMore = ref(true) + const currentFilter = ref('all') // all / friend / group / meeting / system + + // WS 监听:notify.new / notify.unread.total(单端架构,无 notify.read.ack) + const initWsListeners = () => { ... } + + const fetchNotifications = async (filter, beforeId) => { ... } + const markRead = async (id) => { ... } + const markAllRead = async (type) => { ... } +}) +``` + +--- + +## 四、Phase 2e-2 会议 MVP 范围锁定(详细设计待 2e-1 完成后展开) + +### 4.1 范围(硬边界) +- ✅ 即时会议(无预约)、会议号自动生成(格式 `XXX-XXX-XXX`) +- ✅ ≤ 8 人同时参会 +- ✅ 音频 + 视频 开关 +- ✅ 主持人控制:静音他人、移除成员、结束会议 +- ✅ 密码保护(可选) +- ✅ **发起邀请**:仅发起方内嵌"复制会议号"(暂不接通知中心,通知邀请在 2e-3) +- ❌ 不做:录制、屏幕共享、虚拟背景、预约、提醒 + +### 4.2 技术选型(锁定) + +| 组件 | 选型 | 备注 | +|---|---|---| +| 媒体服务 | `media-server/` 独立 Node.js 进程 + mediasoup v3 | 严格遵循原系统设计 | +| 客户端库 | `mediasoup-client` JS SDK | 与服务端强绑定 | +| 信令通道 | **复用现有 WebSocket Hub** | 不开新通道,复用 `Hub.RegisterEvent/DispatchEvent` | +| Go ↔ Node | HTTP REST(docker-compose 内网) | 9 个 API:Router/Transport/Producer/Consumer 生命周期 | +| 数据库表 | `meeting_rooms` + `meeting_participants` | 已在总设计文档定义 | +| Redis 键 | `echo:meeting:room:{code}` + `echo:meeting:members:{code}` + `echo:meeting:transport:{code}` | 已在总设计文档定义 | + +### 4.3 WebSocket 信令事件(11 个) + +**房间事件**:`meeting.room.join / leave / info` +**成员事件**:`meeting.member.join / leave / mute / video` +**媒体事件**:`meeting.transport.create / connect`、`meeting.produce.start / stop`、`meeting.consume.start / resume` + +--- + +## 五、Phase 2e-3 会议增强范围锁定 + +- 预约会议(`meeting_rooms.type=2`)+ 前端预约表单 +- 定时器:到预约时间前 N 分钟触发 `meeting_reminder` 通知 +- 会议邀请:从联系人/群聊发起 → 走 `meeting_invite` 通知类型 +- 入会前设备预览(本地摄像头/麦克风测试页) +- 可选:等候室 / 锁定会议(视时间余量决定) + +--- + +## 六、Pusher 调用点汇总(Phase 2e-1 必须改动的现有代码) + +| 文件 | 变更类型 | 说明 | +|---|---|---| +| `app/contact/service/contact_service.go` | 新增 Pusher 注入 + 4 处调用 | 申请/接受/拒绝 3 个事件 | +| `app/group/service/group_service.go` | 新增 Pusher 注入 + 6 处调用 | 邀请/入群申请/审批/踢人/角色变更 | +| `app/provider/wire.go` | 注册 notify 模块依赖 | Wire 依赖注入 | +| `frontend/src/store/contact.js` | 删除冗余的 `notify.friend.request` 处理(由 notify store 接管)| 避免重复提示 | +| `frontend/src/store/group.js` | 删除 `_onJoinRequest` toast(由 notify store 统一)| 避免重复提示 | +| `frontend/src/components/CustomTabBar.vue` | 保持不变 | 联系人 Tab 徽标仍显示"待处理"数,与通知中心徽标并存 | + +**重要兼容性约束**: +- **联系人 Tab 徽标**(pendingCount)**保留不变**,继续承担"待办事项"入口 +- **通知中心徽标**仅计"未读通知",两套红点语义不同,互不影响 + +--- + +## 七、风险与应对 + +| 风险 | 等级 | 应对 | +|---|---|---| +| Pusher 调用失败导致业务阻塞 | 中 | 采用"异步推送":Pusher 调用失败仅记日志,不阻塞业务主流程 | +| 通知表膨胀 | 低 | 30 天清理任务 + user+is_read 索引 + 按用户分表留作未来优化 | +| ~~多端已读同步消息风暴~~ | — | ~~不适用~~:当前 ws.Hub 单端连接架构已不做多端同步,该风险天然规避 | +| mediasoup(2e-2)技术深度 | 高 | 单独做技术 Spike,必要时先搭 PoC 验证 | +| Docker Compose 增加 Node 服务后的资源占用 | 低 | 设置资源限额,文档化本机运行要求 | + +--- + +## 八、验收标准(Phase 2e-1)✅ 已完成 + +- [x] 11 种通知类型中的 10 种(除 `meeting_invite`/`meeting_reminder`)都能正确触发并落库 +- [x] 用户 A 给 B 发好友申请 → B 端 WS 实时收到 `notify.new`,通知中心出现记录,「我的」Tab 红点 +1 +- [x] ~~多设备登录同一账号同步~~ → **架构限制不支持**(单端 WS Hub),已在 §3.1/§3.5 修订;仅当前连接设备可感知实时更新,其他设备依赖下次重连时的 `notify.unread.total` 补偿 +- [x] 30 天清理任务能正确运行,已读过期通知被删除 +- [x] 通知中心列表按 5 个分类 Tab 筛选正确 +- [x] 群邀请通知卡片支持内联"接受/拒绝"按钮并触发正确业务逻辑 +- [x] 验证清单:`test-report-phase2e-1-notification.md`;Playwright 自动化推迟到 CI 建设阶段(Phase 2f 统一接入) + +--- + +## 九、后续规划清单(必须留档) + +### 9.1 推迟到 Phase 2f(MVP 收尾 + 管理端扩展) + +| 功能 | 原计划阶段 | 说明 | +|---|---|---| +| **WS Hub 多端连接支持改造** | 2e-1 | 当前 `ws.Hub` 的 `clients map[int64]*Client` 仅支持单连接,新登录会踢掉旧设备。需改为 `map[int64]map[deviceID]*Client` 并适配 `PublishToUser` / `ws.OnlineService` / Pusher 等下游消费者 | +| 多端已读同步(notify.read.ack) | 2e-1 | WS Hub 多端就绪后,再补 `notify.read.ack` 事件广播给同一用户的其他设备 | +| 管理端会议列表/详情/强制关闭 | 2e | `/api/v1/admin/meetings*` 已在总设计文档定义,仅缺实现 | +| 管理端会议统计仪表板 | 2e | `/api/v1/admin/meetings/stats` | +| 管理端通知广播发布 UI | 2e-1 | 后端 API 已就绪,仅缺前端表单页 | +| 管理端用户会议记录 | 2e | `/api/v1/admin/users/:id/meetings` | +| 管理端操作日志页面 | 2e | 数据表 `admin_operation_logs` 已设计 | +| 管理端仪表板总览 | 2e | `/api/v1/admin/dashboard` | +| 系统配置管理 | 2e | `/api/v1/admin/system/config` | +| 通知分类开关设置 | 2e-1 | 用户级通知偏好(push/不push) | +| Playwright E2E 自动化 CI | 2e-1 | 当前仅 `test-report-*.md` 手动验证,CI 接入需评估整套 e2e 基础设施 | + +### 9.2 推迟到第二期 + +| 功能 | 说明 | +|---|---| +| 屏幕共享 | mediasoup Producer 扩展为 screen 类型 | +| 会议录制与回放 | 需 mediasoup 录制插件 + 对象存储(MinIO 已就绪) | +| 虚拟背景 / 背景模糊 | 客户端 WebRTC 滤镜 | +| 微信授权登录 | OAuth + UnionID | +| 互动直播(主播/观众/弹幕) | 独立直播流架构 | +| 消息撤回时间延长 / 管理员无时限撤回的审计日志 | —— | +| 视频消息(type=4)| Phase 2d 已显式推迟 | +| 表情包 / 自定义贴纸 | Phase 2d 已显式推迟 | +| 消息转发 / 合并转发 / 引用回复 | Phase 2d 已显式推迟 | + +### 9.3 推迟到第三期 + +- 微服务拆分(auth / im / meeting / notify 拆独立服务) +- Kubernetes 部署编排 +- 跨服务器会议(多 mediasoup Worker 集群 + Router Pipe) +- AI 辅助:语音转文字、会议纪要、智能摘要 + +--- + +## 十、文档同步与变更记录 + +| 日期 | 变更 | +|---|---| +| 2026-04-20 | Phase 2e 规划完成:拆分为 2e-1/2e-2/2e-3 三个子阶段;本文档落盘 | +| 2026-04-20 | Phase 2e-1 实施完成:落实 notify 模块(后端 11 种类型 / 5 REST + 2 WS / 30 天清理)+ 前端通知中心;§3.1/§3.5/§八/§九 同步修订「单端 WS 连接」约束与推迟项 | diff --git a/docs/progress/CURRENT_STATUS.md b/docs/progress/CURRENT_STATUS.md index e5a34e9..82ac4d5 100644 --- a/docs/progress/CURRENT_STATUS.md +++ b/docs/progress/CURRENT_STATUS.md @@ -1,11 +1,123 @@ # EchoChat 项目开发进度 -> **最后更新**:2026-04-20(Phase 2d 完成 + UX 优化 + 已读状态刷新持久化) -> **当前阶段**:Phase 2d 全部完成 + Bug 修复 7 项 + UX 优化 2 项 -> **当前分支**:`feature/phase2c-group-read-receipt`(注:Phase 2d 工作误用了 2c 分支,下一阶段 2e 直接新建分支开发) -> **实施计划**:`docs/plans/2026-03-04-phase2d-implementation.plan.md` -> **设计文档**:`docs/plans/2026-03-04-phase2d-design.md` -> **本次修复报告**:`test-report-phase2d-bugfix.md` +> **最后更新**:2026-04-21(Phase 2e-1 通知系统 + TabBar 聚合红点 UX 完善) +> **当前阶段**:Phase 2e-1 通知系统 ✅ 已完成;下一步进入 Phase 2e-2 会议 MVP +> **当前分支**:`feature/phase2e-meeting-notification` +> **Phase 2e 整体设计**:`docs/plans/2026-04-20-phase2e-design.md`(三子阶段路线图 + 后续规划清单) +> **Phase 2e-1 专用设计**:`docs/plans/2026-04-20-phase2e-1-design.md`(子阶段详细设计 + 实施后修订 + 审查记录) +> **Phase 2e-1 实施计划**:`docs/plans/2026-04-20-phase2e-1-implementation.plan.md`(11 个 Task 全部完成;原 `.cursor/plans/*` Cursor 临时文件已归档至本位置) +> **Phase 2e-1 验证报告**:`test-report-phase2e-1-notification.md` + +--- + +## ✅ 2026-04-20 Phase 2e-1 通知系统完成 + +**交付范围**:统一通知中心,覆盖好友 / 群聊 / 会议(预留枚举)/ 系统广播 四大类,共 11 种业务通知类型。 + +### 后端(`backend/go-service/`) +| 模块 | 路径 | 作用 | +|---|---|---| +| 数据表 | `deploy/docker/postgres/init.sql` | 新增 `notify_notifications` + 3 索引 | +| 常量 | `app/constants/notify.go` | 11 种 type 常量 + 4 种 category + WS 事件名 | +| Model | `app/notify/model/notification.go` | GORM 模型 | +| DTO | `app/dto/notify_dto.go` | 请求/响应/广播 DTO | +| DAO | `app/notify/dao/notification_dao.go` | CRUD + 批量 + 未读统计 + 清理 + 全量用户列表 | +| Service | `app/notify/service/{notify_service.go,pusher.go}` | 业务逻辑 + Pusher 接口 + 持久化+推送降级 | +| Controller | `app/notify/controller/notification_controller.go` | 4 用户接口 + 1 管理员广播接口 | +| Router | `app/notify/router.go` | `/api/v1/notifications/*` + `/api/v1/admin/notifications/broadcast` | +| Cleanup Task | `app/notify/task/cleanup_task.go` | 30 天已读通知定时清理(默认每日) | +| Provider | `app/notify/provider.go` + `app/provider/{provider,wire,wire_gen}.go` | Wire 注入 + NotifyPusher / NotifyConnectHook 接口绑定 | +| WS 钩子 | `app/ws/handler.go` | 连接建立 → 推送 `notify.unread.total` 补偿 | +| contact 集成 | `app/contact/service/contact_service.go` | 3 处 Pusher.Push:friend_request/accepted/rejected | +| group 集成 | `app/group/service/group_service.go` | 6 处 Pusher.Push:invite/join_request/approved/rejected/kicked/role_changed | +| main.go | `cmd/server/main.go` | 启动 CleanupTask + defer Stop | + +### 前端(`frontend/src/`) +| 文件 | 作用 | +|---|---| +| `api/notify.js` | 4 个 REST 封装 | +| `constants/notify.js` | 前端 type / category / 图标 / 颜色常量 | +| `store/notify.js` | Pinia Store:5 分类分页缓存 + 未读数 + WS 事件(notify.new / notify.unread.total) | +| `components/notify/NotifyItem.vue` | 通用通知卡片(按 type 渲染 + 内联接受/拒绝) | +| `pages/notify/index.vue` | 通知中心主页(顶部铃铛 + 5 Tab + 下拉刷新 + 无限滚动 + 全部已读) | +| `pages/profile/index.vue` | 新增铃铛入口 + 徽标 + 菜单项 | +| `pages.json` | 注册 `pages/notify/index` | +| `App.vue` / `pages/auth/login.vue` | 全局初始化 notifyStore 监听 + 未读数拉取 | +| `store/user.js` | logout 时调用 `notifyStore.reset()` 清缓存 | +| `store/contact.js` / `store/group.js` | 清理散落 toast 和冗余 `notify.friend.request` / `group.join.request` 直接处理 | + +### 架构决策 +- **单端 WS 连接架构**:沿用现有 `ws.Hub`,**不做多端已读同步**。多端改造推迟到 Phase 2f/二期(已在设计文档 §3.1/§3.5/§九记录)。 +- **跨模块依赖方向**:`contact` / `group` → `notify`(严格单向),通过接口 `notifyService.Pusher` 注入,类似 Phase 2a `ws.FriendIDsGetter → contact.FriendshipDAO` 模式。 +- **降级策略**:Pusher 先入库、后推送;WS 推送失败不回滚入库;入库失败仅 Warn 日志不影响业务。 +- **游标分页**:通知列表使用 `before_id` + `limit` 替代传统 `page`,天然抗数据插入扰动。 +- **30 天清理**:默认每 24 小时扫描一次,删除 `is_read=true AND created_at < NOW() - INTERVAL 30 DAY`;未读永久保留。 + +### Task 完成情况(11/11) +- [x] Task 0: 数据库 DDL + constants + DTO + model +- [x] Task 1: Notify 模块骨架(DAO + Service + Pusher 接口 + Wire) +- [x] Task 2: REST API(4 用户 + 1 管理员) +- [x] Task 3: WS 事件 `notify.new` + `notify.unread.total` +- [x] Task 4: contact 集成(3 类) +- [x] Task 5: group 集成(6 类) +- [x] Task 6: 30 天清理定时任务 +- [x] Task 7: 前端 Pinia Store + API + WS 监听 +- [x] Task 8: 通知中心页 + NotifyItem + 5 分类 Tab +- [x] Task 9: profile 入口 + 冗余清理(contact.js:155 / group.js:292) +- [x] Task 10: E2E 验证清单(`test-report-phase2e-1-notification.md`) +- [x] Task 11: 文档同步 + `code-reviewer` 子代理审查(本文件 + API 文档 + project-context.mdc + 设计文档 §3.1/§3.5/§八/§九 修订) + +### Task 11 代码审查成果(2026-04-20) +- `code-reviewer` 整体结论:**有条件通过**(1 Blocker / 5 Major / 11 Minor / 10 亮点) +- **Blocker 已当场修复**:前端 `markAllRead` 契约错位 —— 原将 `category` 放入 PUT body 而后端读 query,已改为 `?category=xxx` Query 拼接,并同步 `docs/api/frontend/notify.md` §4 +- **Major / Minor 项**:均不阻塞合入,已纳入 `docs/plans/2026-04-20-phase2e-design.md` §九 Phase 2f 清理清单 +- 涉及修复文件:`frontend/src/api/notify.js`、`docs/api/frontend/notify.md`、`test-report-phase2e-1-notification.md` §七 +- 验证:`cd backend/go-service && go build ./...` 通过 + +### Playwright MCP 端到端验证成果(2026-04-20) +- 使用 Playwright MCP 驱动 H5 浏览器完整走查通知中心:登录 → 进入 `/pages/notify/index` → 构造好友申请 → 管理员广播 → 点击通知 → 标记已读 → 跳转 → 全部已读,均通过 +- **WS 实时推送链路通过**:admin 广播后 1s 内页面自动插入通知到列表顶部,「全部/系统」角标实时变化,证明 `notify.new` 事件、前端 `_onNotifyNew` 处理器、UI 响应式更新三段链路一致 +- **额外修复 2 个交互 Bug**(Playwright 现场发现): + - 🔴 Bug-1:`NotifyItem` 自定义 emit 事件名 `tap` 与 uni-app 原生 DOM 事件冲突,导致 Event 对象覆盖 notify 参数 → `PUT .../undefined/read` 400。修复:emit 名改为 `item-tap` / `item-accept` / `item-reject` + - 🟡 Bug-2:`store/notify.js#markRead` 在 `_patchAll` 后再判断 `!target.is_read` 永假,unreadTotal 没有递减。修复:预先快照 `wasUnread` +- 修改文件:`frontend/src/components/notify/NotifyItem.vue`、`frontend/src/pages/notify/index.vue`、`frontend/src/store/notify.js` +- 详细过程见 `test-report-phase2e-1-notification.md` §八 + +### TabBar「我的」聚合未读红点(2026-04-21) +- **背景**:通知未读状态之前仅在 `/pages/profile/index` 内可见(铃铛 badge、菜单项 badge),用户在其他 tabBar 页面(消息/联系人/会议)无法感知有新事件,必须被动切进"我的"才能发现 +- **设计**:采用业界主流做法(微信/QQ/钉钉的"我"Tab 模式)—— tabBar「我的」图标右上角显示**纯红点(无数字)**作为"我的"模块**聚合未读指示器** + - 信息层级分离:tabBar 一级导航只承载 Boolean(有/无未读),具体数字留在二级页面 + - 聚合开放集合:当前只聚合 `notifyStore.unreadTotal`,未来可无缝追加「资料待完善」「安全提醒」「新版本可用」等 + - 语义清晰:保留原 `getBadge(index)`(数字,用于消息/联系人);新增 `hasDot(index)`(布尔,用于"我的");模板先数字后红点优先级渲染 +- **实现文件**:`frontend/src/components/CustomTabBar.vue`(新增 `hasDot` 方法 + `.tab-dot` 样式) +- **Playwright 回归**:有 3 条未读时消息页/我的页 tabBar 红点均亮起;点"全部已读"后 tabBar 红点消失、铃铛 badge 消失、菜单 badge 消失三层同步响应;所有场景均通过 +- **详细设计**:见 `docs/plans/2026-04-20-phase2e-1-design.md` §6.4 + +--- + + +--- + +## 📘 2026-04-20 Phase 2e 规划:会议与通知系统 + +**拆分理由**:原 Phase 2e(会议+通知)工作量约 23-33 人日,引入全新技术栈(mediasoup + Node.js),必须按风险梯度拆分。 + +| 子阶段 | 范围 | 周期 | 状态 | +|---|---|---|---| +| **2e-1 通知系统** | 统一通知中心(11 种类型)+ 双通道推送 + 跨模块 Pusher 接口 | 3-4 天 | ✅ 已完成 | +| **2e-2 会议 MVP** | mediasoup Node 媒体服务 + 即时会议(≤8人)+ 基础音视频控制 | 10-14 天 | 📋 待开发 | +| **2e-3 会议增强** | 预约会议 + 定时提醒 + 会议邀请(复用 2e-1 通知) | 7-10 天 | 📋 待开发 | + +**关键决策**: +- 维持原 mediasoup SFU 架构(不改用 Mesh) +- 会议 MVP 仅音视频通话(不含录制/屏幕共享/预约) +- 管理端扩展推迟到 Phase 2f(新增阶段) +- 通知系统覆盖全部 10+ 种业务事件,含 `meeting_invite`/`meeting_reminder` 类型预留 + +**后续规划清单**(含推迟项,见设计文档 §九): +- Phase 2f:会议管理后台 + 通知广播发布 UI + 管理端仪表板等 +- 第二期:屏幕共享、录制、虚拟背景、微信登录、互动直播 +- 第三期:微服务拆分、K8s、多 Worker 集群、AI 辅助 --- diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2de7333..2a3d7a5 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -11,6 +11,7 @@ import { useWebSocketStore } from '@/store/websocket' import { useChatStore } from '@/store/chat' import { useContactStore } from '@/store/contact' import { useGroupStore } from '@/store/group' +import { useNotifyStore } from '@/store/notify' export default { onLaunch() { @@ -37,10 +38,13 @@ export default { const chatStore = useChatStore() const contactStore = useContactStore() const groupStore = useGroupStore() + const notifyStore = useNotifyStore() chatStore.initWsListeners() contactStore.initWsListeners() groupStore.initWsListeners() + notifyStore.initWsListeners() contactStore.fetchPendingRequests().catch(() => {}) + notifyStore.fetchUnreadCount().catch(() => {}) } } } diff --git a/frontend/src/api/notify.js b/frontend/src/api/notify.js new file mode 100644 index 0000000..5897289 --- /dev/null +++ b/frontend/src/api/notify.js @@ -0,0 +1,55 @@ +/** + * 通知中心 API + * + * 对应后端路由:/api/v1/notifications/* + * 接口文档见:docs/api/frontend/notify.md + */ + +import { get, put } from '@/utils/request' + +/** + * 获取通知列表(游标分页) + * @param {Object} [params] + * @param {string} [params.category] - 分类过滤:all|friend|group|meeting|system + * @param {boolean} [params.is_read] - 已读状态:true/false,不传代表全部 + * @param {number} [params.before_id] - 游标,返回 id 小于该值的通知 + * @param {number} [params.limit] - 页大小(默认 20,最大 100) + */ +const getNotifications = (params = {}) => { + return get('/api/v1/notifications', params) +} + +/** 获取未读数统计(总数 + 分类统计) */ +const getUnreadCount = () => { + return get('/api/v1/notifications/unread-count') +} + +/** + * 标记单条通知已读 + * @param {number} id - 通知 ID + */ +const markRead = (id) => { + return put(`/api/v1/notifications/${id}/read`) +} + +/** + * 批量标记已读(可按分类过滤) + * + * 说明:后端通过 query 参数接收 category(`PUT /api/v1/notifications/read-all?category=friend`) + * 为保持契约一致,此处将 category 拼接到 URL,避免放入 request body 导致后端读不到。 + * + * @param {string} [category] - 分类过滤:friend|group|meeting|system,不传代表全部 + */ +const markAllRead = (category) => { + const url = category + ? `/api/v1/notifications/read-all?category=${encodeURIComponent(category)}` + : '/api/v1/notifications/read-all' + return put(url) +} + +export default { + getNotifications, + getUnreadCount, + markRead, + markAllRead +} diff --git a/frontend/src/components/CustomTabBar.vue b/frontend/src/components/CustomTabBar.vue index c3511b3..60a71f5 100644 --- a/frontend/src/components/CustomTabBar.vue +++ b/frontend/src/components/CustomTabBar.vue @@ -8,7 +8,9 @@ 功能: - 底部导航栏,4 个 Tab:消息 / 联系人 / 会议 / 我的 - 选中态使用 filled 图标 + Primary 色,未选中态使用轮廓图标 + Muted 色 - - 消息 Tab 支持未读消息 badge,联系人 Tab 支持好友申请未读 badge + - 消息 Tab / 联系人 Tab:数字 badge(getBadge) + - 我的 Tab:聚合小红点(hasDot),当前来源 notifyStore.unreadTotal, + 未来可扩展为「资料待完善」「安全提醒」「新版本可用」等聚合指示器 - 使用 switchTab 跳转 -->