Skip to content

Core Notifications

worable edited this page Oct 20, 2026 · 2 revisions

站内通知(Notifications)

通知系统是平台基础能力,由内核持有:Notification 表、读写 API、用户/管理后台顶栏的铃铛与收件箱。 业务插件通过 ctx.notifications.create(...) 写入,不依赖任何特定插件是否激活。

定位:为什么是内核而不是插件

  • 铃铛 UI 在平台壳里。用户后台与管理后台共用 BackendShell(apps/web)的顶部工具栏, 平台直接渲染铃铛,两个后台自动都有。做成插件则需要新的「工具条部件」扩展点,且要求插件常驻, 否则卸载/升级会打断顶栏。
  • 横向能力。多个插件都要写通知(订单支付、履约失败、服务到期、工单回复、系统告警)。 放到 ctx.notifications 这个「永远存在」的接口上,写方插件不依赖通知插件恰好激活。
  • 数据是平台级的。通知按 userId 归属,与审计日志同级(审计是内核 admin/audit)。

外部渠道推送(邮件 / Webhook / 短信)通过 notification.channel 扩展点实现,内核负责调度: 渠道插件注册实现后,内核 NotificationDispatcher 订阅 notification.created/notification.updated, 为每个「想要该通知」的渠道落一条可重试的投递记录并投递(详见下「通知渠道分发」)。

验证码不是通知。 注册邮箱验证码走独立身份挑战流程(ctx.identity + identity.challengeSender 扩展点),刻意不经过站内通知:未注册用户尚无可靠 userId,且验证码不得进入站内消息、SSE、 通用事件、审计或日志。详见 core/identity.md。

数据模型

packages/db/prisma/schema.prisma:

model Notification {
  id        String   @id @default(cuid())
  userId    String
  type      String   // 通知类型,如 order.paid / delivery.failed / announcement
  title     String
  body      String?
  link      String?  // 前端深链,如 /account/orders/:id
  data      Json?    // 插件自定义载荷;禁止放密钥
  status    String   @default("info") // info | active | success | error
  progress  Int?     // 0–100,仅 active 有意义
  dedupeKey String?  // 实况通知的稳定标识;与 userId 唯一
  readAt    DateTime?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([userId, dedupeKey])
  @@map("notifications")
  @@index([userId, createdAt])
  @@index([userId, readAt])
}

迁移:20260929000000_init 建表;20261003000000_notification_live 追加 status/progress/dedupeKey/ updatedAt 与唯一索引 (userId, dedupeKey)。删除用户会级联删除其通知(onDelete: Cascade)。

内核 API

所有端点要求登录(user 级;管理员也是 User,走同一套),只允许操作自己的通知。

方法 路径 说明
GET /notifications 列表(新→旧),limit/cursor/unreadOnly
GET /notifications/unread-count 未读数
GET /notifications/stream SSE 实时流(见下「实时流」)
POST /notifications/:id/read 标已读
POST /notifications/read-all 全部标已读,返回 { count }

GET /notifications 响应:

{
  "items": [
    {
      "id": "clx…",
      "type": "order.paid",
      "title": "订单已支付",
      "body": "订单 #1001 已支付成功",
      "link": "/account/orders/1001",
      "data": { "orderId": "1001" },
      "readAt": null,
      "createdAt": "2026-08-21T12:00:00.000Z"
    }
  ],
  "unreadCount": 3,
  "nextCursor": "clx…"
}

分页为游标分页:nextCursor 非空时把值作为 cursor 参数请求下一页。

插件写入(ctx.notifications)

import { definePlugin } from '@stackpanel/sdk';
import type { CreateNotificationInput } from '@stackpanel/sdk';

export const myPlugin = definePlugin({
  manifest: { id: 'my-plugin', name: '我的插件', version: '0.1.0' },
  onActivate(ctx) {
    ctx.notifications.create({
      userId: '用户 id',
      type: 'my-plugin.notice',
      title: '你好',
      body: '这是正文',
      link: '/account/my-plugin',
      data: { extra: '元数据' },
    });
  },
});
  • CreateNotificationInput:userId 必填;type、title 必填;body、link、data 可选; status/progress/dedupeKey 可选(实况活动用,见下)。
  • data 只是透传展示用元数据,严禁放入明文密钥。
  • 每次创建后内核发布 notification.created 事件,载荷为 { notification: NotificationView, userId }; 实况活动改写时发布 notification.updated(同载荷)。

通知渠道分发(notification.channel)

内核持有通知事件契约、收件人授权、渠道分发、投递记录、幂等键、可靠任务、退避重试与失败重投; 业务插件只负责「何时通知、通知谁、业务成功语义」。外部渠道(邮件/Webhook/短信)由插件注册 notification.channel 实现(NotificationChannel:id/name/wants?()/deliver())。

notification.created ─┐
                      ├─► NotificationDispatcher ─► 每个 wants() 的渠道
notification.updated ─┘         │
                                ├─ upsert NotificationDelivery (notificationId, channelId) 幂等
                                ├─ deliver() 成功 → DELIVERED
                                └─ deliver() 抛错 → PENDING + 指数退避,交由 sweep 任务重试
  • 调度器:apps/api/src/notifications/dispatcher.ts。dispatch(view, userId) 遍历 getChannels(),对每个 wants() 为真的渠道,先 upsert 一条 NotificationDelivery (唯一键 (notificationId, channelId),重放/多副本只会合并到同一行),再调用 deliver()。
  • 投递契约:deliver() 成功返回即视为已投递;要重试必须抛错,内核据此指数退避。渠道 不得吞掉想重试的错误(例如 SMTP 收件人被拒必须抛,而不能静默成功)。
  • 渠道必须自带超时:deliver() 不能永久挂起——否则会一直占住渠道自身的并发槽位,并可能拖过内核 认领租约导致重复投递。参考实现把「整条消息时限」默认设为 45s < 60s 租约,且服务端静默读也主动断开抛错。
  • 可靠重试:内核任务 notifications.sweep-deliveries(30s)扫描到期的 PENDING 记录, 用原子 updateMany 抢占(status + nextAttemptAt)后重投,耗尽 maxAttempts 标记为 FAILED(死信,可在管理端重放)。
  • wants() 渠道自过滤:不做邮件轰炸。渠道可据通知 type 前缀、用户订阅偏好决定是否处理; 未配置的渠道返回 false,完全不参与。
  • 收件人授权归内核:渠道拿到的是 NotificationDispatch(notificationId/userId/type/ title/body/link/data/status/createdAt 的扁平投影),不需要读内核 DB。
  • 管理员广播:ctx.notifications.notifyPermissionHolders(permission, input) 由内核按 RBAC 解析「当前持有该权限的活跃用户」,业务插件只声明权限名(如 store.admin/ticket.admin)。

渠道实现示例(邮件/Webhook 插件):

import { EXTENSION_POINTS, definePlugin } from '@stackpanel/sdk';
import type { NotificationChannel } from '@stackpanel/sdk';

const channel: NotificationChannel = {
  id: 'smtp',
  name: 'SMTP 邮件',
  wants: (dispatch) => !dispatch.type.startsWith('progress.'), // 不做高频进度轰炸
  async deliver(dispatch) {
    // 抛错 = 请求内核退避重试;成功返回 = 已投递。
    await sendMail(dispatch);
  },
};

export const smtpPlugin = definePlugin({
  manifest: {
    id: 'smtp',
    name: 'SMTP 邮件通知',
    version: '0.1.0',
    provides: [EXTENSION_POINTS.notificationChannel],
  },
  onActivate(ctx) {
    ctx.registerExtension(EXTENSION_POINTS.notificationChannel, channel);
  },
});

SMTP 渠道插件独立于内核,位于 StackPanel-plugins 仓库 (@stackpanel/plugin-smtp):加密凭据(ctx.secrets)、隐式 TLS / STARTTLS、超时、连接池、 并发/速率限制、测试发送、可观测日志;同一插件复用该配置实现 identity.challengeSender 投递注册验证码。订阅偏好 / 事件→渠道映射 / 模板版本的管理界面仍是后续项。

前端

  • 铃铛:apps/web/src/components/notification-bell.tsx,渲染进 BackendShell 顶部工具栏 (ThemeToggle 左侧)。每 30s 轮询一次未读数,窗口获得焦点时刷新;下拉展示最近 8 条。 同时订阅 SSE 实时流,进行中的实况活动(status: 'active')进度原地推进。
  • BFF 代理:apps/web/src/app/api/notifications/*(读取 sp_session cookie 后转发到内核, 令牌不出服务端);/api/notifications/stream 透传上游 text/event-stream(不缓冲)。
  • 收件箱页:/account/notifications 与 /admin/notifications(同一份数据,不同入口), 支持全部/未读筛选、分页、标已读、全部已读;实况活动同样显示进度条。
  • 即时提示(sonner):apps/web/src/components/notification-toaster.tsx 挂进 BackendShell, 收到新通知时弹出右下角 toast(标题 + 正文 + 「查看」动作,深链到 link)。仅定局通知 (success/error/info)弹一次;进行中的 active 不弹(铃铛里已显示进度), 待其收敛为终态时再弹一次。已弹过的 id 在会话内记忆,断线重连不会重复弹。 Toaster 全局挂载在 apps/web/src/app/layout.tsx;配色走 globals.css 的 cn-toast utility (映射 --popover 等 token),不写内联样式。
  • 共享 SSE 连接:apps/web/src/lib/use-notification-stream.ts 是模块级单例—— 铃铛、收件箱、toast 三个消费者共用同一条 EventSource,最后一个订阅者离开时才关闭。
  • 导航入口由内核 GET /nav/:surface 提供(KERNEL_NAV account 与 admin 各一条)。

实况通知(live notification)

普通通知是一次性消息(status: 'info')。当需要「同一条通知原地换内容」——例如前端的 插件/主题构建进度——用 status: 'active' 建一条实况活动,用 dedupeKey 标识它:

await ctx.notifications.upsert({
  userId,
  dedupeKey: 'frontend-apply',        // (userId, dedupeKey) 唯一,决定「同一条」
  type: 'system.frontend-apply',
  title: '安装中:插件「A」',
  body: '正在编译前端资源(第 2/4 步)',
  status: 'active',
  progress: 50,
  data: { kind: 'frontend-apply', step: 2, total: 4 },
});
// …完成后改写同一条为终态:
await ctx.notifications.upsert({ ...same, title: '插件「A」安装完成', status: 'success', progress: 100 });
  • upsert 按 (userId, dedupeKey) 创建或原地改写;更新时清空 readAt,让进行中的活动重新浮到未读。
  • status:info | active | success | error;progress:0–100(仅 active 有意义)。
  • 每次写入发布 notification.created(首次)或 notification.updated(改写)事件,载荷含 { notification, userId }。外部渠道插件据此转发;内核 SSE 流据此按用户实时推送。
  • 首批接入:apps/api/src/notifications/frontend-apply-notifications.ts(插件/主题前端构建)。

插件/主题生命周期通知

管理员对插件/主题的安装、升级、启用/激活、停用、卸载/删除(含失败)会写入一条一次性 站内通知,进铃铛与收件箱,仅操作者本人可见。

  • 实现:apps/api/src/notifications/lifecycle-notifications.ts。纯函数 lifecycleNotification(event) 生成文案;notifyLifecycle(userId, event) 经 NotificationsService.create 写入,best-effort (失败只告警,绝不阻断操作)。
  • 状态:成功 status:'success',失败 status:'error';type 为 system.<plugin|theme>.<action>, data.kind='lifecycle',link 指向对应控制台(/admin/plugins 或 /admin/themes)。
  • 去重:当同一次操作同时触发前端构建(目标存在前端产物)时,安装/升级/卸载不再补发生命周期 通知——前端构建实况通知(见上)已覆盖结果,避免一次操作收到两条相似通知。启用/停用/激活不触发 构建,始终发通知;失败时沿后端结果发 error 通知。

实时流(SSE)

  • 内核 GET /notifications/stream(apps/api/src/routes/notifications-stream.ts): text/event-stream,25s 心跳;按会话用户过滤。
  • 跨副本:notification.* 事件经既有 outbox 事件总线(Redis Pub/Sub)广播到每个副本, NotificationStreamHub(apps/api/src/notifications/stream-hub.ts)只做进程内按用户分发, 不新增 Redis key 或 channel。
  • 前端 hook apps/web/src/lib/use-notification-stream.ts:模块级单例包裹 EventSource 连 BFF, 收 notification 事件帧后分发给铃铛/收件箱/toast,三者共用一条连接。
  • shouldToast(view, seen):纯函数,判定某帧是否该弹 toast(跳过 active、已读、已弹过的 id), 便于单测。

接入点与演进

  • 实时推送:已实现 SSE(见上)。轮询仍作为初始加载与断线兜底。
  • 业务事件 → 通知:例如 store 可在结算处理器里 ctx.notifications.create(...) (notification.created 事件的监听者也能做同样的事)。
  • 一次性通知只增不改:已读状态存在 readAt 上。只有 dedupeKey 标识的实况活动会被原地改写; 删除历史通知由内核运维任务负责(暂无)。

测试

  • apps/api/tests/notifications.test.ts:内核服务的创建/分页/已读/权限隔离/上下文注入, 以及 upsert 原地改写、刷新未读、按用户隔离。
  • apps/api/tests/unit/notification-stream-hub.test.ts:SSE hub 按用户分发、退订、异常隔离。
  • apps/api/tests/unit/notification-dispatcher.test.ts:渠道分发——wants() 过滤、按 (notificationId, channelId) 幂等、失败留 PENDING 退避、耗尽 maxAttempts 死信、渠道缺失标记 FAILED。
  • apps/api/tests/integration/notification-dispatcher.test.ts:真实 PostgreSQL 下的投递记录落库、 失败重试后 sweep 收敛为 DELIVERED、重放合并到同一行。
  • apps/api/tests/unit/frontend-apply-notifications.test.ts:实况文案与状态映射。
  • apps/api/tests/unit/lifecycle-notifications.test.ts:生命周期通知文案(成功/失败/缺省原因)、 仅对有主体操作者写入、写入失败 best-effort 吞错。
  • apps/api/tests/integration/frontend-apply-live-notification.test.ts:requestFrontendApply 落库实况通知(真实 PostgreSQL)。
  • apps/web/tests/notification-stream.test.ts:mergeNotification 合并与 shouldToast 弹窗判定(跳过 active/已读/重复 id)。
  • SMTP 渠道插件(StackPanel-plugins/packages/plugins/smtp):报文编码/模板转义单测 + 内存 SMTP 服务器端到端(命令序列、点转义、AUTH PLAIN、收件人被拒、连接池复用)。
  • 运行:pnpm --filter @stackpanel/api exec vitest run tests/notifications.test.ts。

Clone this wiki locally