diff --git a/src/core/Deal.Infrastructure/Integrations/Abstractions/IAiClassifierFactory.cs b/src/core/Deal.Infrastructure/Integrations/Abstractions/IAiClassifierFactory.cs new file mode 100644 index 0000000..d6646b0 --- /dev/null +++ b/src/core/Deal.Infrastructure/Integrations/Abstractions/IAiClassifierFactory.cs @@ -0,0 +1,15 @@ +using Deal.Contracts.Integrations.Abstractions; + +namespace Deal.Infrastructure.Integrations.Abstractions; + +/// +/// Фабрика ИИ-классификатора с бюджетным гейтом +/// +public interface IAiClassifierFactory +{ + /// + /// Создаёт ИИ-классификатор + /// + /// Готовый порт классификации. + public IAiClassifier Create(); +} diff --git a/src/core/Deal.Infrastructure/Integrations/Abstractions/IAiToolsFactory.cs b/src/core/Deal.Infrastructure/Integrations/Abstractions/IAiToolsFactory.cs new file mode 100644 index 0000000..0952945 --- /dev/null +++ b/src/core/Deal.Infrastructure/Integrations/Abstractions/IAiToolsFactory.cs @@ -0,0 +1,15 @@ +using Deal.Contracts.Integrations.Abstractions; + +namespace Deal.Infrastructure.Integrations.Abstractions; + +/// +/// Фабрика ИИ-инструментов с бюджетным гейтом +/// +public interface IAiToolsFactory +{ + /// + /// Создаёт ИИ-инструменты + /// + /// Готовый порт ИИ-инструментов. + public IAiTools Create(); +} diff --git a/src/core/Deal.Infrastructure/Integrations/Services/AiClassifierFactory.cs b/src/core/Deal.Infrastructure/Integrations/Services/AiClassifierFactory.cs new file mode 100644 index 0000000..fc0f698 --- /dev/null +++ b/src/core/Deal.Infrastructure/Integrations/Services/AiClassifierFactory.cs @@ -0,0 +1,50 @@ +using Deal.Contracts.Integrations.Abstractions; +using Deal.Infrastructure.Integrations.Abstractions; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.SharedKernel.Tenants.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Deal.Infrastructure.Integrations.Services; + +/// +/// Фабрика декоратора бюджетного гейта классификатора +/// +public sealed class AiClassifierFactory : IAiClassifierFactory +{ + private readonly GrpcAiClassifier _paidClassifier; + private readonly LocalAiClassifier _localClassifier; + private readonly ITenantLimitStore _tenantLimits; + private readonly ITenantContext _tenantContext; + private readonly ILogger _logger; + + /// + /// Создаёт фабрику классификатора + /// + /// Платный исполнитель (gRPC-адаптер ai-service). + /// Бесплатный локальный разбор/фильтр (fallback). + /// Хранилище лимитов бюджета. + /// Контекст текущего тенанта. + /// Логгер переходов на локальный путь. + public AiClassifierFactory( + GrpcAiClassifier paidClassifier, + LocalAiClassifier localClassifier, + ITenantLimitStore tenantLimits, + ITenantContext tenantContext, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(paidClassifier); + ArgumentNullException.ThrowIfNull(localClassifier); + ArgumentNullException.ThrowIfNull(tenantLimits); + ArgumentNullException.ThrowIfNull(tenantContext); + ArgumentNullException.ThrowIfNull(logger); + _paidClassifier = paidClassifier; + _localClassifier = localClassifier; + _tenantLimits = tenantLimits; + _tenantContext = tenantContext; + _logger = logger; + } + + /// + public IAiClassifier Create() => + new BudgetedAiClassifier(_paidClassifier, _localClassifier, _tenantLimits, _tenantContext, _logger); +} diff --git a/src/core/Deal.Infrastructure/Integrations/Services/AiToolsFactory.cs b/src/core/Deal.Infrastructure/Integrations/Services/AiToolsFactory.cs new file mode 100644 index 0000000..3815c42 --- /dev/null +++ b/src/core/Deal.Infrastructure/Integrations/Services/AiToolsFactory.cs @@ -0,0 +1,44 @@ +using Deal.Contracts.Integrations.Abstractions; +using Deal.Infrastructure.Integrations.Abstractions; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.SharedKernel.Tenants.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Deal.Infrastructure.Integrations.Services; + +/// +/// Фабрика декоратора бюджетного гейта ИИ-инструментов +/// +public sealed class AiToolsFactory : IAiToolsFactory +{ + private readonly GrpcAiTools _paidTools; + private readonly ITenantLimitStore _tenantLimits; + private readonly ITenantContext _tenantContext; + private readonly ILogger _logger; + + /// + /// Создаёт фабрику ИИ-инструментов + /// + /// Платный исполнитель (gRPC-адаптер ai-service). + /// Хранилище лимитов бюджета. + /// Контекст текущего тенанта. + /// Логгер переходов на локальный путь. + public AiToolsFactory( + GrpcAiTools paidTools, + ITenantLimitStore tenantLimits, + ITenantContext tenantContext, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(paidTools); + ArgumentNullException.ThrowIfNull(tenantLimits); + ArgumentNullException.ThrowIfNull(tenantContext); + ArgumentNullException.ThrowIfNull(logger); + _paidTools = paidTools; + _tenantLimits = tenantLimits; + _tenantContext = tenantContext; + _logger = logger; + } + + /// + public IAiTools Create() => new BudgetedAiTools(_paidTools, _tenantLimits, _tenantContext, _logger); +} diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Abstractions/IFileStorageFactory.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Abstractions/IFileStorageFactory.cs new file mode 100644 index 0000000..1760129 --- /dev/null +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Abstractions/IFileStorageFactory.cs @@ -0,0 +1,15 @@ +using Deal.Contracts.Integrations.Abstractions; + +namespace Deal.Infrastructure.Integrations.Storage.Abstractions; + +/// +/// Фабрика файлового хранилища вложений (Local или MinIO по конфигурации) +/// +public interface IFileStorageFactory +{ + /// + /// Создаёт файловое хранилище + /// + /// Готовый порт файлового хранилища. + public IFileStorage Create(); +} diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Options/LocalStorageRoot.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Options/LocalStorageRoot.cs new file mode 100644 index 0000000..363fc4b --- /dev/null +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Options/LocalStorageRoot.cs @@ -0,0 +1,7 @@ +namespace Deal.Infrastructure.Integrations.Storage.Options; + +/// +/// Абсолютный путь корня локального файлового хранилища +/// +/// Абсолютный путь каталога вложений. +public sealed record LocalStorageRoot(string Path); diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageFactory.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageFactory.cs new file mode 100644 index 0000000..42f73cd --- /dev/null +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageFactory.cs @@ -0,0 +1,42 @@ +using Deal.Contracts.Integrations.Abstractions; +using Deal.Infrastructure.Integrations.Storage.Abstractions; +using Deal.Infrastructure.Integrations.Storage.Extensions; +using Deal.Infrastructure.Integrations.Storage.Options; +using Microsoft.Extensions.Logging; + +namespace Deal.Infrastructure.Integrations.Storage.Services; + +/// +/// Фабрика файлового хранилища: MinIO при заполненной секции, иначе локальный каталог +/// +public sealed class FileStorageFactory : IFileStorageFactory +{ + private readonly StorageOptions _options; + private readonly LocalStorageRoot _localRoot; + private readonly ILogger _minioLogger; + + /// + /// Создаёт фабрику файлового хранилища + /// + /// Настройки секции Storage. + /// Абсолютный путь корня локального режима. + /// Логгер MinIO-адаптера. + public FileStorageFactory( + StorageOptions options, + LocalStorageRoot localRoot, + ILogger minioLogger) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(localRoot); + ArgumentNullException.ThrowIfNull(minioLogger); + _options = options; + _localRoot = localRoot; + _minioLogger = minioLogger; + } + + /// + public IFileStorage Create() => + _options.Minio.IsConfigured() + ? new MinioFileStorage(_options.Minio, _minioLogger) + : new LocalFileStorage(_localRoot.Path); +} diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageRegistrar.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageRegistrar.cs index 007b84c..b993afb 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageRegistrar.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageRegistrar.cs @@ -1,4 +1,5 @@ using Deal.Contracts.Integrations.Abstractions; +using Deal.Infrastructure.Integrations.Storage.Abstractions; using Deal.Infrastructure.Integrations.Storage.Extensions; using Deal.Infrastructure.Integrations.Storage.Options; using Microsoft.Extensions.Configuration; @@ -48,16 +49,12 @@ public static class FileStorageRegistrar ArgumentNullException.ThrowIfNull(configuration); StorageOptions options = ReadOptions(configuration); - - if (options.Minio.IsConfigured()) - { - services.AddSingleton(serviceProvider => - new MinioFileStorage(options.Minio, serviceProvider.GetRequiredService>())); - return services; - } - string rootPath = ResolveLocalRoot(options.Local, contentRootPath); - services.AddSingleton(new LocalFileStorage(rootPath)); + services.AddSingleton(options); + services.AddSingleton(new LocalStorageRoot(rootPath)); + services.AddSingleton(); + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService().Create()); return services; } diff --git a/src/core/Deal.Infrastructure/Persistence/Abstractions/ITenantLimitStoreFactory.cs b/src/core/Deal.Infrastructure/Persistence/Abstractions/ITenantLimitStoreFactory.cs new file mode 100644 index 0000000..f8fadbd --- /dev/null +++ b/src/core/Deal.Infrastructure/Persistence/Abstractions/ITenantLimitStoreFactory.cs @@ -0,0 +1,15 @@ +using Deal.Modules.Tenants.Application.Abstractions; + +namespace Deal.Infrastructure.Persistence.Abstractions; + +/// +/// Фабрика хранилища лимитов ИИ-бюджета +/// +public interface ITenantLimitStoreFactory +{ + /// + /// Создаёт хранилище лимитов + /// + /// Готовый порт лимитов тенанта. + public ITenantLimitStore Create(); +} diff --git a/src/core/Deal.Infrastructure/Persistence/Services/TenantLimitStoreFactory.cs b/src/core/Deal.Infrastructure/Persistence/Services/TenantLimitStoreFactory.cs new file mode 100644 index 0000000..f6a9cd3 --- /dev/null +++ b/src/core/Deal.Infrastructure/Persistence/Services/TenantLimitStoreFactory.cs @@ -0,0 +1,31 @@ +using Deal.Infrastructure.Persistence.Abstractions; +using Deal.Infrastructure.Persistence.Repositories; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Infrastructure.Persistence.Services; + +/// +/// Фабрика EF-адаптера хранилища лимитов с дефолт-бюджетом из конфигурации +/// +public sealed class TenantLimitStoreFactory : ITenantLimitStoreFactory +{ + private readonly DealDbContext _dbContext; + private readonly TokenLimitDefaults _defaults; + + /// + /// Создаёт фабрику хранилища лимитов + /// + /// Системный контекст (public-схема). + /// Дефолт-параметры лениво создаваемой строки. + public TenantLimitStoreFactory(DealDbContext dbContext, TokenLimitDefaults defaults) + { + ArgumentNullException.ThrowIfNull(dbContext); + ArgumentNullException.ThrowIfNull(defaults); + _dbContext = dbContext; + _defaults = defaults; + } + + /// + public ITenantLimitStore Create() => new TenantLimitStore(_dbContext, _defaults); +} diff --git a/src/core/Deal.Infrastructure/Security/Abstractions/ISecretCipherFactory.cs b/src/core/Deal.Infrastructure/Security/Abstractions/ISecretCipherFactory.cs new file mode 100644 index 0000000..e79b491 --- /dev/null +++ b/src/core/Deal.Infrastructure/Security/Abstractions/ISecretCipherFactory.cs @@ -0,0 +1,15 @@ +using Deal.Modules.Settings.Application.Abstractions; + +namespace Deal.Infrastructure.Security.Abstractions; + +/// +/// Фабрика шифра секретов тенанта +/// +public interface ISecretCipherFactory +{ + /// + /// Создаёт шифр секретов + /// + /// Готовый порт симметричного шифрования. + public ISecretCipher Create(); +} diff --git a/src/core/Deal.Infrastructure/Security/Services/SecretCipherFactory.cs b/src/core/Deal.Infrastructure/Security/Services/SecretCipherFactory.cs new file mode 100644 index 0000000..b6aeaad --- /dev/null +++ b/src/core/Deal.Infrastructure/Security/Services/SecretCipherFactory.cs @@ -0,0 +1,25 @@ +using Deal.Infrastructure.Security.Abstractions; +using Deal.Modules.Settings.Application.Abstractions; + +namespace Deal.Infrastructure.Security.Services; + +/// +/// Фабрика AES-256-GCM-шифра по ключу приложения +/// +public sealed class SecretCipherFactory : ISecretCipherFactory +{ + private readonly EncryptionKeyProvider _keyProvider; + + /// + /// Создаёт фабрику шифра + /// + /// Источник ключа шифрования приложения. + public SecretCipherFactory(EncryptionKeyProvider keyProvider) + { + ArgumentNullException.ThrowIfNull(keyProvider); + _keyProvider = keyProvider; + } + + /// + public ISecretCipher Create() => new AesGcmSecretCipher(_keyProvider.GetKey()); +} diff --git a/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs b/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs index 84c5734..7d466c2 100644 --- a/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs +++ b/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs @@ -5,8 +5,12 @@ using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Services; using Deal.Infrastructure.Integrations.Sources; using Deal.Infrastructure.Persistence; +using Deal.Infrastructure.Persistence.Abstractions; using Deal.Infrastructure.Persistence.Repositories; +using Deal.Infrastructure.Persistence.Services; using Deal.Infrastructure.Security; +using Deal.Infrastructure.Security.Abstractions; +using Deal.Infrastructure.Security.Services; using Deal.Infrastructure.Services; using Deal.Infrastructure.Tenancy; using Deal.Modules.Cards.Application.Abstractions; @@ -20,7 +24,6 @@ using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Models; using Deal.SharedKernel.Tenants.Abstractions; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Deal.Infrastructure; @@ -46,9 +49,9 @@ public static class ServiceCollectionExtensions services.AddScoped(); - services.AddScoped(provider => new TenantLimitStore( - provider.GetRequiredService(), - tenantLimitDefaults ?? TokenBudgetDefaults.Default)); + services.AddSingleton(tenantLimitDefaults ?? TokenBudgetDefaults.Default); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService().Create()); services.AddScoped(); @@ -117,18 +120,11 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(provider => new BudgetedAiClassifier( - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService>())); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService().Create()); services.AddScoped(); - services.AddScoped(provider => new BudgetedAiTools( - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService>())); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService().Create()); } if (telegramOptions.UseLocal) @@ -152,8 +148,9 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddDealSecurity(this IServiceCollection services, string contentRootPath) { EncryptionKeyProvider keyProvider = new(contentRootPath); - byte[] key = keyProvider.GetKey(); - services.AddSingleton(new AesGcmSecretCipher(key)); + services.AddSingleton(keyProvider); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService().Create()); return services; } } diff --git a/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs b/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs index d87810f..848908b 100644 --- a/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs +++ b/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs @@ -21,8 +21,7 @@ public static class DiscoveryModuleRegistrar services.AddScoped(); services.AddScoped(); - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(sp => new DiscoveryBanGuard( diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs index a5e66ea..13a9291 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs @@ -31,7 +31,7 @@ public sealed partial class DiscoveryWorkerService /// Квоты дня/flood/стоп-кран (DiscoveryBanGuard). /// Паузы между авто-вступлениями (интерфейс — фейк в тестах). /// Гейт telegram-service (Search/Info/ReadForEval/Join/SetMonitor/Backfill). - /// Singleton-счётчик ошибок ключей поиска; null — локальный (на инстанс воркера). + /// Singleton-счётчик ошибок ключей поиска. public DiscoveryWorkerService( IDiscoveryStore store, DiscoveryTasksService tasks, @@ -41,7 +41,7 @@ public sealed partial class DiscoveryWorkerService DiscoveryBanGuard banGuard, IDiscoveryPacer pacer, ITelegramGateway gateway, - IDiscoverySearchErrorCounter? searchErrors = null) + IDiscoverySearchErrorCounter searchErrors) { ArgumentNullException.ThrowIfNull(store); ArgumentNullException.ThrowIfNull(tasks); @@ -51,6 +51,7 @@ public sealed partial class DiscoveryWorkerService ArgumentNullException.ThrowIfNull(banGuard); ArgumentNullException.ThrowIfNull(pacer); ArgumentNullException.ThrowIfNull(gateway); + ArgumentNullException.ThrowIfNull(searchErrors); _store = store; _tasks = tasks; _candidates = candidates; @@ -59,7 +60,7 @@ public sealed partial class DiscoveryWorkerService _banGuard = banGuard; _pacer = pacer; _gateway = gateway; - _searchErrors = searchErrors ?? new DiscoverySearchErrorCounter(); + _searchErrors = searchErrors; } /// diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs index fea2453..ec6eb0a 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs @@ -443,7 +443,8 @@ public sealed class DiscoveryWorkerServiceTests var pacer = Substitute.For(); var gateway = new TestDiscoveryGateway(); var worker = new DiscoveryWorkerService( - store.Store, tasks, candidates, log, new DiscoveryEvaluator(settings.Store, ml.Client, ai), banGuard, pacer, gateway.Gateway); + store.Store, tasks, candidates, log, new DiscoveryEvaluator(settings.Store, ml.Client, ai), banGuard, pacer, gateway.Gateway, + new DiscoverySearchErrorCounter()); return new Fixture(store, settings, gateway, pacer, worker, tasks); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/FileStorageFactoryTests.cs b/src/core/tests/Deal.Tests.Unit/Support/FileStorageFactoryTests.cs new file mode 100644 index 0000000..094310b --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/FileStorageFactoryTests.cs @@ -0,0 +1,49 @@ +using Deal.Contracts.Integrations.Abstractions; +using Deal.Infrastructure.Integrations.Storage.Options; +using Deal.Infrastructure.Integrations.Storage.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты FileStorageFactory — выбор Local/MinIO по заполненности секции Storage:Minio. +/// +public sealed class FileStorageFactoryTests +{ + // Локальный режим: относительно пустая секция Minio → LocalFileStorage. + [Fact] + public void Create_MinioNotConfigured_ReturnsLocalFileStorage() + { + var factory = new FileStorageFactory( + new StorageOptions(), + new LocalStorageRoot("/tmp/deal-attachments"), + NullLogger.Instance); + + IFileStorage storage = factory.Create(); + + Assert.IsType(storage); + } + + // MinIO-режим: заполнены Endpoint/AccessKey/SecretKey → MinioFileStorage. + [Fact] + public void Create_MinioConfigured_ReturnsMinioFileStorage() + { + var options = new StorageOptions + { + Minio = new MinioStorageOptions + { + Endpoint = "localhost:9000", + AccessKey = "deal", + SecretKey = "deal-secret", + }, + }; + var factory = new FileStorageFactory( + options, + new LocalStorageRoot("/tmp/deal-attachments"), + NullLogger.Instance); + + IFileStorage storage = factory.Create(); + + Assert.IsType(storage); + } +}