diff --git a/src/Api/TakeoutSaaS.TenantApi/Auth/SignalRJwtEvents.cs b/src/Api/TakeoutSaaS.TenantApi/Auth/SignalRJwtEvents.cs
new file mode 100644
index 0000000..d42af2c
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Auth/SignalRJwtEvents.cs
@@ -0,0 +1,27 @@
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+
+namespace TakeoutSaaS.TenantApi.Auth;
+
+///
+/// SignalR WebSocket 连接的 JWT 认证事件处理。
+///
+public static class SignalRJwtEvents
+{
+ ///
+ /// 从 query string 提取 access_token 供 SignalR Hub 认证使用。
+ ///
+ 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;
+ }
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Hubs/OrderBoardHub.cs b/src/Api/TakeoutSaaS.TenantApi/Hubs/OrderBoardHub.cs
new file mode 100644
index 0000000..2c542f0
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Hubs/OrderBoardHub.cs
@@ -0,0 +1,66 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.SignalR;
+
+namespace TakeoutSaaS.TenantApi.Hubs;
+
+///
+/// 订单大厅实时推送 Hub(只读,所有写操作走 HTTP API)。
+///
+[Authorize]
+public sealed class OrderBoardHub : Hub
+{
+ ///
+ /// 连接建立时,自动加入门店 Group。
+ ///
+ 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();
+ }
+
+ ///
+ /// 连接断开时,移出 Group。
+ ///
+ 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);
+ }
+
+ ///
+ /// 切换门店时调用,移出旧 Group 加入新 Group。
+ ///
+ 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;
+ }
+ }
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Program.cs b/src/Api/TakeoutSaaS.TenantApi/Program.cs
index 306d119..dfd2967 100644
--- a/src/Api/TakeoutSaaS.TenantApi/Program.cs
+++ b/src/Api/TakeoutSaaS.TenantApi/Program.cs
@@ -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();
+ configurator.AddConsumer();
+ configurator.AddConsumer();
+ configurator.AddConsumer();
+
configurator.AddEntityFrameworkOutbox(outbox =>
{
outbox.UsePostgres();
@@ -241,6 +263,7 @@ if (app.Environment.IsDevelopment())
}
app.MapHealthChecks("/healthz");
app.MapPrometheusScrapingEndpoint();
+app.MapHub("/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
{
diff --git a/src/Api/TakeoutSaaS.TenantApi/TakeoutSaaS.TenantApi.csproj b/src/Api/TakeoutSaaS.TenantApi/TakeoutSaaS.TenantApi.csproj
index 258b1b4..34607ab 100644
--- a/src/Api/TakeoutSaaS.TenantApi/TakeoutSaaS.TenantApi.csproj
+++ b/src/Api/TakeoutSaaS.TenantApi/TakeoutSaaS.TenantApi.csproj
@@ -17,6 +17,7 @@
+
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs
index b346321..1b7d8c8 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs
@@ -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;