欢迎光临
我们一直在努力

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

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

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

成为全栈·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 的延伸)。以评论为例:

  • 用户发评论 → 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 是薄路由的又一个范本:

    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

    成为全栈专栏订阅

    赞(0)
    未经允许不得转载:171主机测评 » 成为全栈·Node 后端篇·通知系统:事件消费与已读态管理
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址