EasyAdminBlazor SignalR 实时消息源码解析:从 NotificationHub 到站内消息

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

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

这篇用 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 写法

十、小结

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

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

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


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

相关推荐
程序猿乐锅1 小时前
【黑马点评 | 第十一篇】关注 Feed 流实现
java·数据库·redis·分布式·后端·缓存·maven
用户EasyAdminBlazor1 小时前
EasyAdminBlazor 定时任务:FreeScheduler 可视化调度与任务管理
后端
Cosolar1 小时前
云端部署阿里 Qwen-Image-2.1 保姆级教程
人工智能·后端·github
yunwei372 小时前
eBPF 开发实践:使用 sockops 加速网络请求转发
linux·后端·性能优化
看浪的路人2 小时前
第7讲:实时告警与自动化响应
开发语言·后端·golang
Gopher_HBo2 小时前
zap WriteSyncer与Sink体系
后端
合尘猫2 小时前
Nginx stream 做 GitHub 443 SNI 透传:完整配置、多上游故障转移与三个坑
后端·程序员·开源
知守观2 小时前
百万级数据导出OOM:POI的坑与EasyExcel的流式写入实战(附内存对比)
java·后端
蜗牛互联网3 小时前
Computer Use公共预览的安全设计:应用、动作与数据三维门禁
java·人工智能·后端·安全·策略模式