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 与消息服务分层清晰,离线消息、已读状态、多租户前缀都考虑到了。

相关推荐
金銀銅鐵5 小时前
[Java] 用 GUI 展示 EnumSet 增减元素时的内部变化(上)
java·后端·ai编程
念何架构之路5 小时前
go-grpc设计思想
开发语言·后端·golang
system_error_x6 小时前
SpringBoot 集成 Thymeleaf 简洁入门
java·spring boot·后端
步行cgn7 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
码事漫谈8 小时前
AI 读你的提示词,其实很没耐心
后端
用户8356290780519 小时前
如何使用 Python 格式化 Excel 单元格中的长文本
后端·python
我命由我123459 小时前
Python 开发 - Python 的 pathlib
java·开发语言·后端·python·学习·java-ee·学习方法
IT_陈寒9 小时前
Java性能优化完全指南
前端·人工智能·后端
电子IT笔记9 小时前
对接群晖NAS,上传和删除踩坑记录
前端·后端
Mk598789 小时前
P4:把 Agent 的记忆搬进 Redis —— AgentScope-Java 工程化实战
后端