33. SignalR 实时通信与在线用户
一句话定位:Admin.NET 用 Furion [MapHub] + 强类型 Hub 接口 IOnlineUserHub 实现了"在线用户表 + 强制下线 + 站内消息"三件套;服务端任何地方注入 IHubContext<OnlineUserHub, IOnlineUserHub> 就能主动推送。
一、注册与路由
Admin.NET.Core/SignalR/SignalRSetup.cs ── services.AddSignalR(options).AddJsonProtocol(...)
── 若 Cache=Redis 且 Cluster.Enabled=true → AddStackExchangeRedis(背板)
Admin.NET.Core/Hub/OnlineUserHub.cs ── [MapHub("/hubs/onlineUser")] 声明路由
Admin.NET.Web.Core/Startup.cs:538 ── endpoints.MapHubs() 批量注册所有 MapHub
SignalRSetup.AddSignalR与Startup.cs中另有一段services.AddSignalR(...)配置。两处的KeepAliveInterval/ClientTimeoutInterval取值不同(后者为 15s/30s),实际以后注册生效者为准,生产环境请统一写在SignalRSetup.cs一处,避免改了半天没生效。
Hub 声明
// Admin.NET.Core/Hub/OnlineUserHub.cs
[MapHub("/hubs/onlineUser")]
public class OnlineUserHub : Hub<IOnlineUserHub>
{
private const string GROUP_ONLINE = "GROUP_ONLINE_"; // 租户分组前缀
...
}强类型客户端接口(Admin.NET.Core/Hub/IOnlineUserHub.cs):
public interface IOnlineUserHub
{
Task OnlineUserList(OnlineUserList context); // 在线用户列表推送
Task ForceOffline(object context); // 强制下线
Task PublicNotice(SysNotice context); // 发布站内消息
Task ReceiveMessage(object context); // 接收消息
}好处:服务端调用方编译期就能校验方法名与参数,避免字符串 SDK 的拼写事故。
二、OnlineUserHub 源码讲解
2.1 连接建立
public override async Task OnConnectedAsync()
{
var httpContext = Context.GetHttpContext();
var userId = (httpContext.User.FindFirst(ClaimConst.UserId)?.Value).ToLong();
var account = httpContext.User.FindFirst(ClaimConst.Account)?.Value;
if (userId < 0 || string.IsNullOrWhiteSpace(account)) return; // 未认证直接忽略
var realName = httpContext.User.FindFirst(ClaimConst.RealName)?.Value;
var tenantId = (httpContext.User.FindFirst(ClaimConst.TenantId)?.Value).ToLong();
var loginMode = (LoginModeEnum)(httpContext.User.FindFirst(ClaimConst.LoginMode)?.Value).ToInt();
loginMode = loginMode == 0 ? LoginModeEnum.PC : loginMode; // 默认 PC
var (os, browser) = CommonHelper.GetClientDeviceInfo(httpContext.Request.Headers.UserAgent);
var remoteIp = httpContext.Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(remoteIp)) remoteIp = httpContext.GetRemoteIpAddressToIPv4(true);
var user = new OnlineUser { ConnectionId = Context.ConnectionId, UserId = userId, UserName = account,
RealName = realName, Time = DateTime.Now, Ip = remoteIp, Browser = browser,
Os = os, TenantId = tenantId, LoginMode = loginMode, Device = $"{os}|{browser}" };
_sysCacheService.HashAdd(CacheConst.KeyUserOnline, Context.ConnectionId, user); // Hash 缓存在线用户
// 以租户Id进行分组
var groupName = $"{GROUP_ONLINE}{user.TenantId}";
await _onlineUserHubContext.Groups.AddToGroupAsync(Context.ConnectionId, groupName);
// 更新在线用户列表(只推本租户)
var userList = _sysCacheService.HashGetAll<OnlineUser>(CacheConst.KeyUserOnline)
.Where(u => u.TenantId == tenantId).ToList();
await _onlineUserHubContext.Clients.Groups(groupName).OnlineUserList(new OnlineUserList
{
RealName = user.RealName, Ip = user.Ip, Online = true, UserList = userList
});
}要点:
- 在线用户不存在数据库,全部放 Redis/内存 Hash
sys_user_online,field = ConnectionId - 按租户分组:
GROUP_ONLINE_{tenantId},广播天然隔离租户 X-Forwarded-For优先,Nginx 反代要透传该头(框架已在Startup.cs配置ForwardedHeaders.All)
2.2 断开
public override async Task OnDisconnectedAsync(Exception exception)
{
if (string.IsNullOrEmpty(Context.ConnectionId)) return;
var user = _sysCacheService.HashGetOne<OnlineUser>(CacheConst.KeyUserOnline, Context.ConnectionId);
if (user == null) return;
_sysCacheService.HashDel<OnlineUser>(CacheConst.KeyUserOnline, Context.ConnectionId);
// 同租户再推一次 OnlineUserList(Online=false)
}2.3 Hub 可被客户端调用的方法
public async Task ForceOffline(OnlineUserHubInput input)
=> await _onlineUserHubContext.Clients.Client(input.ConnectionId).ForceOffline("强制下线");
public async Task SendMessageToUser(MessageInput message)
=> await _sysMessageService.SendUser(message);
public async Task SendMessageToAllUser(MessageInput message)
=> await _sysMessageService.SendAllUser(message);三、按租户分组推送
分组名规则是私有的 GROUP_ONLINE_ + TenantId。服务层无法引用该常量,因此统一走 SysOnlineUserService 或在自己代码里按同样格式拼:
private static string GroupName(long tenantId) => $"GROUP_ONLINE_{tenantId}";推荐做法:不要自己拼,用已封装好的服务(见下一节),避免分组名漂移。
四、服务端主动推送(IHubContext)
Admin.NET.Core/Service/OnlineUser/SysOnlineUserService.cs 是标准范例:
[ApiDescriptionSettings(Order = 300, Description = "在线用户")]
public class SysOnlineUserService : IDynamicApiController, ITransient
{
private readonly UserManager _userManager;
private readonly SysConfigService _sysConfigService;
private readonly SysCacheService _sysCacheService;
private readonly IHubContext<OnlineUserHub, IOnlineUserHub> _onlineUserHubContext;
/// <summary>获取在线用户列表 🔖</summary>
[DisplayName("获取在线用户列表")]
public async Task<List<OnlineUser>> GetOnlineUserList()
{
var userList = _sysCacheService.HashGetAll<OnlineUser>(CacheConst.KeyUserOnline)
.Where(u => u.TenantId == _userManager.TenantId).ToList();
return await Task.FromResult(userList);
}
/// <summary>强制下线 🔖</summary>
[NonValidation]
[DisplayName("强制下线")]
public async Task ForceOffline(OnlineUser user)
=> await _onlineUserHubContext.Clients.Client(user.ConnectionId ?? "").ForceOffline("强制下线");
/// <summary>通过用户Id踢掉在线用户</summary>
[NonAction]
public async Task ForceOfflineByUserId(long userId) { /* 遍历 hash 里 UserId 匹配项逐个 ForceOffline */ }
/// <summary>发布站内消息给指定用户</summary>
[NonAction]
public async Task PublicNotice(SysNotice notice, List<long> userIds)
{
var userList = _sysCacheService.HashGetAll<OnlineUser>(CacheConst.KeyUserOnline)
.Where(u => userIds.Contains(u.UserId)).ToList();
foreach (var item in userList)
await _onlineUserHubContext.Clients.Client(item.ConnectionId ?? "").PublicNotice(notice);
}
/// <summary>单用户登录:把同 LoginMode 的旧连接踢掉</summary>
[NonAction]
public async Task SingleLogin(long userId, LoginModeEnum loginMode)
{
if (!await _sysConfigService.GetConfigValueByCode<bool>(ConfigConst.SysSingleLogin)) return;
var users = _sysCacheService.HashGetAll<OnlineUser>(CacheConst.KeyUserOnline)
.Where(u => u.UserId == userId).ToList();
foreach (var user in users)
if (user.LoginMode == loginMode) await ForceOffline(user);
}
}在自己的业务里推一条自定义消息
因为 IOnlineUserHub 已有 ReceiveMessage(object context),直接复用:
public class BizAlarmService : ITransient
{
private readonly IHubContext<OnlineUserHub, IOnlineUserHub> _hub;
private readonly SysCacheService _sysCacheService;
public BizAlarmService(IHubContext<OnlineUserHub, IOnlineUserHub> hub, SysCacheService sysCacheService)
{
_hub = hub; _sysCacheService = sysCacheService;
}
/// <summary>给指定用户推一条设备告警</summary>
public async Task PushAlarm(long userId, object payload)
{
var connIds = _sysCacheService.HashGetAll<OnlineUser>(CacheConst.KeyUserOnline)
.Where(u => u.UserId == userId)
.Select(u => u.ConnectionId).ToList();
if (connIds.Count == 0) return; // 离线:落库走站内信即可
await _hub.Clients.Clients(connIds).ReceiveMessage(new { type = "alarm", data = payload });
}
/// <summary>给当前租户全员广播(例如系统公告)</summary>
public async Task PushTenantNotice(long tenantId, object payload)
=> await _hub.Clients.Group($"GROUP_ONLINE_{tenantId}").ReceiveMessage(payload);
}
Clients.Client(id)/Clients.Clients(ids)/Clients.Group(name)/Clients.All四种粒度,按场景挑。跨租户广播请用Clients.All再在客户端过滤,或为每个租户单独 Group。
五、前端连接与 Token 传参
ASP.NET Core SignalR 的 WebSocket 无法携带自定义 Header,标准做法是用 query string:
import * as signalR from '@microsoft/signalr';
import { Session } from '/@/utils/storage';
const connection = new signalR.HubConnectionBuilder()
.withUrl(`${import.meta.env.VITE_API_URL}/hubs/onlineUser?access_token=${Session.get('token')}`, {
skipNegotiation: true, // 直连 WebSocket,跳过 negotiate
transport: signalR.HttpTransportType.WebSockets,
})
.withAutomaticReconnect([0, 2000, 5000, 10000, 30000]) // 重连退避
.configureLogging(signalR.LogLevel.Warning)
.build();
// 四个服务端回调(对应 IOnlineUserHub 的四个方法)
connection.on('OnlineUserList', (ctx) => {
// ctx: { realName, ip, online, userList }
});
connection.on('ForceOffline', (ctx) => {
// 被踢:清 token 并跳登录
ElMessageBox.alert('您的账号已在其它地方登录,即将退出!', '提示', { type: 'warning' })
.then(() => { Session.clear(); window.location.href = '/login'; });
});
connection.on('PublicNotice', (notice) => { /* 右下角公告弹窗 */ });
connection.on('ReceiveMessage', (ctx) => { /* 业务消息 */ });
await connection.start();服务端为何能读到 token?因为 SignalR 走的是 ASP.NET Core 管道,Context.GetHttpContext().User 已由 JWT 中间件填充,query 里的 access_token 会被 Furion/ASP.NET Core 的 JWT Bearer 处理器识别并在 startup 时搬到 ClaimsPrincipal。
跨域记得打开(框架默认已开):
// App.json
"CorsAccessorSettings": {
"WithExposedHeaders": [ "Content-Disposition", "access-token", "x-access-token", ... ],
"SignalRSupport": true // ← 关键,允许 SignalR 跨域
}六、集群 Redis 背板配置
Admin.NET.Core/SignalR/SignalRSetup.cs 的生效条件很明确——两个开关都要开:
// 若未启用 Redis 缓存,直接返回
var cacheOptions = App.GetConfig<CacheOptions>("Cache", true);
if (cacheOptions.CacheType != CacheTypeEnum.Redis.ToString()) return;
// 若未开启集群配置,直接返回
var clusterOpt = App.GetConfig<ClusterOptions>("Cluster", true);
if (!clusterOpt.Enabled) return;配置在 Admin.NET.Application/Configuration/Cache.json:
{
"Cache": {
"CacheType": "Redis",
"Redis": { "ConnectionString": "127.0.0.1:6379,password=,defaultDatabase=1", ... }
},
"Cluster": {
"Enabled": false, // ← 改成 true 才会接背板
"ServerId": "adminnet",
"ServerIp": "",
"SignalR": {
"RedisConfiguration": "127.0.0.1:6379,ssl=false,password=,defaultDatabase=5",
"ChannelPrefix": "signalrPrefix_"
},
"DataProtecteKey": "AdminNet:DataProtection-Keys",
"IsSentinel": false,
"SentinelConfig": {
"DefaultDb": "4",
"EndPoints": [ /* "10.10.0.124:26380" */ ],
"MainPrefix": "adminNet:",
"Password": "123456",
"SentinelPassword": "adminNet",
"ServiceName": "adminNet",
"SignalRChannelPrefix": "signalR:"
}
}
}背板接入后 signalRBuilder.AddStackExchangeRedis(...) 会把所有节点的消息走 Redis Pub/Sub 转发,多副本部署时 A 节点也能推到 B 节点上的连接。同时还会注册 PersistKeysToStackExchangeRedis,用于 DataProtection 密钥共享。
源码注释警告(务必读):
// 此处设置的 ChannelPrefix 并不会生效,如果两个不同的项目,且[程序集名+类名]一样,
// 使用同一个 redis 服务,请注意修改 Hub/OnlineUserHub 的类名。
options.Configuration.ChannelPrefix = new RedisChannel(clusterOpt.SignalR.ChannelPrefix, RedisChannel.PatternMode.Auto);也就是说:同一 Redis 上跑多个 Admin.NET 实例/项目时,必须改 OnlineUserHub 的类名,否则 Hub 名冲突会导致串消息。
七、完整示例:给模块加一条实时通知
// 1. 实体:SysNotice 已有,直接用
// 2. 发布:业务完成时调用
public class BizAuditService : IDynamicApiController, ITransient
{
private readonly SysOnlineUserService _onlineUserService;
public BizAuditService(SysOnlineUserService onlineUserService) => _onlineUserService = onlineUserService;
/// <summary>
/// 审批通过 🔖
/// </summary>
[DisplayName("审批通过")]
public async Task Approve(BaseIdInput input)
{
// ... 业务处理
var notice = new SysNotice
{
Title = "审批结果",
Content = $"单据 {input.Id} 已审批通过",
Type = NoticeTypeEnum.Notice,
Status = NoticeStatusEnum.Published
};
// 只推给发起人(可离线,离线时不推)
await _onlineUserService.PublicNotice(notice, new List<long> { creatorId });
}
}八、步骤清单
- 确认
Startup.cs里endpoints.MapHubs()已调用(框架默认有) - 新建 Hub 时贴
[MapHub("/hubs/xxx")],并继承Hub<IXxxHub>用强类型接口 - 需要服务端推送的地方注入
IHubContext<XxxHub, IXxxHub> - 在线状态一律写
CacheConst.KeyUserOnlineHash,别自己维护字典(集群下会不一致) - 前端用
@microsoft/signalr,token 走 query string,skipNegotiation: true - 集群部署:改
Cache.json的Cache.CacheType=Redis且Cluster.Enabled=true - 同 Redis 多项目:改 Hub 类名避免 Channel 冲突
⚠️ 注意事项 / 常见坑
OnlineUserHub里注入的是IHubContext<OnlineUserHub, IOnlineUserHub>,不是Clients。Hub 实例本身生命周期是每次调用一个,不能直接拿Clients做长时间持有;要跨请求推送必须注入IHubContext。- 在线用户缓存 key 全系统只有一份(
sys_user_online),没有按租户分 key,所以每个查询都要.Where(u => u.TenantId == xxx)过滤——框架里三处都这么写了,你新写的代码别忘。 - 单实例内存缓存在集群下不成立:
CacheType=Memory时,A 节点不知道 B 节点的在线连接,ForceOfflineByUserId只能踢掉本节点的。 skipNegotiation: true与 Windows 认证/Sticky Session:开了它就不需要会话粘滞;如果关掉 negotiate(默认 false)走 SSE/long-polling,负载均衡必须开会话保持。MaximumReceiveMessageSize = 1024 * 1024 * 10(10M),超长 payload 会直接断连;大对象请分批或走对象存储 URL。- Nginx 要支持 WebSocket 升级:
proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";,并透传X-Forwarded-For(否则在线 IP 全是网关 IP)。 Clients.All跨租户:在线用户按租户分组,但ReceiveMessage拉了所有人;广播前先按userList.Where(u => u.TenantId == 目标租户)过滤。- 心跳配置两处打架:
SignalRSetup.cs写的是ClientTimeoutInterval=2min / KeepAliveInterval=1min,Startup.cs写的是 30s/15s。改之前先确认哪段代码实际生效。 - Hub 方法里的异常不会走
AdminNETResultProvider:SignalR 不走 MVC 管线,出错不会包成{code,message},务必自己 try/catch 并写日志。 - token 过期后连接仍在线:SignalR 建立后不会每次消息重新鉴权,长时间连接要自己配合
TokenVersion校验或定期重连。
小结
- Hub:
[MapHub("/hubs/onlineUser")]+Hub<IOnlineUserHub>强类型 - 在线状态存
sys_user_onlineHash(field=ConnectionId),按GROUP_ONLINE_{tenantId}分组广播 - 服务端推送靠注入
IHubContext<OnlineUserHub, IOnlineUserHub>,SysOnlineUserService已封装 ForceOffline / SingleLogin / PublicNotice - 前端
@microsoft/signalr+ query string 传 token +skipNegotiation - 集群背板需同时满足:
Cache.CacheType=Redis且Cluster.Enabled=true,且注意 Hub 类名冲突