通知与提醒机制:让信息精准触达每一位用户
作业截止提醒、成绩发布通知、课程开始预警——教育平台的每一个关键节点,都需要通知系统来保障信息触达。本文将讲解如何设计一个可靠、灵活、不扰民的通知系统。
通知系统需求分析
典型通知场景
| 场景 | 触发时机 | 紧急程度 | 渠道 |
|---|---|---|---|
| 作业发布 | 教师发布后 | 中 | 站内信、邮件 |
| 截止提醒 | 截止前 24h/1h | 高 | 推送、短信 |
| 成绩发布 | 教师发布后 | 中 | 站内信、推送 |
| 课程开始 | 开课前 30min | 高 | 推送、短信 |
| 系统公告 | 管理员发布 | 低 | 站内信 |
| 互动消息 | 评论/回复 | 低 | 站内信、推送 |
多渠道支持
通知渠道矩阵
├── 站内信 (In-App)
│ ├── 通知中心
│ ├── 小红点提示
│ └── 弹窗提醒
├── 推送 (Push)
│ ├── Web Push
│ ├── iOS APNs
│ └── Android FCM
├── 邮件 (Email)
│ ├── 即时邮件
│ └── 摘要邮件(每日/每周)
├── 短信 (SMS)
│ └── 紧急通知
└── 微信 (WeChat)
├── 公众号模板消息
└── 小程序订阅消息
数据模型设计
核心实体
// 通知模板
interface NotificationTemplate {
id: string
code: string // 如 'assignment_deadline'
name: string
description: string
titleTemplate: string // 支持变量:{{assignment_name}}
contentTemplate: string
channels: NotificationChannel[]
variables: TemplateVariable[]
createdAt: Date
}
interface TemplateVariable {
name: string
type: 'string' | 'number' | 'date' | 'url'
required: boolean
defaultValue?: string
}
type NotificationChannel = 'in_app' | 'push' | 'email' | 'sms' | 'wechat'
// 通知记录
interface Notification {
id: string
userId: string
templateCode: string
title: string
content: string
data: Record<string, any> // 附加数据(如跳转链接)
channels: NotificationChannel[]
status: NotificationStatus
readAt?: Date
createdAt: Date
scheduledAt?: Date // 定时发送时间
sentAt?: Date
}
type NotificationStatus = 'pending' | 'sent' | 'failed' | 'read'
// 渠道发送记录
interface ChannelDelivery {
id: string
notificationId: string
channel: NotificationChannel
status: 'pending' | 'sent' | 'delivered' | 'failed'
externalId?: string // 第三方平台的消息 ID
errorMessage?: string
sentAt?: Date
deliveredAt?: Date
}
// 用户通知偏好
interface NotificationPreference {
userId: string
templateCode: string
enabled: boolean
channels: {
in_app: boolean
push: boolean
email: boolean
sms: boolean
wechat: boolean
}
quietHours?: {
start: string // '22:00'
end: string // '08:00'
}
}
数据库表结构
-- 通知模板表
CREATE TABLE notification_templates (
id UUID PRIMARY KEY,
code VARCHAR(100) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
title_template TEXT NOT NULL,
content_template TEXT NOT NULL,
channels JSONB DEFAULT '["in_app"]',
variables JSONB DEFAULT '[]',
created_at TIMESTAMP DEFAULT NOW()
);
-- 通知表
CREATE TABLE notifications (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id) NOT NULL,
template_code VARCHAR(100),
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
data JSONB DEFAULT '{}',
channels JSONB DEFAULT '[]',
status VARCHAR(20) DEFAULT 'pending',
read_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
scheduled_at TIMESTAMP,
sent_at TIMESTAMP
);
-- 渠道投递记录
CREATE TABLE channel_deliveries (
id UUID PRIMARY KEY,
notification_id UUID REFERENCES notifications(id),
channel VARCHAR(20) NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
external_id VARCHAR(255),
error_message TEXT,
sent_at TIMESTAMP,
delivered_at TIMESTAMP
);
-- 用户通知偏好
CREATE TABLE notification_preferences (
user_id UUID REFERENCES users(id),
template_code VARCHAR(100),
enabled BOOLEAN DEFAULT TRUE,
channels JSONB DEFAULT '{}',
quiet_hours JSONB,
PRIMARY KEY (user_id, template_code)
);
-- 索引
CREATE INDEX idx_notifications_user ON notifications(user_id);
CREATE INDEX idx_notifications_status ON notifications(status);
CREATE INDEX idx_notifications_scheduled ON notifications(scheduled_at)
WHERE scheduled_at IS NOT NULL AND status = 'pending';
通知发送服务
核心发送逻辑
// services/notification.service.ts
import { render } from 'mustache'
export class NotificationService {
async send(options: SendNotificationOptions): Promise<Notification> {
const { userId, templateCode, variables, scheduledAt } = options
// 1. 获取模板
const template = await this.getTemplate(templateCode)
// 2. 检查用户偏好
const preference = await this.getUserPreference(userId, templateCode)
if (!preference.enabled) {
return null // 用户已禁用此类通知
}
// 3. 渲染内容
const title = render(template.titleTemplate, variables)
const content = render(template.contentTemplate, variables)
// 4. 确定发送渠道
const channels = this.determineChannels(template.channels, preference.channels)
// 5. 检查静默时间
if (this.isQuietHours(preference.quietHours)) {
// 延迟到静默时间结束后发送
scheduledAt = this.getQuietHoursEnd(preference.quietHours)
}
// 6. 创建通知记录
const notification = await db.notifications.create({
data: {
userId,
templateCode,
title,
content,
data: variables,
channels,
status: scheduledAt ? 'pending' : 'sent',
scheduledAt,
sentAt: scheduledAt ? null : new Date()
}
})
// 7. 发送到各渠道
if (!scheduledAt) {
await this.dispatchToChannels(notification, channels)
}
return notification
}
async dispatchToChannels(notification: Notification, channels: string[]) {
const dispatchers = {
in_app: this.sendInApp.bind(this),
push: this.sendPush.bind(this),
email: this.sendEmail.bind(this),
sms: this.sendSMS.bind(this),
wechat: this.sendWechat.bind(this)
}
await Promise.allSettled(
channels.map(channel =>
this.wrapWithDeliveryRecord(notification, channel, dispatchers[channel])
)
)
}
private async wrapWithDeliveryRecord(
notification: Notification,
channel: string,
dispatcher: Function
) {
const delivery = await db.channelDeliveries.create({
data: {
notificationId: notification.id,
channel,
status: 'pending'
}
})
try {
const result = await dispatcher(notification)
await db.channelDeliveries.update({
where: { id: delivery.id },
data: {
status: 'sent',
externalId: result?.externalId,
sentAt: new Date()
}
})
} catch (error) {
await db.channelDeliveries.update({
where: { id: delivery.id },
data: {
status: 'failed',
errorMessage: error.message
}
})
}
}
}
各渠道发送实现
// services/channels/push.channel.ts
import webpush from 'web-push'
export class PushChannel {
async send(notification: Notification): Promise<{ externalId?: string }> {
// 获取用户的推送订阅
const subscriptions = await db.pushSubscriptions.findMany({
where: { userId: notification.userId }
})
if (!subscriptions.length) {
throw new Error('用户未订阅推送')
}
const payload = JSON.stringify({
title: notification.title,
body: notification.content,
icon: '/icons/notification-icon.png',
data: notification.data
})
await Promise.all(
subscriptions.map(sub =>
webpush.sendNotification(sub.subscription, payload)
.catch(() => this.removeInvalidSubscription(sub.id))
)
)
return {}
}
}
// services/channels/email.channel.ts
import { Resend } from 'resend'
export class EmailChannel {
private resend = new Resend(process.env.RESEND_API_KEY)
async send(notification: Notification): Promise<{ externalId: string }> {
const user = await db.users.findUnique({ where: { id: notification.userId } })
const { data, error } = await this.resend.emails.send({
from: '学习平台 <notify@learning.com>',
to: user.email,
subject: notification.title,
html: this.renderEmailTemplate(notification)
})
if (error) throw error
return { externalId: data.id }
}
private renderEmailTemplate(notification: Notification): string {
return `
<!DOCTYPE html>
<html>
<head>
<style>
.container { max-width: 600px; margin: 0 auto; font-family: sans-serif; }
.header { background: #4F46E5; color: white; padding: 20px; }
.content { padding: 20px; }
.button {
display: inline-block;
padding: 12px 24px;
background: #4F46E5;
color: white;
text-decoration: none;
border-radius: 6px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>${notification.title}</h1>
</div>
<div class="content">
<p>${notification.content}</p>
${notification.data.actionUrl ? `
<a href="${notification.data.actionUrl}" class="button">
${notification.data.actionText || '查看详情'}
</a>
` : ''}
</div>
</div>
</body>
</html>
`
}
}
定时通知调度
定时任务处理
// jobs/notification-scheduler.ts
import { CronJob } from 'cron'
export function startNotificationScheduler() {
// 每分钟检查待发送的通知
new CronJob('* * * * *', async () => {
const pendingNotifications = await db.notifications.findMany({
where: {
status: 'pending',
scheduledAt: { lte: new Date() }
},
take: 100
})
for (const notification of pendingNotifications) {
try {
await notificationService.dispatchToChannels(
notification,
notification.channels
)
await db.notifications.update({
where: { id: notification.id },
data: { status: 'sent', sentAt: new Date() }
})
} catch (error) {
console.error(`发送通知失败: ${notification.id}`, error)
}
}
}).start()
// 作业截止提醒任务
new CronJob('0 * * * *', async () => {
await sendDeadlineReminders()
}).start()
}
async function sendDeadlineReminders() {
const now = new Date()
const in24Hours = new Date(now.getTime() + 24 * 60 * 60 * 1000)
const in1Hour = new Date(now.getTime() + 1 * 60 * 60 * 1000)
// 24 小时内截止的作业
const upcoming24h = await db.assignments.findMany({
where: {
deadline: { gte: now, lte: in24Hours },
reminderSent24h: false
},
include: {
course: { include: { students: true } }
}
})
for (const assignment of upcoming24h) {
// 找到未提交的学生
const submittedStudentIds = await getSubmittedStudentIds(assignment.id)
const unsubmittedStudents = assignment.course.students
.filter(s => !submittedStudentIds.includes(s.id))
// 发送提醒
for (const student of unsubmittedStudents) {
await notificationService.send({
userId: student.id,
templateCode: 'assignment_deadline_24h',
variables: {
assignment_name: assignment.title,
deadline: formatDate(assignment.deadline),
action_url: `/assignments/${assignment.id}`
}
})
}
// 标记已发送
await db.assignments.update({
where: { id: assignment.id },
data: { reminderSent24h: true }
})
}
}
前端通知中心
通知列表组件
<template>
<div class="notification-center">
<div class="notification-header">
<h3>通知</h3>
<button @click="markAllAsRead" v-if="unreadCount > 0">
全部已读
</button>
</div>
<div class="notification-tabs">
<button
:class="{ active: activeTab === 'all' }"
@click="activeTab = 'all'"
>
全部
</button>
<button
:class="{ active: activeTab === 'unread' }"
@click="activeTab = 'unread'"
>
未读 <span v-if="unreadCount" class="badge">{{ unreadCount }}</span>
</button>
</div>
<div class="notification-list">
<div
v-for="item in filteredNotifications"
:key="item.id"
class="notification-item"
:class="{ unread: !item.readAt }"
@click="handleNotificationClick(item)"
>
<div class="notification-icon" :class="item.type">
<component :is="getIcon(item.templateCode)" />
</div>
<div class="notification-content">
<h4>{{ item.title }}</h4>
<p>{{ item.content }}</p>
<span class="notification-time">
{{ formatRelativeTime(item.createdAt) }}
</span>
</div>
<button
v-if="!item.readAt"
@click.stop="markAsRead(item.id)"
class="mark-read-btn"
>
<CheckIcon />
</button>
</div>
<div v-if="!notifications.length" class="empty-state">
<BellOffIcon />
<p>暂无通知</p>
</div>
</div>
<div class="notification-footer">
<NuxtLink to="/settings/notifications">
通知设置
</NuxtLink>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useNotifications } from '@/composables/useNotifications'
const {
notifications,
unreadCount,
markAsRead,
markAllAsRead,
refresh
} = useNotifications()
const activeTab = ref('all')
const filteredNotifications = computed(() => {
if (activeTab.value === 'unread') {
return notifications.value.filter(n => !n.readAt)
}
return notifications.value
})
function handleNotificationClick(item) {
if (!item.readAt) {
markAsRead(item.id)
}
if (item.data?.actionUrl) {
navigateTo(item.data.actionUrl)
}
}
function formatRelativeTime(date) {
const now = new Date()
const diff = now - new Date(date)
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes} 分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} 小时前`
const days = Math.floor(hours / 24)
if (days < 7) return `${days} 天前`
return new Date(date).toLocaleDateString()
}
</script>
通知偏好设置
<template>
<div class="notification-settings">
<h2>通知设置</h2>
<div class="global-settings">
<h3>全局设置</h3>
<div class="setting-item">
<label>免打扰时间</label>
<div class="quiet-hours">
<input type="time" v-model="quietHours.start">
<span>至</span>
<input type="time" v-model="quietHours.end">
</div>
</div>
</div>
<div class="notification-types">
<h3>通知类型</h3>
<div
v-for="category in categories"
:key="category.key"
class="category-section"
>
<h4>{{ category.name }}</h4>
<div
v-for="template in category.templates"
:key="template.code"
class="template-setting"
>
<div class="template-info">
<span class="template-name">{{ template.name }}</span>
<span class="template-desc">{{ template.description }}</span>
</div>
<div class="channel-toggles">
<label
v-for="channel in channels"
:key="channel.key"
class="channel-toggle"
>
<input
type="checkbox"
:checked="getChannelEnabled(template.code, channel.key)"
@change="toggleChannel(template.code, channel.key, $event)"
>
<span class="channel-icon">
<component :is="channel.icon" />
</span>
<span class="channel-name">{{ channel.name }}</span>
</label>
</div>
</div>
</div>
</div>
<button @click="saveSettings" class="save-btn">
保存设置
</button>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const channels = [
{ key: 'in_app', name: '站内信', icon: 'BellIcon' },
{ key: 'push', name: '推送', icon: 'SmartphoneIcon' },
{ key: 'email', name: '邮件', icon: 'MailIcon' },
{ key: 'sms', name: '短信', icon: 'MessageSquareIcon' },
]
const categories = ref([
{
key: 'assignment',
name: '作业通知',
templates: [
{ code: 'assignment_published', name: '作业发布', description: '教师发布新作业时' },
{ code: 'assignment_deadline_24h', name: '截止提醒(24h)', description: '作业截止前24小时' },
{ code: 'assignment_graded', name: '成绩发布', description: '作业批改完成时' },
]
},
{
key: 'course',
name: '课程通知',
templates: [
{ code: 'course_start_reminder', name: '上课提醒', description: '课程开始前30分钟' },
{ code: 'course_material_updated', name: '资料更新', description: '课程资料有更新时' },
]
},
{
key: 'interaction',
name: '互动通知',
templates: [
{ code: 'comment_reply', name: '评论回复', description: '有人回复我的评论' },
{ code: 'mention', name: '@提及', description: '有人在讨论中@我' },
]
}
])
const preferences = ref({})
const quietHours = ref({ start: '22:00', end: '08:00' })
onMounted(async () => {
const response = await $fetch('/api/notification-preferences')
preferences.value = response.preferences
quietHours.value = response.quietHours
})
function getChannelEnabled(templateCode, channelKey) {
return preferences.value[templateCode]?.channels?.[channelKey] ?? true
}
function toggleChannel(templateCode, channelKey, event) {
if (!preferences.value[templateCode]) {
preferences.value[templateCode] = { channels: {} }
}
preferences.value[templateCode].channels[channelKey] = event.target.checked
}
async function saveSettings() {
await $fetch('/api/notification-preferences', {
method: 'PUT',
body: {
preferences: preferences.value,
quietHours: quietHours.value
}
})
toast.success('设置已保存')
}
</script>
实时通知
WebSocket 集成
// composables/useNotifications.ts
import { useWebSocket } from '@vueuse/core'
export function useNotifications() {
const notifications = ref<Notification[]>([])
const unreadCount = ref(0)
// 加载初始通知
async function loadNotifications() {
const response = await $fetch('/api/notifications')
notifications.value = response.notifications
unreadCount.value = response.unreadCount
}
// WebSocket 实时更新
const { data, status } = useWebSocket(
`${import.meta.env.VITE_WS_URL}/notifications`,
{
autoReconnect: true,
heartbeat: {
message: 'ping',
interval: 30000
}
}
)
watch(data, (message) => {
if (!message) return
try {
const event = JSON.parse(message)
if (event.type === 'new_notification') {
// 添加新通知到列表头部
notifications.value.unshift(event.notification)
unreadCount.value++
// 显示桌面通知(如果已授权)
showDesktopNotification(event.notification)
}
if (event.type === 'notification_read') {
const notification = notifications.value.find(n => n.id === event.id)
if (notification) {
notification.readAt = new Date()
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
}
} catch (e) {
console.error('解析通知消息失败', e)
}
})
async function markAsRead(id: string) {
await $fetch(`/api/notifications/${id}/read`, { method: 'POST' })
const notification = notifications.value.find(n => n.id === id)
if (notification) {
notification.readAt = new Date()
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
}
async function markAllAsRead() {
await $fetch('/api/notifications/read-all', { method: 'POST' })
notifications.value.forEach(n => n.readAt = new Date())
unreadCount.value = 0
}
onMounted(() => {
loadNotifications()
})
return {
notifications,
unreadCount,
markAsRead,
markAllAsRead,
refresh: loadNotifications
}
}
总结
通知与提醒系统的核心要点:
| 维度 | 关键点 |
|---|---|
| 多渠道 | 站内信、推送、邮件、短信按需选择 |
| 个性化 | 用户可自定义偏好和静默时间 |
| 可靠性 | 投递记录、失败重试、异步处理 |
| 实时性 | WebSocket 推送、定时任务调度 |
| 不扰民 | 静默时间、频率限制、智能合并 |
好的通知系统是隐形的——用户不会因为信息过载而关闭通知,也不会因为错过重要信息而抱怨。找到这个平衡点,是通知系统设计的艺术。


