Repository navigation
Core 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)。
所有端点要求登录(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(同载荷)。
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_sessioncookie 后转发到内核, 令牌不出服务端);/api/notifications/stream透传上游text/event-stream(不缓冲)。 -
收件箱页:
/account/notifications与/admin/notifications(同一份数据,不同入口), 支持全部/未读筛选、分页、标已读、全部已读;实况活动同样显示进度条。 - 导航入口由内核
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事件帧后合并进铃铛/收件箱列表。
- 实时推送:已实现 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。
核心开发
插件开发
主题开发
前端包开发
RESTful API