Skip to content

实时通知

实时通知默认是关的,SmartAdmin:Realtime:Enabled 给的默认值就是 false。关着的时候,整套系统行为不变:公告角标照样 30 秒轮询一次,被强退的人也照样等到下次请求才吃 401。开启只是把这两处从「最终一致」提成「即时」,所以它是增强,不是新契约。

开关与配置

jsonc
{
  "SmartAdmin": {
    "Realtime": {
      "Enabled": true,             // 默认 false
      "HubPath": "/hub/realtime"   // 默认值,改它要连前端一起改,见下文
    }
  }
}

关闭时内核不调用 AddSignalR()、不映射 Hub、不建任何长连接,IRealtimePublisher 落在 Services 层的 NoopRealtimePublisher 上。业务代码照调不误,只是什么也不发生。

SignalR 属于 ASP.NET Core 共享框架,FrameworkReference 里本来就有。所以这套东西没有引入任何新的 NuGet 依赖。运行时只依赖 SqlSugarCore 加 Microsoft.* 这条红线没破,它才被放进内核,而不是做成可选包。

推什么

内核只推两个事件,都是纯信号,不带正文:

事件谁触发前端收到后做什么
notice-changedNoticeService 发布公告重拉未读角标
force-logoutSessionService.RevokeAsync清会话、提示、跳登录页

负载留给客户端回查,是刻意的。推送只负责喊一声「有变化了」,具体内容仍走原来的接口。这么设计,推送通道挂了也只是退回轮询,不会冒出「界面上有一条谁也查不到的公告」。

Hub 上没有任何客户端可调的方法,它是单向的。

契约

csharp
public interface IRealtimePublisher
{
    Task NotifyUserAsync(long userId, string @event, object? data = null, CancellationToken cancellationToken = default);
    Task NotifyAllAsync(string @event, object? data = null, CancellationToken cancellationToken = default);
    Task NotifySessionAsync(string sessionId, string @event, object? data = null, CancellationToken cancellationToken = default);
}

连接建立时,SmartHub.OnConnectedAsync 按 claims 把这条连接加进两个组:user-{sub}session-{sid}。三个方法分别推给一个用户的所有在线端、所有人、以及某一次登录。

强退推给会话,不推给用户

这是三个方法里 NotifySessionAsync 存在的理由。管理员在「在线用户」里踢掉的是一次登录,不是一个人。同一个账号在手机上还开着的那个会话,不该被连累。所以 force-logout 只推 session-{sid}

触发点收在 SessionService.RevokeAsync 一处。强退、超并发收敛、刷新令牌复用检测,还有停用删号走的 RevokeAllForUserAsync,最后都汇到这里。接一处,就全覆盖了。

鉴权:令牌走 query

Hub 带 [Authorize],走的还是 JwtBearer。但浏览器的 WebSocket 握手带不了 Authorization 头,所以令牌只能放 query:

/hub/realtime?access_token=<token>

OnMessageReceived 里对这个 query 参数的采信限定在 Hub 路径下:

csharp
var accessToken = ctx.Request.Query["access_token"];
if (!string.IsNullOrEmpty(accessToken) &&
    ctx.HttpContext.Request.Path.StartsWithSegments(options.Realtime.HubPath))
    ctx.Token = accessToken;

普通 API 的取令牌方式一点没放宽。令牌进了 URL,就可能落进网关访问日志。把这个口子限死在一条路径上,是为了让这份代价只由 Hub 承担。

多副本:内置实现是进程内的

SignalRRealtimePublisherIHubContext<SmartHub> 推送,只到连在同一个副本上的连接。两个副本时,A 副本发的公告推不到连在 B 上的人。

内核不给它配 backplane。那要引 Redis,而 Redis 在这里是可选包。降级路径本来就留着。公告有 30 秒轮询兜底,最终一致;跨副本的 force-logout 退回惰性 401。要即时,消费方自己给 SignalR 叠一层:

csharp
builder.Services.AddSignalR().AddStackExchangeRedis("localhost:6379");

替换

真实现在 AddSmartAdminServices() 之前用 TryAddSingleton 注册,压过 Services 层的 Noop。消费方想换自己的 IRealtimePublisher,比如推给 MQ 而不是 SignalR,照旧在 AddSmartAdmin() 之前注册,先到者胜:

csharp
builder.Services.AddSingleton<IRealtimePublisher, MyMqPublisher>();
builder.Services.AddSmartAdmin(builder.Configuration);

这条替换路径被 ReplaceabilityTests 锁着。

前端

web/packages/admin/src/composables/useRealtime.ts 是客户端,在鉴权外壳 default.vue 挂载时 start()、卸载时 stop()@microsoft/signalr 版本对齐 .NET 10。

应用自己推的事件,在前端用 onRealtime(event, handler) 挂到这条内置连接上。它从 smart-admin-web 导入,返回值是退订函数。别另建一条连接:那样一个在线用户会占两条 WebSocket,推给这个用户的每个事件都要投递两遍。

HubPath 改了前端不会跟着改

客户端里的路径是写死的 `${runtime.apiBase}/hub/realtime`。后端把 SmartAdmin:Realtime:HubPath 改成别的,前端连不上,却也不会报错,只会当成「实时没开」,静默退回轮询。要改路径,就得两边一起改。

初次连接失败是静默的,这是设计。后端没开实时的时候,Hub 路径返 404。客户端不重试、不刷屏,直接退回 NoticeBell 的 30 秒轮询和「下次请求 401」的惰性登出。所以前后端开关状态对不上时,表现是功能降级,不是报错。

dev 环境下 Vite 要代理 /hub 并打开 ws: true,这一条已经写在前端模板的 vite.config.ts 里。

没做的验证

测试工程没有引 Microsoft.AspNetCore.SignalR.Client 去跑真的 HubConnection。单测锁的是接线:默认走 Noop、RevokeAsyncPublishAsync 会触发推送、开启后 Hub 返 401 而非 404、关闭后返 404,以及 IRealtimePublisherReplaceabilityTests 里的可替换性。

推送全链路是用一次性 Node 脚本直连 MinimalHost 冒烟过的,不在 CI 里。改动这一块时心里有数:接线有测试兜着,传输层没有。

基于 Apache License 2.0 开源