L28 · WebSocket:实时通知
🎯 本节目标:用 Socket.IO 实现服务端向客户端的实时消息推送
📦 本节产出:订单状态变化实时通知 + 通知中心组件 + useSocket composable
🔗 前置钩子:L27 的完整上传功能、L25 的订单状态变化
🔗 后续钩子:L29 将独立比较 Nuxt SSR 的商品页渲染1. 为什么需要 WebSocket
2. 后端:Socket.IO 集成
Socket.IO 有自己的事件协议和自动重连机制,默认可从 HTTP long-polling 升级为 WebSocket;它不是浏览器原生 WebSocket 的直接替代端点。前后端都使用 4.x:
# 在 server/ 中
npm install socket.io@4// server/src/socket.ts
import type { Server as HttpServer } from 'node:http'
import { randomUUID } from 'node:crypto'
import { Server } from 'socket.io'
import jwt from 'jsonwebtoken'
import User from './models/User'
import { verifyToken } from './config/jwt'
import type { OrderStatus } from './models/Order'
export interface OrderNotification {
id: string; type: 'order_status'; title: string; message: string; createdAt: string
data: { orderId: string; newStatus: OrderStatus }
}
interface ServerEvents { notification: (value: OrderNotification) => void; 'auth:expired': () => void }
interface SocketData { userId: string; expiresAt: number }
let io: Server<Record<string, never>, ServerEvents, Record<string, never>, SocketData> | undefined
export function initSocket(httpServer: HttpServer) {
io = new Server<Record<string, never>, ServerEvents, Record<string, never>, SocketData>(httpServer, {
cors: { origin: process.env.CLIENT_URL || 'http://localhost:5173', methods: ['GET', 'POST'] },
})
io.use(async (socket, next) => {
try {
const token: unknown = socket.handshake.auth.token
if (typeof token !== 'string') throw new Error('缺少 token')
const userId = verifyToken(token, 'access')
// 此处只取已验签 token 的 exp;decode 本身不能替代上面的 verify。
const payload = jwt.decode(token)
if (!payload || typeof payload === 'string' || typeof payload.exp !== 'number') throw new Error('缺少 exp')
if (!(await User.exists({ _id: userId }))) throw new Error('用户不存在')
if (payload.exp * 1000 <= Date.now()) throw new Error('token 已过期')
socket.data = { userId, expiresAt: payload.exp * 1000 }
next()
} catch {
const error = Object.assign(new Error('连接认证失败'), { data: { code: 'AUTH_INVALID' } })
next(error)
}
})
io.on('connection', socket => {
void socket.join(`user:${socket.data.userId}`)
// middleware 只在握手时执行,所以本例另外在 access token 到期时断开。
const timer = setTimeout(() => {
socket.emit('auth:expired')
socket.disconnect(true)
}, Math.max(0, socket.data.expiresAt - Date.now()))
timer.unref()
socket.on('disconnect', () => clearTimeout(timer))
})
return io
}
export function sendNotification(userId: string, notification: Omit<OrderNotification, 'id' | 'createdAt'>) {
io?.to(`user:${userId}`).emit('notification', {
...notification, id: randomUUID(), createdAt: new Date().toISOString(),
})
}Socket.IO 自带心跳,移除额外的业务 ping/pong。握手 middleware 不是逐消息鉴权;本课只向用户房间推送,不接受修改订单的 Socket 命令。若新增写消息,仍需分别验证权限。Socket.IO middleware
替换 index.ts 时保留 L19 的 dotenv/数据库启动、L22 的用户索引等待和 L25 的过期扫描;HTTP 与 Socket.IO 共享同一个 server,不能再同时调用 app.listen:
// server/src/index.ts
import 'dotenv/config'
import http from 'node:http'
import app from './app'
import { connectDB } from './config/db'
import User from './models/User'
import { expirePendingOrders } from './services/orderService'
import { initSocket } from './socket'
async function start() {
await connectDB()
await User.init()
const server = http.createServer(app)
initSocket(server)
let scanning = false
async function scanExpired() {
if (scanning) return
scanning = true
try { await expirePendingOrders() }
catch (error) { console.error('过期订单扫描失败', error) }
finally { scanning = false }
}
void scanExpired()
setInterval(() => { void scanExpired() }, 60000).unref()
const port = Number(process.env.PORT || 3000)
server.listen(port, '127.0.0.1', () => console.log(`Server running on http://127.0.0.1:${port}`))
}
start().catch(error => { console.error('启动失败:', error); process.exit(1) })在事务提交后发送通知
L25 的状态变化集中在 server/src/services/orderService.ts,不只发生于 orderController。给该文件添加 import { sendNotification } from '../socket',并把现有 orderStateMachine 导入增加 STATUS_META。
在 transitionOrder 中,保留事务回调内容,把末尾的 }) 改成下面的 .then 链,placeOrder 不变:
// server/src/services/orderService.ts:transitionOrder 的事务返回尾部
// ……事务内仍是 await order.save({ session }); return order
}).then(order => {
// 此时事务已经提交。不能放在回调内部,否则事务重试可能重复发送。
sendNotification(order.user.toString(), {
type: 'order_status', title: '订单状态更新',
message: `您的订单状态变为「${STATUS_META[order.status].label}」`,
data: { orderId: order._id.toString(), newStatus: order.status },
})
return order
})这样顾客操作、管理员操作、支付确认和超时取消都经过同一发送入口。数据库提交与 emit 之间仍有进程退出窗口;本课没有事务 outbox 或持久化通知队列,因此通知是即时提示,订单 API 才是最终状态来源。不能据此承诺通知恰好送达一次。
3. 前端:useSocket composable
# 在 client/ 中
npm install socket.io-client@4本课仍是浏览器 SPA。连接由 main.ts 初始化一次,页面 composable 只订阅状态/事件;不为每个组件重复注册全局通知处理。先定义与后端相同的传输类型:
// client/src/types/notification.ts
import type { OrderStatus } from './order'
export interface OrderNotification {
id: string; type: 'order_status'; title: string; message: string; createdAt: string
data: { orderId: string; newStatus: OrderStatus }
}
export interface LocalNotification extends OrderNotification { read: boolean }// client/src/utils/socketClient.ts
import { ref, watch, nextTick } from 'vue'
import { io, type Socket } from 'socket.io-client'
import { ApiError } from '@/utils/request'
import type { useAuthStore } from '@/stores/authStore'
import type { OrderNotification, LocalNotification } from '@/types/notification'
interface ServerEvents { notification: (value: OrderNotification) => void; 'auth:expired': () => void }
const socketOrigin = import.meta.env.VITE_SOCKET_URL || new URL(
import.meta.env.VITE_API_URL || 'http://127.0.0.1:3000/api', window.location.origin,
).origin
export const socket: Socket<ServerEvents, Record<string, never>> = io(socketOrigin, {
autoConnect: false, reconnection: true, reconnectionAttempts: 10,
reconnectionDelay: 1000, reconnectionDelayMax: 5000,
})
export const isConnected = ref(false)
export const connectionError = ref<string | null>(null)
export const notifications = ref<LocalNotification[]>([])
let stopCurrentSession: (() => void) | undefined
export function startSocketSession(auth: ReturnType<typeof useAuthStore>, onUnauthorized: () => void) {
stopCurrentSession?.()
let disposed = false
let connectionUserId: string | undefined
let connectionToken: string | null = null
const isCurrent = (session: number) => !disposed && session === auth.sessionVersion()
let refreshAttempted = false
let refreshing = false
socket.auth = done => done({ token: auth.accessToken }) // 每次握手都读取当前 token
async function renew() {
if (disposed || refreshAttempted || refreshing || !auth.isLoggedIn) return
refreshAttempted = true
refreshing = true
const session = auth.sessionVersion()
try {
await auth.refresh()
await nextTick() // 让 token watcher 先完成重新握手,避免重复 connect
if (isCurrent(session) && !socket.active) socket.connect()
} catch (error) {
if (!isCurrent(session)) return
connectionError.value = error instanceof Error ? error.message : '连接认证失败'
if (error instanceof ApiError && error.status === 401) onUnauthorized()
} finally { if (isCurrent(session)) refreshing = false }
}
socket.on('connect', () => { isConnected.value = true; connectionError.value = null; refreshAttempted = false })
socket.on('disconnect', () => { isConnected.value = false })
socket.on('connect_error', error => {
isConnected.value = false
connectionError.value = error.message
if ((error as Error & { data?: { code?: string } }).data?.code === 'AUTH_INVALID') void renew()
})
socket.on('auth:expired', () => { void renew() })
socket.on('notification', value => {
if (disposed || connectionUserId !== auth.user?.id || connectionToken !== auth.accessToken) return
if (!auth.user || notifications.value.some(item => item.id === value.id)) return
notifications.value.unshift({ ...value, read: false })
notifications.value = notifications.value.slice(0, 100)
// 授权在按钮点击时请求;消息到来时不弹权限请求。
if ('Notification' in window && window.Notification.permission === 'granted') {
try { new window.Notification(value.title, { body: value.message }) } catch { /* 部分浏览器不支持构造桌面通知 */ }
}
})
const stopWatching = watch(() => ({ token: auth.accessToken, userId: auth.user?.id, session: auth.sessionVersion() }), (current, previous) => {
socket.disconnect()
isConnected.value = false
connectionUserId = current.userId
connectionToken = current.token
if (current.userId !== previous?.userId || current.session !== previous?.session) {
notifications.value = []
connectionError.value = null
refreshAttempted = false
refreshing = false
}
if (current.token && current.userId) socket.connect()
}, { immediate: true })
const stop = () => {
if (disposed) return
disposed = true
stopWatching()
socket.disconnect()
socket.removeAllListeners()
isConnected.value = false
}
stopCurrentSession = stop
return stop
}设置 VITE_SOCKET_URL 时填 Socket 服务 origin,例如 http://127.0.0.1:3000,不靠字符串 replace('/api', '') 猜地址。token 刷新后重新握手,退出时断开,换账号时清掉前账号通知。站内通知列表只保留当前页面内存中的最近 100 条,刷新页面会清空;read 也是本地状态。
// client/src/composables/useSocket.ts
import { readonly, onScopeDispose } from 'vue'
import { socket, isConnected, connectionError } from '@/utils/socketClient'
import type { OrderNotification } from '@/types/notification'
export function useSocket() {
function onNotification(handler: (value: OrderNotification) => void) {
socket.on('notification', handler)
const off = () => socket.off('notification', handler)
onScopeDispose(off)
return off
}
return { isConnected: readonly(isConnected), connectionError: readonly(connectionError), onNotification }
}在 setup 中同步调用 useSocket 和 onNotification,作用域销毁时只移除自己注册的 handler。全局连接由 main.ts 管理,通知中心不再同时创建两份连接状态监听。
接入应用初始化
在 L22 的 main.ts 中导入 startSocketSession,把已有 onUnauthorized 回调提取成共享函数,再分别交给 HTTP 与 Socket。保留 L24/L27 使用的 Pinia 插件:
// client/src/main.ts:替换 L22 入口
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
import router from './router'
import { useAuthStore } from '@/stores/authStore'
import { configureAuth } from '@/utils/request'
import { startSocketSession } from '@/utils/socketClient'
import './assets/main.css'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
const app = createApp(App).use(pinia)
const auth = useAuthStore(pinia)
function onUnauthorized() {
auth.logout()
const route = router.currentRoute.value
if (route.meta.requiresAuth || route.meta.requiresAdmin) {
void router.replace({ name: 'login', query: { redirect: route.fullPath } })
}
}
configureAuth({ accessToken: () => auth.accessToken, sessionVersion: auth.sessionVersion, refresh: auth.refresh, onUnauthorized })
const stopSocket = startSocketSession(auth, onUnauthorized)
app.onUnmount(stopSocket)
app.use(router).mount('#app')4. 通知中心组件
// client/src/composables/useNotifications.ts
import { computed, readonly } from 'vue'
import { notifications } from '@/utils/socketClient'
export function useNotifications() {
const unreadCount = computed(() => notifications.value.filter(value => !value.read).length)
function markAsRead(id: string) {
const value = notifications.value.find(item => item.id === id)
if (value) value.read = true
}
function markAllAsRead() { notifications.value.forEach(value => { value.read = true }) }
function clearAll() { notifications.value = [] }
return { notifications: readonly(notifications), unreadCount, markAsRead, markAllAsRead, clearAll }
}这里只访问共享列表,不再给每个调用者添加 notification 监听,所以同一条消息不会随着组件数增加而重复插入。
<!-- client/src/components/NotificationCenter.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useNotifications } from '@/composables/useNotifications'
import { useSocket } from '@/composables/useSocket'
import type { LocalNotification } from '@/types/notification'
const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotifications()
const { isConnected, connectionError } = useSocket()
const router = useRouter()
const isOpen = ref(false)
const permissionMessage = ref('')
function togglePanel() { isOpen.value = !isOpen.value }
function handleNotificationClick(notification: LocalNotification) {
markAsRead(notification.id)
isOpen.value = false
void router.push({ name: 'order-detail', params: { id: notification.data.orderId } })
}
async function enableDesktopNotifications() {
if (!('Notification' in window)) { permissionMessage.value = '当前浏览器不支持桌面通知'; return }
try {
const permission = await window.Notification.requestPermission()
permissionMessage.value = permission === 'granted' ? '桌面通知已允许' : '未允许桌面通知,仍可在此查看'
} catch { permissionMessage.value = '当前环境无法申请桌面通知' }
}
</script>
<template>
<div class="notification-center">
<!-- 触发按钮 -->
<button @click="togglePanel" class="notify-trigger" aria-label="打开通知中心" :aria-expanded="isOpen">
🔔
<span v-if="unreadCount > 0" class="badge">
{{ unreadCount > 99 ? '99+' : unreadCount }}
</span>
<span v-if="!isConnected" class="offline-dot" title="未连接"></span>
</button>
<!-- 通知面板 -->
<div v-if="isOpen" class="notify-panel">
<div class="panel-header">
<h3>通知</h3>
<button v-if="unreadCount > 0" @click="markAllAsRead" class="mark-all">
全部已读
</button>
</div>
<p v-if="connectionError" role="status">{{ connectionError }}</p>
<button @click="enableDesktopNotifications">开启桌面通知</button>
<p v-if="permissionMessage" role="status">{{ permissionMessage }}</p>
<div v-if="notifications.length === 0" class="empty">
暂无通知
</div>
<div v-else class="notify-list">
<button
v-for="n in notifications"
:key="n.id"
class="notify-item"
:class="{ unread: !n.read }"
@click="handleNotificationClick(n)"
>
<div class="notify-content">
<strong>{{ n.title }}</strong>
<p>{{ n.message }}</p>
<span class="notify-time">
{{ new Date(n.createdAt).toLocaleString() }}
</span>
</div>
<span v-if="!n.read" class="unread-dot"></span>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.notification-center { position: relative; }
.notify-trigger {
position: relative; background: none; border: none;
font-size: 1.4rem; cursor: pointer; padding: 4px;
}
.badge {
position: absolute; top: -4px; right: -8px;
background: #e74c3c; color: white; font-size: 0.6rem;
padding: 1px 5px; border-radius: 8px; font-weight: 700;
}
.offline-dot {
position: absolute; bottom: 0; right: 0;
width: 8px; height: 8px; border-radius: 50%;
background: #aaa; border: 2px solid white;
}
.notify-panel {
position: absolute; top: 100%; right: 0;
width: min(360px, calc(100vw - 32px)); max-height: 450px;
background: white; border-radius: 12px;
box-shadow: 0 8px 30px rgba(0,0,0,0.15);
display: flex; flex-direction: column;
overflow: hidden; z-index: 1000;
}
.panel-header {
display: flex; justify-content: space-between; align-items: center;
padding: 14px 16px; border-bottom: 1px solid #f0f0f0;
}
.panel-header h3 { margin: 0; font-size: 1rem; }
.mark-all { background: none; border: none; color: #42b883; cursor: pointer; font-size: 0.8rem; }
.notify-list { min-height: 0; overflow-y: auto; }
.notify-item {
width: 100%; border: 0; background: white; text-align: left;
display: flex; align-items: flex-start; gap: 8px;
padding: 12px 16px; cursor: pointer;
border-bottom: 1px solid #f8f8f8;
transition: background 0.15s;
}
.notify-item:hover { background: #f8f9fa; }
.notify-item.unread { background: #42b88308; }
.notify-content { flex: 1; }
.notify-content strong { font-size: 0.85rem; display: block; margin-bottom: 2px; }
.notify-content p { font-size: 0.8rem; color: #666; margin: 0 0 4px; }
.notify-time { font-size: 0.7rem; color: #bbb; }
.unread-dot { width: 8px; height: 8px; border-radius: 50%; background: #42b883; flex-shrink: 0; margin-top: 6px; }
.empty { text-align: center; padding: 40px; color: #999; font-size: 0.85rem; }
</style>在 App.vue 的 script 添加 import NotificationCenter from '@/components/NotificationCenter.vue',在现有 header 中追加 <NotificationCenter v-if="auth.isLoggedIn" />。浏览器桌面通知通常要求安全上下文、浏览器支持和用户授权;点击按钮申请,拒绝授权不会影响站内通知。Notification API
5. 连接生命周期
临时网络中断通常会自动重连,服务端主动断开、客户端主动断开和 middleware 拒绝认证不会都自动重试。本例对 token 过期显式刷新后 connect;认证持续失败时停止刷新循环。客户端连接事件、auth 配置
默认投递是 at-most-once,离线期间的服务端通知不会自动补齐。重连成功后应通过订单页面重新读取真实状态;要补历史通知,需要通知存储、游标和去重等额外协议。Socket.IO 投递保证
6. 本节总结
检查清单
- [ ] 能在后端集成 Socket.IO(认证中间件 + 房间分组)
- [ ] 能在业务逻辑中触发实时通知(
sendNotification) - [ ] 能封装
useSocketcomposable(单例连接 + 作用域监听清理) - [ ] 能实现通知中心组件(未读计数 + 面板 + 标记已读)
- [ ] 理解 Socket.IO 重连、JWT 刷新与通知投递的边界
- [ ] 能用浏览器 Notification API 发送桌面通知
Git 提交
git add .
git commit -m "L28: Socket.IO 实时通知 + 通知中心"🔗 → 下一节
L29 将用独立的 Nuxt 4 项目对照 SSR 与客户端渲染,检查 HTML 内容与请求隔离。