feat: 添加 SignalR 基础设施(Hub + Redis Backplane + JWT 认证)
Some checks failed
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Has been cancelled
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:
27
src/Api/TakeoutSaaS.TenantApi/Auth/SignalRJwtEvents.cs
Normal file
27
src/Api/TakeoutSaaS.TenantApi/Auth/SignalRJwtEvents.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
66
src/Api/TakeoutSaaS.TenantApi/Hubs/OrderBoardHub.cs
Normal file
66
src/Api/TakeoutSaaS.TenantApi/Hubs/OrderBoardHub.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 需要 AllowCredentials,与 AllowAnyOrigin 互斥,
|
||||
// 因此无配置时使用 SetIsOriginAllowed 替代。
|
||||
policy.SetIsOriginAllowed(_ => true)
|
||||
.AllowCredentials();
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user