feat: 添加 SignalR 基础设施(Hub + Redis Backplane + JWT 认证)
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Has been cancelled

- 添加 Microsoft.AspNetCore.SignalR.StackExchangeRedis NuGet 包
- 新建 OrderBoardHub(连接/断开/切换门店 Group)
- 新建 SignalRJwtEvents(WebSocket query string JWT 提取)
- Program.cs 注册 SignalR + Redis Backplane + MapHub
- JwtAuthenticationExtensions 添加 OnMessageReceived 事件
- CORS 修复 AllowAnyOrigin 与 AllowCredentials 互斥问题
This commit is contained in:
2026-02-27 13:07:51 +08:00
parent 502f80473e
commit 7c06ac3e29
5 changed files with 139 additions and 1 deletions

View File

@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
namespace TakeoutSaaS.TenantApi.Auth;
/// <summary>
/// SignalR WebSocket 连接的 JWT 认证事件处理。
/// </summary>
public static class SignalRJwtEvents
{
/// <summary>
/// 从 query string 提取 access_token 供 SignalR Hub 认证使用。
/// </summary>
public static Task OnMessageReceived(MessageReceivedContext context)
{
// 1. 仅对 Hub 路径生效
if (context.Request.Path.StartsWithSegments("/hubs"))
{
var token = context.Request.Query["access_token"];
if (!string.IsNullOrEmpty(token))
{
context.Token = token;
}
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace TakeoutSaaS.TenantApi.Hubs;
/// <summary>
/// 订单大厅实时推送 Hub只读所有写操作走 HTTP API
/// </summary>
[Authorize]
public sealed class OrderBoardHub : Hub
{
/// <summary>
/// 连接建立时,自动加入门店 Group。
/// </summary>
public override async Task OnConnectedAsync()
{
// 1. 从 JWT claims 提取 tenant_id
var tenantId = Context.User?.FindFirst("tenant_id")?.Value;
var storeId = Context.GetHttpContext()?.Request.Query["storeId"].ToString();
// 2. 加入门店 Group
if (!string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(storeId))
{
var group = $"store:{tenantId}:{storeId}";
await Groups.AddToGroupAsync(Context.ConnectionId, group);
Context.Items["currentGroup"] = group;
}
await base.OnConnectedAsync();
}
/// <summary>
/// 连接断开时,移出 Group。
/// </summary>
public override async Task OnDisconnectedAsync(Exception? exception)
{
// 1. 移出当前 Group
if (Context.Items.TryGetValue("currentGroup", out var groupObj) && groupObj is string group)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, group);
}
await base.OnDisconnectedAsync(exception);
}
/// <summary>
/// 切换门店时调用,移出旧 Group 加入新 Group。
/// </summary>
public async Task JoinStore(string storeId)
{
// 1. 移出旧 Group
if (Context.Items.TryGetValue("currentGroup", out var oldGroupObj) && oldGroupObj is string oldGroup)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, oldGroup);
}
// 2. 加入新 Group
var tenantId = Context.User?.FindFirst("tenant_id")?.Value;
if (!string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(storeId))
{
var newGroup = $"store:{tenantId}:{storeId}";
await Groups.AddToGroupAsync(Context.ConnectionId, newGroup);
Context.Items["currentGroup"] = newGroup;
}
}
}

View File

@@ -7,6 +7,7 @@ using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Serilog;
using StackExchange.Redis;
using TakeoutSaaS.Application.App.Common.Geo;
using TakeoutSaaS.Application.App.Extensions;
using TakeoutSaaS.Application.Dictionary.Extensions;
@@ -29,6 +30,8 @@ using TakeoutSaaS.Shared.Web.Extensions;
using TakeoutSaaS.Shared.Web.Filters;
using TakeoutSaaS.Shared.Web.Security;
using TakeoutSaaS.Shared.Web.Swagger;
using TakeoutSaaS.TenantApi.Consumers;
using TakeoutSaaS.TenantApi.Hubs;
using TakeoutSaaS.TenantApi.Options;
using TakeoutSaaS.TenantApi.Services;
@@ -100,6 +103,19 @@ builder.Services.AddAuthorization();
builder.Services.AddPermissionAuthorization();
builder.Services.AddHealthChecks();
// 5.1 注册 SignalR + Redis Backplane
var signalRBuilder = builder.Services.AddSignalR()
.AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.Converters.Add(new SnowflakeIdJsonConverter());
});
var redisConn = builder.Configuration.GetConnectionString("Redis");
if (!string.IsNullOrWhiteSpace(redisConn))
{
signalRBuilder.AddStackExchangeRedis(redisConn, opt =>
opt.Configuration.ChannelPrefix = RedisChannel.Literal("takeout-signalr"));
}
// 6. 注册应用层与基础设施(仅租户侧所需)
builder.Services.AddAppApplication();
builder.Services.AddIdentityApplication(enableMiniSupport: false);
@@ -118,6 +134,12 @@ builder.Services.AddMessagingApplication();
builder.Services.AddMessagingModule(builder.Configuration);
builder.Services.AddMassTransit(configurator =>
{
// 注册 SignalR 推送消费者
configurator.AddConsumer<OrderCreatedConsumer>();
configurator.AddConsumer<OrderStatusChangedConsumer>();
configurator.AddConsumer<OrderUrgeConsumer>();
configurator.AddConsumer<PaymentSucceededConsumer>();
configurator.AddEntityFrameworkOutbox<TakeoutAppDbContext>(outbox =>
{
outbox.UsePostgres();
@@ -241,6 +263,7 @@ if (app.Environment.IsDevelopment())
}
app.MapHealthChecks("/healthz");
app.MapPrometheusScrapingEndpoint();
app.MapHub<OrderBoardHub>("/hubs/order-board");
app.MapControllers();
app.Run();
@@ -259,7 +282,10 @@ static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins)
{
if (origins.Length == 0)
{
policy.AllowAnyOrigin();
// SignalR 需要 AllowCredentialsAllowAnyOrigin 互斥,
// 因此无配置时使用 SetIsOriginAllowed 替代。
policy.SetIsOriginAllowed(_ => true)
.AllowCredentials();
}
else
{

View File

@@ -17,6 +17,7 @@
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.14.0" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.14.0" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.14.0-beta.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="10.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -47,6 +47,24 @@ public static class JwtAuthenticationExtensions
NameClaimType = ClaimTypes.NameIdentifier,
RoleClaimType = ClaimTypes.Role
};
// SignalR WebSocket 通过 query string 传递 JWT
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
if (context.Request.Path.StartsWithSegments("/hubs"))
{
var token = context.Request.Query["access_token"];
if (!string.IsNullOrEmpty(token))
{
context.Token = token;
}
}
return Task.CompletedTask;
}
};
});
return services;