成为全栈·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))保证最新的在最前,和所有列表接口一致用parsePage做page/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 的延伸)。以评论为例:
- 用户发评论 →
createComment自动moderateContent→approved或rejected(评论内容安全:敏感词过滤、三态审核与级联删除的三态自动流)。 - 若进入
reviewing被编辑人工PATCH置approved------这一刻就是"评论通过"事件。 - 这个"评论被通过"事件,应当触发一条
type=comment_approved的通知发给评论作者:"你的评论已通过审核"。
同理,文章从 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 是薄路由的又一个范本:
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 与投稿状态机到现在,薄路由这一条纪律贯穿了所有功能模块,通知也不例外。
十、一条评论通过通知的真实时序
把"评论通过 → 收到通知 → 标记已读"串成一条线,前面讲的就活了:
- 作者在某文章下发了一条合规评论 →
createComment自动moderateContent判定approved(无敏感词)。 - 该"评论通过"事件触发上游写通知逻辑(后续批次实现),
INSERT一行notifications:userId=评论作者、type=comment_approved、title='你的评论已通过审核'、link='/articles/123'、isRead=false。 - 作者稍后打开 App,顶栏先拉
GET /me/notifications/unread-count→ 返回count=1,亮起小红点。 - 作者点开通知列表
GET /me/notifications→ 看到这条,点进去跳转link对应文章。 - 前端顺手
PATCH /me/notifications/:id { isRead:true }→ 标记已读。 - 再查
unread-count→count=0,红点消失。
整条链路里,通知模块只负责 3/4/5/6 的读与标记,而第 2 步"写通知"来自评论审核事件------这正是第五节说的"通知是事件消费者"、第六节说的"写触发不在本批"的具象化。你调用的每个端点都有真实落点,没有一步是悬空的。
十一、小结
通知系统是"事件 → 用户感知"的翻译层:
- 定位清晰 :通知是事件的消费者,读 / 标记为主,写触发归上游业务。
- 模型简洁 :
notifications表userId分片、type事件类别、link可点击、isRead落库。 - 四个读端点:列表(分页 + isRead 筛选 + 倒序)、未读数、全部已读、单条已读,全走标准信封。
- 强隐私 :仅本人
authMiddleware;越权标记返回 404(不泄露存在性)。 - P-27 联动 :评论
approved/ 文章发布等状态机变化触发comment_approved/article_published通知给作者;reviewing兜底态保证只发"通过"通知。 - 诚实边界:本批只实现读 / 标记,写通知触发登记到后续批次(NOTES),不冒充已有。
- P-49 规模适配 :未读数
COUNT直查在小规模最优;海量时冗余字段 / 缓存是明确升级信号。 - 薄路由:通知路由零 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
