Files
EchoChat/backend/go-service/app/notify/dao/notification_dao.go
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

201 lines
6.3 KiB
Go
Raw 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.

// 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
}