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;