站内消息看起来只是"服务器推一条消息给前端",真正落地时要回答三个问题:
- 怎么保证用户只能收到自己的消息?
- 用户离线时消息去哪了?
- 多实例部署时推送怎么到达正确的连接?
这篇用 EasyAdminBlazor 的 NotificationHub 逐个回答。
一、整体链路
text
业务代码(审批提交、文件通知、系统广播...)
↓
AdminMessageService.PublishInternalMessage / PublishSystemMessage
↓
落库(SysMessage + SysMessageUser)
↓
IHubContext<NotificationHub>.Clients.Group($"user_{userId}")
.SendAsync("ReceiveNotification", msg)
↓
浏览器收到 → 角标刷新 / 消息列表更新
注意一个关键设计:先落库,再推送。推送失败不影响消息本身,用户下次打开消息中心照样能看到。
二、Hub 的第一原则:不接受客户端传 userId
很多"SignalR 用户组"的实现长这样:
csharp
// ❌ 危险写法
public Task JoinGroup(long userId) => Groups.AddToGroupAsync(Context.ConnectionId, $"user_{userId}");
客户端只要调用 JoinGroup(别人的Id),就能订阅别人的通知。这是很典型的越权订阅漏洞。
EasyAdminBlazor 的做法是取消这类方法,改由连接生命周期自动完成:
csharp
[Authorize]
public class NotificationHub : Hub
{
/// <summary>用户组名称:user_{userId}</summary>
public static string GetUserGroup(long userId) => $"user_{userId}";
/// <summary>
/// 连接建立时自动加入"当前登录用户"自己的用户组。
/// 用户 Id 来自服务端权威身份(ClaimTypes.NameIdentifier / 登录票据 / 登录 Cookie),
/// 不再接受客户端传入的 userId,防止订阅他人通知。
/// </summary>
public override async Task OnConnectedAsync()
{
var userId = GetCurrentUserId();
if (userId <= 0)
{
_logger.LogWarning("NotificationHub 拒绝未认证连接:{ConnectionId}", Context.ConnectionId);
Context.Abort();
return;
}
await Groups.AddToGroupAsync(Context.ConnectionId, GetUserGroup(userId));
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var userId = GetCurrentUserId();
if (userId > 0)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, GetUserGroup(userId));
}
await base.OnDisconnectedAsync(exception);
}
}
三个要点:
- 类上有
[Authorize],未认证连接进不来; - 组名由服务端计算,客户端无法指定;
- 拿不到用户 Id 时直接
Context.Abort(),而不是"静默加入一个空组"。
三、用户 Id 从哪来:三级回退
csharp
private long GetCurrentUserId()
{
// 1) 标准认证声明
var nameIdentifier = Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (long.TryParse(nameIdentifier, NumberStyles.Integer, CultureInfo.InvariantCulture, out var claimUserId) &&
claimUserId > 0)
{
return claimUserId;
}
var httpContext = Context?.GetHttpContext();
if (httpContext == null) return 0;
// 2) 服务端自连的短期签名票据
var ticket = httpContext.Request.Query["ticket"].ToString();
if (!string.IsNullOrEmpty(ticket))
{
var ticketUserId = TryReadTicket(ticket);
if (ticketUserId > 0) return ticketUserId;
}
// 3) 浏览器直连的登录 Cookie
var cookie = httpContext.Request.Cookies[_cookieKey];
if (!string.IsNullOrEmpty(cookie)) return TryReadTicket(cookie);
return 0;
}
为什么要三级?
| 来源 | 场景 |
|---|---|
ClaimTypes.NameIdentifier |
认证中间件正常工作时的首选 |
?ticket= 查询参数 |
服务端 Self-Connect 场景:Blazor Server 服务端自己建立 SignalR 连接时,浏览器 Cookie 不会带上,只能用短期票据 |
| 登录 Cookie | 浏览器直连 |
票据本身是加密的,并且带时效校验:
csharp
private long TryReadTicket(string protectedValue)
{
try
{
var decrypted = _loginTicketProtector.Unprotect(Uri.UnescapeDataString(protectedValue));
if (string.IsNullOrEmpty(decrypted)) return 0;
var parts = decrypted.Split('|');
if (parts.Length < 2 ||
!long.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var userId) ||
userId <= 0)
{
return 0;
}
if (DateTime.TryParse(parts[1], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var issuedAt) &&
DateTime.UtcNow - issuedAt.ToUniversalTime() > TimeSpan.FromDays(7))
{
return 0;
}
return userId;
}
catch (Exception)
{
// 票据无效/密钥不匹配时按未登录处理
return 0;
}
}
票据格式是 userId|loginTime,用 Data Protection 加密(purpose 是 EasyAdminBlazor.LoginTicket.v1,与登录流程共用)。超过 7 天自动失效,密钥不匹配时按未登录处理。
四、服务端推送
AdminMessageService 是消息的统一入口:
csharp
public class AdminMessageService(
IAggregateRootRepository<SysMessage> repo,
IHubContext<NotificationHub> hubContext,
AdminContext admin,
IRedisService? redisService = null)
1. 两种消息
csharp
/// <summary>发站内信</summary>
public async Task PublishInternalMessage(SysMessage msg)
{
msg.MessageType = MessageType.Internal;
await PublishMessage(msg);
}
/// <summary>发布系统通知</summary>
public async Task PublishSystemMessage(long[] recUserIds, string subject, string content, string? linkUrl = null)
{
await PublishMessage(new SysMessage
{
RecUserIds = recUserIds,
Subject = subject,
Content = content,
LinkUrl = linkUrl ?? string.Empty,
MessageType = MessageType.System
});
}
站内信的发送人取当前登录用户;系统通知没有发送人。两者共用同一条落库 + 推送链路。
2. 落库 + 推送
csharp
await _repo.InsertAsync(msg);
// 通过 SignalR 推送通知给收件人
if (msg.RecUserIds != null)
{
foreach (var recUserId in msg.RecUserIds)
{
await _hubContext.Clients.Group($"user_{recUserId}").SendAsync("ReceiveNotification", msg);
}
}
else
{
await _hubContext.Clients.All.SendAsync("ReceiveNotification", msg);
}
await RaiseMessagesChangedAsync();
推送目标用的是 Clients.Group($"user_{id}"),和 Hub 里 GetUserGroup 的规则一致------两端必须用同一个命名规则 ,这也是测试里专门断言 GetUserGroup(12345) == "user_12345" 的原因。
3. 收件人数据模型
csharp
// 如果只有一个收件人,则直接跟消息一起存
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 (var item in msg.RecUserIds)
{
msg.Users.Add(new() { Id = item });
}
}
if (msg.RecUserIds == null) // 所有人
{
foreach (var item in await _admin.GetAllUsers())
{
var userId = 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. 标记已读只影响自己
方法上的注释把意图写得很清楚:
csharp
/// <summary>
/// 批量设置消息为已读。
/// 不信任外部传入的完整实体,只取 Id 列表并结合当前登录用户重新构造更新条件,
/// 确保只能标记"发给当前用户"的消息,防止越权修改他人消息状态。
/// </summary>
public async Task UpdateMessagesStatus(List<SysMessage> unreadMessagesForUser)
csharp
var userId = _admin.User.Id;
// 单收件人消息:仅更新"收件人是当前用户"的记录
await _repo.Orm.Update<SysMessage>()
.Set(x => x.IsRead, true)
.Where(x => messageIds.Contains(x.Id) && x.RecUserId == userId)
.ExecuteAffrowsAsync();
// 多收件人消息:仅更新"关联记录属于当前用户"的记录
var now = 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 的消息也会变成已读。
五、界面刷新事件
服务里暴露了一个事件,供右上角角标这类界面订阅:
csharp
/// <summary>
/// 消息状态变化(新增 / 标记已读)时触发,供右上角通知角标等界面刷新。
/// 订阅者异常不会影响消息本身的处理。
/// </summary>
public event Func<Task>? MessagesChanged;
private async Task RaiseMessagesChangedAsync()
{
if (MessagesChanged is null) return;
foreach (var handler in MessagesChanged.GetInvocationList().Cast<Func<Task>>())
{
try
{
await handler();
}
catch (Exception)
{
// 界面刷新失败不应影响消息读写
}
}
}
逐个订阅者 try/catch 是细节:一个组件的刷新异常不应该让"发消息"这个动作失败。
六、连接保活与断线容忍
Blazor Server 和 SignalR 都是长连接,后台标签页被浏览器冻结时心跳发不出去,默认 30 秒就会断线重连。框架在注册时调大了容忍度:
csharp
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 列表,再由后台任务批量落库。
csharp
// Chat.razor:写入
_redisService?.LPush($"{admin.TenantCachePrefix}chat_messages:{receiverId}", JsonConvert.SerializeObject(newMessage));
csharp
// AdminMessageService:批量落库(带租户前缀的分布式锁)
var lockObj = _redis.Lock($"{_admin.TenantCachePrefix}{LockKey}", LockExpireSeconds);
if (lockObj != null)
{
var keys = _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 与消息服务分层清晰,离线消息、已读状态、多租户前缀都考虑到了。