Skip to content

Core Notifications

worable edited this page Oct 12, 2026 · 1 revision

站内通知(Notifications)

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

归属:内核能力而非插件

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

外部渠道推送(邮件 / Webhook / 短信)不在第一版范围,通过预留的 notification.channel 扩展点实现。

数据模型

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

EXTENSION_POINTS.notificationChannel === 'notification.channel'。

第一版不实现任何消费者。未来插件可以监听 notification.created 事件(或注册该扩展点), 把站内通知转发到邮件 / Webhook / 短信。内核只保证站内投递,外部渠道由插件负责幂等与重试。

前端

  • 铃铛: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(同一份数据,不同入口), 支持全部/未读筛选、分页、标已读、全部已读;实况活动同样显示进度条。
  • 导航入口由内核 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 事件帧后合并进铃铛/收件箱列表。

接入点与演进

  • 实时推送:已实现 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/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)。
  • 运行:pnpm --filter @stackpanel/api exec vitest run tests/notifications.test.ts。

Clone this wiki locally