通知与提醒机制:让信息精准触达每一位用户

HTMLPAGE 团队
18 分钟阅读

系统讲解在线教育平台中通知系统的设计与实现,包括多渠道推送、消息模板、偏好设置、实时与定时通知等核心功能。

#通知系统 #消息推送 #在线教育 #用户提醒 #系统设计

通知与提醒机制:让信息精准触达每一位用户

作业截止提醒、成绩发布通知、课程开始预警——教育平台的每一个关键节点,都需要通知系统来保障信息触达。本文将讲解如何设计一个可靠、灵活、不扰民的通知系统。

通知系统需求分析

典型通知场景

场景触发时机紧急程度渠道
作业发布教师发布后站内信、邮件
截止提醒截止前 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 推送、定时任务调度
不扰民静默时间、频率限制、智能合并

好的通知系统是隐形的——用户不会因为信息过载而关闭通知,也不会因为错过重要信息而抱怨。找到这个平衡点,是通知系统设计的艺术。

延伸阅读