diff --git a/.gitignore b/.gitignore index 16baddd..90a5c26 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ obj/ **/bin/ **/obj/ .claude/ +*.log diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json index 5e78e4f..6bb47d4 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json @@ -1,7 +1,7 @@ { "App": { "Seed": { - "Enabled": true, + "Enabled": false, "DefaultTenant": { "TenantId": 1000000000001, "Code": "demo", @@ -39,6 +39,7 @@ }, "Identity": { "AdminSeed": { + "Enabled": false, "RoleTemplates": [ { "TemplateCode": "platform-admin", diff --git a/src/Api/TakeoutSaaS.MiniApi/Program.cs b/src/Api/TakeoutSaaS.MiniApi/Program.cs index 8369004..a6685cb 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Program.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Program.cs @@ -1,4 +1,6 @@ +using FluentValidation; using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.Extensions.Caching.StackExchangeRedis; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -14,6 +16,7 @@ using TakeoutSaaS.Shared.Abstractions.Ids; using TakeoutSaaS.Shared.Kernel.Ids; using TakeoutSaaS.Shared.Web.Extensions; using TakeoutSaaS.Shared.Web.Swagger; +using System.Reflection; // 1. 创建构建器与日志模板 var builder = WebApplication.CreateBuilder(args); @@ -43,6 +46,14 @@ builder.Services.AddSharedSwagger(options => options.Description = "小程序 API 文档"; options.EnableAuthorization = true; }); +builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + +// 4. 注册 Redis 分布式缓存,供验证码等功能使用 +var redisConnection = builder.Configuration.GetValue("Redis"); +builder.Services.AddStackExchangeRedisCache(options => +{ + options.Configuration = redisConnection; +}); // 4. 注册多租户与业务模块 builder.Services.AddTenantResolution(builder.Configuration); diff --git a/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj b/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj index 3ee3e92..1fd145f 100644 Binary files a/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj and b/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj differ diff --git a/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs index c4af1ce..8f2944e 100644 --- a/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs +++ b/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs @@ -18,7 +18,7 @@ public static class AppApplicationServiceCollectionExtensions /// 服务集合。 public static IServiceCollection AddAppApplication(this IServiceCollection services) { - services.AddMediatR(Assembly.GetExecutingAssembly()); + services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())); services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); diff --git a/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj b/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj index 9b5901b..73fff0e 100644 Binary files a/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj and b/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj differ diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/TakeoutSaaS.ApiGateway.csproj b/src/Gateway/TakeoutSaaS.ApiGateway/TakeoutSaaS.ApiGateway.csproj index 8d989af..11ed7b8 100644 Binary files a/src/Gateway/TakeoutSaaS.ApiGateway/TakeoutSaaS.ApiGateway.csproj and b/src/Gateway/TakeoutSaaS.ApiGateway/TakeoutSaaS.ApiGateway.csproj differ diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs index 31ee1cf..03f6333 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs @@ -7,6 +7,11 @@ namespace TakeoutSaaS.Infrastructure.Identity.Options; /// public sealed class AdminSeedOptions { + /// + /// 是否启用后台账号与权限种子。 + /// + public bool Enabled { get; set; } = true; + /// /// 初始用户列表。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs index 01c8da2..17727ae 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Npgsql; using TakeoutSaaS.Infrastructure.Identity.Options; using TakeoutSaaS.Shared.Abstractions.Tenancy; using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser; @@ -29,6 +30,11 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger var passwordHasher = scope.ServiceProvider.GetRequiredService>(); var tenantContextAccessor = scope.ServiceProvider.GetRequiredService(); + if (!options.Enabled) + { + logger.LogInformation("AdminSeed 已禁用,跳过后台账号初始化"); + return; + } await context.Database.MigrateAsync(cancellationToken); 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) .ToListAsync(cancellationToken); context.UserRoles.RemoveRange(existingUserRoles); + await context.SaveChangesAsync(cancellationToken); var roleIds = roleEntities.Select(r => r.Id).Distinct().ToArray(); - var userRoles = roleIds.Select(roleId => new DomainUserRole + foreach (var roleId in roleIds) { - TenantId = userOptions.TenantId, - UserId = user.Id, - RoleId = roleId - }); - await context.UserRoles.AddRangeAsync(userRoles, cancellationToken); + try + { + var alreadyExists = await context.UserRoles.AnyAsync( + ur => ur.TenantId == userOptions.TenantId && ur.UserId == user.Id && ur.RoleId == roleId, + 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) @@ -145,14 +171,41 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger .Where(rp => rp.TenantId == userOptions.TenantId && roleIds.Contains(rp.RoleId)) .ToListAsync(cancellationToken); 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, - RoleId = roleId, - PermissionId = permissionId - })); - await context.RolePermissions.AddRangeAsync(newRolePermissions, cancellationToken); + foreach (var permissionId in distinctPermissionIds) + { + try + { + 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); 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); + 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(); + } + } } } diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqConnectionFactory.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqConnectionFactory.cs index ad844c2..e015c72 100644 --- a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqConnectionFactory.cs +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqConnectionFactory.cs @@ -12,7 +12,7 @@ public sealed class RabbitMqConnectionFactory(IOptionsMonitor o /// /// 创建连接。 /// - public IConnection CreateConnection() + public Task CreateConnectionAsync(CancellationToken cancellationToken = default) { var options = optionsMonitor.CurrentValue; var factory = new ConnectionFactory @@ -21,10 +21,9 @@ public sealed class RabbitMqConnectionFactory(IOptionsMonitor o Port = options.Port, UserName = options.Username, Password = options.Password, - VirtualHost = options.VirtualHost, - DispatchConsumersAsync = true + VirtualHost = options.VirtualHost }; - return factory.CreateConnection(); + return factory.CreateConnectionAsync(cancellationToken); } } diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs index 3f2178c..4a5b215 100644 --- a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs @@ -14,41 +14,42 @@ public sealed class RabbitMqMessagePublisher(RabbitMqConnectionFactory connectio : IMessagePublisher, IAsyncDisposable { private IConnection? _connection; - private IModel? _channel; + private IChannel? _channel; private bool _disposed; /// - public Task PublishAsync(string routingKey, T message, CancellationToken cancellationToken = default) + public async Task PublishAsync(string routingKey, T message, CancellationToken cancellationToken = default) { // 1. 确保通道可用 - EnsureChannel(); + await EnsureChannelAsync(cancellationToken); var options = optionsMonitor.CurrentValue; var channel = _channel ?? throw new InvalidOperationException("RabbitMQ channel is not available."); // 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. 序列化消息并设置属性 var body = serializer.Serialize(message); - var props = channel.CreateBasicProperties(); - props.ContentType = "application/json"; - props.DeliveryMode = 2; - props.MessageId = Guid.NewGuid().ToString("N"); + var props = new BasicProperties + { + ContentType = "application/json", + DeliveryMode = DeliveryModes.Persistent, + MessageId = Guid.NewGuid().ToString("N") + }; // 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); - return Task.CompletedTask; } - private void EnsureChannel() + private async Task EnsureChannelAsync(CancellationToken cancellationToken) { if (_channel != null && _channel.IsOpen) { return; } - _connection ??= connectionFactory.CreateConnection(); - _channel = _connection.CreateModel(); + _connection ??= await connectionFactory.CreateConnectionAsync(cancellationToken); + _channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken); } /// diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessageSubscriber.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessageSubscriber.cs index ef50289..6287c77 100644 --- a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessageSubscriber.cs +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessageSubscriber.cs @@ -15,32 +15,32 @@ public sealed class RabbitMqMessageSubscriber(RabbitMqConnectionFactory connecti : IMessageSubscriber { private IConnection? _connection; - private IModel? _channel; + private IChannel? _channel; private bool _disposed; /// public async Task SubscribeAsync(string queue, string routingKey, Func> handler, CancellationToken cancellationToken = default) { // 1. 确保通道可用 - EnsureChannel(); + await EnsureChannelAsync(cancellationToken); var options = optionsMonitor.CurrentValue; var channel = _channel ?? throw new InvalidOperationException("RabbitMQ channel is not available."); // 2. 声明交换机、队列及绑定 - channel.ExchangeDeclare(options.Exchange, options.ExchangeType, durable: true, autoDelete: false); - channel.QueueDeclare(queue, durable: true, exclusive: false, autoDelete: false); - channel.QueueBind(queue, options.Exchange, routingKey); - channel.BasicQos(0, options.PrefetchCount, global: false); + await channel.ExchangeDeclareAsync(options.Exchange, options.ExchangeType, durable: true, autoDelete: false, cancellationToken: cancellationToken); + await channel.QueueDeclareAsync(queue, durable: true, exclusive: false, autoDelete: false, cancellationToken: cancellationToken); + await channel.QueueBindAsync(queue, options.Exchange, routingKey, cancellationToken: cancellationToken); + await channel.BasicQosAsync(0, options.PrefetchCount, global: false, cancellationToken: cancellationToken); // 3. 设置消费者回调 var consumer = new AsyncEventingBasicConsumer(channel); - consumer.Received += async (_, ea) => + consumer.ReceivedAsync += async (_, ea) => { var message = serializer.Deserialize(ea.Body.ToArray()); if (message == null) { - channel.BasicAck(ea.DeliveryTag, multiple: false); + await channel.BasicAckAsync(ea.DeliveryTag, multiple: false, cancellationToken); return; } @@ -56,28 +56,27 @@ public sealed class RabbitMqMessageSubscriber(RabbitMqConnectionFactory connecti if (success) { - channel.BasicAck(ea.DeliveryTag, multiple: false); + await channel.BasicAckAsync(ea.DeliveryTag, multiple: false, cancellationToken); } else { - channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: false); + await channel.BasicNackAsync(ea.DeliveryTag, multiple: false, requeue: false, cancellationToken); } }; // 4. 开始消费 - channel.BasicConsume(queue, autoAck: false, consumer); - await Task.CompletedTask.ConfigureAwait(false); + await channel.BasicConsumeAsync(queue, autoAck: false, consumer, cancellationToken); } - private void EnsureChannel() + private async Task EnsureChannelAsync(CancellationToken cancellationToken) { if (_channel != null && _channel.IsOpen) { return; } - _connection ??= connectionFactory.CreateConnection(); - _channel = _connection.CreateModel(); + _connection ??= await connectionFactory.CreateConnectionAsync(cancellationToken); + _channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken); } /// diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/TakeoutSaaS.Module.Messaging.csproj b/src/Modules/TakeoutSaaS.Module.Messaging/TakeoutSaaS.Module.Messaging.csproj index ef94b34..f5650aa 100644 --- a/src/Modules/TakeoutSaaS.Module.Messaging/TakeoutSaaS.Module.Messaging.csproj +++ b/src/Modules/TakeoutSaaS.Module.Messaging/TakeoutSaaS.Module.Messaging.csproj @@ -5,13 +5,13 @@ enable - - - - - - - + + + + + + + diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/TakeoutSaaS.Module.Scheduler.csproj b/src/Modules/TakeoutSaaS.Module.Scheduler/TakeoutSaaS.Module.Scheduler.csproj index 6c7f697..5024078 100644 --- a/src/Modules/TakeoutSaaS.Module.Scheduler/TakeoutSaaS.Module.Scheduler.csproj +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/TakeoutSaaS.Module.Scheduler.csproj @@ -5,14 +5,14 @@ enable - + - - - - - - + + + + + + diff --git a/src/Modules/TakeoutSaaS.Module.Sms/TakeoutSaaS.Module.Sms.csproj b/src/Modules/TakeoutSaaS.Module.Sms/TakeoutSaaS.Module.Sms.csproj index 7ac9fcd..7353ff4 100644 --- a/src/Modules/TakeoutSaaS.Module.Sms/TakeoutSaaS.Module.Sms.csproj +++ b/src/Modules/TakeoutSaaS.Module.Sms/TakeoutSaaS.Module.Sms.csproj @@ -5,13 +5,13 @@ enable - - - - - - - + + + + + + + diff --git a/src/Modules/TakeoutSaaS.Module.Storage/TakeoutSaaS.Module.Storage.csproj b/src/Modules/TakeoutSaaS.Module.Storage/TakeoutSaaS.Module.Storage.csproj index b8007bc..733f714 100644 Binary files a/src/Modules/TakeoutSaaS.Module.Storage/TakeoutSaaS.Module.Storage.csproj and b/src/Modules/TakeoutSaaS.Module.Storage/TakeoutSaaS.Module.Storage.csproj differ