跳到主要内容

CmsKit 实时通知实施指南

📋 概述

CmsKit 的实时通知系统基于 SignalR 实现,支持以下特性:

  • 实时推送:点赞、评论、关注等操作后 3 秒内推送给接收方
  • 多设备支持:同一用户可在多个设备同时接收通知
  • 离线消息:用户不在线时消息保存到数据库,上线后同步
  • 已读同步:单条通知已读状态实时同步到所有设备
  • 未读计数:未读通知数量实时更新
  • 连接鉴权:基于 JWT Token 的连接认证
  • 断线重连:客户端自动重连机制

🏗️ 技术架构

核心组件

  1. NotificationHub (Hubs/NotificationHub.cs)

    • SignalR 中心,管理用户连接
    • 支持一对多推送(单用户多设备)
    • 连接/断开事件处理
    • JWT Token 鉴权
    • 基于 FreeRedis 存储连接状态
  2. INotificationClient (Hubs/INotificationClient.cs)

    • SignalR 客户端接口定义
    • 定义可推送的通知类型
  3. NotificationPushService (Application/Notifications/NotificationPushService.cs)

    • 通知推送服务
    • 持久化 + 实时推送双保险
    • 使用 FreeRedis 查询连接状态
  4. RedisConnectionManager (Hubs/RedisConnectionManager.cs)

    • 基于 FreeRedis 的连接管理器
    • 用户连接状态存储(UserId -> ConnectionIds)
    • 支持多实例部署
    • 连接状态 Redis Key: signalr:connections:{userId}

数据流程

用户操作(点赞/评论/关注)

应用层创建通知

NotificationService.CreateOrCancelAsync

AfterCreateNotification

NotificationPushService.PushNotificationAsync

┌─────────────────────────────────────┐
│ 1. 持久化到数据库 │
│ 2. 查询 Redis 获取用户连接 │
│ - 从 signalr:connections:{userId} │
│ 3. 通过 SignalR 实时推送 │
│ - 用户在线:立即推送到所有连接 │
│ - 用户离线:保存到数据库 │
└─────────────────────────────────────┘

多实例部署场景:
┌─────────────┐ ┌─────────────┐
│ 实例 A │ │ 实例 B │
│ User1: Conn1│ │ User1: Conn2│
│ Redis 共享连接状态 │◄──────────►│
└─────────────┘ └─────────────┘

技术架构说明

为什么使用 FreeRedis 而不是 SignalR 官方 Backplane?

  1. 项目统一性:项目已使用 FreeRedis 作为 Redis 客户端,避免引入多个 Redis 库
  2. 简化依赖:不需要额外安装 Microsoft.AspNetCore.SignalR.StackExchangeRedis
  3. 自定义控制:可以灵活控制连接存储结构和过期策略
  4. 多实例支持:通过 Redis 共享连接状态,支持水平扩展

Redis 数据结构

Key: signalr:connections:{userId}
Type: Set
Value: [connectionId1, connectionId2, ...]
TTL: 24 小时(自动过期)

多实例部署工作原理

  • 所有实例共享同一个 Redis
  • 用户连接到任意实例时,连接信息都写入 Redis
  • 推送通知时,从 Redis 读取该用户的所有连接(可能分布在多个实例)
  • 通过 SignalR HubContext 向这些连接推送

🚀 快速开始

1. 后端配置

后端已在 CmsKitModuleStartup.cs 中完成配置:

// ConfigureServices 中
services.AddSignalR();
services.AddScoped<INotificationPushService, NotificationPushService>();
services.AddSingleton<FreeKit.CmsKit.Hubs.RedisConnectionManager>();

// Configure 中
app.MapHub<Hubs.NotificationHub>("/hubs/notifications");

注意事项

  • 确保项目中已配置 FreeRedis 连接
  • RedisConnectionManager 会自动使用项目中已注册的 IRedisClient
  • 连接状态存储在 Redis 中,支持多实例部署

2. 前端集成示例

2.1 连接 SignalR

import * as signalR from "@microsoft/signalr";

class NotificationClient {
constructor() {
this.connection = null;
this.reconnectInterval = 5000; // 5 秒重连
}

// 建立连接
async connect() {
const token = localStorage.getItem("access_token");

this.connection = new signalR.HubConnectionBuilder()
.withUrl(`/hubs/notifications?access_token=${token}`)
.withAutomaticReconnect([0, 2000, 5000, 10000, 30000]) // 指数退避
.build();

// 注册事件处理
this.registerHandlers();

try {
await this.connection.start();
console.log("SignalR 连接成功");
} catch (err) {
console.error("SignalR 连接失败:", err);
// 触发重连逻辑
setTimeout(() => this.connect(), this.reconnectInterval);
}
}

// 注册事件处理
registerHandlers() {
// 接收新通知
this.connection.on("SendNotificationAsync", (notification) => {
console.log("收到新通知:", notification);
this.showNotificationToast(notification);
this.updateUnreadCount();
});

// 更新未读数量
this.connection.on("UpdateUnreadCountAsync", (count) => {
console.log("未读数量:", count);
this.updateUnreadBadge(count);
});

// 标记通知已读
this.connection.on("MarkNotificationAsReadAsync", (notificationId) => {
console.log("通知已读:", notificationId);
this.markAsRead(notificationId);
});

// 连接关闭
this.connection.onclose(() => {
console.log("SignalR 连接已关闭");
setTimeout(() => this.connect(), this.reconnectInterval);
});
}

// 显示通知弹窗
showNotificationToast(notification) {
// 使用你的 UI 组件库显示通知
// 例如:Element Plus, Ant Design Vue 等
ElNotification({
title: this.getNotificationTitle(notification.notificationType),
message: this.getNotificationMessage(notification),
type: "info",
duration: 4000
});
}

// 更新未读徽章
updateUnreadBadge(count) {
const badge = document.querySelector(".notification-badge");
if (badge) {
badge.textContent = count;
badge.style.display = count > 0 ? "block" : "none";
}
}

// 标记已读
markAsRead(notificationId) {
// 更新 UI 中的通知状态
const element = document.querySelector(`[data-notification-id="${notificationId}"]`);
if (element) {
element.classList.add("read");
}
}

// 获取通知标题
getNotificationTitle(type) {
const titles = {
1: "点赞通知",
2: "评论通知",
3: "关注通知"
};
return titles[type] || "新通知";
}

// 获取通知消息
getNotificationMessage(notification) {
return `${notification.userInfo?.nickName || "用户"}: ${this.getMessageContent(notification)}`;
}

getMessageContent(notification) {
switch (notification.notificationType) {
case 1: // UserLikeArticle
return "点赞了您的文章";
case 2: // UserCommentOnArticle
return "评论了您的文章";
case 3: // UserSubscribeUser
return "关注了您";
default:
return "发送了一条消息";
}
}
}

// 使用示例
const notificationClient = new NotificationClient();
notificationClient.connect();

2.2 Vue 3 组合式 API 示例

<template>
<div class="notification-center">
<el-badge :value="unreadCount" :hidden="unreadCount === 0">
<el-button @click="showNotifications">
<i class="el-icon-bell"></i>
</el-button>
</el-badge>

<el-dialog v-model="dialogVisible" title="消息通知">
<el-tabs v-model="activeTab">
<el-tab-pane label="评论" name="comment">
<notification-list :notifications="commentNotifications" />
</el-tab-pane>
<el-tab-pane label="点赞" name="like">
<notification-list :notifications="likeNotifications" />
</el-tab-pane>
<el-tab-pane label="关注" name="subscribe">
<notification-list :notifications="subscribeNotifications" />
</el-tab-pane>
</el-tabs>
</el-dialog>
</div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import * as signalR from '@microsoft/signalr';
import { useNotificationStore } from '@/stores/notification';

const notificationStore = useNotificationStore();
const unreadCount = ref(0);
const dialogVisible = ref(false);
const activeTab = ref('comment');

let connection = null;

onMounted(async () => {
await initSignalR();
await loadNotifications();
});

onUnmounted(() => {
if (connection) {
connection.stop();
}
});

async function initSignalR() {
const token = localStorage.getItem('access_token');

connection = new signalR.HubConnectionBuilder()
.withUrl(`/hubs/notifications?access_token=${token}`)
.withAutomaticReconnect()
.build();

connection.on('SendNotificationAsync', (notification) => {
notificationStore.addNotification(notification);
updateUnreadCount();
showNotificationToast(notification);
});

connection.on('UpdateUnreadCountAsync', (count) => {
unreadCount.value = count;
});

connection.on('MarkNotificationAsReadAsync', (id) => {
notificationStore.markAsRead(id);
});

await connection.start();
console.log('SignalR Connected');
}

async function loadNotifications() {
const response = await fetch('/api/cms/notifications/unread-count');
const data = await response.json();
unreadCount.value = data.userLikeCount + data.userCommentCount + data.userSubscribeUserCount;
}

function updateUnreadCount() {
loadNotifications();
}

function showNotificationToast(notification) {
ElNotification({
title: '新消息',
message: `${notification.userInfo?.nickName} ${getMessage(notification)}`,
type: 'info',
duration: 4000
});
}

function getMessage(notification) {
const messages = {
1: '点赞了您的内容',
2: '评论了您的内容',
3: '关注了您'
};
return messages[notification.notificationType] || '发送了消息';
}
</script>

📊 监控与调试

1. 查看连接状态

// 在 NotificationHub 中已添加日志
logger.LogInformation("用户 {UserId} 连接到通知 Hub,连接 ID: {ConnectionId}", userId, Context.ConnectionId);

2. 测试实时推送

使用 Postman 或浏览器控制台测试:

// 浏览器控制台
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/notifications?access_token=YOUR_TOKEN")
.build();

connection.on("SendNotificationAsync", (data) => {
console.log("收到通知:", data);
});

connection.start();

3. 常见问题排查

问题 1: 连接失败

  • 检查 Token 是否有效
  • 检查 Hub 路由是否正确 (/hubs/notifications)
  • 查看浏览器控制台和服务器日志

问题 2: 通知不推送

  • 检查用户是否在线(查看 Hub 日志)
  • 检查数据库通知是否保存成功
  • 查看 NotificationPushService 日志

问题 3: 断线后不重连

  • 检查 withAutomaticReconnect 配置
  • 查看网络状态
  • 检查 Token 是否过期

🎯 验收标准

  • 点赞后,被点赞用户 3 秒内收到通知
  • 评论后,被评论用户 3 秒内收到通知
  • 关注后,被关注用户 3 秒内收到通知
  • 通知列表实时刷新
  • 未读数量实时更新
  • 单条通知已读状态实时同步
  • 用户离线时消息不丢失(保存到数据库)
  • 用户上线后自动同步未读消息
  • 支持多设备同时接收通知
  • 连接鉴权正常工作
  • 断线后自动重连
  • 支持多实例部署(基于 FreeRedis 共享连接状态)

📝 注意事项

  1. 性能优化

    • ✅ 已使用 Redis 存储连接状态,支持多实例部署
    • 连接信息 24 小时自动过期,避免脏数据积累
    • 使用 Redis Set 数据结构,支持快速添加/删除连接
  2. 安全性

    • Token 通过查询参数传递,确保使用 HTTPS
    • Hub 已添加 [Authorize] 特性进行鉴权
  3. 容错处理

    • 实时推送失败不影响数据库保存
    • 用户上线后可查询未读通知
    • Redis 连接失败时自动降级(通知仍保存到数据库)
  4. 扩展性

    • 支持自定义通知类型
    • 支持批量推送
    • 支持通知模板和国际化
    • ✅ 支持多实例水平扩展
  5. Redis 依赖

    • 项目已使用 FreeRedis,无需额外依赖
    • Redis 故障时,通知仍会保存到数据库
    • 建议使用 Redis 集群提高可用性

🔗 相关文件

  • Hubs/NotificationHub.cs - SignalR Hub 实现(基于 FreeRedis)
  • Hubs/INotificationClient.cs - 客户端接口
  • Hubs/RedisConnectionManager.cs - Redis 连接管理器
  • Application/Notifications/NotificationPushService.cs - 推送服务
  • Application/Notifications/OfflineNotificationQueue.cs - 离线队列(保留)
  • CmsKitModuleStartup.cs - 模块启动配置