成为全栈·Node 后端篇·通知系统:事件消费与已读态管理

成为全栈·Node 后端篇·通知系统:事件消费与已读态管理

文章发布成功了、评论被通过了------这些事都发生在系统内部,用户凭什么知道?没有通知,用户只能靠"刷新看看"来感知,产品的互动链路就断在了最后一环。

这一篇对照真实的 src/services/notification.ts,讲清通知怎么存、怎么读、已读态怎么管、越权访问为什么一律 404,以及一条要交代清楚的边界:本批我们只做"读与标记",通知的写入触发不在这批------这不是偷懒,是诚实的范围声明。

一、通知系统解决什么

没有通知,用户发完内容就"石沉大海",毫无正反馈,活跃度自然掉。通知要做的,是把"后端发生了一件事"变成"某个用户应该知道"的持久化记录,并且让用户可以:看到列表、看到未读数、点开标记已读。

注意一个关键定位:通知是"事件的消费者",不是"事件的源头"。真正产生事件的,是文章发布、评论审核通过这些业务动作;通知模块只负责"把那些事件落成一个个给我的信"。这决定了它的接口长什么样------以"读"和"标记"为主,而"写通知"的触发逻辑属于上游业务(这点第六节会诚实说明)。

二、数据模型:一张 notifications 表

notification.ts 里的契约 Notification 就是表的形状:

ts 复制代码
export interface Notification {
  id: number;
  userId: number;       // 这条通知给谁
  type: string;         // 事件类型:article_published / comment_approved / system
  title: string;        // 标题
  body: string | null;  // 正文
  link: string | null;  // 点击跳转(如文章详情 URL)
  isRead: boolean;      // 是否已读
  createdAt: string;
}

设计要点:

  • userId 是分片键 :所有查询都带 WHERE userId=?,通知天然是"每人一份",不存在跨用户可见。
  • type 是事件类别article_published(你发的文章通过审核发布了)、comment_approved(你的评论被通过了)、system(系统公告)。用字符串枚举而非关联表,是因为通知类型少且稳定,没必要再建一张类型表。
  • link 让通知可点击 :存跳转目标(如 /articles/123),前端点通知直接跳对应页面------这是通知"有用"的关键,否则只是条死文字。
  • isRead 布尔列 :已读态直接落库,不另算,读取时 WHERE isRead=false 就能数未读。

三、读端点:列表、未读数、全部已读、单条已读

通知的接口全在"读"和"标记"上,四个端点:

ts 复制代码
// src/services/notification.ts --- listNotifications
export const listNotifications = async (
  userId: number,
  params: ListNotificationsParams,
): Promise<{ items: Notification[]; total: number }> => {
  const { pageSize, offset, isRead } = params;
  const filter = isRead === undefined ? undefined : eq(notifications.isRead, isRead);
  const where = and(eq(notifications.userId, userId), filter);
  const rows = await getDb()
    .select()
    .from(notifications)
    .where(where)
    .orderBy(desc(notifications.createdAt))
    .limit(pageSize)
    .offset(offset)
    .all();
  const totalRow = (
    await getDb().select({ c: sql<number>`count(*)` }).from(notifications).where(where).all()
  )[0];
  return { items: rows.map(toNotification), total: Number(totalRow?.c ?? 0) };
};

// GET /me/notifications/unread-count ------ 未读数
export const getUnreadCount = async (userId: number): Promise<number> => {
  const row = (
    await getDb()
      .select({ c: sql<number>`count(*)` })
      .from(notifications)
      .where(and(eq(notifications.userId, userId), eq(notifications.isRead, false)))
      .all()
  )[0];
  return Number(row?.c ?? 0);
};

// POST /me/notifications/read-all ------ 全部已读
export const markAllRead = async (userId: number): Promise<void> => {
  await getDb().update(notifications).set({ isRead: true }).where(eq(notifications.userId, userId)).run();
};

细节都讲究:

  • 列表分页 + 倒序orderBy(desc(createdAt)) 保证最新的在最前,和所有列表接口一致用 parsePagepage/pageSize/offset 钳制,返回标准 { items, total, pagination } 信封。
  • isRead 筛选可缺省?isRead=true/false 只列已读/未读,不传则全列------前端"全部 / 未读"两个 tab 共用一个端点。
  • 未读数独立端点 :顶栏小红点要高频显示数字,/unread-count 用一条 COUNT(*) 返回,轻量;前端轮询或进页面时拉一次即可。
  • 全部已读 :一键清空所有未读,UPDATE SET isRead=true WHERE userId 一行搞定,幂等(点多次结果一样)。

四、可见性:仅本人,越权即 404

通知是最私人的数据,权限收得很死。markRead(单条标记)里有这句:

ts 复制代码
// src/services/notification.ts --- markRead
export const markRead = async (
  userId: number,
  id: number,
  isRead: boolean,
): Promise<Notification> => {
  const db = getDb();
  const existing = (
    await db.select().from(notifications).where(eq(notifications.id, id)).limit(1).all()
  )[0];
  if (!existing || existing.userId !== userId) throw new AppError(ErrCode.NOT_FOUND, 404);
  const updated = (
    await db.update(notifications).set({ isRead }).where(eq(notifications.id, id)).returning().all()
  )[0];
  if (!updated) throw new AppError(ErrCode.INTERNAL, 500);
  return toNotification(updated);
};

existing.userId !== userId 直接 404------不是 403。原因和全站铁律一致:如果返回 403,等于告诉对方"这条通知存在、只是你不归你",泄露了存在性;返回 404 则"这条通知对你而言不存在",连"有没有"都不透露。这是"最小化信息泄露"的原则,在私信、订单、通知这类强隐私数据上必须严格执行。

所有通知端点路由层都用 authMiddleware------必须登录才能看自己的通知,匿名连"我有没有未读"都不能问。薄路由只做"鉴权 + 取参 + 调 service + 包信封",没有任何 DB 查询散在路由里,干净利落。

五、P-27:通知是评论三态 / 审核流的下游消费者

把通知和前面几篇串起来,能看到它处在事件链的末端(P-27 的延伸)。以评论为例:

  1. 用户发评论 → createComment 自动 moderateContentapprovedrejected评论内容安全:敏感词过滤、三态审核与级联删除的三态自动流)。
  2. 若进入 reviewing 被编辑人工 PATCHapproved------这一刻就是"评论通过"事件
  3. 这个"评论被通过"事件,应当触发一条 type=comment_approved 的通知发给评论作者:"你的评论已通过审核"。

同理,文章从 pending 被 editor 审核 approved 发布,触发 type=article_published 通知给作者 。通知模块因此成为"业务状态机变化的观察者"------上游状态一变,下游通知就生成。这也再次印证评论内容安全:敏感词过滤、三态审核与级联删除讲的 reviewing 兜底态的价值:只有最终 approved 才发"通过"通知,待复核 / 被拒都不发,避免给用户发一条"你的评论正在被挂起"的奇怪提示。type=system 则留给运营手动推送公告,和自动事件分流。

六、诚实边界:本批只做"读 / 标记",写通知的触发不在本批

notifications.tsroutes/notifications.ts 的注释会看到一个重要事实:"生成端(系统事件写通知)不在本批,NOTES 登记后续归属" 。也就是说,当前冻结代码里,通知的读取和已读管理是完整的,但"谁在什么事件下 INSERT 一条通知"的触发逻辑,被显式登记到后续批次实现,本批不写。

这是项目里一种健康的工作方式,必须如实告诉读者:不要误以为通知会自动产生 。当前你能调的是"看通知、标记已读",而"发通知"的触发器(可能挂在文章审核通过、评论审核通过的 service 里,或一个事件总线 / 钩子里)是另一个待办。把它写进 NOTES 而不是假装已实现,比"文档说有、代码没有"诚实得多------这和容器化:给 Node 应用写一个像样的 Dockerfile讲 Dockerfile 时"标注待补入"是同一套纪律:文档里的功能,要么实测存在,要么明确标"计划补入 / 后续归属",绝不冒充已有。

七、P-49:未读数的规模适配

/unread-count 现在是每次 COUNT(*) WHERE isRead=false。在小规模下完全没问题,但 P-49 的"规模意识"要在这里点破:当用户有成千上万条通知时,这条 COUNT 虽只扫单用户数据,高频轮询仍是不小的负担。升级信号出现时,有两个方向:

  • 冗余未读计数字段 :在 users 表加 unreadNotificationCount,发通知时 +1、标记已读时 -1(和{{LINK:M1-29}}的 likeCount 同一个冗余手艺),读未读变成一次字段读取,零聚合。
  • 缓存:把未读数放 Redis / 内存缓存,标记已读时失效,轮询读缓存。

但要注意过度设计的红线 :在通知量不大时,加冗余字段和缓存纯属负担。当前 COUNT 直查就是对的------P-49 要传达的,是"知道这条查询在哪条规模线会吃紧,并把它当作明确的优化信号",而不是现在就上重型方案。这和辅助接口:相邻、相关、目录、统计与搜索的薄路由实现相关文章"全量内存打分"的尺度判断是一脉相承的。

八、薄路由纪律再验证

routes/notifications.ts 是薄路由的又一个范本:

ts 复制代码
notificationsRoute.get('/me/notifications', authMiddleware, async (c) => {
  const userId = Number(c.get('user').id);
  const { page, pageSize, offset } = parsePage(c);
  const isReadParam = c.req.query('isRead');
  const isRead = isReadParam === 'true' ? true : isReadParam === 'false' ? false : undefined;
  const { items, total } = await listNotifications(userId, { pageSize, offset, isRead });
  return paginate(items, meta(page, pageSize, total));
});

路由里没有一句 SQL、没有一个业务判断 :鉴权(authMiddleware)、解析页码、解析 isRead 查询参数、调 listNotifications、用标准 paginate 包信封------四件事分工清晰。所有"通知怎么筛、未读怎么数、越权怎么拦"都在 services/notification.ts。从文章 CRUD 与投稿状态机到现在,薄路由这一条纪律贯穿了所有功能模块,通知也不例外。

十、一条评论通过通知的真实时序

把"评论通过 → 收到通知 → 标记已读"串成一条线,前面讲的就活了:

  1. 作者在某文章下发了一条合规评论 → createComment 自动 moderateContent 判定 approved(无敏感词)。
  2. 该"评论通过"事件触发上游写通知逻辑(后续批次实现),INSERT 一行 notificationsuserId=评论作者type=comment_approvedtitle='你的评论已通过审核'link='/articles/123'isRead=false
  3. 作者稍后打开 App,顶栏先拉 GET /me/notifications/unread-count → 返回 count=1,亮起小红点。
  4. 作者点开通知列表 GET /me/notifications → 看到这条,点进去跳转 link 对应文章。
  5. 前端顺手 PATCH /me/notifications/:id { isRead:true } → 标记已读。
  6. 再查 unread-countcount=0,红点消失。

整条链路里,通知模块只负责 3/4/5/6 的读与标记,而第 2 步"写通知"来自评论审核事件------这正是第五节说的"通知是事件消费者"、第六节说的"写触发不在本批"的具象化。你调用的每个端点都有真实落点,没有一步是悬空的。

十一、小结

通知系统是"事件 → 用户感知"的翻译层:

  1. 定位清晰 :通知是事件的消费者,读 / 标记为主,写触发归上游业务。
  2. 模型简洁notificationsuserId 分片、type 事件类别、link 可点击、isRead 落库。
  3. 四个读端点:列表(分页 + isRead 筛选 + 倒序)、未读数、全部已读、单条已读,全走标准信封。
  4. 强隐私 :仅本人 authMiddleware;越权标记返回 404(不泄露存在性)。
  5. P-27 联动 :评论 approved / 文章发布等状态机变化触发 comment_approved / article_published 通知给作者;reviewing 兜底态保证只发"通过"通知。
  6. 诚实边界:本批只实现读 / 标记,写通知触发登记到后续批次(NOTES),不冒充已有。
  7. P-49 规模适配 :未读数 COUNT 直查在小规模最优;海量时冗余字段 / 缓存是明确升级信号。
  8. 薄路由:通知路由零 SQL、零业务判断,全委托 service。

下一篇({{LINK:M1-31}})是 Node 后端篇的收官:我们回头看这 31 篇文章背后,那些数据建模的手艺------状态机、冗余计数、适配层、唯一约束当锁------把它们拧成一张可带走的心法清单。


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
西瓜太郎4998 小时前
API Key 轮换不该靠“瞬间替换”:用双 Key 灰度避免线上中断
node.js·api
星辰徐哥1 天前
本地视频预览别只自己看:把Remotion动效项目发给客户远程验收
docker·ai·node.js·html·音视频·react·remotion
ID34610744201 天前
【课程设计】基于Spring Boot+Vue的校园共享无人机服务系统设计与实现-计算机毕设 附源码44219
javascript·vue.js·spring boot·python·node.js·php·课程设计
敲敲敲敲暴你脑袋1 天前
地图瓦片批量改色来啦!
node.js·gis·数据可视化
太子釢2 天前
AI 开发个人记账 App(服务端篇)
node.js·ai编程
用户64340495148512 天前
Elpis 项目构建工具与前端基建实践总结
node.js
FungLeo2 天前
成为全栈·Node 后端篇·后端测试策略:单元、集成与测试数据库
单元测试·node.js·集成测试·测试策略·成为全栈·测试数据库
FungLeo2 天前
成为全栈·Node 后端篇·评论内容安全:敏感词过滤、三态审核与级联删除
node.js·敏感词过滤·成为全栈·评论内容安全·评论审核·级联删除
FungLeo3 天前
成为全栈·Node 后端篇·阅读量防刷:去重、冷却与计数写分离
node.js·读写分离·数据去重·接口防刷·成为全栈·数据冷却