feat: Phase 2e-1 统一通知中心 + 我的 TabBar 聚合未读红点
后端(notify 模块) - 新增 notify 模块:DAO/Service/Pusher 接口/Controller/Router/CleanupTask - 数据库 DDL:notify_notifications 表 + 3 索引(user+created/user+is_read/user+category) - 11 种 type 枚举(好友/群聊 9 种 + meeting_* 2 种预留)+ 4 种 category - 跨模块集成:contact 3 处 Pusher(friend_request/accepted/rejected) - 跨模块集成:group 6 处 Pusher(invite/join_request/approved/rejected/kicked/role_changed) - WS handler 断线补偿:连接建立即推送 notify.unread.total - 5 REST API(4 用户 + 1 管理员广播)+ 2 WS 事件(notify.new / notify.unread.total) - 30 天已读通知定时清理(未读永久保留) - Provider/Wire 依赖注入(NotifyPusher、NotifyConnectHook、UserInfoResolver 接口) 前端 - 新增 notify 模块:API/Pinia Store(5 分类分页缓存 + 未读数 + WS 事件)/NotifyItem/通知中心主页 - profile 入口:铃铛 badge + 菜单项 badge + 数字显示 - App.vue/login 初始化 notifyStore WS 监听;logout 调用 notifyStore.reset() 清缓存 - 清理 contact.js/group.js 中散落 toast 与冗余 notify.friend.request/group.join.request 处理 - CustomTabBar 新增 hasDot() 聚合指示器:我的 Tab 显示纯红点(无数字), 当前聚合 notifyStore.unreadTotal,未来可扩展「资料待完善/安全提醒/新版本」等 文档 - 新增 Phase 2e 整体路线图 docs/plans/2026-04-20-phase2e-design.md - 新增 Phase 2e-1 专用设计 docs/plans/2026-04-20-phase2e-1-design.md(§6.4 TabBar 聚合红点) - 新增 Phase 2e-1 实施计划 docs/plans/2026-04-20-phase2e-1-implementation.plan.md - 新增 E2E 验证报告 test-report-phase2e-1-notification.md(含 Playwright MCP 2 个现场 Bug 修复记录) - 更新 docs/progress/CURRENT_STATUS.md、docs/api/README.md、docs/api/frontend/notify.md - 更新 .cursor/rules/project-context.mdc、docs/plans/2026-02-27-echochat-system-design.md 其他 - .gitignore 排除 .playwright-mcp/ MCP 临时快照 架构决策 - 单端 WS 连接:沿用现有 ws.Hub,多端已读同步推迟到 Phase 2f/二期 - 跨模块依赖:contact/group → notify 严格单向(接口注入模式) - 降级策略:Pusher 先入库后推送;WS 失败不回滚入库;入库失败仅 Warn 不影响业务 Playwright MCP 回归(4 类场景全通) - 实时推送(admin 广播 → 1s 内前端自动插入 + 角标 +1) - Deep-link 跳转(好友申请通知 → contact/request 页) - 批量清零(全部已读按钮) - TabBar 聚合红点(有未读亮/全部已读灭)与 notifyStore.unreadTotal 三层同步 Made-with: Cursor
This commit is contained in:
@@ -22,13 +22,26 @@ alwaysApply: true
|
|||||||
- 分支:`feature/phase2c-group-read-receipt`
|
- 分支:`feature/phase2c-group-read-receipt`
|
||||||
- **Phase 2d(消息类型扩展)**:✅ 全部完成(14 个 Task + 代码审查修复 2 项 + 富媒体 Bug 修复 4 项 + UX 优化 1 项),设计文档 `docs/plans/2026-03-04-phase2d-design.md`
|
- **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 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 社区模式)
|
- **开发者运维脚本(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)端到端验证通过
|
- **聊天页 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 记录写入
|
- **语音消息 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,仿微信)**:语音需"听"才算真消费,仅通用已读不能准确反映 → 为「对方发来的」语音叠加独立状态。`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,不增加请求次数,向前兼容
|
- **已读状态刷新持久化(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 仪表板
|
- 范围:图片/语音/文件消息完整流程 + 管理端消息管理(列表+统计+撤回+删除)+ 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**)
|
||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -59,6 +59,8 @@ build/
|
|||||||
coverage/
|
coverage/
|
||||||
*.coverprofile
|
*.coverprofile
|
||||||
|
|
||||||
# --- Playwright 截图 ---
|
# --- Playwright 截图与 MCP 快照 ---
|
||||||
*.png
|
*.png
|
||||||
!design-system/**/*.png
|
!design-system/**/*.png
|
||||||
|
.playwright-mcp/
|
||||||
|
!.playwright-mcp/.gitkeep
|
||||||
|
|||||||
105
backend/go-service/app/constants/notify.go
Normal file
105
backend/go-service/app/constants/notify.go
Normal file
@@ -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)
|
||||||
|
)
|
||||||
@@ -6,10 +6,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
|
"github.com/echochat/backend/app/constants"
|
||||||
"github.com/echochat/backend/app/contact/dao"
|
"github.com/echochat/backend/app/contact/dao"
|
||||||
"github.com/echochat/backend/app/dto"
|
"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/logs"
|
||||||
"github.com/echochat/backend/pkg/ws"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -30,26 +31,33 @@ type OnlineChecker interface {
|
|||||||
BatchCheckOnline(ctx context.Context, userIDs []int64) map[int64]bool
|
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 联系人业务服务
|
// ContactService 联系人业务服务
|
||||||
type ContactService struct {
|
type ContactService struct {
|
||||||
friendshipDAO *dao.FriendshipDAO
|
friendshipDAO *dao.FriendshipDAO
|
||||||
friendGroupDAO *dao.FriendGroupDAO
|
friendGroupDAO *dao.FriendGroupDAO
|
||||||
pubsub *ws.PubSub
|
|
||||||
onlineChecker OnlineChecker
|
onlineChecker OnlineChecker
|
||||||
|
notifyPusher NotifyPusher
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewContactService 创建 ContactService 实例
|
// NewContactService 创建 ContactService 实例
|
||||||
func NewContactService(
|
func NewContactService(
|
||||||
friendshipDAO *dao.FriendshipDAO,
|
friendshipDAO *dao.FriendshipDAO,
|
||||||
friendGroupDAO *dao.FriendGroupDAO,
|
friendGroupDAO *dao.FriendGroupDAO,
|
||||||
pubsub *ws.PubSub,
|
|
||||||
onlineChecker OnlineChecker,
|
onlineChecker OnlineChecker,
|
||||||
|
notifyPusher NotifyPusher,
|
||||||
) *ContactService {
|
) *ContactService {
|
||||||
return &ContactService{
|
return &ContactService{
|
||||||
friendshipDAO: friendshipDAO,
|
friendshipDAO: friendshipDAO,
|
||||||
friendGroupDAO: friendGroupDAO,
|
friendGroupDAO: friendGroupDAO,
|
||||||
pubsub: pubsub,
|
|
||||||
onlineChecker: onlineChecker,
|
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{}{
|
if s.notifyPusher != nil {
|
||||||
"from_user_id": userID,
|
actorID := userID
|
||||||
"message": message,
|
targetUser := targetID
|
||||||
})
|
s.notifyPusher.Push(ctx, ¬ifyService.PushPayload{
|
||||||
if pubErr := s.pubsub.PublishToUser(ctx, targetID, push); pubErr != nil {
|
UserID: targetID,
|
||||||
logs.Warn(ctx, funcName, "推送好友申请通知失败", zap.Error(pubErr))
|
Type: constants.NotifyTypeFriendRequest,
|
||||||
|
Content: message,
|
||||||
|
ActorID: &actorID,
|
||||||
|
TargetType: constants.NotifyTargetUser,
|
||||||
|
TargetID: &targetUser,
|
||||||
|
Extra: map[string]interface{}{
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -132,11 +148,20 @@ func (s *ContactService) AcceptFriendRequest(ctx context.Context, requestID, use
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
push := ws.NewPushMessage("contact.request.accepted", map[string]interface{}{
|
if s.notifyPusher != nil {
|
||||||
"user_id": userID,
|
actorID := userID
|
||||||
})
|
targetUser := userID
|
||||||
if pubErr := s.pubsub.PublishToUser(ctx, req.UserID, push); pubErr != nil {
|
s.notifyPusher.Push(ctx, ¬ifyService.PushPayload{
|
||||||
logs.Warn(ctx, funcName, "推送申请接受通知失败", zap.Error(pubErr))
|
UserID: req.UserID,
|
||||||
|
Type: constants.NotifyTypeFriendAccepted,
|
||||||
|
Content: "对方已接受你的好友申请",
|
||||||
|
ActorID: &actorID,
|
||||||
|
TargetType: constants.NotifyTargetUser,
|
||||||
|
TargetID: &targetUser,
|
||||||
|
Extra: map[string]interface{}{
|
||||||
|
"request_id": requestID,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -148,11 +173,41 @@ func (s *ContactService) RejectFriendRequest(ctx context.Context, requestID, use
|
|||||||
logs.Info(ctx, funcName, "拒绝好友申请",
|
logs.Info(ctx, funcName, "拒绝好友申请",
|
||||||
zap.Int64("request_id", requestID), zap.Int64("user_id", userID))
|
zap.Int64("request_id", requestID), zap.Int64("user_id", userID))
|
||||||
|
|
||||||
err := s.friendshipDAO.RejectRequest(ctx, requestID, userID)
|
req, err := s.friendshipDAO.GetRequestByID(ctx, requestID)
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrRequestNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if req.FriendID != userID {
|
||||||
return ErrRequestNotFound
|
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 获取好友列表(包含在线状态)
|
// GetFriendList 获取好友列表(包含在线状态)
|
||||||
|
|||||||
67
backend/go-service/app/dto/notify_dto.go
Normal file
67
backend/go-service/app/dto/notify_dto.go
Normal file
@@ -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"` // 成功入库的用户数
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/echochat/backend/app/group/dao"
|
"github.com/echochat/backend/app/group/dao"
|
||||||
"github.com/echochat/backend/app/group/model"
|
"github.com/echochat/backend/app/group/model"
|
||||||
imModel "github.com/echochat/backend/app/im/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/logs"
|
||||||
"github.com/echochat/backend/pkg/ws"
|
"github.com/echochat/backend/pkg/ws"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -46,6 +47,12 @@ type MessageWriter interface {
|
|||||||
Create(ctx context.Context, msg *imModel.Message) error
|
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 群聊业务服务
|
// GroupService 群聊业务服务
|
||||||
type GroupService struct {
|
type GroupService struct {
|
||||||
groupDAO *dao.GroupDAO
|
groupDAO *dao.GroupDAO
|
||||||
@@ -53,6 +60,7 @@ type GroupService struct {
|
|||||||
userInfo UserInfoProvider
|
userInfo UserInfoProvider
|
||||||
pubsub *ws.PubSub
|
pubsub *ws.PubSub
|
||||||
msgWriter MessageWriter
|
msgWriter MessageWriter
|
||||||
|
notifyPusher NotifyPusher
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGroupService 创建 GroupService 实例
|
// NewGroupService 创建 GroupService 实例
|
||||||
@@ -62,6 +70,7 @@ func NewGroupService(
|
|||||||
userInfo UserInfoProvider,
|
userInfo UserInfoProvider,
|
||||||
pubsub *ws.PubSub,
|
pubsub *ws.PubSub,
|
||||||
msgWriter MessageWriter,
|
msgWriter MessageWriter,
|
||||||
|
notifyPusher NotifyPusher,
|
||||||
) *GroupService {
|
) *GroupService {
|
||||||
return &GroupService{
|
return &GroupService{
|
||||||
groupDAO: groupDAO,
|
groupDAO: groupDAO,
|
||||||
@@ -69,6 +78,7 @@ func NewGroupService(
|
|||||||
userInfo: userInfo,
|
userInfo: userInfo,
|
||||||
pubsub: pubsub,
|
pubsub: pubsub,
|
||||||
msgWriter: msgWriter,
|
msgWriter: msgWriter,
|
||||||
|
notifyPusher: notifyPusher,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,6 +318,17 @@ func (s *GroupService) InviteMembers(ctx context.Context, userID, groupID int64,
|
|||||||
"user_ids": addedIDs,
|
"user_ids": addedIDs,
|
||||||
"operator_id": userID,
|
"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
|
return nil
|
||||||
@@ -357,6 +378,16 @@ func (s *GroupService) KickMember(ctx context.Context, userID, groupID, targetID
|
|||||||
"operator_id": userID,
|
"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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,6 +433,22 @@ func (s *GroupService) SetMemberRole(ctx context.Context, userID, groupID, targe
|
|||||||
"operator_id": userID,
|
"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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,6 +632,18 @@ func (s *GroupService) SubmitJoinRequest(ctx context.Context, userID, groupID in
|
|||||||
"message": message,
|
"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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,10 +751,33 @@ func (s *GroupService) ReviewJoinRequest(ctx context.Context, userID, groupID, r
|
|||||||
"user_ids": []int64{req.UserID},
|
"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 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 搜索公开群
|
// SearchGroups 搜索公开群
|
||||||
@@ -858,6 +940,52 @@ func (s *GroupService) pushToGroupMembers(ctx context.Context, conversationID in
|
|||||||
s.pushToMembers(ctx, memberIDs, excludeUID, event, data)
|
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 写入系统消息到群会话
|
// writeSystemMessage 写入系统消息到群会话
|
||||||
func (s *GroupService) writeSystemMessage(ctx context.Context, conversationID int64, content string) {
|
func (s *GroupService) writeSystemMessage(ctx context.Context, conversationID int64, content string) {
|
||||||
if s.msgWriter == nil {
|
if s.msgWriter == nil {
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
200
backend/go-service/app/notify/dao/notification_dao.go
Normal file
200
backend/go-service/app/notify/dao/notification_dao.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
29
backend/go-service/app/notify/model/notification.go
Normal file
29
backend/go-service/app/notify/model/notification.go
Normal file
@@ -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"
|
||||||
|
}
|
||||||
23
backend/go-service/app/notify/provider.go
Normal file
23
backend/go-service/app/notify/provider.go
Normal file
@@ -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,
|
||||||
|
)
|
||||||
29
backend/go-service/app/notify/router.go
Normal file
29
backend/go-service/app/notify/router.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
420
backend/go-service/app/notify/service/notify_service.go
Normal file
420
backend/go-service/app/notify/service/notify_service.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
66
backend/go-service/app/notify/service/pusher.go
Normal file
66
backend/go-service/app/notify/service/pusher.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
83
backend/go-service/app/notify/task/cleanup_task.go
Normal file
83
backend/go-service/app/notify/task/cleanup_task.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ import (
|
|||||||
groupController "github.com/echochat/backend/app/group/controller"
|
groupController "github.com/echochat/backend/app/group/controller"
|
||||||
imController "github.com/echochat/backend/app/im/controller"
|
imController "github.com/echochat/backend/app/im/controller"
|
||||||
imHandler "github.com/echochat/backend/app/im/handler"
|
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"
|
wsApp "github.com/echochat/backend/app/ws"
|
||||||
"github.com/echochat/backend/config"
|
"github.com/echochat/backend/config"
|
||||||
"github.com/echochat/backend/pkg/db"
|
"github.com/echochat/backend/pkg/db"
|
||||||
@@ -46,6 +49,9 @@ type App struct {
|
|||||||
OfflinePusher *imHandler.OfflinePusher // 离线消息推送器
|
OfflinePusher *imHandler.OfflinePusher // 离线消息推送器
|
||||||
FileController *fileController.FileController // 文件上传控制器
|
FileController *fileController.FileController // 文件上传控制器
|
||||||
GroupController *groupController.GroupController // 群聊管理控制器
|
GroupController *groupController.GroupController // 群聊管理控制器
|
||||||
|
NotifyService *notifyService.NotifyService // 通知业务服务(兼 Pusher、ConnectHook)
|
||||||
|
NotifyController *notifyController.NotificationController // 通知控制器
|
||||||
|
NotifyCleanupTask *notifyTask.CleanupTask // 通知清理定时任务
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp 创建应用实例
|
// NewApp 创建应用实例
|
||||||
@@ -72,9 +78,12 @@ func NewApp(
|
|||||||
offlinePusher *imHandler.OfflinePusher,
|
offlinePusher *imHandler.OfflinePusher,
|
||||||
fileCtrl *fileController.FileController,
|
fileCtrl *fileController.FileController,
|
||||||
groupCtrl *groupController.GroupController,
|
groupCtrl *groupController.GroupController,
|
||||||
|
notifySvc *notifyService.NotifyService,
|
||||||
|
notifyCtrl *notifyController.NotificationController,
|
||||||
|
notifyCleanup *notifyTask.CleanupTask,
|
||||||
) *App {
|
) *App {
|
||||||
// 注入离线消息推送器到 WS Handler
|
|
||||||
wsHandler.SetOfflinePusher(offlinePusher)
|
wsHandler.SetOfflinePusher(offlinePusher)
|
||||||
|
wsHandler.SetNotifyConnectHook(notifySvc)
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
@@ -99,6 +108,9 @@ func NewApp(
|
|||||||
OfflinePusher: offlinePusher,
|
OfflinePusher: offlinePusher,
|
||||||
FileController: fileCtrl,
|
FileController: fileCtrl,
|
||||||
GroupController: groupCtrl,
|
GroupController: groupCtrl,
|
||||||
|
NotifyService: notifySvc,
|
||||||
|
NotifyController: notifyCtrl,
|
||||||
|
NotifyCleanupTask: notifyCleanup,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import (
|
|||||||
imApp "github.com/echochat/backend/app/im"
|
imApp "github.com/echochat/backend/app/im"
|
||||||
imDAO "github.com/echochat/backend/app/im/dao"
|
imDAO "github.com/echochat/backend/app/im/dao"
|
||||||
imService "github.com/echochat/backend/app/im/service"
|
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"
|
wsApp "github.com/echochat/backend/app/ws"
|
||||||
"github.com/echochat/backend/config"
|
"github.com/echochat/backend/config"
|
||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
@@ -33,6 +35,7 @@ func InitializeApp(cfg *config.Config) (*App, error) {
|
|||||||
imApp.IMSet,
|
imApp.IMSet,
|
||||||
fileApp.FileSet,
|
fileApp.FileSet,
|
||||||
groupApp.GroupSet,
|
groupApp.GroupSet,
|
||||||
|
notifyApp.NotifySet,
|
||||||
wire.Bind(new(wsApp.FriendIDsGetter), new(*contactDAO.FriendshipDAO)),
|
wire.Bind(new(wsApp.FriendIDsGetter), new(*contactDAO.FriendshipDAO)),
|
||||||
wire.Bind(new(groupService.UserInfoProvider), new(*contactDAO.FriendshipDAO)),
|
wire.Bind(new(groupService.UserInfoProvider), new(*contactDAO.FriendshipDAO)),
|
||||||
wire.Bind(new(imService.GroupInfoGetter), new(*groupDAO.GroupDAO)),
|
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(imService.UserInfoGetter), new(*contactDAO.FriendshipDAO)),
|
||||||
wire.Bind(new(contactService.OnlineChecker), new(*wsApp.OnlineService)),
|
wire.Bind(new(contactService.OnlineChecker), new(*wsApp.OnlineService)),
|
||||||
wire.Bind(new(groupService.MessageWriter), new(*imDAO.MessageDAO)),
|
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
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import (
|
|||||||
dao4 "github.com/echochat/backend/app/group/dao"
|
dao4 "github.com/echochat/backend/app/group/dao"
|
||||||
service5 "github.com/echochat/backend/app/group/service"
|
service5 "github.com/echochat/backend/app/group/service"
|
||||||
imApp "github.com/echochat/backend/app/im"
|
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/app/ws"
|
||||||
"github.com/echochat/backend/config"
|
"github.com/echochat/backend/config"
|
||||||
"github.com/echochat/backend/pkg/db"
|
"github.com/echochat/backend/pkg/db"
|
||||||
@@ -67,7 +71,14 @@ func InitializeApp(cfg *config.Config) (*App, error) {
|
|||||||
contactManageController := controller2.NewContactManageController(contactManageService)
|
contactManageController := controller2.NewContactManageController(contactManageService)
|
||||||
handler := ws.ProvideWSHandler(hub, pubSub, jwtConfig, onlineService, authService)
|
handler := ws.ProvideWSHandler(hub, pubSub, jwtConfig, onlineService, authService)
|
||||||
friendGroupDAO := dao3.NewFriendGroupDAO(gormDB)
|
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)
|
contactController := controller3.NewContactController(contactService)
|
||||||
|
|
||||||
// IM 模块初始化
|
// IM 模块初始化
|
||||||
@@ -86,7 +97,7 @@ func InitializeApp(cfg *config.Config) (*App, error) {
|
|||||||
|
|
||||||
// Group 模块初始化
|
// Group 模块初始化
|
||||||
joinRequestDAO := dao4.NewJoinRequestDAO(gormDB)
|
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)
|
groupController := controller5.NewGroupController(groupService)
|
||||||
|
|
||||||
// Admin 群组管理初始化
|
// Admin 群组管理初始化
|
||||||
@@ -98,6 +109,6 @@ func InitializeApp(cfg *config.Config) (*App, error) {
|
|||||||
messageManageService := service2.NewMessageManageService(messageManageDAO, userDAO, conversationDAO, pubSub)
|
messageManageService := service2.NewMessageManageService(messageManageDAO, userDAO, conversationDAO, pubSub)
|
||||||
messageManageController := controller2.NewMessageManageController(messageManageService)
|
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
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ type OfflineMessagePusher interface {
|
|||||||
PushOfflineMessages(ctx context.Context, userID int64)
|
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{
|
var upgrader = websocket.Upgrader{
|
||||||
ReadBufferSize: 1024,
|
ReadBufferSize: 1024,
|
||||||
WriteBufferSize: 1024,
|
WriteBufferSize: 1024,
|
||||||
@@ -37,12 +45,13 @@ var upgrader = websocket.Upgrader{
|
|||||||
|
|
||||||
// Handler WebSocket 连接处理器
|
// Handler WebSocket 连接处理器
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
hub *ws.Hub
|
hub *ws.Hub
|
||||||
pubsub *ws.PubSub
|
pubsub *ws.PubSub
|
||||||
jwtCfg *config.JWTConfig
|
jwtCfg *config.JWTConfig
|
||||||
onlineService *OnlineService
|
onlineService *OnlineService
|
||||||
tokenValidator TokenValidator
|
tokenValidator TokenValidator
|
||||||
offlinePusher OfflineMessagePusher
|
offlinePusher OfflineMessagePusher
|
||||||
|
notifyConnectHook NotifyConnectHook
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler 创建 WebSocket Handler 实例
|
// NewHandler 创建 WebSocket Handler 实例
|
||||||
@@ -61,6 +70,11 @@ func (h *Handler) SetOfflinePusher(pusher OfflineMessagePusher) {
|
|||||||
h.offlinePusher = pusher
|
h.offlinePusher = pusher
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetNotifyConnectHook 设置通知未读补偿钩子(由 notify 模块在初始化时注入)
|
||||||
|
func (h *Handler) SetNotifyConnectHook(hook NotifyConnectHook) {
|
||||||
|
h.notifyConnectHook = hook
|
||||||
|
}
|
||||||
|
|
||||||
// Upgrade 处理 WebSocket 升级请求
|
// Upgrade 处理 WebSocket 升级请求
|
||||||
// GET /ws?token=xxx → JWT 认证 → 升级连接 → 注册 Hub → 订阅 Redis 频道
|
// GET /ws?token=xxx → JWT 认证 → 升级连接 → 注册 Hub → 订阅 Redis 频道
|
||||||
func (h *Handler) Upgrade(c *gin.Context) {
|
func (h *Handler) Upgrade(c *gin.Context) {
|
||||||
@@ -121,6 +135,10 @@ func (h *Handler) Upgrade(c *gin.Context) {
|
|||||||
if h.offlinePusher != nil {
|
if h.offlinePusher != nil {
|
||||||
go h.offlinePusher.PushOfflineMessages(context.Background(), claims.UserID)
|
go h.offlinePusher.PushOfflineMessages(context.Background(), claims.UserID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.notifyConnectHook != nil {
|
||||||
|
go h.notifyConnectHook.PushUnreadTotalOnConnect(context.Background(), claims.UserID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createReadHandler 创建带生命周期管理的消息处理函数
|
// createReadHandler 创建带生命周期管理的消息处理函数
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ func main() {
|
|||||||
// 7. 注册路由(由 router.Setup 统一汇总各模块路由)
|
// 7. 注册路由(由 router.Setup 统一汇总各模块路由)
|
||||||
router.Setup(engine, app)
|
router.Setup(engine, app)
|
||||||
|
|
||||||
|
// 7.1 启动 notify 模块过期通知清理定时任务(后台周期执行)
|
||||||
|
app.NotifyCleanupTask.Start()
|
||||||
|
defer app.NotifyCleanupTask.Stop()
|
||||||
|
|
||||||
// 8. 启动 HTTP 服务(优雅关闭)
|
// 8. 启动 HTTP 服务(优雅关闭)
|
||||||
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
fileApp "github.com/echochat/backend/app/file"
|
fileApp "github.com/echochat/backend/app/file"
|
||||||
groupApp "github.com/echochat/backend/app/group"
|
groupApp "github.com/echochat/backend/app/group"
|
||||||
imApp "github.com/echochat/backend/app/im"
|
imApp "github.com/echochat/backend/app/im"
|
||||||
|
notifyApp "github.com/echochat/backend/app/notify"
|
||||||
"github.com/echochat/backend/app/provider"
|
"github.com/echochat/backend/app/provider"
|
||||||
wsApp "github.com/echochat/backend/app/ws"
|
wsApp "github.com/echochat/backend/app/ws"
|
||||||
"github.com/echochat/backend/pkg/middleware"
|
"github.com/echochat/backend/pkg/middleware"
|
||||||
@@ -42,7 +43,7 @@ func Setup(engine *gin.Engine, app *provider.App) {
|
|||||||
imApp.RegisterRoutes(engine, app.IMController, jwtAuth)
|
imApp.RegisterRoutes(engine, app.IMController, jwtAuth)
|
||||||
fileApp.RegisterRoutes(engine, app.FileController, jwtAuth)
|
fileApp.RegisterRoutes(engine, app.FileController, jwtAuth)
|
||||||
groupApp.RegisterRoutes(engine, app.GroupController, jwtAuth)
|
groupApp.RegisterRoutes(engine, app.GroupController, jwtAuth)
|
||||||
|
notifyApp.RegisterRoutes(engine, app.NotifyController, jwtAuth)
|
||||||
|
|
||||||
// [未来] meeting.RegisterRoutes(engine, app.MeetingController, jwtAuth)
|
// [未来] meeting.RegisterRoutes(engine, app.MeetingController, jwtAuth)
|
||||||
// [未来] notify.RegisterRoutes(engine, app.NotifyController, jwtAuth)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,3 +331,44 @@ COMMENT ON COLUMN im_message_reads.user_id IS '已读用户 ID';
|
|||||||
COMMENT ON COLUMN im_message_reads.read_at IS '已读时间';
|
COMMENT ON COLUMN im_message_reads.read_at IS '已读时间';
|
||||||
|
|
||||||
CREATE INDEX idx_msg_reads_user ON im_message_reads(user_id, read_at);
|
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);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
| [frontend/im.md](frontend/im.md) | 即时通讯 | ✅ Phase 2b | 7 个 API:会话列表/置顶/删除/清空、历史消息、全局搜索、未读数 |
|
| [frontend/im.md](frontend/im.md) | 即时通讯 | ✅ Phase 2b | 7 个 API:会话列表/置顶/删除/清空、历史消息、全局搜索、未读数 |
|
||||||
| [frontend/group.md](frontend/group.md) | 群聊管理 | ✅ Phase 2c | 16 个 API:建群/管理/成员/角色/禁言/公告/搜索/入群审批 |
|
| [frontend/group.md](frontend/group.md) | 群聊管理 | ✅ Phase 2c | 16 个 API:建群/管理/成员/角色/禁言/公告/搜索/入群审批 |
|
||||||
| [frontend/meeting.md](frontend/meeting.md) | 会议 | 📋 后续 | 即时会议、预约会议、加入/离开、会议列表 |
|
| [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/`)
|
### 后台管理端 (`admin/`)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
|
|
||||||
> 通用规范(认证方式、响应格式、错误码)见 [README.md](../README.md)
|
> 通用规范(认证方式、响应格式、错误码)见 [README.md](../README.md)
|
||||||
> 新通知的实时推送通过 WebSocket 完成,见 [websocket.md](../websocket.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 | 需认证 | 获取通知列表 |
|
| GET | /api/v1/notifications | 需认证 | 获取通知列表(游标分页) |
|
||||||
| PUT | /api/v1/notifications/:id/read | 需认证 | 标记通知已读 |
|
| GET | /api/v1/notifications/unread-count | 需认证 | 获取未读数统计 |
|
||||||
| PUT | /api/v1/notifications/read-all | 需认证 | 全部标记已读 |
|
| 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 | 无 | 筛选已读/未读,不传则返回全部 |
|
| category | string | `all` | 分类过滤:`all` / `friend` / `group` / `meeting` / `system` |
|
||||||
| type | string | 无 | 筛选通知类型(meeting_invite / friend_request / friend_accepted / meeting_reminder / system) |
|
| is_read | bool | 无 | 是否已读:`true`/`false`,不传代表全部 |
|
||||||
| page | int | 1 | 页码 |
|
| before_id | int64 | 无 | 游标:返回 id 小于此值的记录(按 id 降序) |
|
||||||
| page_size | int | 20 | 每页数量 |
|
| limit | int | 20 | 页大小(最大 100) |
|
||||||
|
|
||||||
**成功响应:**
|
**成功响应:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"message": "ok",
|
"message": "success",
|
||||||
"data": {
|
"data": {
|
||||||
"list": [
|
"list": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 128,
|
||||||
"type": "meeting_invite",
|
"type": "group_invite",
|
||||||
"title": "会议邀请",
|
"category": "group",
|
||||||
"content": "张三邀请你参加会议「产品需求讨论」",
|
"title": "",
|
||||||
"extra": {
|
"content": "张三 邀请你加入「产品交流群」",
|
||||||
"room_code": "123-456-789",
|
"extra": "{\"group_id\":12,\"group_name\":\"产品交流群\",\"conversation_id\":45,\"inviter_id\":3,\"inviter_name\":\"张三\"}",
|
||||||
"room_title": "产品需求讨论",
|
"actor_id": 3,
|
||||||
"from_user_id": 1,
|
"actor_name": "张三",
|
||||||
"from_username": "zhangsan"
|
"actor_avatar": "",
|
||||||
},
|
"target_type": "group",
|
||||||
|
"target_id": 12,
|
||||||
"is_read": false,
|
"is_read": false,
|
||||||
"created_at": "2026-02-27 10:00:00"
|
"created_at": "2026-04-20 10:15: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"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"total": 15,
|
"has_more": true
|
||||||
"page": 1,
|
}
|
||||||
"page_size": 20
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `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`
|
`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 为直接加入成员)。
|
||||||
|
|||||||
@@ -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
|
||||||
|
- 管理端仪表板总览、操作日志页、系统配置管理
|
||||||
|
- 用户会议记录查询
|
||||||
|
|
||||||
### 第二期
|
### 第二期
|
||||||
- 屏幕共享
|
- 屏幕共享
|
||||||
|
|||||||
294
docs/plans/2026-04-20-phase2e-1-design.md
Normal file
294
docs/plans/2026-04-20-phase2e-1-design.md
Normal file
@@ -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 页面的未读感知问题(为后续我的模块功能扩展预留聚合入口) |
|
||||||
249
docs/plans/2026-04-20-phase2e-1-implementation.plan.md
Normal file
249
docs/plans/2026-04-20-phase2e-1-implementation.plan.md
Normal file
@@ -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)
|
||||||
358
docs/plans/2026-04-20-phase2e-design.md
Normal file
358
docs/plans/2026-04-20-phase2e-design.md
Normal file
@@ -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=<actor_id>` |
|
||||||
|
| `friend_rejected` | 好友申请被拒绝 | contact | 2e-1 | 无跳转(告知型) |
|
||||||
|
| `group_invite` | 被邀请加入群聊 | group | 2e-1 | **内联接受/拒绝** |
|
||||||
|
| `group_join_request` | 收到入群申请(群管理员)| group | 2e-1 | `pages/group/join-requests?groupId=<target_id>` |
|
||||||
|
| `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 连接」约束与推迟项 |
|
||||||
@@ -1,11 +1,123 @@
|
|||||||
# EchoChat 项目开发进度
|
# EchoChat 项目开发进度
|
||||||
|
|
||||||
> **最后更新**:2026-04-20(Phase 2d 完成 + UX 优化 + 已读状态刷新持久化)
|
> **最后更新**:2026-04-21(Phase 2e-1 通知系统 + TabBar 聚合红点 UX 完善)
|
||||||
> **当前阶段**:Phase 2d 全部完成 + Bug 修复 7 项 + UX 优化 2 项
|
> **当前阶段**:Phase 2e-1 通知系统 ✅ 已完成;下一步进入 Phase 2e-2 会议 MVP
|
||||||
> **当前分支**:`feature/phase2c-group-read-receipt`(注:Phase 2d 工作误用了 2c 分支,下一阶段 2e 直接新建分支开发)
|
> **当前分支**:`feature/phase2e-meeting-notification`
|
||||||
> **实施计划**:`docs/plans/2026-03-04-phase2d-implementation.plan.md`
|
> **Phase 2e 整体设计**:`docs/plans/2026-04-20-phase2e-design.md`(三子阶段路线图 + 后续规划清单)
|
||||||
> **设计文档**:`docs/plans/2026-03-04-phase2d-design.md`
|
> **Phase 2e-1 专用设计**:`docs/plans/2026-04-20-phase2e-1-design.md`(子阶段详细设计 + 实施后修订 + 审查记录)
|
||||||
> **本次修复报告**:`test-report-phase2d-bugfix.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 辅助
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useWebSocketStore } from '@/store/websocket'
|
|||||||
import { useChatStore } from '@/store/chat'
|
import { useChatStore } from '@/store/chat'
|
||||||
import { useContactStore } from '@/store/contact'
|
import { useContactStore } from '@/store/contact'
|
||||||
import { useGroupStore } from '@/store/group'
|
import { useGroupStore } from '@/store/group'
|
||||||
|
import { useNotifyStore } from '@/store/notify'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
onLaunch() {
|
onLaunch() {
|
||||||
@@ -37,10 +38,13 @@ export default {
|
|||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const contactStore = useContactStore()
|
const contactStore = useContactStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
|
const notifyStore = useNotifyStore()
|
||||||
chatStore.initWsListeners()
|
chatStore.initWsListeners()
|
||||||
contactStore.initWsListeners()
|
contactStore.initWsListeners()
|
||||||
groupStore.initWsListeners()
|
groupStore.initWsListeners()
|
||||||
|
notifyStore.initWsListeners()
|
||||||
contactStore.fetchPendingRequests().catch(() => {})
|
contactStore.fetchPendingRequests().catch(() => {})
|
||||||
|
notifyStore.fetchUnreadCount().catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
55
frontend/src/api/notify.js
Normal file
55
frontend/src/api/notify.js
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
功能:
|
功能:
|
||||||
- 底部导航栏,4 个 Tab:消息 / 联系人 / 会议 / 我的
|
- 底部导航栏,4 个 Tab:消息 / 联系人 / 会议 / 我的
|
||||||
- 选中态使用 filled 图标 + Primary 色,未选中态使用轮廓图标 + Muted 色
|
- 选中态使用 filled 图标 + Primary 色,未选中态使用轮廓图标 + Muted 色
|
||||||
- 消息 Tab 支持未读消息 badge,联系人 Tab 支持好友申请未读 badge
|
- 消息 Tab / 联系人 Tab:数字 badge(getBadge)
|
||||||
|
- 我的 Tab:聚合小红点(hasDot),当前来源 notifyStore.unreadTotal,
|
||||||
|
未来可扩展为「资料待完善」「安全提醒」「新版本可用」等聚合指示器
|
||||||
- 使用 switchTab 跳转
|
- 使用 switchTab 跳转
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
@@ -26,7 +28,9 @@
|
|||||||
size="24"
|
size="24"
|
||||||
:color="currentIndex === index ? '#2563EB' : '#94A3B8'"
|
:color="currentIndex === index ? '#2563EB' : '#94A3B8'"
|
||||||
/>
|
/>
|
||||||
|
<!-- 优先显示数字 badge(消息/联系人);否则若有未读红点则显示小红点(我的) -->
|
||||||
<text v-if="getBadge(index) > 0" class="tab-badge">{{ getBadge(index) > 99 ? '99+' : getBadge(index) }}</text>
|
<text v-if="getBadge(index) > 0" class="tab-badge">{{ getBadge(index) > 99 ? '99+' : getBadge(index) }}</text>
|
||||||
|
<text v-else-if="hasDot(index)" class="tab-dot" />
|
||||||
</view>
|
</view>
|
||||||
<text class="tab-label">{{ item.label }}</text>
|
<text class="tab-label">{{ item.label }}</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -48,6 +52,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useChatStore } from '@/store/chat'
|
import { useChatStore } from '@/store/chat'
|
||||||
import { useContactStore } from '@/store/contact'
|
import { useContactStore } from '@/store/contact'
|
||||||
|
import { useNotifyStore } from '@/store/notify'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'CustomTabBar',
|
name: 'CustomTabBar',
|
||||||
@@ -84,7 +89,7 @@ export default {
|
|||||||
uni.switchTab({ url: this.tabs[index].path })
|
uni.switchTab({ url: this.tabs[index].path })
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 获取指定 Tab 的 badge 数
|
* 获取指定 Tab 的数字 badge 数(消息/联系人 Tab 使用)
|
||||||
* @param {number} index - Tab 索引
|
* @param {number} index - Tab 索引
|
||||||
* @returns {number} badge 数量(0 表示不显示)
|
* @returns {number} badge 数量(0 表示不显示)
|
||||||
*/
|
*/
|
||||||
@@ -98,6 +103,33 @@ export default {
|
|||||||
return contactStore.pendingCount
|
return contactStore.pendingCount
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 判断指定 Tab 是否需要显示小红点(聚合指示器)
|
||||||
|
*
|
||||||
|
* 当前实现:
|
||||||
|
* - index=3(我的):聚合 notifyStore.unreadTotal > 0
|
||||||
|
*
|
||||||
|
* 未来扩展示例(仅示意,不是 TODO):
|
||||||
|
* - 资料待完善:profileStore.hasProfileReminder
|
||||||
|
* - 安全提醒:securityStore.hasSecurityAlert
|
||||||
|
* - 新版本提示:appStore.hasNewVersion
|
||||||
|
* 追加新来源时,仅需在 index=3 分支里 `|| 新来源`,外层调用方无需感知
|
||||||
|
*
|
||||||
|
* 与 getBadge 语义区分:
|
||||||
|
* - getBadge 返回数字(0 表示不显示)
|
||||||
|
* - hasDot 返回布尔(true 表示仅显示纯红点)
|
||||||
|
* 模板先判断 getBadge,数字优先;无数字时再判断 hasDot
|
||||||
|
*
|
||||||
|
* @param {number} index - Tab 索引
|
||||||
|
* @returns {boolean} 是否显示红点
|
||||||
|
*/
|
||||||
|
hasDot(index) {
|
||||||
|
if (index === 3) {
|
||||||
|
const notifyStore = useNotifyStore()
|
||||||
|
return notifyStore.unreadTotal > 0
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +189,18 @@ export default {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 聚合未读小红点(用于"我的" Tab,纯红色圆点,不显示数字) */
|
||||||
|
.tab-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: -4rpx;
|
||||||
|
right: -8rpx;
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
background-color: #EF4444;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2rpx solid #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
.tab-label {
|
.tab-label {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: #94A3B8;
|
color: #94A3B8;
|
||||||
|
|||||||
287
frontend/src/components/notify/NotifyItem.vue
Normal file
287
frontend/src/components/notify/NotifyItem.vue
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
<!--
|
||||||
|
通知卡片组件
|
||||||
|
|
||||||
|
设计系统:design-system/echochat/MASTER.md
|
||||||
|
色板:Primary #2563EB / BG #F8FAFC / Text #1E293B / Muted #94A3B8
|
||||||
|
ui-ux-pro-max 规范:触摸目标 ≥ 88rpx / 可点击 cursor-pointer / 新旧状态差异化
|
||||||
|
|
||||||
|
功能:
|
||||||
|
- 按 type 渲染图标和文案
|
||||||
|
- 未读加红点标识
|
||||||
|
- group_invite / group_join_request 支持内联“接受/拒绝”按钮
|
||||||
|
- 点击卡片按 target_type + target_id 进行 deep-link 跳转
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view
|
||||||
|
class="notify-item"
|
||||||
|
:class="{ 'notify-item--unread': !notify.is_read }"
|
||||||
|
@tap="handleTap"
|
||||||
|
>
|
||||||
|
<!-- 图标 -->
|
||||||
|
<view
|
||||||
|
class="notify-icon"
|
||||||
|
:style="{ backgroundColor: iconBg }"
|
||||||
|
>
|
||||||
|
<text class="notify-icon-text">{{ icon }}</text>
|
||||||
|
<view v-if="!notify.is_read" class="notify-dot"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 内容 -->
|
||||||
|
<view class="notify-body">
|
||||||
|
<view class="notify-header">
|
||||||
|
<text class="notify-title">{{ displayTitle }}</text>
|
||||||
|
<text class="notify-time">{{ formatTime(notify.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="notify-content" v-if="notify.content">{{ notify.content }}</text>
|
||||||
|
|
||||||
|
<!-- 内联操作按钮:仅限群聊邀请 / 入群申请 -->
|
||||||
|
<view
|
||||||
|
v-if="showActions"
|
||||||
|
class="notify-actions"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="notify-btn notify-btn--primary"
|
||||||
|
:disabled="processing"
|
||||||
|
@tap.stop="handleAccept"
|
||||||
|
>
|
||||||
|
接受
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="notify-btn notify-btn--ghost"
|
||||||
|
:disabled="processing"
|
||||||
|
@tap.stop="handleReject"
|
||||||
|
>
|
||||||
|
拒绝
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* 通知卡片
|
||||||
|
* 仅负责渲染和派发事件;具体业务动作由父页面实现
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import {
|
||||||
|
NOTIFY_TYPE_ICON,
|
||||||
|
NOTIFY_DEFAULT_ICON,
|
||||||
|
NOTIFY_TYPE_LABEL,
|
||||||
|
NOTIFY_CATEGORY_COLOR,
|
||||||
|
supportsInlineAction
|
||||||
|
} from '@/constants/notify'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/** 通知对象,包含 id / type / category / title / content / is_read / created_at / extra 等 */
|
||||||
|
notify: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 注意:uni-app 的 `tap` 是原生 DOM 事件名,若将自定义 emit 事件名也取作 `tap`,
|
||||||
|
// 则原生 tap 冒泡会与自定义 emit 在父组件处形成双重触发,且原生事件会把 Event 对象
|
||||||
|
// 作为参数,导致父组件收到的 notify 变成 Event 对象,随后读取 notify.id 为 undefined。
|
||||||
|
// 为彻底规避冲突,这里统一使用非 DOM 原生事件名。
|
||||||
|
const emit = defineEmits(['item-tap', 'item-accept', 'item-reject'])
|
||||||
|
|
||||||
|
/** 按钮处理中状态(防止重复点击) */
|
||||||
|
const processing = ref(false)
|
||||||
|
|
||||||
|
/** 显示标题:优先 notify.title,其次按 type 取默认文案 */
|
||||||
|
const displayTitle = computed(() => {
|
||||||
|
return props.notify.title || NOTIFY_TYPE_LABEL[props.notify.type] || '通知'
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 图标 */
|
||||||
|
const icon = computed(() => {
|
||||||
|
return NOTIFY_TYPE_ICON[props.notify.type] || NOTIFY_DEFAULT_ICON
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 图标背景色(按分类) */
|
||||||
|
const iconBg = computed(() => {
|
||||||
|
return NOTIFY_CATEGORY_COLOR[props.notify.category] || '#F1F5F9'
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 是否展示内联操作按钮(仅未读的 invite/join_request 通知) */
|
||||||
|
const showActions = computed(() => {
|
||||||
|
if (props.notify.is_read) return false
|
||||||
|
return supportsInlineAction(props.notify.type)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleTap = () => {
|
||||||
|
emit('item-tap', props.notify)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAccept = async () => {
|
||||||
|
if (processing.value) return
|
||||||
|
processing.value = true
|
||||||
|
try {
|
||||||
|
await emit('item-accept', props.notify)
|
||||||
|
} finally {
|
||||||
|
processing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReject = async () => {
|
||||||
|
if (processing.value) return
|
||||||
|
processing.value = true
|
||||||
|
try {
|
||||||
|
await emit('item-reject', props.notify)
|
||||||
|
} finally {
|
||||||
|
processing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 ISO 时间字符串格式化为相对时间
|
||||||
|
* - 1 分钟内:刚刚
|
||||||
|
* - 1 小时内:X 分钟前
|
||||||
|
* - 24 小时内:X 小时前
|
||||||
|
* - 否则:MM-DD HH:mm
|
||||||
|
*/
|
||||||
|
const formatTime = (str) => {
|
||||||
|
if (!str) return ''
|
||||||
|
const t = new Date(str.replace(' ', 'T'))
|
||||||
|
if (isNaN(t.getTime())) return str
|
||||||
|
const now = Date.now()
|
||||||
|
const diff = Math.max(0, now - t.getTime())
|
||||||
|
const min = Math.floor(diff / 60000)
|
||||||
|
if (min < 1) return '刚刚'
|
||||||
|
if (min < 60) return `${min} 分钟前`
|
||||||
|
const hour = Math.floor(min / 60)
|
||||||
|
if (hour < 24) return `${hour} 小时前`
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
return `${pad(t.getMonth() + 1)}-${pad(t.getDate())} ${pad(t.getHours())}:${pad(t.getMinutes())}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notify-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
gap: 20rpx;
|
||||||
|
padding: 28rpx 32rpx;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-item:active {
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-item--unread {
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-icon {
|
||||||
|
position: relative;
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-icon-text {
|
||||||
|
font-size: 36rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: -2rpx;
|
||||||
|
right: -2rpx;
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #EF4444;
|
||||||
|
border: 2rpx solid #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1E293B;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-time {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-content {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #64748B;
|
||||||
|
line-height: 1.5;
|
||||||
|
/* 限制两行显示 */
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内联操作按钮 */
|
||||||
|
.notify-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 64rpx;
|
||||||
|
line-height: 64rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-btn::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-btn[disabled] {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-btn--primary {
|
||||||
|
background-color: #2563EB;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-btn--ghost {
|
||||||
|
background-color: #F1F5F9;
|
||||||
|
color: #1E293B;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
92
frontend/src/constants/notify.js
Normal file
92
frontend/src/constants/notify.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* 通知模块前端常量
|
||||||
|
*
|
||||||
|
* 与后端 backend/go-service/app/constants/notify.go 一致
|
||||||
|
* 新增/修改通知类型时必须同步维护两端常量
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 通知类型(与后端 NotifyType* 保持一致)
|
||||||
|
export const NOTIFY_TYPE_FRIEND_REQUEST = 'friend_request'
|
||||||
|
export const NOTIFY_TYPE_FRIEND_ACCEPTED = 'friend_accepted'
|
||||||
|
export const NOTIFY_TYPE_FRIEND_REJECTED = 'friend_rejected'
|
||||||
|
|
||||||
|
export const NOTIFY_TYPE_GROUP_INVITE = 'group_invite'
|
||||||
|
export const NOTIFY_TYPE_GROUP_JOIN_REQUEST = 'group_join_request'
|
||||||
|
export const NOTIFY_TYPE_GROUP_JOIN_APPROVED = 'group_join_approved'
|
||||||
|
export const NOTIFY_TYPE_GROUP_JOIN_REJECTED = 'group_join_rejected'
|
||||||
|
export const NOTIFY_TYPE_GROUP_KICKED = 'group_kicked'
|
||||||
|
export const NOTIFY_TYPE_GROUP_ROLE_CHANGED = 'group_role_changed'
|
||||||
|
|
||||||
|
export const NOTIFY_TYPE_SYSTEM_BROADCAST = 'system_broadcast'
|
||||||
|
|
||||||
|
export const NOTIFY_TYPE_MEETING_INVITE = 'meeting_invite'
|
||||||
|
export const NOTIFY_TYPE_MEETING_REMINDER = 'meeting_reminder'
|
||||||
|
|
||||||
|
// 通知分类(前端 5 Tab)
|
||||||
|
export const NOTIFY_CATEGORY_ALL = 'all'
|
||||||
|
export const NOTIFY_CATEGORY_FRIEND = 'friend'
|
||||||
|
export const NOTIFY_CATEGORY_GROUP = 'group'
|
||||||
|
export const NOTIFY_CATEGORY_MEETING = 'meeting'
|
||||||
|
export const NOTIFY_CATEGORY_SYSTEM = 'system'
|
||||||
|
|
||||||
|
/** Tab 列表(顺序即 UI 展示顺序) */
|
||||||
|
export const NOTIFY_CATEGORY_TABS = [
|
||||||
|
{ key: NOTIFY_CATEGORY_ALL, label: '全部' },
|
||||||
|
{ key: NOTIFY_CATEGORY_FRIEND, label: '好友' },
|
||||||
|
{ key: NOTIFY_CATEGORY_GROUP, label: '群聊' },
|
||||||
|
{ key: NOTIFY_CATEGORY_MEETING, label: '会议' },
|
||||||
|
{ key: NOTIFY_CATEGORY_SYSTEM, label: '系统' }
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 通知类型中文映射(默认标题兜底) */
|
||||||
|
export const NOTIFY_TYPE_LABEL = {
|
||||||
|
[NOTIFY_TYPE_FRIEND_REQUEST]: '好友申请',
|
||||||
|
[NOTIFY_TYPE_FRIEND_ACCEPTED]: '好友申请通过',
|
||||||
|
[NOTIFY_TYPE_FRIEND_REJECTED]: '好友申请被拒',
|
||||||
|
[NOTIFY_TYPE_GROUP_INVITE]: '群聊邀请',
|
||||||
|
[NOTIFY_TYPE_GROUP_JOIN_REQUEST]: '入群申请',
|
||||||
|
[NOTIFY_TYPE_GROUP_JOIN_APPROVED]: '入群申请通过',
|
||||||
|
[NOTIFY_TYPE_GROUP_JOIN_REJECTED]: '入群申请被拒',
|
||||||
|
[NOTIFY_TYPE_GROUP_KICKED]: '移出群聊',
|
||||||
|
[NOTIFY_TYPE_GROUP_ROLE_CHANGED]: '群角色变更',
|
||||||
|
[NOTIFY_TYPE_SYSTEM_BROADCAST]: '系统通知',
|
||||||
|
[NOTIFY_TYPE_MEETING_INVITE]: '会议邀请',
|
||||||
|
[NOTIFY_TYPE_MEETING_REMINDER]: '会议提醒'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通知图标(emoji 占位;后续可替换为 SVG) */
|
||||||
|
export const NOTIFY_TYPE_ICON = {
|
||||||
|
[NOTIFY_TYPE_FRIEND_REQUEST]: '\uD83D\uDC65', // 👥
|
||||||
|
[NOTIFY_TYPE_FRIEND_ACCEPTED]: '\u2705', // ✅
|
||||||
|
[NOTIFY_TYPE_FRIEND_REJECTED]: '\u274C', // ❌
|
||||||
|
[NOTIFY_TYPE_GROUP_INVITE]: '\uD83C\uDF89', // 🎉
|
||||||
|
[NOTIFY_TYPE_GROUP_JOIN_REQUEST]: '\uD83D\uDCE5', // 📥
|
||||||
|
[NOTIFY_TYPE_GROUP_JOIN_APPROVED]: '\u2705', // ✅
|
||||||
|
[NOTIFY_TYPE_GROUP_JOIN_REJECTED]: '\u274C', // ❌
|
||||||
|
[NOTIFY_TYPE_GROUP_KICKED]: '\uD83D\uDEAA', // 🚪
|
||||||
|
[NOTIFY_TYPE_GROUP_ROLE_CHANGED]: '\uD83D\uDD11', // 🔑
|
||||||
|
[NOTIFY_TYPE_SYSTEM_BROADCAST]: '\uD83D\uDCE2', // 📢
|
||||||
|
[NOTIFY_TYPE_MEETING_INVITE]: '\uD83D\uDCC5', // 📅
|
||||||
|
[NOTIFY_TYPE_MEETING_REMINDER]: '\u23F0' // ⏰
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认图标 */
|
||||||
|
export const NOTIFY_DEFAULT_ICON = '\uD83D\uDD14' // 🔔
|
||||||
|
|
||||||
|
/** 图标背景颜色(按分类) */
|
||||||
|
export const NOTIFY_CATEGORY_COLOR = {
|
||||||
|
[NOTIFY_CATEGORY_FRIEND]: '#DBEAFE', // 蓝浅
|
||||||
|
[NOTIFY_CATEGORY_GROUP]: '#DCFCE7', // 绿浅
|
||||||
|
[NOTIFY_CATEGORY_MEETING]: '#FEF3C7', // 黄浅
|
||||||
|
[NOTIFY_CATEGORY_SYSTEM]: '#E0E7FF' // 紫浅
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断该通知是否支持内联操作(接受/拒绝按钮)
|
||||||
|
* 仅 group_invite 和 group_join_request 支持,对方待处理状态
|
||||||
|
* @param {string} type
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export const supportsInlineAction = (type) => {
|
||||||
|
return type === NOTIFY_TYPE_GROUP_INVITE || type === NOTIFY_TYPE_GROUP_JOIN_REQUEST
|
||||||
|
}
|
||||||
@@ -142,6 +142,13 @@
|
|||||||
"navigationBarTitleText": "会议"
|
"navigationBarTitleText": "会议"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/notify/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "通知中心",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/profile/index",
|
"path": "pages/profile/index",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ import { useUserStore } from '@/store/user'
|
|||||||
import { useWebSocketStore } from '@/store/websocket'
|
import { useWebSocketStore } from '@/store/websocket'
|
||||||
import { useChatStore } from '@/store/chat'
|
import { useChatStore } from '@/store/chat'
|
||||||
import { useContactStore } from '@/store/contact'
|
import { useContactStore } from '@/store/contact'
|
||||||
|
import { useNotifyStore } from '@/store/notify'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'LoginPage',
|
name: 'LoginPage',
|
||||||
@@ -159,6 +160,9 @@ export default {
|
|||||||
wsStore.connect()
|
wsStore.connect()
|
||||||
useChatStore().initWsListeners()
|
useChatStore().initWsListeners()
|
||||||
useContactStore().initWsListeners()
|
useContactStore().initWsListeners()
|
||||||
|
const notifyStore = useNotifyStore()
|
||||||
|
notifyStore.initWsListeners()
|
||||||
|
notifyStore.fetchUnreadCount().catch(() => {})
|
||||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||||
setTimeout(() => uni.reLaunch({ url: '/pages/index/index' }), 800)
|
setTimeout(() => uni.reLaunch({ url: '/pages/index/index' }), 800)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
549
frontend/src/pages/notify/index.vue
Normal file
549
frontend/src/pages/notify/index.vue
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
<!--
|
||||||
|
通知中心主页
|
||||||
|
|
||||||
|
设计系统:design-system/echochat/MASTER.md
|
||||||
|
色板:Primary #2563EB / BG #F8FAFC / Text #1E293B / Muted #94A3B8 / Danger #EF4444
|
||||||
|
|
||||||
|
功能:
|
||||||
|
- 顶部导航:返回 + 标题 + 全部已读按钮
|
||||||
|
- 5 个分类 Tab:全部 / 好友 / 群聊 / 会议 / 系统(未读数角标)
|
||||||
|
- 下拉/上拉刷新(游标分页)
|
||||||
|
- 点击通知 → 标记已读 + deep-link 跳转
|
||||||
|
- 群邀请/入群申请通知:支持内联“接受/拒绝”
|
||||||
|
- 空状态、骨架屏、实时 WS 事件接入
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="page-wrapper">
|
||||||
|
<!-- 顶部栏 -->
|
||||||
|
<view class="header">
|
||||||
|
<view class="back-btn" @tap="goBack">
|
||||||
|
<text class="back-icon">‹</text>
|
||||||
|
</view>
|
||||||
|
<text class="header-title">通知中心</text>
|
||||||
|
<view class="mark-all-btn" @tap="handleMarkAll">
|
||||||
|
<text class="mark-all-text">全部已读</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 分类 Tab -->
|
||||||
|
<scroll-view
|
||||||
|
scroll-x
|
||||||
|
class="tab-scroll"
|
||||||
|
:show-scrollbar="false"
|
||||||
|
>
|
||||||
|
<view class="tab-list">
|
||||||
|
<view
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ 'tab-item--active': activeCategory === tab.key }"
|
||||||
|
@tap="switchTab(tab.key)"
|
||||||
|
>
|
||||||
|
<text class="tab-label">{{ tab.label }}</text>
|
||||||
|
<view v-if="tabBadge(tab.key) > 0" class="tab-badge">
|
||||||
|
{{ tabBadge(tab.key) > 99 ? '99+' : tabBadge(tab.key) }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 骨架屏 -->
|
||||||
|
<view v-if="isInitLoading" class="skeleton-list">
|
||||||
|
<view v-for="i in 4" :key="i" class="skeleton-item">
|
||||||
|
<view class="skeleton-icon"></view>
|
||||||
|
<view class="skeleton-body">
|
||||||
|
<view class="skeleton-line skeleton-line--title"></view>
|
||||||
|
<view class="skeleton-line skeleton-line--content"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 列表 -->
|
||||||
|
<scroll-view
|
||||||
|
v-else
|
||||||
|
scroll-y
|
||||||
|
class="notify-list"
|
||||||
|
:refresher-enabled="true"
|
||||||
|
:refresher-triggered="refreshing"
|
||||||
|
@refresherrefresh="onRefresh"
|
||||||
|
@scrolltolower="onLoadMore"
|
||||||
|
>
|
||||||
|
<view v-if="currentList.length === 0" class="empty-state">
|
||||||
|
<text class="empty-icon">🔔</text>
|
||||||
|
<text class="empty-title">暂无通知</text>
|
||||||
|
<text class="empty-desc">你暂时没有新的消息通知</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<NotifyItem
|
||||||
|
v-for="item in currentList"
|
||||||
|
:key="item.id"
|
||||||
|
:notify="item"
|
||||||
|
@item-tap="handleNotifyTap"
|
||||||
|
@item-accept="handleAccept"
|
||||||
|
@item-reject="handleReject"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<view v-if="currentList.length > 0" class="footer-hint">
|
||||||
|
<text v-if="currentState.loading">加载中...</text>
|
||||||
|
<text v-else-if="!currentState.hasMore">已经到底啦</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* 通知中心页面
|
||||||
|
* 状态集中在 useNotifyStore 中;本组件只负责 UI 编排与事件绑定
|
||||||
|
*/
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { onShow, onUnload } from '@dcloudio/uni-app'
|
||||||
|
import { useNotifyStore } from '@/store/notify'
|
||||||
|
import {
|
||||||
|
NOTIFY_CATEGORY_TABS,
|
||||||
|
NOTIFY_CATEGORY_ALL,
|
||||||
|
NOTIFY_CATEGORY_FRIEND,
|
||||||
|
NOTIFY_CATEGORY_GROUP,
|
||||||
|
NOTIFY_CATEGORY_MEETING,
|
||||||
|
NOTIFY_CATEGORY_SYSTEM,
|
||||||
|
NOTIFY_TYPE_FRIEND_REQUEST,
|
||||||
|
NOTIFY_TYPE_GROUP_INVITE,
|
||||||
|
NOTIFY_TYPE_GROUP_JOIN_REQUEST,
|
||||||
|
NOTIFY_TYPE_GROUP_JOIN_APPROVED
|
||||||
|
} from '@/constants/notify'
|
||||||
|
import NotifyItem from '@/components/notify/NotifyItem.vue'
|
||||||
|
import groupApi from '@/api/group'
|
||||||
|
|
||||||
|
const notifyStore = useNotifyStore()
|
||||||
|
const tabs = NOTIFY_CATEGORY_TABS
|
||||||
|
|
||||||
|
const refreshing = ref(false)
|
||||||
|
const isInitLoading = ref(true)
|
||||||
|
|
||||||
|
const activeCategory = computed(() => notifyStore.activeCategory)
|
||||||
|
const currentState = computed(() => notifyStore.getCategoryState(activeCategory.value))
|
||||||
|
const currentList = computed(() => currentState.value.list)
|
||||||
|
|
||||||
|
/** 计算 Tab 徽标(友 / 群 / 会议 / 系统 基于 unreadByCategory,全部 基于 unreadTotal) */
|
||||||
|
const tabBadge = (key) => {
|
||||||
|
if (key === NOTIFY_CATEGORY_ALL) return notifyStore.unreadTotal
|
||||||
|
return notifyStore.unreadByCategory[key] || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 初始化:注册 WS + 拉未读数 + 首屏列表 */
|
||||||
|
onMounted(async () => {
|
||||||
|
notifyStore.initWsListeners()
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
notifyStore.fetchUnreadCount(),
|
||||||
|
notifyStore.fetchList(activeCategory.value)
|
||||||
|
])
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[notify] 初始化失败', e)
|
||||||
|
} finally {
|
||||||
|
isInitLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 每次页面重新显示:刷新当前 Tab + 未读数(保证从其他页面返回时数据最新) */
|
||||||
|
onShow(() => {
|
||||||
|
if (isInitLoading.value) return
|
||||||
|
notifyStore.fetchUnreadCount().catch(() => {})
|
||||||
|
notifyStore.fetchList(activeCategory.value).catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnload(() => {
|
||||||
|
// 不重置 store,保留缓存以便下次进入直接看到
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 切换 Tab,若缓存为空则拉取 */
|
||||||
|
const switchTab = async (key) => {
|
||||||
|
if (activeCategory.value === key) return
|
||||||
|
notifyStore.setActiveCategory(key)
|
||||||
|
const state = notifyStore.getCategoryState(key)
|
||||||
|
if (state.list.length === 0) {
|
||||||
|
try {
|
||||||
|
await notifyStore.fetchList(key)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[notify] 切 Tab 加载失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下拉刷新当前 Tab */
|
||||||
|
const onRefresh = async () => {
|
||||||
|
refreshing.value = true
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
notifyStore.fetchUnreadCount(),
|
||||||
|
notifyStore.fetchList(activeCategory.value)
|
||||||
|
])
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[notify] 刷新失败', e)
|
||||||
|
} finally {
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 触底加载更多 */
|
||||||
|
const onLoadMore = async () => {
|
||||||
|
try {
|
||||||
|
await notifyStore.loadMore(activeCategory.value)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[notify] 加载更多失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记全部已读(当前 Tab 为分类则只清该类) */
|
||||||
|
const handleMarkAll = async () => {
|
||||||
|
const category = activeCategory.value === NOTIFY_CATEGORY_ALL ? undefined : activeCategory.value
|
||||||
|
try {
|
||||||
|
await notifyStore.markAllRead(category)
|
||||||
|
uni.showToast({ title: '已全部标记为已读', icon: 'none' })
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: '操作失败,请重试', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击通知:标记已读 + deep-link 跳转
|
||||||
|
* 根据 target_type + target_id 或 type 语义决定跳转路径
|
||||||
|
*/
|
||||||
|
const handleNotifyTap = async (notify) => {
|
||||||
|
if (!notify || !notify.id) {
|
||||||
|
console.warn('[notify] handleNotifyTap 收到无效 payload', notify)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!notify.is_read) {
|
||||||
|
try {
|
||||||
|
await notifyStore.markRead(notify.id)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[notify] 标记已读失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_navigateByNotify(notify)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群邀请/入群申请 —— 内联“接受”
|
||||||
|
*/
|
||||||
|
const handleAccept = async (notify) => {
|
||||||
|
try {
|
||||||
|
if (notify.type === NOTIFY_TYPE_GROUP_JOIN_REQUEST) {
|
||||||
|
const extra = _parseExtra(notify.extra)
|
||||||
|
if (extra && extra.group_id && extra.request_id) {
|
||||||
|
await groupApi.reviewJoinRequest(extra.group_id, extra.request_id, 'approve')
|
||||||
|
uni.showToast({ title: '已批准入群申请', icon: 'success' })
|
||||||
|
}
|
||||||
|
} else if (notify.type === NOTIFY_TYPE_GROUP_INVITE) {
|
||||||
|
// 群邀请当前后端为直接加入,接受即跳转到群聊会话
|
||||||
|
const extra = _parseExtra(notify.extra)
|
||||||
|
uni.showToast({ title: '已接受邀请', icon: 'success' })
|
||||||
|
_navigateToGroup(extra)
|
||||||
|
}
|
||||||
|
await notifyStore.markRead(notify.id).catch(() => {})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[notify] 接受失败', e)
|
||||||
|
uni.showToast({ title: e && e.message ? e.message : '操作失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群邀请/入群申请 —— 内联“拒绝”
|
||||||
|
*/
|
||||||
|
const handleReject = async (notify) => {
|
||||||
|
try {
|
||||||
|
if (notify.type === NOTIFY_TYPE_GROUP_JOIN_REQUEST) {
|
||||||
|
const extra = _parseExtra(notify.extra)
|
||||||
|
if (extra && extra.group_id && extra.request_id) {
|
||||||
|
await groupApi.reviewJoinRequest(extra.group_id, extra.request_id, 'reject')
|
||||||
|
uni.showToast({ title: '已拒绝入群申请', icon: 'success' })
|
||||||
|
}
|
||||||
|
} else if (notify.type === NOTIFY_TYPE_GROUP_INVITE) {
|
||||||
|
const extra = _parseExtra(notify.extra)
|
||||||
|
if (extra && extra.group_id) {
|
||||||
|
await groupApi.leaveGroup(extra.group_id)
|
||||||
|
uni.showToast({ title: '已退出群聊', icon: 'success' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await notifyStore.markRead(notify.id).catch(() => {})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[notify] 拒绝失败', e)
|
||||||
|
uni.showToast({ title: e && e.message ? e.message : '操作失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据通知的业务对象进行跳转 */
|
||||||
|
const _navigateByNotify = (notify) => {
|
||||||
|
const extra = _parseExtra(notify.extra)
|
||||||
|
switch (notify.type) {
|
||||||
|
case NOTIFY_TYPE_FRIEND_REQUEST:
|
||||||
|
uni.navigateTo({ url: '/pages/contact/request' })
|
||||||
|
return
|
||||||
|
case NOTIFY_TYPE_GROUP_JOIN_REQUEST:
|
||||||
|
if (extra && extra.group_id) {
|
||||||
|
uni.navigateTo({ url: `/pages/group/join-requests?groupId=${extra.group_id}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case NOTIFY_TYPE_GROUP_INVITE:
|
||||||
|
case NOTIFY_TYPE_GROUP_JOIN_APPROVED:
|
||||||
|
_navigateToGroup(extra)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// 回退:基于 target_type
|
||||||
|
if (notify.target_type === 'group' && notify.target_id) {
|
||||||
|
_navigateToGroup({ group_id: notify.target_id, conversation_id: extra && extra.conversation_id })
|
||||||
|
} else if (notify.target_type === 'user' && notify.target_id) {
|
||||||
|
uni.navigateTo({ url: `/pages/contact/detail?userId=${notify.target_id}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跳转到群聊会话(优先使用 conversation_id,否则群设置页) */
|
||||||
|
const _navigateToGroup = (extra) => {
|
||||||
|
if (!extra) return
|
||||||
|
if (extra.conversation_id) {
|
||||||
|
uni.navigateTo({ url: `/pages/group/conversation?conversationId=${extra.conversation_id}` })
|
||||||
|
} else if (extra.group_id) {
|
||||||
|
uni.navigateTo({ url: `/pages/group/settings?groupId=${extra.group_id}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 extra(后端以 JSON 字符串存储) */
|
||||||
|
const _parseExtra = (extra) => {
|
||||||
|
if (!extra) return null
|
||||||
|
if (typeof extra === 'object') return extra
|
||||||
|
try {
|
||||||
|
return JSON.parse(extra)
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回上一页
|
||||||
|
* - 正常路径:从 profile/其他页 → 通知中心 → navigateBack 到上一页
|
||||||
|
* - 兜底路径:直接 URL 访问通知中心导致页面栈为空时,降级到 tabBar 页
|
||||||
|
* 注意:profile 与 chat 都是 tabBar 页,必须使用 switchTab(不能用 navigateTo/reLaunch)。
|
||||||
|
* reLaunch 在 H5 下跳 tabBar 页面可能导致 tabBar UI 不渲染,故改用 switchTab。
|
||||||
|
*/
|
||||||
|
const goBack = () => {
|
||||||
|
uni.navigateBack({
|
||||||
|
delta: 1,
|
||||||
|
fail: () => {
|
||||||
|
uni.switchTab({
|
||||||
|
url: '/pages/profile/index',
|
||||||
|
fail: () => {
|
||||||
|
uni.switchTab({ url: '/pages/chat/index' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #F8FAFC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 顶部栏 ---- */
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
padding-top: calc(var(--status-bar-height, 44px) + 20rpx);
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
box-shadow: 0 1rpx 0 rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:active {
|
||||||
|
background-color: #F1F5F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-icon {
|
||||||
|
font-size: 48rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1E293B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-all-btn {
|
||||||
|
padding: 14rpx 20rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-all-btn:active {
|
||||||
|
background-color: #F1F5F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-all-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #2563EB;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Tab ---- */
|
||||||
|
.tab-scroll {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-list {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 8rpx 24rpx;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 18rpx 28rpx;
|
||||||
|
gap: 8rpx;
|
||||||
|
border-radius: 100rpx;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item--active {
|
||||||
|
background-color: #EFF6FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-label {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #64748B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item--active .tab-label {
|
||||||
|
color: #2563EB;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-badge {
|
||||||
|
min-width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
padding: 0 8rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background-color: #EF4444;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 列表 ---- */
|
||||||
|
.notify-list {
|
||||||
|
height: calc(100vh - 200rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 骨架屏 ---- */
|
||||||
|
.skeleton-list {
|
||||||
|
padding: 20rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 20rpx;
|
||||||
|
padding: 28rpx 32rpx;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
border-bottom: 1rpx solid #F1F5F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-icon {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background-color: #E2E8F0;
|
||||||
|
animation: pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
height: 24rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background-color: #E2E8F0;
|
||||||
|
animation: pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line--title {
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line--content {
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 空状态 ---- */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 160rpx 40rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 96rpx;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 底部提示 ---- */
|
||||||
|
.footer-hint {
|
||||||
|
padding: 32rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-hint text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -15,6 +15,17 @@
|
|||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
<view class="page-wrapper">
|
<view class="page-wrapper">
|
||||||
|
<!-- 顶部栏(铃铛入口 + 徽标) -->
|
||||||
|
<view class="top-bar">
|
||||||
|
<text class="top-title">我的</text>
|
||||||
|
<view class="bell-btn" @tap="goNotifyCenter">
|
||||||
|
<text class="bell-icon">🔔</text>
|
||||||
|
<view v-if="notifyStore.unreadTotal > 0" class="bell-badge">
|
||||||
|
{{ notifyStore.unreadTotal > 99 ? '99+' : notifyStore.unreadTotal }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 用户信息头部 -->
|
<!-- 用户信息头部 -->
|
||||||
<view class="profile-header">
|
<view class="profile-header">
|
||||||
<view class="avatar-box">
|
<view class="avatar-box">
|
||||||
@@ -26,6 +37,16 @@
|
|||||||
|
|
||||||
<!-- 功能菜单 -->
|
<!-- 功能菜单 -->
|
||||||
<view class="menu-card">
|
<view class="menu-card">
|
||||||
|
<view class="menu-item" @tap="goNotifyCenter">
|
||||||
|
<text class="menu-label">通知中心</text>
|
||||||
|
<view class="menu-right">
|
||||||
|
<view v-if="notifyStore.unreadTotal > 0" class="menu-badge">
|
||||||
|
{{ notifyStore.unreadTotal > 99 ? '99+' : notifyStore.unreadTotal }}
|
||||||
|
</view>
|
||||||
|
<text class="menu-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="menu-divider"></view>
|
||||||
<view class="menu-item" @tap="goEditProfile">
|
<view class="menu-item" @tap="goEditProfile">
|
||||||
<text class="menu-label">编辑资料</text>
|
<text class="menu-label">编辑资料</text>
|
||||||
<text class="menu-arrow">›</text>
|
<text class="menu-arrow">›</text>
|
||||||
@@ -56,6 +77,7 @@
|
|||||||
* 退出登录时调用 store.logout(),清除 Redis Token + 本地缓存
|
* 退出登录时调用 store.logout(),清除 Redis Token + 本地缓存
|
||||||
*/
|
*/
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
|
import { useNotifyStore } from '@/store/notify'
|
||||||
import CustomTabBar from '@/components/CustomTabBar.vue'
|
import CustomTabBar from '@/components/CustomTabBar.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -67,13 +89,28 @@ export default {
|
|||||||
const store = useUserStore()
|
const store = useUserStore()
|
||||||
return store.userInfo || {}
|
return store.userInfo || {}
|
||||||
},
|
},
|
||||||
|
/** 通知 Store(用于读取未读数并驱动铃铛徽标) */
|
||||||
|
notifyStore() {
|
||||||
|
return useNotifyStore()
|
||||||
|
},
|
||||||
/** 头像占位字母(取昵称或用户名首字符) */
|
/** 头像占位字母(取昵称或用户名首字符) */
|
||||||
avatarLetter() {
|
avatarLetter() {
|
||||||
const name = this.userInfo.nickname || this.userInfo.username || '?'
|
const name = this.userInfo.nickname || this.userInfo.username || '?'
|
||||||
return name.charAt(0).toUpperCase()
|
return name.charAt(0).toUpperCase()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onShow() {
|
||||||
|
// 每次进入「我的」页面时刷新未读数,保证徽标实时
|
||||||
|
const store = useNotifyStore()
|
||||||
|
store.initWsListeners()
|
||||||
|
store.fetchUnreadCount().catch(() => {})
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
/** 跳转到通知中心页 */
|
||||||
|
goNotifyCenter() {
|
||||||
|
uni.navigateTo({ url: '/pages/notify/index' })
|
||||||
|
},
|
||||||
|
|
||||||
/** 编辑资料(后续实现,当前提示开发中) */
|
/** 编辑资料(后续实现,当前提示开发中) */
|
||||||
goEditProfile() {
|
goEditProfile() {
|
||||||
uni.showToast({ title: '功能开发中', icon: 'none' })
|
uni.showToast({ title: '功能开发中', icon: 'none' })
|
||||||
@@ -114,6 +151,82 @@ export default {
|
|||||||
padding-bottom: 120rpx;
|
padding-bottom: 120rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- 顶部栏(铃铛入口) ---- */
|
||||||
|
.top-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20rpx 32rpx;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1E293B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-btn {
|
||||||
|
position: relative;
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #F1F5F9;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-btn:active {
|
||||||
|
background-color: #E2E8F0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-icon {
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: #1E293B;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 4rpx;
|
||||||
|
right: 4rpx;
|
||||||
|
min-width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
padding: 0 8rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background-color: #EF4444;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2rpx solid #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-badge {
|
||||||
|
min-width: 36rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
padding: 0 10rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background-color: #EF4444;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- 用户信息头部 ---- */
|
/* ---- 用户信息头部 ---- */
|
||||||
.profile-header {
|
.profile-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -152,9 +152,8 @@ export const useContactStore = defineStore('contact', () => {
|
|||||||
if (_wsInitialized) return
|
if (_wsInitialized) return
|
||||||
_wsInitialized = true
|
_wsInitialized = true
|
||||||
|
|
||||||
wsService.on('notify.friend.request', () => {
|
// 好友申请、接受、拒绝的实时通知统一由 notify store 接管(notify.new 事件)
|
||||||
fetchPendingRequests()
|
// 本 store 仅保留好友列表变更监听,避免重复弹窗和数据刷新
|
||||||
})
|
|
||||||
|
|
||||||
wsService.on('contact.request.accepted', () => {
|
wsService.on('contact.request.accepted', () => {
|
||||||
fetchFriends()
|
fetchFriends()
|
||||||
|
|||||||
@@ -286,17 +286,18 @@ export const useGroupStore = defineStore('group', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新入群申请 → 提示通知 */
|
/**
|
||||||
const _onJoinRequest = (msg) => {
|
* 新入群申请 → 不再 toast,由 notify store(notify.new 事件)统一负责提示和徽标
|
||||||
if (!msg || !msg.data) return
|
* 保留空监听以便未来扩展(如高亮申请列表),避免重复弹窗
|
||||||
uni.showToast({ title: '收到新的入群申请', icon: 'none' })
|
*/
|
||||||
}
|
const _onJoinRequest = () => {}
|
||||||
|
|
||||||
/** 入群申请通过 → 刷新会话列表 */
|
/**
|
||||||
|
* 入群申请通过 → 刷新会话列表(toast 由 notify store 统一处理)
|
||||||
|
*/
|
||||||
const _onJoinApproved = () => {
|
const _onJoinApproved = () => {
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
chatStore.fetchConversations()
|
chatStore.fetchConversations()
|
||||||
uni.showToast({ title: '入群申请已通过', icon: 'none' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
352
frontend/src/store/notify.js
Normal file
352
frontend/src/store/notify.js
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
/**
|
||||||
|
* 通知中心 Store
|
||||||
|
*
|
||||||
|
* 统一管理:
|
||||||
|
* - 通知列表(按分类 Tab 分页缓存,游标分页)
|
||||||
|
* - 未读数统计(总数 + 分类统计)
|
||||||
|
* - WebSocket 实时事件:notify.new(新通知)/ notify.unread.total(断线补偿)
|
||||||
|
*
|
||||||
|
* 对应后端 API:/api/v1/notifications/*
|
||||||
|
* 对应 WS 事件:notify.new、notify.unread.total
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import notifyApi from '@/api/notify'
|
||||||
|
import wsService from '@/services/websocket'
|
||||||
|
|
||||||
|
/** 前端 5 个 Tab 分类常量 */
|
||||||
|
export const NOTIFY_CATEGORY_ALL = 'all'
|
||||||
|
export const NOTIFY_CATEGORY_FRIEND = 'friend'
|
||||||
|
export const NOTIFY_CATEGORY_GROUP = 'group'
|
||||||
|
export const NOTIFY_CATEGORY_MEETING = 'meeting'
|
||||||
|
export const NOTIFY_CATEGORY_SYSTEM = 'system'
|
||||||
|
|
||||||
|
const CATEGORY_LIST = [
|
||||||
|
NOTIFY_CATEGORY_ALL,
|
||||||
|
NOTIFY_CATEGORY_FRIEND,
|
||||||
|
NOTIFY_CATEGORY_GROUP,
|
||||||
|
NOTIFY_CATEGORY_MEETING,
|
||||||
|
NOTIFY_CATEGORY_SYSTEM
|
||||||
|
]
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 20
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建空的分类分页状态
|
||||||
|
*/
|
||||||
|
const createCategoryState = () => ({
|
||||||
|
list: [],
|
||||||
|
hasMore: true,
|
||||||
|
loading: false
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useNotifyStore = defineStore('notify', () => {
|
||||||
|
/** 按分类存储通知列表(Map 结构 category -> { list, hasMore, loading }) */
|
||||||
|
const byCategory = ref({
|
||||||
|
[NOTIFY_CATEGORY_ALL]: createCategoryState(),
|
||||||
|
[NOTIFY_CATEGORY_FRIEND]: createCategoryState(),
|
||||||
|
[NOTIFY_CATEGORY_GROUP]: createCategoryState(),
|
||||||
|
[NOTIFY_CATEGORY_MEETING]: createCategoryState(),
|
||||||
|
[NOTIFY_CATEGORY_SYSTEM]: createCategoryState()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 未读总数 */
|
||||||
|
const unreadTotal = ref(0)
|
||||||
|
|
||||||
|
/** 未读分类统计 { friend, group, meeting, system } */
|
||||||
|
const unreadByCategory = ref({
|
||||||
|
[NOTIFY_CATEGORY_FRIEND]: 0,
|
||||||
|
[NOTIFY_CATEGORY_GROUP]: 0,
|
||||||
|
[NOTIFY_CATEGORY_MEETING]: 0,
|
||||||
|
[NOTIFY_CATEGORY_SYSTEM]: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 当前激活的 Tab(页面用于驱动切换) */
|
||||||
|
const activeCategory = ref(NOTIFY_CATEGORY_ALL)
|
||||||
|
|
||||||
|
/** Tab 角标:未读总数 */
|
||||||
|
const badgeTotal = computed(() => unreadTotal.value)
|
||||||
|
|
||||||
|
/** 获取指定分类的状态(安全读取) */
|
||||||
|
const getCategoryState = (category) => {
|
||||||
|
const key = CATEGORY_LIST.includes(category) ? category : NOTIFY_CATEGORY_ALL
|
||||||
|
if (!byCategory.value[key]) {
|
||||||
|
byCategory.value[key] = createCategoryState()
|
||||||
|
}
|
||||||
|
return byCategory.value[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Actions ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取未读数统计
|
||||||
|
* 场景:页面初始化、WS 断线补偿 notify.unread.total 事件、明确需要刷新时
|
||||||
|
*/
|
||||||
|
const fetchUnreadCount = async () => {
|
||||||
|
try {
|
||||||
|
const res = await notifyApi.getUnreadCount()
|
||||||
|
const data = res.data || {}
|
||||||
|
unreadTotal.value = data.total || 0
|
||||||
|
const byCat = data.by_category || {}
|
||||||
|
unreadByCategory.value = {
|
||||||
|
[NOTIFY_CATEGORY_FRIEND]: byCat[NOTIFY_CATEGORY_FRIEND] || 0,
|
||||||
|
[NOTIFY_CATEGORY_GROUP]: byCat[NOTIFY_CATEGORY_GROUP] || 0,
|
||||||
|
[NOTIFY_CATEGORY_MEETING]: byCat[NOTIFY_CATEGORY_MEETING] || 0,
|
||||||
|
[NOTIFY_CATEGORY_SYSTEM]: byCat[NOTIFY_CATEGORY_SYSTEM] || 0
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[notify] 获取未读数失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载指定分类的通知列表(首屏刷新)
|
||||||
|
* @param {string} category - 分类
|
||||||
|
* @param {Object} [opts]
|
||||||
|
* @param {boolean} [opts.onlyUnread] - 是否仅看未读
|
||||||
|
*/
|
||||||
|
const fetchList = async (category = NOTIFY_CATEGORY_ALL, opts = {}) => {
|
||||||
|
const state = getCategoryState(category)
|
||||||
|
state.loading = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
category,
|
||||||
|
limit: DEFAULT_PAGE_SIZE
|
||||||
|
}
|
||||||
|
if (opts.onlyUnread === true) params.is_read = false
|
||||||
|
const res = await notifyApi.getNotifications(params)
|
||||||
|
const data = res.data || {}
|
||||||
|
state.list = data.list || []
|
||||||
|
state.hasMore = data.has_more === true
|
||||||
|
} finally {
|
||||||
|
state.loading = false
|
||||||
|
}
|
||||||
|
return state.list
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载下一页(游标分页,基于当前列表最后一条的 ID)
|
||||||
|
* @param {string} category
|
||||||
|
* @param {Object} [opts]
|
||||||
|
* @param {boolean} [opts.onlyUnread]
|
||||||
|
*/
|
||||||
|
const loadMore = async (category = NOTIFY_CATEGORY_ALL, opts = {}) => {
|
||||||
|
const state = getCategoryState(category)
|
||||||
|
if (!state.hasMore || state.loading) return []
|
||||||
|
const last = state.list[state.list.length - 1]
|
||||||
|
if (!last) return fetchList(category, opts)
|
||||||
|
|
||||||
|
state.loading = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
category,
|
||||||
|
limit: DEFAULT_PAGE_SIZE,
|
||||||
|
before_id: last.id
|
||||||
|
}
|
||||||
|
if (opts.onlyUnread === true) params.is_read = false
|
||||||
|
const res = await notifyApi.getNotifications(params)
|
||||||
|
const data = res.data || {}
|
||||||
|
const newList = data.list || []
|
||||||
|
state.list = state.list.concat(newList)
|
||||||
|
state.hasMore = data.has_more === true
|
||||||
|
return newList
|
||||||
|
} finally {
|
||||||
|
state.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记单条已读
|
||||||
|
* 后端成功后本地更新列表状态 + 扣减对应分类未读数
|
||||||
|
* @param {number} id - 通知 ID
|
||||||
|
*/
|
||||||
|
const markRead = async (id) => {
|
||||||
|
const targetCategory = _findCategoryById(id)
|
||||||
|
const target = _findNotifyById(id)
|
||||||
|
// 先快照原始未读状态,避免 _patchAll 改写 target.is_read 后再判断失效
|
||||||
|
const wasUnread = !!target && !target.is_read
|
||||||
|
if (target && target.is_read) return
|
||||||
|
|
||||||
|
await notifyApi.markRead(id)
|
||||||
|
|
||||||
|
// 更新所有缓存列表中对应通知的状态
|
||||||
|
_patchAll(id, { is_read: true, read_at: _nowString() })
|
||||||
|
|
||||||
|
if (wasUnread) {
|
||||||
|
if (targetCategory && unreadByCategory.value[targetCategory] > 0) {
|
||||||
|
unreadByCategory.value[targetCategory]--
|
||||||
|
}
|
||||||
|
if (unreadTotal.value > 0) unreadTotal.value--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记全部(或某分类)已读
|
||||||
|
* @param {string} [category] - 分类:friend/group/meeting/system,不传则全部
|
||||||
|
*/
|
||||||
|
const markAllRead = async (category) => {
|
||||||
|
await notifyApi.markAllRead(category)
|
||||||
|
|
||||||
|
// 本地更新所有缓存列表
|
||||||
|
Object.keys(byCategory.value).forEach(cat => {
|
||||||
|
if (!category || cat === category || cat === NOTIFY_CATEGORY_ALL) {
|
||||||
|
byCategory.value[cat].list.forEach(n => {
|
||||||
|
if (!category || n.category === category) {
|
||||||
|
if (!n.is_read) {
|
||||||
|
n.is_read = true
|
||||||
|
n.read_at = _nowString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重新拉取未读数,保证后端权威
|
||||||
|
await fetchUnreadCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换激活 Tab
|
||||||
|
* @param {string} category
|
||||||
|
*/
|
||||||
|
const setActiveCategory = (category) => {
|
||||||
|
if (CATEGORY_LIST.includes(category)) {
|
||||||
|
activeCategory.value = category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置 store(登出调用)
|
||||||
|
*/
|
||||||
|
const reset = () => {
|
||||||
|
Object.keys(byCategory.value).forEach(cat => {
|
||||||
|
byCategory.value[cat] = createCategoryState()
|
||||||
|
})
|
||||||
|
unreadTotal.value = 0
|
||||||
|
unreadByCategory.value = {
|
||||||
|
[NOTIFY_CATEGORY_FRIEND]: 0,
|
||||||
|
[NOTIFY_CATEGORY_GROUP]: 0,
|
||||||
|
[NOTIFY_CATEGORY_MEETING]: 0,
|
||||||
|
[NOTIFY_CATEGORY_SYSTEM]: 0
|
||||||
|
}
|
||||||
|
activeCategory.value = NOTIFY_CATEGORY_ALL
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== WebSocket 事件 ====================
|
||||||
|
|
||||||
|
/** 防止 WebSocket 事件监听重复注册 */
|
||||||
|
let _wsInitialized = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 notify.new 事件:新通知到达
|
||||||
|
* 行为:
|
||||||
|
* 1) 插入到对应分类列表和 all 分类列表顶部
|
||||||
|
* 2) 未读总数 +1、对应分类 +1
|
||||||
|
*/
|
||||||
|
const _onNotifyNew = (msg) => {
|
||||||
|
if (!msg || !msg.data) return
|
||||||
|
const notify = msg.data
|
||||||
|
|
||||||
|
const targetCat = notify.category
|
||||||
|
if (targetCat && byCategory.value[targetCat]) {
|
||||||
|
byCategory.value[targetCat].list.unshift(notify)
|
||||||
|
}
|
||||||
|
byCategory.value[NOTIFY_CATEGORY_ALL].list.unshift(notify)
|
||||||
|
|
||||||
|
if (!notify.is_read) {
|
||||||
|
unreadTotal.value++
|
||||||
|
if (targetCat && targetCat in unreadByCategory.value) {
|
||||||
|
unreadByCategory.value[targetCat]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 notify.unread.total 事件:断线补偿
|
||||||
|
* 行为:以权威推送值覆盖本地缓存的未读数
|
||||||
|
*/
|
||||||
|
const _onUnreadTotal = (msg) => {
|
||||||
|
if (!msg || !msg.data) return
|
||||||
|
const data = msg.data
|
||||||
|
if (typeof data.total === 'number') unreadTotal.value = data.total
|
||||||
|
if (data.by_category) {
|
||||||
|
unreadByCategory.value = {
|
||||||
|
[NOTIFY_CATEGORY_FRIEND]: data.by_category[NOTIFY_CATEGORY_FRIEND] || 0,
|
||||||
|
[NOTIFY_CATEGORY_GROUP]: data.by_category[NOTIFY_CATEGORY_GROUP] || 0,
|
||||||
|
[NOTIFY_CATEGORY_MEETING]: data.by_category[NOTIFY_CATEGORY_MEETING] || 0,
|
||||||
|
[NOTIFY_CATEGORY_SYSTEM]: data.by_category[NOTIFY_CATEGORY_SYSTEM] || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 初始化 WebSocket 事件监听(幂等,多次调用只注册一次) */
|
||||||
|
const initWsListeners = () => {
|
||||||
|
if (_wsInitialized) return
|
||||||
|
_wsInitialized = true
|
||||||
|
wsService.on('notify.new', _onNotifyNew)
|
||||||
|
wsService.on('notify.unread.total', _onUnreadTotal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 内部工具 ====================
|
||||||
|
|
||||||
|
/** 在所有缓存列表中查找指定 ID 的通知 */
|
||||||
|
const _findNotifyById = (id) => {
|
||||||
|
for (const cat of CATEGORY_LIST) {
|
||||||
|
const state = byCategory.value[cat]
|
||||||
|
if (!state) continue
|
||||||
|
const found = state.list.find(n => n.id === id)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 推断某条通知所属的分类(根据缓存查找) */
|
||||||
|
const _findCategoryById = (id) => {
|
||||||
|
for (const cat of CATEGORY_LIST) {
|
||||||
|
if (cat === NOTIFY_CATEGORY_ALL) continue
|
||||||
|
const state = byCategory.value[cat]
|
||||||
|
if (!state) continue
|
||||||
|
if (state.list.some(n => n.id === id)) return cat
|
||||||
|
}
|
||||||
|
// 兜底:从 all 列表查找 category 字段
|
||||||
|
const inAll = byCategory.value[NOTIFY_CATEGORY_ALL].list.find(n => n.id === id)
|
||||||
|
return inAll ? inAll.category : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在所有分类列表中 patch 对应 ID 的通知字段 */
|
||||||
|
const _patchAll = (id, patch) => {
|
||||||
|
CATEGORY_LIST.forEach(cat => {
|
||||||
|
const state = byCategory.value[cat]
|
||||||
|
if (!state) return
|
||||||
|
const target = state.list.find(n => n.id === id)
|
||||||
|
if (target) Object.assign(target, patch)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const _nowString = () => {
|
||||||
|
const d = new Date()
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// state
|
||||||
|
byCategory,
|
||||||
|
unreadTotal,
|
||||||
|
unreadByCategory,
|
||||||
|
activeCategory,
|
||||||
|
// computed
|
||||||
|
badgeTotal,
|
||||||
|
// actions
|
||||||
|
getCategoryState,
|
||||||
|
fetchUnreadCount,
|
||||||
|
fetchList,
|
||||||
|
loadMore,
|
||||||
|
markRead,
|
||||||
|
markAllRead,
|
||||||
|
setActiveCategory,
|
||||||
|
reset,
|
||||||
|
initWsListeners
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -126,6 +126,11 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
const { useChatStore } = await import('@/store/chat')
|
const { useChatStore } = await import('@/store/chat')
|
||||||
useChatStore().resetVoicePlayedState()
|
useChatStore().resetVoicePlayedState()
|
||||||
} catch (_) { /* ignore */ }
|
} catch (_) { /* ignore */ }
|
||||||
|
// 清空通知中心缓存,避免账号切换后看到前一个用户的通知
|
||||||
|
try {
|
||||||
|
const { useNotifyStore } = await import('@/store/notify')
|
||||||
|
useNotifyStore().reset()
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
token.value = ''
|
token.value = ''
|
||||||
userInfo.value = null
|
userInfo.value = null
|
||||||
clearAll()
|
clearAll()
|
||||||
|
|||||||
253
test-report-phase2e-1-notification.md
Normal file
253
test-report-phase2e-1-notification.md
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
# Phase 2e-1 通知系统 · 测试验证报告
|
||||||
|
|
||||||
|
> 创建日期:2026-04-20
|
||||||
|
> 分支:`feature/phase2e-meeting-notification`
|
||||||
|
> 范围:Phase 2e-1 统一通知系统(后端 notify 模块 + 前端通知中心 UI + contact/group 集成)
|
||||||
|
|
||||||
|
## 一、测试环境
|
||||||
|
|
||||||
|
| 组件 | 版本/路径 | 启动方式 |
|
||||||
|
|---|---|---|
|
||||||
|
| Backend Go 服务 | `backend/go-service/` | `go run cmd/server/main.go`(端口 8085) |
|
||||||
|
| Frontend 用户端 | `frontend/`(uni-app H5) | `npm run dev:h5` |
|
||||||
|
| PostgreSQL | 14+ | Docker `deploy/docker/postgres/init.sql` 初始化表结构 |
|
||||||
|
| Redis | 7+ | 用于 JWT Token 存储 + WS Pub/Sub |
|
||||||
|
|
||||||
|
> 新增 DDL:`notify_notifications` 表(含 3 个索引)。部署时需要执行 `docker exec -i <pg-container> psql -U postgres -d echochat < deploy/docker/postgres/init.sql`(或仅运行表定义片段)。
|
||||||
|
|
||||||
|
## 二、构建验证
|
||||||
|
|
||||||
|
### 2.1 后端编译 ✅
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/go-service && go build ./...
|
||||||
|
# 输出:无报错
|
||||||
|
```
|
||||||
|
|
||||||
|
已通过,包括 Wire 生成的依赖注入路径(`wire_gen.go` 已更新注入 `NotificationDAO` / `NotifyService` / `NotificationController` / `CleanupTask` / `NotifyPusher` 接口绑定)。
|
||||||
|
|
||||||
|
### 2.2 前端模块完整性 ✅
|
||||||
|
|
||||||
|
| 文件 | 类型 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/api/notify.js` | REST 封装 | ✅ 新增 |
|
||||||
|
| `src/store/notify.js` | Pinia Store | ✅ 新增 |
|
||||||
|
| `src/constants/notify.js` | 类型/分类常量 | ✅ 新增 |
|
||||||
|
| `src/components/notify/NotifyItem.vue` | 通知卡片组件 | ✅ 新增 |
|
||||||
|
| `src/pages/notify/index.vue` | 通知中心主页 | ✅ 新增 |
|
||||||
|
| `src/pages.json` | 路由注册 | ✅ 追加 `pages/notify/index` |
|
||||||
|
| `src/App.vue` | 全局初始化 | ✅ 接入 `notifyStore.initWsListeners/fetchUnreadCount` |
|
||||||
|
| `src/pages/auth/login.vue` | 登录后挂载 | ✅ 接入 |
|
||||||
|
| `src/pages/profile/index.vue` | 铃铛入口 | ✅ 新增徽标与菜单项 |
|
||||||
|
| `src/store/user.js` | 登出清空 | ✅ 接入 `notifyStore.reset()` |
|
||||||
|
| `src/store/contact.js:155` | 冗余清理 | ✅ 已删除 `notify.friend.request` 散落处理 |
|
||||||
|
| `src/store/group.js:292` | 冗余清理 | ✅ 已移除重复 toast |
|
||||||
|
|
||||||
|
## 三、功能验证清单
|
||||||
|
|
||||||
|
### 3.1 REST API(4 用户 + 1 管理员)
|
||||||
|
|
||||||
|
| 接口 | 方法 | 路径 | 鉴权 | 验收 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 通知列表 | GET | `/api/v1/notifications?category=&is_read=&before_id=&limit=` | JWT | 游标分页 + `has_more` + 按分类过滤 |
|
||||||
|
| 未读数 | GET | `/api/v1/notifications/unread-count` | JWT | 返回 `{total, by_category: {friend, group, meeting, system}}` |
|
||||||
|
| 标记已读 | PUT | `/api/v1/notifications/:id/read` | JWT | 只能标记自己的通知;重复调用幂等 |
|
||||||
|
| 全部已读 | PUT | `/api/v1/notifications/read-all` body `{category?}` | JWT | 支持按分类标记 |
|
||||||
|
| 管理员广播 | POST | `/api/v1/admin/notifications/broadcast` | JWT + `RoleAdmin`/`RoleSuperAdmin` | 全员入库,在线用户实时收到 |
|
||||||
|
|
||||||
|
**手动 E2E 脚本**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 登录获取 Token
|
||||||
|
curl -X POST http://localhost:8085/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"account":"alice","password":"password123"}'
|
||||||
|
# 取 data.access_token 作为 $TOKEN
|
||||||
|
|
||||||
|
# 2. A 给 B 发好友申请
|
||||||
|
curl -X POST http://localhost:8085/api/v1/contacts/request \
|
||||||
|
-H "Authorization: Bearer $TOKEN_A" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"target_id":<B_USER_ID>, "message":"加个好友"}'
|
||||||
|
|
||||||
|
# 3. B 查询通知列表
|
||||||
|
curl "http://localhost:8085/api/v1/notifications?category=all&limit=20" \
|
||||||
|
-H "Authorization: Bearer $TOKEN_B"
|
||||||
|
|
||||||
|
# 4. B 查询未读数
|
||||||
|
curl http://localhost:8085/api/v1/notifications/unread-count \
|
||||||
|
-H "Authorization: Bearer $TOKEN_B"
|
||||||
|
|
||||||
|
# 5. B 标记单条已读
|
||||||
|
curl -X PUT http://localhost:8085/api/v1/notifications/1/read \
|
||||||
|
-H "Authorization: Bearer $TOKEN_B"
|
||||||
|
|
||||||
|
# 6. 管理员广播
|
||||||
|
curl -X POST http://localhost:8085/api/v1/admin/notifications/broadcast \
|
||||||
|
-H "Authorization: Bearer $TOKEN_ADMIN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"title":"系统公告","content":"服务器将于今晚维护"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 WS 事件(单端连接架构)
|
||||||
|
|
||||||
|
| 事件 | 触发 | 方向 | 载荷 | 验收 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `notify.new` | 新通知写入 | S→C | `NotificationDTO` 完整对象 | 前端列表顶部插入 + 徽标 +1 |
|
||||||
|
| `notify.unread.total` | WS 连接成功 | S→C | `{total, by_category}` | 前端以权威值覆盖本地缓存 |
|
||||||
|
|
||||||
|
### 3.3 跨模块集成(contact + group)
|
||||||
|
|
||||||
|
| 触发动作 | 通知类型 | 接收者 | extra 关键字段 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A 发好友申请给 B | `friend_request` | B | `message` |
|
||||||
|
| A 接受 B 的申请 | `friend_accepted` | B | 好友详情 |
|
||||||
|
| A 拒绝 B 的申请 | `friend_rejected` | B | - |
|
||||||
|
| A 邀请 B 入群 | `group_invite` | B | `group_id/group_name/conversation_id/inviter_id/inviter_name` |
|
||||||
|
| B 申请入群 X | `group_join_request` | 群主 + 管理员 | `group_id/group_name/request_id/applicant_id/applicant_name/message` |
|
||||||
|
| 管理员批准 | `group_join_approved` | 申请人 | `group_id/group_name/conversation_id/request_id` |
|
||||||
|
| 管理员拒绝 | `group_join_rejected` | 申请人 | `group_id/group_name/request_id` |
|
||||||
|
| 群主踢人 | `group_kicked` | 被踢用户 | `group_id/group_name` |
|
||||||
|
| 设置/取消管理员 | `group_role_changed` | 被变更成员 | `group_id/group_name/role` |
|
||||||
|
|
||||||
|
### 3.4 核心 E2E 场景(对应设计文档 §5 验收)
|
||||||
|
|
||||||
|
1. **好友申请**:A 发给 B → B 通知中心可看到,铃铛徽标 +1,点击跳好友申请页 → 处理后通知自动标已读 → 徽标 -1
|
||||||
|
2. **群邀请内联操作**:A 邀请 B 入群 → B 通知卡片出现“接受/拒绝”按钮;点“接受”弹 toast 并跳转群聊;点“拒绝”调用 `leaveGroup` 退群
|
||||||
|
3. **入群申请审批**:C 申请入群 X → 管理员 Alice 通知卡片出现“接受/拒绝”按钮;点“接受”后 C 被添加为成员,本条通知消失;点“拒绝”后写入 rejected 状态
|
||||||
|
4. **系统广播**:管理员通过 `POST /api/v1/admin/notifications/broadcast` → 所有在线用户通知中心顶部出现新卡片,徽标 +1,图标为 📢
|
||||||
|
5. **断线补偿**:关闭 WS 后让后端累积未读 → 重新连接后前端收到 `notify.unread.total`,徽标与后端查询一致
|
||||||
|
6. **30 天清理**:DB 插入 `created_at < NOW() - INTERVAL '30 days'` 且 `is_read=true` 的记录 → 等待 `CleanupTask.runOnce()`(或手动触发)→ 被清理;未读通知不受影响
|
||||||
|
|
||||||
|
### 3.5 视觉回归(建议截图)
|
||||||
|
|
||||||
|
- 通知中心空态:`empty` 文案居中
|
||||||
|
- 通知中心未读列表:未读条目左侧红点 + 浅灰背景
|
||||||
|
- 通知中心 Tab 带徽标:未读数角标显示在 Tab 右上
|
||||||
|
- Profile 页顶部铃铛徽标:数值正确
|
||||||
|
|
||||||
|
## 四、已知限制
|
||||||
|
|
||||||
|
1. **单端 WS 连接**(沿用 `ws.Hub` 现状):同一用户多端登录时仅最新连接有效,旧连接被关闭,**不提供多端已读同步**。此限制已在 `docs/plans/2026-04-20-phase2e-design.md` §3.1/§3.5 修订,多端改造推迟到 Phase 2f 或二期。
|
||||||
|
2. **group_invite 语义**:后端当前 `InviteMembers` 为直接加入,"接受"为仅跳转会话,"拒绝"为调用 `leaveGroup`。后续 Phase 可改为预加入 → 明确确认流程。
|
||||||
|
3. **管理端广播 UI**:本期仅提供后端接口,管理端页面(`admin/` 项目)未落地,推迟到 Phase 2f。
|
||||||
|
4. **推送退化**:Pusher 内部 WS 推送失败不回滚入库(已写入 DB);下次重连会通过 `notify.unread.total` + 列表拉取补偿展示。
|
||||||
|
|
||||||
|
## 五、测试结论
|
||||||
|
|
||||||
|
- ✅ **后端编译通过**,Wire 依赖注入完整。
|
||||||
|
- ✅ **代码静态检查通过**(前端 `ReadLints` 无报错)。
|
||||||
|
- ⏭️ **E2E 交互测试**:需要启动完整服务栈后按照 §3 脚本手动验证;CI 集成可后续接入 Playwright 自动化(待 Phase 2f 规划)。
|
||||||
|
- ⏭️ **视觉回归**:建议在交付前由开发/QA 截图并与设计文档 `docs/plans/2026-04-20-phase2e-design.md` 中的页面草图对比。
|
||||||
|
|
||||||
|
## 六、联动动作回顾(`.plan.md` §实施后的连带动作)
|
||||||
|
|
||||||
|
- [x] 修订 `docs/plans/2026-04-20-phase2e-design.md`:已在规划阶段同步完成(§3.1/§3.5、§八、§九)
|
||||||
|
- [x] 所有规划 + 实施文档在同一分支提交
|
||||||
|
- [x] Agent 模式按 Task 顺序完成全部 11 个 Task(Task 10 以本报告形式记录验证清单;Task 11 由 `code-reviewer` 子代理审查 + `docs/progress/CURRENT_STATUS.md` 同步)
|
||||||
|
|
||||||
|
## 七、代码审查修复记录(Task 11 追踪)
|
||||||
|
|
||||||
|
`code-reviewer` 子代理于 2026-04-20 完成整体审查,总体结论「**有条件通过**」,发现并修复以下必修项:
|
||||||
|
|
||||||
|
### 🔴 Blocker-1:`markAllRead` 前后端契约错位(已修复)
|
||||||
|
|
||||||
|
| 项 | 修复前 | 修复后 |
|
||||||
|
|---|---|---|
|
||||||
|
| 前端 `api/notify.js#markAllRead` | `PUT /api/v1/notifications/read-all` + body `{category}` | `PUT /api/v1/notifications/read-all?category={category}`(Query) |
|
||||||
|
| 后端 Controller | `c.Query("category")`(无改动) | 无改动 |
|
||||||
|
| 结果 | category 过滤完全失效(后端读不到 body 字段) | 分类标记已读行为恢复正确 |
|
||||||
|
|
||||||
|
**涉及文件**:
|
||||||
|
- `frontend/src/api/notify.js`(已修改)
|
||||||
|
- `docs/api/frontend/notify.md` §4(已同步说明 Query 契约)
|
||||||
|
|
||||||
|
### 🟡 Major / 🟢 Minor 项
|
||||||
|
|
||||||
|
子代理列出的剩余 5 个 Major / 11 个 Minor 项均不阻塞合入,统一纳入 Phase 2f 清理清单(见 `docs/plans/2026-04-20-phase2e-design.md` §九),包括:
|
||||||
|
|
||||||
|
- Response 字段与文档一致性细化
|
||||||
|
- 前端常量重复定义合并
|
||||||
|
- WS `notify.new` / `notify.unread.total` 事件竞态兜底(当前方案已提供排序字段)
|
||||||
|
- NotifyItem 防重点击
|
||||||
|
- Broadcast 错误语义增强
|
||||||
|
- DDL 字段尺寸微调 / Pusher 签名与设计稿对齐 / WS 幂等去重 / goroutine 限流
|
||||||
|
|
||||||
|
本次修复仅覆盖 Blocker 项。验证执行:`cd backend/go-service && go build ./...` 通过。
|
||||||
|
|
||||||
|
## 八、Playwright MCP 自动化验证与 Bug 修复(2026-04-20)
|
||||||
|
|
||||||
|
用户要求通过 Playwright MCP 对通知中心进行端到端自动化验证,测试过程中发现并修复 2 个 Bug,最终全链路通过。
|
||||||
|
|
||||||
|
### 8.1 验证步骤与结果
|
||||||
|
|
||||||
|
1. **DDL 补齐**:测试环境 PostgreSQL 容器 `echochat-postgres` 中 `notify_notifications` 表缺失(`init.sql` 新增 DDL 未应用),手动执行表与 3 个索引创建语句 → 已修复。
|
||||||
|
2. **登录 testuser1(id=4)**:Playwright 驱动 H5 登录,本地存储正确保存 JWT + user 信息。
|
||||||
|
3. **构造好友申请数据**:以 testuser(id=2)通过 REST API 向 testuser1 发起好友申请 → `POST /api/v1/contacts/request` 200,后端日志显示 `notify_notifications` 插入成功。
|
||||||
|
4. **打开通知中心 `/pages/notify/index`**:列表正确显示「好友申请」,分类角标「全部 1 / 好友 1」,左侧红色未读点 + 相对时间「刚刚」均正常。
|
||||||
|
5. **WS 实时推送验证**:testuser1 自身带 `admin` 角色,调用 `POST /api/v1/admin/notifications/broadcast` 广播 → 1 秒内页面**自动**插入新通知到列表顶部,「全部」角标由 1 → 2,「系统」新增 1。WS `notify.new` 推送 + 前端 `_onNotifyNew` 处理器 + UI 实时刷新链路全部通过。
|
||||||
|
6. **标记单条已读**:点击通知后后端返回 200,但最初发现前端 `PUT .../undefined/read` 400 报错 → 修复 Bug-1(见 §8.2)。
|
||||||
|
7. **Deep-link 跳转**:点击「好友申请」通知 → 自动跳转到 `/pages/contact/request`,并正确显示 testuser 的申请记录(头像/昵称/消息/时间)。
|
||||||
|
8. **全部已读**:新触发一条「第二轮广播」,点击页面右上角「全部已读」按钮 → 3 条通知瞬时变为已读(无红点、背景白色),所有分类角标归零。
|
||||||
|
|
||||||
|
### 8.2 发现并修复的 Bug
|
||||||
|
|
||||||
|
#### 🔴 Bug-1:点击通知时 `notify.id` 为 `undefined`
|
||||||
|
|
||||||
|
**症状**:点击任意通知卡片后,浏览器控制台出现 `PUT /api/v1/notifications/undefined/read` → 后端返回 `400 通知 ID 格式错误`。
|
||||||
|
|
||||||
|
**根因**:uni-app 的 `tap` 是 DOM 原生事件名。`NotifyItem.vue` 将**自定义 emit 事件**也命名为 `tap`,在父组件 `<NotifyItem @tap="handleNotifyTap" />` 处形成「双重触发」:
|
||||||
|
1. 子组件 `emit('tap', props.notify)` —— 参数是通知对象
|
||||||
|
2. 根元素 `<view @tap>` 的**原生 tap 事件冒泡** —— 参数是 `Event` 对象
|
||||||
|
|
||||||
|
两次调用中,原生事件覆盖了 emit 的自定义参数,导致 `handleNotifyTap` 实际拿到 Event,`notify.id` 自然为 undefined。
|
||||||
|
|
||||||
|
**修复**(涉及 2 个文件):
|
||||||
|
- `frontend/src/components/notify/NotifyItem.vue`:emit 事件名统一改为非 DOM 原生名 `item-tap` / `item-accept` / `item-reject`,附详细注释说明原因
|
||||||
|
- `frontend/src/pages/notify/index.vue`:`<NotifyItem>` 监听改为 `@item-tap` / `@item-accept` / `@item-reject`,并在 `handleNotifyTap` 开头增加 `if (!notify || !notify.id) return` 防御性 guard
|
||||||
|
|
||||||
|
#### 🟡 Bug-2:标记单条已读后 `unreadTotal` / `unreadByCategory` 没有递减
|
||||||
|
|
||||||
|
**症状**:点击通知后,卡片的红点和灰底**立即消失**(UI 渲染正常),但顶部分类 Tab 角标「全部 2」「系统 1」保持不变,直到下次页面 `onShow` 触发 `fetchUnreadCount()` 从服务端拉回权威值才修正。
|
||||||
|
|
||||||
|
**根因**:`frontend/src/store/notify.js#markRead` 逻辑顺序错误:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const target = _findNotifyById(id)
|
||||||
|
if (target && target.is_read) return
|
||||||
|
await notifyApi.markRead(id)
|
||||||
|
_patchAll(id, { is_read: true }) // 此处直接修改 target 对象(引用相同)
|
||||||
|
if (target && !target.is_read) { // 上一行已把 target.is_read 置为 true,此条件永假
|
||||||
|
unreadTotal.value--
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**修复**(`frontend/src/store/notify.js`):在 `_patchAll` 之前用 `const wasUnread = !!target && !target.is_read` **快照原始状态**,后续条件改为 `if (wasUnread) { ... }`,即可正确识别「本次操作把 unread → read」的情形。
|
||||||
|
|
||||||
|
### 8.3 验证结论
|
||||||
|
|
||||||
|
| 功能点 | 验证结果 |
|
||||||
|
|---|---|
|
||||||
|
| 通知列表 REST API + 分页 | ✅ |
|
||||||
|
| 未读数 REST API | ✅ |
|
||||||
|
| 标记单条已读 REST + 角标递减 | ✅(修复后) |
|
||||||
|
| 全部已读(批量清零) | ✅ |
|
||||||
|
| 管理员广播 REST API | ✅ |
|
||||||
|
| WS `notify.new` 实时推送(testuser1 在线收到) | ✅ |
|
||||||
|
| WS `notify.unread.total` 断线补偿(初次进入页面立即有角标) | ✅ |
|
||||||
|
| 分类 Tab 切换 + 角标独立统计 | ✅ |
|
||||||
|
| 未读红点、已读淡灰背景、相对时间、图标按分类配色 | ✅ |
|
||||||
|
| Deep-link 跳转到 `/pages/contact/request` | ✅ |
|
||||||
|
| 登录后自动挂载 WS 监听 + fetchUnreadCount | ✅ |
|
||||||
|
| 页面 `onShow` 回到通知中心时自动刷新 | ✅ |
|
||||||
|
|
||||||
|
前后端构建校验:
|
||||||
|
```bash
|
||||||
|
cd backend/go-service && go build ./... # ✅ 无报错
|
||||||
|
# 前端 lint:frontend/src/components/notify/NotifyItem.vue
|
||||||
|
# frontend/src/pages/notify/index.vue
|
||||||
|
# frontend/src/store/notify.js 均通过
|
||||||
|
```
|
||||||
|
|
||||||
|
**至此 Phase 2e-1 通知系统已从单元 → 接口 → 端到端全链路验收通过,并完成 2 处交互缺陷的定位与修复。**
|
||||||
|
|
||||||
Reference in New Issue
Block a user