站内消息看起来只是"服务器推一条消息给前端",真正落地时要回答三个问题:
- 怎么保证用户只能收到自己的消息?
- 用户离线时消息去哪了?
- 多实例部署时推送怎么到达正确的连接?
这篇用 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;});| 配置 | 值 | 作用 |
|---|---|---|
KeepAliveInterval | 15 秒 | 服务端 ping 频率 |
ClientTimeoutInterval | 3 分钟 | 客户端多久没响应算断线 |
DisconnectedCircuitRetentionPeriod | 10 分钟 | 断线后电路保留时长 |
DisconnectedCircuitMaxRetained | 200 | 最多保留多少个断开的电路 |
这几个参数直接影响"切回标签页后页面是不是还活着"的体验,也影响内存占用(保留 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_RequiresAuthorization | Hub 上有[Authorize] |
Hub_DoesNotExposeClientControllableGroupMethods | 不得存在JoinGroup/LeaveGroup这类可传 userId 的方法 |
Hub_OverridesLifecycleMethods | OnConnectedAsync/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写法 |
十、小结
一个安全的站内消息系统,关键就四条:
- 身份只信服务端:Hub 不接受客户端 userId,订阅自己的组由连接建立时自动完成;
- 先落库再推送:推送失败不影响消息可达性,离线用户也能看到;
- 已读按人存:多收件人用关联表,更新必须带当前用户条件;
- 连接参数要调:Blazor Server + 后台标签页冻结的场景下,默认超时太激进。
EasyAdminBlazor 的NotificationHub把这四条都写进了代码和测试里,可以直接对照实现自己的通知系统。
如果你正在用 .NET 10 + Blazor 做后台,需要站内消息、待办提醒这类实时能力,可以看看 EasyAdminBlazor 的实现:Hub 与消息服务分层清晰,离线消息、已读状态、多租户前缀都考虑到了。
- 文档:https://easyadmin.wang-zhan.com.cn/doc
- 源码:https://gitee.com/gudufy/EasyAdminBlazor