成为全栈·Node 后端篇·通知系统:事件消费与已读态管理
文章发布成功了、评论被通过了——这些事都发生在系统内部,用户凭什么知道?没有通知,用户只能靠"刷新看看"来感知,产品的互动链路就断在了最后一环。

这一篇对照真实的 src/services/notification.ts,讲清通知怎么存、怎么读、已读态怎么管、越权访问为什么一律 404,以及一条要交代清楚的边界:本批我们只做"读与标记",通知的写入触发不在这批——这不是偷懒,是诚实的范围声明。
一、通知系统解决什么
没有通知,用户发完内容就"石沉大海",毫无正反馈,活跃度自然掉。通知要做的,是把"后端发生了一件事"变成"某个用户应该知道"的持久化记录,并且让用户可以:看到列表、看到未读数、点开标记已读。
注意一个关键定位:通知是"事件的消费者",不是"事件的源头"。真正产生事件的,是文章发布、评论审核通过这些业务动作;通知模块只负责"把那些事件落成一个个给我的信"。这决定了它的接口长什么样——以"读"和"标记"为主,而"写通知"的触发逻辑属于上游业务(这点第六节会诚实说明)。
二、数据模型:一张 notifications 表
notification.ts 里的契约 Notification 就是表的形状:
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 就能数未读。
三、读端点:列表、未读数、全部已读、单条已读
通知的接口全在"读"和"标记"上,四个端点:
// 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)) 保证最新的在最前,和所有列表接口一致用 parsePage 做 page/pageSize/offset 钳制,返回标准 { items, total, pagination } 信封。
- isRead 筛选可缺省:?isRead=true/false 只列已读/未读,不传则全列——前端"全部 / 未读"两个 tab 共用一个端点。
- 未读数独立端点:顶栏小红点要高频显示数字,/unread-count 用一条 COUNT(*) 返回,轻量;前端轮询或进页面时拉一次即可。
- 全部已读:一键清空所有未读,UPDATE SET isRead=true WHERE userId 一行搞定,幂等(点多次结果一样)。
四、可见性:仅本人,越权即 404
通知是最私人的数据,权限收得很死。markRead(单条标记)里有这句:
// 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 的延伸)。以评论为例:
同理,文章从 pending 被 editor 审核 approved 发布,触发 type=article_published 通知给作者。通知模块因此成为"业务状态机变化的观察者"——上游状态一变,下游通知就生成。这也再次印证评论内容安全:敏感词过滤、三态审核与级联删除讲的 reviewing 兜底态的价值:只有最终 approved 才发"通过"通知,待复核 / 被拒都不发,避免给用户发一条"你的评论正在被挂起"的奇怪提示。type=system 则留给运营手动推送公告,和自动事件分流。
六、诚实边界:本批只做"读 / 标记",写通知的触发不在本批
读 notifications.ts 和 routes/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 是薄路由的又一个范本:
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 与投稿状态机到现在,薄路由这一条纪律贯穿了所有功能模块,通知也不例外。
十、一条评论通过通知的真实时序
把"评论通过 → 收到通知 → 标记已读"串成一条线,前面讲的就活了:
整条链路里,通知模块只负责 3/4/5/6 的读与标记,而第 2 步"写通知"来自评论审核事件——这正是第五节说的"通知是事件消费者"、第六节说的"写触发不在本批"的具象化。你调用的每个端点都有真实落点,没有一步是悬空的。
十一、小结
通知系统是"事件 → 用户感知"的翻译层:
下一篇({{LINK:M1-31}})是 Node 后端篇的收官:我们回头看这 31 篇文章背后,那些数据建模的手艺——状态机、冗余计数、适配层、唯一约束当锁——把它们拧成一张可带走的心法清单。
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer



