Repository navigation
Core 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)。
所有端点要求登录(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 参数请求下一页。
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(同载荷)。
内核持有通知事件契约、收件人授权、渠道分发、投递记录、幂等键、可靠任务、退避重试与失败重投;
业务插件只负责「何时通知、通知谁、业务成功语义」。外部渠道(邮件/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_sessioncookie 后转发到内核, 令牌不出服务端);/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-toastutility (映射--popover等 token),不写内联样式。 -
共享 SSE 连接:
apps/web/src/lib/use-notification-stream.ts是模块级单例—— 铃铛、收件箱、toast 三个消费者共用同一条EventSource,最后一个订阅者离开时才关闭。 - 导航入口由内核
GET /nav/:surface提供(KERNEL_NAVaccount 与 admin 各一条)。
普通通知是一次性消息(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通知。
- 内核
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。
开始
- 核心开发
构建内核扩展
- 插件
- 主题与前端包
AI 与自动化
- 内核 Agent
- MCP
对接平台
- Open Platform API
- Partner API