chore: 升级依赖并优化种子

This commit is contained in:
2025-12-04 17:30:09 +08:00
parent f50d2d4bb2
commit 1d7836a173
16 changed files with 163 additions and 75 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ obj/
**/bin/ **/bin/
**/obj/ **/obj/
.claude/ .claude/
*.log

View File

@@ -1,7 +1,7 @@
{ {
"App": { "App": {
"Seed": { "Seed": {
"Enabled": true, "Enabled": false,
"DefaultTenant": { "DefaultTenant": {
"TenantId": 1000000000001, "TenantId": 1000000000001,
"Code": "demo", "Code": "demo",
@@ -39,6 +39,7 @@
}, },
"Identity": { "Identity": {
"AdminSeed": { "AdminSeed": {
"Enabled": false,
"RoleTemplates": [ "RoleTemplates": [
{ {
"TemplateCode": "platform-admin", "TemplateCode": "platform-admin",

View File

@@ -1,4 +1,6 @@
using FluentValidation;
using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.Extensions.Caching.StackExchangeRedis;
using OpenTelemetry.Metrics; using OpenTelemetry.Metrics;
using OpenTelemetry.Resources; using OpenTelemetry.Resources;
using OpenTelemetry.Trace; using OpenTelemetry.Trace;
@@ -14,6 +16,7 @@ using TakeoutSaaS.Shared.Abstractions.Ids;
using TakeoutSaaS.Shared.Kernel.Ids; using TakeoutSaaS.Shared.Kernel.Ids;
using TakeoutSaaS.Shared.Web.Extensions; using TakeoutSaaS.Shared.Web.Extensions;
using TakeoutSaaS.Shared.Web.Swagger; using TakeoutSaaS.Shared.Web.Swagger;
using System.Reflection;
// 1. 创建构建器与日志模板 // 1. 创建构建器与日志模板
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -43,6 +46,14 @@ builder.Services.AddSharedSwagger(options =>
options.Description = "小程序 API 文档"; options.Description = "小程序 API 文档";
options.EnableAuthorization = true; options.EnableAuthorization = true;
}); });
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
// 4. 注册 Redis 分布式缓存,供验证码等功能使用
var redisConnection = builder.Configuration.GetValue<string>("Redis");
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = redisConnection;
});
// 4. 注册多租户与业务模块 // 4. 注册多租户与业务模块
builder.Services.AddTenantResolution(builder.Configuration); builder.Services.AddTenantResolution(builder.Configuration);

View File

@@ -18,7 +18,7 @@ public static class AppApplicationServiceCollectionExtensions
/// <returns>服务集合。</returns> /// <returns>服务集合。</returns>
public static IServiceCollection AddAppApplication(this IServiceCollection services) public static IServiceCollection AddAppApplication(this IServiceCollection services)
{ {
services.AddMediatR(Assembly.GetExecutingAssembly()); services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

View File

@@ -7,6 +7,11 @@ namespace TakeoutSaaS.Infrastructure.Identity.Options;
/// </summary> /// </summary>
public sealed class AdminSeedOptions public sealed class AdminSeedOptions
{ {
/// <summary>
/// 是否启用后台账号与权限种子。
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary> /// <summary>
/// 初始用户列表。 /// 初始用户列表。
/// </summary> /// </summary>

View File

@@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Npgsql;
using TakeoutSaaS.Infrastructure.Identity.Options; using TakeoutSaaS.Infrastructure.Identity.Options;
using TakeoutSaaS.Shared.Abstractions.Tenancy; using TakeoutSaaS.Shared.Abstractions.Tenancy;
using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser; using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser;
@@ -29,6 +30,11 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<DomainIdentityUser>>(); var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<DomainIdentityUser>>();
var tenantContextAccessor = scope.ServiceProvider.GetRequiredService<ITenantContextAccessor>(); var tenantContextAccessor = scope.ServiceProvider.GetRequiredService<ITenantContextAccessor>();
if (!options.Enabled)
{
logger.LogInformation("AdminSeed 已禁用,跳过后台账号初始化");
return;
}
await context.Database.MigrateAsync(cancellationToken); await context.Database.MigrateAsync(cancellationToken);
if (options.Users is null or { Count: 0 }) if (options.Users is null or { Count: 0 })
@@ -127,15 +133,35 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger
.Where(ur => ur.TenantId == userOptions.TenantId && ur.UserId == user.Id) .Where(ur => ur.TenantId == userOptions.TenantId && ur.UserId == user.Id)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
context.UserRoles.RemoveRange(existingUserRoles); context.UserRoles.RemoveRange(existingUserRoles);
await context.SaveChangesAsync(cancellationToken);
var roleIds = roleEntities.Select(r => r.Id).Distinct().ToArray(); var roleIds = roleEntities.Select(r => r.Id).Distinct().ToArray();
var userRoles = roleIds.Select(roleId => new DomainUserRole foreach (var roleId in roleIds)
{ {
TenantId = userOptions.TenantId, try
UserId = user.Id, {
RoleId = roleId var alreadyExists = await context.UserRoles.AnyAsync(
}); ur => ur.TenantId == userOptions.TenantId && ur.UserId == user.Id && ur.RoleId == roleId,
await context.UserRoles.AddRangeAsync(userRoles, cancellationToken); cancellationToken);
if (alreadyExists)
{
continue;
}
await context.UserRoles.AddAsync(new DomainUserRole
{
TenantId = userOptions.TenantId,
UserId = user.Id,
RoleId = roleId
}, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException ex) when (ex.InnerException is PostgresException pg && pg.SqlState == PostgresErrorCodes.UniqueViolation)
{
context.ChangeTracker.Clear();
}
}
// 为种子角色绑定种子权限 // 为种子角色绑定种子权限
if (permissions.Length > 0 && roleIds.Length > 0) if (permissions.Length > 0 && roleIds.Length > 0)
@@ -145,14 +171,41 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger
.Where(rp => rp.TenantId == userOptions.TenantId && roleIds.Contains(rp.RoleId)) .Where(rp => rp.TenantId == userOptions.TenantId && roleIds.Contains(rp.RoleId))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
context.RolePermissions.RemoveRange(existingRolePermissions); context.RolePermissions.RemoveRange(existingRolePermissions);
await context.SaveChangesAsync(cancellationToken);
var newRolePermissions = roleIds.SelectMany(roleId => permissionIds.Select(permissionId => new DomainRolePermission var distinctRoleIds = roleIds.Distinct().ToArray();
var distinctPermissionIds = permissionIds.Distinct().ToArray();
foreach (var roleId in distinctRoleIds)
{ {
TenantId = userOptions.TenantId, foreach (var permissionId in distinctPermissionIds)
RoleId = roleId, {
PermissionId = permissionId try
})); {
await context.RolePermissions.AddRangeAsync(newRolePermissions, cancellationToken); var exists = await context.RolePermissions.AnyAsync(
rp => rp.TenantId == userOptions.TenantId
&& rp.RoleId == roleId
&& rp.PermissionId == permissionId,
cancellationToken);
if (exists)
{
continue;
}
await context.RolePermissions.AddAsync(new DomainRolePermission
{
TenantId = userOptions.TenantId,
RoleId = roleId,
PermissionId = permissionId
}, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException ex) when (ex.InnerException is PostgresException pg && pg.SqlState == PostgresErrorCodes.UniqueViolation)
{
context.ChangeTracker.Clear();
}
}
}
} }
} }
@@ -209,15 +262,33 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
context.RoleTemplatePermissions.RemoveRange(existingPermissions); context.RoleTemplatePermissions.RemoveRange(existingPermissions);
var toAdd = permissionCodes.Select(code => new DomainRoleTemplatePermission
{
RoleTemplateId = existing.Id,
PermissionCode = code
});
await context.RoleTemplatePermissions.AddRangeAsync(toAdd, cancellationToken);
await context.SaveChangesAsync(cancellationToken); await context.SaveChangesAsync(cancellationToken);
var distinctPermissionCodes = permissionCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
foreach (var permissionCode in distinctPermissionCodes)
{
try
{
var alreadyExists = await context.RoleTemplatePermissions.AnyAsync(
x => x.RoleTemplateId == existing.Id && x.PermissionCode == permissionCode,
cancellationToken);
if (alreadyExists)
{
continue;
}
await context.RoleTemplatePermissions.AddAsync(new DomainRoleTemplatePermission
{
RoleTemplateId = existing.Id,
PermissionCode = permissionCode
}, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException ex) when (ex.InnerException is PostgresException pg && pg.SqlState == PostgresErrorCodes.UniqueViolation)
{
context.ChangeTracker.Clear();
}
}
} }
} }

View File

@@ -12,7 +12,7 @@ public sealed class RabbitMqConnectionFactory(IOptionsMonitor<RabbitMqOptions> o
/// <summary> /// <summary>
/// 创建连接。 /// 创建连接。
/// </summary> /// </summary>
public IConnection CreateConnection() public Task<IConnection> CreateConnectionAsync(CancellationToken cancellationToken = default)
{ {
var options = optionsMonitor.CurrentValue; var options = optionsMonitor.CurrentValue;
var factory = new ConnectionFactory var factory = new ConnectionFactory
@@ -21,10 +21,9 @@ public sealed class RabbitMqConnectionFactory(IOptionsMonitor<RabbitMqOptions> o
Port = options.Port, Port = options.Port,
UserName = options.Username, UserName = options.Username,
Password = options.Password, Password = options.Password,
VirtualHost = options.VirtualHost, VirtualHost = options.VirtualHost
DispatchConsumersAsync = true
}; };
return factory.CreateConnection(); return factory.CreateConnectionAsync(cancellationToken);
} }
} }

View File

@@ -14,41 +14,42 @@ public sealed class RabbitMqMessagePublisher(RabbitMqConnectionFactory connectio
: IMessagePublisher, IAsyncDisposable : IMessagePublisher, IAsyncDisposable
{ {
private IConnection? _connection; private IConnection? _connection;
private IModel? _channel; private IChannel? _channel;
private bool _disposed; private bool _disposed;
/// <inheritdoc /> /// <inheritdoc />
public Task PublishAsync<T>(string routingKey, T message, CancellationToken cancellationToken = default) public async Task PublishAsync<T>(string routingKey, T message, CancellationToken cancellationToken = default)
{ {
// 1. 确保通道可用 // 1. 确保通道可用
EnsureChannel(); await EnsureChannelAsync(cancellationToken);
var options = optionsMonitor.CurrentValue; var options = optionsMonitor.CurrentValue;
var channel = _channel ?? throw new InvalidOperationException("RabbitMQ channel is not available."); var channel = _channel ?? throw new InvalidOperationException("RabbitMQ channel is not available.");
// 2. 声明交换机 // 2. 声明交换机
channel.ExchangeDeclare(options.Exchange, options.ExchangeType, durable: true, autoDelete: false); await channel.ExchangeDeclareAsync(options.Exchange, options.ExchangeType, durable: true, autoDelete: false, cancellationToken: cancellationToken);
// 3. 序列化消息并设置属性 // 3. 序列化消息并设置属性
var body = serializer.Serialize(message); var body = serializer.Serialize(message);
var props = channel.CreateBasicProperties(); var props = new BasicProperties
props.ContentType = "application/json"; {
props.DeliveryMode = 2; ContentType = "application/json",
props.MessageId = Guid.NewGuid().ToString("N"); DeliveryMode = DeliveryModes.Persistent,
MessageId = Guid.NewGuid().ToString("N")
};
// 4. 发布消息 // 4. 发布消息
channel.BasicPublish(options.Exchange, routingKey, props, body); await channel.BasicPublishAsync(options.Exchange, routingKey, mandatory: false, props, body, cancellationToken);
logger.LogDebug("发布消息到交换机 {Exchange} RoutingKey {RoutingKey}", options.Exchange, routingKey); logger.LogDebug("发布消息到交换机 {Exchange} RoutingKey {RoutingKey}", options.Exchange, routingKey);
return Task.CompletedTask;
} }
private void EnsureChannel() private async Task EnsureChannelAsync(CancellationToken cancellationToken)
{ {
if (_channel != null && _channel.IsOpen) if (_channel != null && _channel.IsOpen)
{ {
return; return;
} }
_connection ??= connectionFactory.CreateConnection(); _connection ??= await connectionFactory.CreateConnectionAsync(cancellationToken);
_channel = _connection.CreateModel(); _channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken);
} }
/// <summary> /// <summary>

View File

@@ -15,32 +15,32 @@ public sealed class RabbitMqMessageSubscriber(RabbitMqConnectionFactory connecti
: IMessageSubscriber : IMessageSubscriber
{ {
private IConnection? _connection; private IConnection? _connection;
private IModel? _channel; private IChannel? _channel;
private bool _disposed; private bool _disposed;
/// <inheritdoc /> /// <inheritdoc />
public async Task SubscribeAsync<T>(string queue, string routingKey, Func<T, CancellationToken, Task<bool>> handler, CancellationToken cancellationToken = default) public async Task SubscribeAsync<T>(string queue, string routingKey, Func<T, CancellationToken, Task<bool>> handler, CancellationToken cancellationToken = default)
{ {
// 1. 确保通道可用 // 1. 确保通道可用
EnsureChannel(); await EnsureChannelAsync(cancellationToken);
var options = optionsMonitor.CurrentValue; var options = optionsMonitor.CurrentValue;
var channel = _channel ?? throw new InvalidOperationException("RabbitMQ channel is not available."); var channel = _channel ?? throw new InvalidOperationException("RabbitMQ channel is not available.");
// 2. 声明交换机、队列及绑定 // 2. 声明交换机、队列及绑定
channel.ExchangeDeclare(options.Exchange, options.ExchangeType, durable: true, autoDelete: false); await channel.ExchangeDeclareAsync(options.Exchange, options.ExchangeType, durable: true, autoDelete: false, cancellationToken: cancellationToken);
channel.QueueDeclare(queue, durable: true, exclusive: false, autoDelete: false); await channel.QueueDeclareAsync(queue, durable: true, exclusive: false, autoDelete: false, cancellationToken: cancellationToken);
channel.QueueBind(queue, options.Exchange, routingKey); await channel.QueueBindAsync(queue, options.Exchange, routingKey, cancellationToken: cancellationToken);
channel.BasicQos(0, options.PrefetchCount, global: false); await channel.BasicQosAsync(0, options.PrefetchCount, global: false, cancellationToken: cancellationToken);
// 3. 设置消费者回调 // 3. 设置消费者回调
var consumer = new AsyncEventingBasicConsumer(channel); var consumer = new AsyncEventingBasicConsumer(channel);
consumer.Received += async (_, ea) => consumer.ReceivedAsync += async (_, ea) =>
{ {
var message = serializer.Deserialize<T>(ea.Body.ToArray()); var message = serializer.Deserialize<T>(ea.Body.ToArray());
if (message == null) if (message == null)
{ {
channel.BasicAck(ea.DeliveryTag, multiple: false); await channel.BasicAckAsync(ea.DeliveryTag, multiple: false, cancellationToken);
return; return;
} }
@@ -56,28 +56,27 @@ public sealed class RabbitMqMessageSubscriber(RabbitMqConnectionFactory connecti
if (success) if (success)
{ {
channel.BasicAck(ea.DeliveryTag, multiple: false); await channel.BasicAckAsync(ea.DeliveryTag, multiple: false, cancellationToken);
} }
else else
{ {
channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: false); await channel.BasicNackAsync(ea.DeliveryTag, multiple: false, requeue: false, cancellationToken);
} }
}; };
// 4. 开始消费 // 4. 开始消费
channel.BasicConsume(queue, autoAck: false, consumer); await channel.BasicConsumeAsync(queue, autoAck: false, consumer, cancellationToken);
await Task.CompletedTask.ConfigureAwait(false);
} }
private void EnsureChannel() private async Task EnsureChannelAsync(CancellationToken cancellationToken)
{ {
if (_channel != null && _channel.IsOpen) if (_channel != null && _channel.IsOpen)
{ {
return; return;
} }
_connection ??= connectionFactory.CreateConnection(); _connection ??= await connectionFactory.CreateConnectionAsync(cancellationToken);
_channel = _connection.CreateModel(); _channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken);
} }
/// <inheritdoc /> /// <inheritdoc />

View File

@@ -5,13 +5,13 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.0" />
<PackageReference Include="RabbitMQ.Client" Version="6.6.0" /> <PackageReference Include="RabbitMQ.Client" Version="7.2.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" /> <ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />

View File

@@ -5,14 +5,14 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" /> <PackageReference Include="Hangfire.AspNetCore" Version="1.8.22" />
<PackageReference Include="Hangfire.PostgreSql" Version="1.20.12" /> <PackageReference Include="Hangfire.PostgreSql" Version="1.20.12" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" /> <ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />

View File

@@ -5,13 +5,13 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" /> <ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />