Files
EchoChat/docs/plans/2026-04-20-phase2e-1-design.md
bujinyuan f1853f125d 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
2026-04-21 10:21:01 +08:00

295 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 浏览器对 testuser1id=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 页面的未读感知问题(为后续我的模块功能扩展预留聚合入口) |