☰
Blazor SignalR 实时消息怎么做?从 NotificationHub 到站内通知
2026/10/8 2:09:17 网站建设 项目流程

站内消息看起来只是"服务器推一条消息给前端",真正落地时要回答三个问题:

  1. 怎么保证用户只能收到自己的消息?
  2. 用户离线时消息去哪了?
  3. 多实例部署时推送怎么到达正确的连接?

这篇用 EasyAdminBlazor 的NotificationHub逐个回答。


一、整体链路

业务代码(审批提交、文件通知、系统广播…) ↓ AdminMessageService.PublishInternalMessage / PublishSystemMessage ↓ 落库(SysMessage + SysMessageUser) ↓ IHubContext<NotificationHub>.Clients.Group($"user_{userId}") .SendAsync("ReceiveNotification", msg) ↓ 浏览器收到 → 角标刷新 / 消息列表更新

注意一个关键设计:先落库,再推送。推送失败不影响消息本身,用户下次打开消息中心照样能看到。


二、Hub 的第一原则:不接受客户端传 userId

很多"SignalR 用户组"的实现长这样:

// ❌ 危险写法publicTaskJoinGroup(longuserId)=>Groups.AddToGroupAsync(Context.ConnectionId,$"user_{userId}");

客户端只要调用JoinGroup(别人的Id),就能订阅别人的通知。这是很典型的越权订阅漏洞。

EasyAdminBlazor 的做法是取消这类方法,改由连接生命周期自动完成:

[Authorize]publicclassNotificationHub:Hub{/// <summary>用户组名称:user_{userId}</summary>publicstaticstringGetUserGroup(longuserId)=>$"user_{userId}";/// <summary>/// 连接建立时自动加入"当前登录用户"自己的用户组。/// 用户 Id 来自服务端权威身份(ClaimTypes.NameIdentifier / 登录票据 / 登录 Cookie),/// 不再接受客户端传入的 userId,防止订阅他人通知。/// </summary>publicoverrideasyncTaskOnConnectedAsync(){varuserId=GetCurrentUserId();if(userId<=0){_logger.LogWarning("NotificationHub 拒绝未认证连接:{ConnectionId}",Context.ConnectionId);Context.Abort();return;}awaitGroups.AddToGroupAsync(Context.ConnectionId,GetUserGroup(userId));awaitbase.OnConnectedAsync();}publicoverrideasyncTaskOnDisconnectedAsync(Exception?exception){varuserId=GetCurrentUserId();if(userId>0){awaitGroups.RemoveFromGroupAsync(Context.ConnectionId,GetUserGroup(userId));}awaitbase.OnDisconnectedAsync(exception);}}

三个要点:

  • 类上有[Authorize],未认证连接进不来;
  • 组名由服务端计算,客户端无法指定;
  • 拿不到用户 Id 时直接Context.Abort(),而不是"静默加入一个空组"。

三、用户 Id 从哪来:三级回退

privatelongGetCurrentUserId(){// 1) 标准认证声明varnameIdentifier=Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;if(long.TryParse(nameIdentifier,NumberStyles.Integer,CultureInfo.InvariantCulture,outvarclaimUserId)&&claimUserId>0){returnclaimUserId;}varhttpContext=Context?.GetHttpContext();if(httpContext==null)return0;// 2) 服务端自连的短期签名票据varticket=httpContext.Request.Query["ticket"].ToString();if(!string.IsNullOrEmpty(ticket)){varticketUserId=TryReadTicket(ticket);if(ticketUserId>0)returnticketUserId;}// 3) 浏览器直连的登录 Cookievarcookie=httpContext.Request.Cookies[_cookieKey];if(!string.IsNullOrEmpty(cookie))returnTryReadTicket(cookie);return0;}

为什么要三级?

来源场景
ClaimTypes.NameIdentifier认证中间件正常工作时的首选
?ticket=查询参数服务端 Self-Connect 场景:Blazor Server 服务端自己建立 SignalR 连接时,浏览器 Cookie 不会带上,只能用短期票据
登录 Cookie浏览器直连

票据本身是加密的,并且带时效校验:

privatelongTryReadTicket(stringprotectedValue){try{vardecrypted=_loginTicketProtector.Unprotect(Uri.UnescapeDataString(protectedValue));if(string.IsNullOrEmpty(decrypted))return0;varparts=decrypted.Split('|');if(parts.Length<2||!long.TryParse(parts[0],NumberStyles.Integer,CultureInfo.InvariantCulture,outvaruserId)||userId<=0){return0;}if(DateTime.TryParse(parts[1],CultureInfo.InvariantCulture,DateTimeStyles.RoundtripKind,outvarissuedAt)&&DateTime.UtcNow-issuedAt.ToUniversalTime()>TimeSpan.FromDays(7)){return0;}returnuserId;}catch(Exception){// 票据无效/密钥不匹配时按未登录处理return0;}}

票据格式是userId|loginTime,用 Data Protection 加密(purpose 是EasyAdminBlazor.LoginTicket.v1,与登录流程共用)。超过 7 天自动失效,密钥不匹配时按未登录处理。


四、服务端推送

AdminMessageService是消息的统一入口:

publicclassAdminMessageService(IAggregateRootRepository<SysMessage>repo,IHubContext<NotificationHub>hubContext,AdminContextadmin,IRedisService?redisService=null)

1. 两种消息

/// <summary>发站内信</summary>publicasyncTaskPublishInternalMessage(SysMessagemsg){msg.MessageType=MessageType.Internal;awaitPublishMessage(msg);}/// <summary>发布系统通知</summary>publicasyncTaskPublishSystemMessage(long[]recUserIds,stringsubject,stringcontent,string?linkUrl=null){awaitPublishMessage(newSysMessage{RecUserIds=recUserIds,Subject=subject,Content=content,LinkUrl=linkUrl??string.Empty,MessageType=MessageType.System});}

站内信的发送人取当前登录用户;系统通知没有发送人。两者共用同一条落库 + 推送链路。

2. 落库 + 推送

await_repo.InsertAsync(msg);// 通过 SignalR 推送通知给收件人if(msg.RecUserIds!=null){foreach(varrecUserIdinmsg.RecUserIds){await_hubContext.Clients.Group($"user_{recUserId}").SendAsync("ReceiveNotification",msg);}}else{await_hubContext.Clients.All.SendAsync("ReceiveNotification",msg);}awaitRaiseMessagesChangedAsync();

推送目标用的是Clients.Group($"user_{id}"),和 Hub 里GetUserGroup的规则一致——两端必须用同一个命名规则,这也是测试里专门断言GetUserGroup(12345) == "user_12345"的原因。

3. 收件人数据模型

// 如果只有一个收件人,则直接跟消息一起存if(msg.RecUserIds!=null&&msg.RecUserIds.Length==1){msg.RecUserId=msg.RecUserIds[0];msg.IsRead=false;}else{msg.Users=[];// 插入多个收件人if(msg.RecUserIds!=null&&msg.RecUserIds.Length>1){foreach(variteminmsg.RecUserIds){msg.Users.Add(new(){Id=item});}}if(msg.RecUserIds==null)// 所有人{foreach(variteminawait_admin.GetAllUsers()){varuserId=item.Id;if(userId!=_admin.User.Id)msg.Users.Add(new(){Id=userId});}}msg.RecUserIds=msg.Users.Select(x=>x.Id).ToArray();}
场景存储方式
单收件人直接写SysMessage.RecUserId+IsRead
多收件人写SysMessageUser关联记录,每人一条(含各自的已读状态)
全员广播展开成多收件人(排除自己)

多收件人时必须用关联表,因为"已读"是每人独立的。

4. 标记已读只影响自己

方法上的注释把意图写得很清楚:

/// <summary>/// 批量设置消息为已读。/// 不信任外部传入的完整实体,只取 Id 列表并结合当前登录用户重新构造更新条件,/// 确保只能标记"发给当前用户"的消息,防止越权修改他人消息状态。/// </summary>publicasyncTaskUpdateMessagesStatus(List<SysMessage>unreadMessagesForUser)
varuserId=_admin.User.Id;// 单收件人消息:仅更新"收件人是当前用户"的记录await_repo.Orm.Update<SysMessage>().Set(x=>x.IsRead,true).Where(x=>messageIds.Contains(x.Id)&&x.RecUserId==userId).ExecuteAffrowsAsync();// 多收件人消息:仅更新"关联记录属于当前用户"的记录varnow=DateTime.Now;await_repo.Orm.Update<SysMessageUser>().Set(x=>x.IsRead,true).Set(x=>x.ReadTime,now).Where(x=>messageIds.Contains(x.MessageId)&&x.UserId==userId).ExecuteAffrowsAsync();

两个WHERE都带当前用户条件。否则 A 点了"标记已读",B 的消息也会变成已读。


五、界面刷新事件

服务里暴露了一个事件,供右上角角标这类界面订阅:

/// <summary>/// 消息状态变化(新增 / 标记已读)时触发,供右上角通知角标等界面刷新。/// 订阅者异常不会影响消息本身的处理。/// </summary>publiceventFunc<Task>?MessagesChanged;privateasyncTaskRaiseMessagesChangedAsync(){if(MessagesChangedisnull)return;foreach(varhandlerinMessagesChanged.GetInvocationList().Cast<Func<Task>>()){try{awaithandler();}catch(Exception){// 界面刷新失败不应影响消息读写}}}

逐个订阅者 try/catch 是细节:一个组件的刷新异常不应该让"发消息"这个动作失败。


六、连接保活与断线容忍

Blazor Server 和 SignalR 都是长连接,后台标签页被浏览器冻结时心跳发不出去,默认 30 秒就会断线重连。框架在注册时调大了容忍度:

builder.Services.AddSignalR(options=>{options.KeepAliveInterval=TimeSpan.FromSeconds(15);options.ClientTimeoutInterval=TimeSpan.FromMinutes(3);});// 电路断开后保留时长:后台 Tab 冻结导致连接断开时,保留电路供切回时快速恢复builder.Services.Configure<Microsoft.AspNetCore.Components.Server.CircuitOptions>(options=>{options.DisconnectedCircuitRetentionPeriod=TimeSpan.FromMinutes(10);options.DisconnectedCircuitMaxRetained=200;});
配置值作用
KeepAliveInterval15 秒服务端 ping 频率
ClientTimeoutInterval3 分钟客户端多久没响应算断线
DisconnectedCircuitRetentionPeriod10 分钟断线后电路保留时长
DisconnectedCircuitMaxRetained200最多保留多少个断开的电路

这几个参数直接影响"切回标签页后页面是不是还活着"的体验,也影响内存占用(保留 200 个电路是有代价的,大并发场景要按服务器内存调整)。


七、离线消息与聊天消息的落库

在线用户走 SignalR 实时推送;离线用户下次进入消息中心时从数据库读取。这两条路径是靠"先落库"统一的。

聊天场景还多一层:消息先写 Redis 列表,再由后台任务批量落库。

// Chat.razor:写入_redisService?.LPush($"{admin.TenantCachePrefix}chat_messages:{receiverId}",JsonConvert.SerializeObject(newMessage));
// AdminMessageService:批量落库(带租户前缀的分布式锁)varlockObj=_redis.Lock($"{_admin.TenantCachePrefix}{LockKey}",LockExpireSeconds);if(lockObj!=null){varkeys=_redis.Keys($"{_admin.TenantCachePrefix}chat_messages:*");..._redis.ReleaseLock(lockObj);}

细节见第 19 篇(Redis 缓存与分布式锁)。


八、测试锁定了哪些安全约束

NotificationHubTests.cs用反射 + 源码检查锁定了几条不能退化的约束:

测试断言
Hub_RequiresAuthorizationHub 上有[Authorize]
Hub_DoesNotExposeClientControllableGroupMethods不得存在JoinGroup/LeaveGroup这类可传 userId 的方法
Hub_OverridesLifecycleMethodsOnConnectedAsync/OnDisconnectedAsync必须在 Hub 里重写
GetUserGroup_UsesUserIdSuffix组名规则user_{id}
GetUserGroup_DifferentUsers_DoNotCollide不同用户不同组
Hub_ReadsUserIdFromClaims源码里出现ClaimTypes.NameIdentifier(身份来自服务端)
Hub_DoesNotTrustClientUserIdParameter源码里不得出现信任外部 userId 的签名

最后两条用读源码的方式断言,很直接:以后有人为了"方便"加回JoinGroup(string userId),测试立刻失败。


九、常见问题

现象原因处理
收不到实时消息但消息列表里有SignalR 连接断了或未认证看浏览器控制台与 Hub 日志;确认登录态
连接建立后立刻断开GetCurrentUserId()返回 0检查认证中间件、登录票据密钥(Data Protection)配置
多实例部署下部分用户收不到Hub 连接在不同的实例上需要 Redis backplane 或粘性会话;消息本身已落库,不影响最终一致
切回标签页后页面白屏/重连Circuit 已释放调整DisconnectedCircuitRetentionPeriod
标记已读把别人的也标了自定义代码漏了用户条件参考UpdateMessagesStatus的双WHERE写法

十、小结

一个安全的站内消息系统,关键就四条:

  1. 身份只信服务端:Hub 不接受客户端 userId,订阅自己的组由连接建立时自动完成;
  2. 先落库再推送:推送失败不影响消息可达性,离线用户也能看到;
  3. 已读按人存:多收件人用关联表,更新必须带当前用户条件;
  4. 连接参数要调:Blazor Server + 后台标签页冻结的场景下,默认超时太激进。

EasyAdminBlazor 的NotificationHub把这四条都写进了代码和测试里,可以直接对照实现自己的通知系统。


如果你正在用 .NET 10 + Blazor 做后台,需要站内消息、待办提醒这类实时能力,可以看看 EasyAdminBlazor 的实现:Hub 与消息服务分层清晰,离线消息、已读状态、多租户前缀都考虑到了。

  • 文档:https://easyadmin.wang-zhan.com.cn/doc
  • 源码:https://gitee.com/gudufy/EasyAdminBlazor

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询