Внедрить фабрики объектов вместо прямого new

Создание сервисов с порт-интерфейсом перенесено в IXxxFactory:
шифр секретов, хранилище лимитов, ИИ-классификатор, ИИ-инструменты,
файловое хранилище (Local/MinIO). Регистраторы и DiscoveryWorkerService
больше не создают реализации напрямую. Добавлен тест FileStorageFactory.
This commit is contained in:
2026-09-13 03:41:39 +03:00
parent b58ac08717
commit d172b6408b
17 changed files with 350 additions and 32 deletions
@@ -0,0 +1,15 @@
using Deal.Contracts.Integrations.Abstractions;
namespace Deal.Infrastructure.Integrations.Abstractions;
/// <summary>
/// Фабрика ИИ-классификатора с бюджетным гейтом
/// </summary>
public interface IAiClassifierFactory
{
/// <summary>
/// Создаёт ИИ-классификатор
/// </summary>
/// <returns>Готовый порт классификации.</returns>
public IAiClassifier Create();
}
@@ -0,0 +1,15 @@
using Deal.Contracts.Integrations.Abstractions;
namespace Deal.Infrastructure.Integrations.Abstractions;
/// <summary>
/// Фабрика ИИ-инструментов с бюджетным гейтом
/// </summary>
public interface IAiToolsFactory
{
/// <summary>
/// Создаёт ИИ-инструменты
/// </summary>
/// <returns>Готовый порт ИИ-инструментов.</returns>
public IAiTools Create();
}
@@ -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;
/// <summary>
/// Фабрика декоратора бюджетного гейта классификатора
/// </summary>
public sealed class AiClassifierFactory : IAiClassifierFactory
{
private readonly GrpcAiClassifier _paidClassifier;
private readonly LocalAiClassifier _localClassifier;
private readonly ITenantLimitStore _tenantLimits;
private readonly ITenantContext _tenantContext;
private readonly ILogger<BudgetedAiClassifier> _logger;
/// <summary>
/// Создаёт фабрику классификатора
/// </summary>
/// <param name="paidClassifier">Платный исполнитель (gRPC-адаптер ai-service).</param>
/// <param name="localClassifier">Бесплатный локальный разбор/фильтр (fallback).</param>
/// <param name="tenantLimits">Хранилище лимитов бюджета.</param>
/// <param name="tenantContext">Контекст текущего тенанта.</param>
/// <param name="logger">Логгер переходов на локальный путь.</param>
public AiClassifierFactory(
GrpcAiClassifier paidClassifier,
LocalAiClassifier localClassifier,
ITenantLimitStore tenantLimits,
ITenantContext tenantContext,
ILogger<BudgetedAiClassifier> 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;
}
/// <inheritdoc />
public IAiClassifier Create() =>
new BudgetedAiClassifier(_paidClassifier, _localClassifier, _tenantLimits, _tenantContext, _logger);
}
@@ -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;
/// <summary>
/// Фабрика декоратора бюджетного гейта ИИ-инструментов
/// </summary>
public sealed class AiToolsFactory : IAiToolsFactory
{
private readonly GrpcAiTools _paidTools;
private readonly ITenantLimitStore _tenantLimits;
private readonly ITenantContext _tenantContext;
private readonly ILogger<BudgetedAiTools> _logger;
/// <summary>
/// Создаёт фабрику ИИ-инструментов
/// </summary>
/// <param name="paidTools">Платный исполнитель (gRPC-адаптер ai-service).</param>
/// <param name="tenantLimits">Хранилище лимитов бюджета.</param>
/// <param name="tenantContext">Контекст текущего тенанта.</param>
/// <param name="logger">Логгер переходов на локальный путь.</param>
public AiToolsFactory(
GrpcAiTools paidTools,
ITenantLimitStore tenantLimits,
ITenantContext tenantContext,
ILogger<BudgetedAiTools> logger)
{
ArgumentNullException.ThrowIfNull(paidTools);
ArgumentNullException.ThrowIfNull(tenantLimits);
ArgumentNullException.ThrowIfNull(tenantContext);
ArgumentNullException.ThrowIfNull(logger);
_paidTools = paidTools;
_tenantLimits = tenantLimits;
_tenantContext = tenantContext;
_logger = logger;
}
/// <inheritdoc />
public IAiTools Create() => new BudgetedAiTools(_paidTools, _tenantLimits, _tenantContext, _logger);
}
@@ -0,0 +1,15 @@
using Deal.Contracts.Integrations.Abstractions;
namespace Deal.Infrastructure.Integrations.Storage.Abstractions;
/// <summary>
/// Фабрика файлового хранилища вложений (Local или MinIO по конфигурации)
/// </summary>
public interface IFileStorageFactory
{
/// <summary>
/// Создаёт файловое хранилище
/// </summary>
/// <returns>Готовый порт файлового хранилища.</returns>
public IFileStorage Create();
}
@@ -0,0 +1,7 @@
namespace Deal.Infrastructure.Integrations.Storage.Options;
/// <summary>
/// Абсолютный путь корня локального файлового хранилища
/// </summary>
/// <param name="Path">Абсолютный путь каталога вложений.</param>
public sealed record LocalStorageRoot(string Path);
@@ -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;
/// <summary>
/// Фабрика файлового хранилища: MinIO при заполненной секции, иначе локальный каталог
/// </summary>
public sealed class FileStorageFactory : IFileStorageFactory
{
private readonly StorageOptions _options;
private readonly LocalStorageRoot _localRoot;
private readonly ILogger<MinioFileStorage> _minioLogger;
/// <summary>
/// Создаёт фабрику файлового хранилища
/// </summary>
/// <param name="options">Настройки секции Storage.</param>
/// <param name="localRoot">Абсолютный путь корня локального режима.</param>
/// <param name="minioLogger">Логгер MinIO-адаптера.</param>
public FileStorageFactory(
StorageOptions options,
LocalStorageRoot localRoot,
ILogger<MinioFileStorage> minioLogger)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(localRoot);
ArgumentNullException.ThrowIfNull(minioLogger);
_options = options;
_localRoot = localRoot;
_minioLogger = minioLogger;
}
/// <inheritdoc />
public IFileStorage Create() =>
_options.Minio.IsConfigured()
? new MinioFileStorage(_options.Minio, _minioLogger)
: new LocalFileStorage(_localRoot.Path);
}
@@ -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<IFileStorage>(serviceProvider =>
new MinioFileStorage(options.Minio, serviceProvider.GetRequiredService<ILogger<MinioFileStorage>>()));
return services;
}
string rootPath = ResolveLocalRoot(options.Local, contentRootPath);
services.AddSingleton<IFileStorage>(new LocalFileStorage(rootPath));
services.AddSingleton(options);
services.AddSingleton(new LocalStorageRoot(rootPath));
services.AddSingleton<IFileStorageFactory, FileStorageFactory>();
services.AddSingleton<IFileStorage>(serviceProvider =>
serviceProvider.GetRequiredService<IFileStorageFactory>().Create());
return services;
}
@@ -0,0 +1,15 @@
using Deal.Modules.Tenants.Application.Abstractions;
namespace Deal.Infrastructure.Persistence.Abstractions;
/// <summary>
/// Фабрика хранилища лимитов ИИ-бюджета
/// </summary>
public interface ITenantLimitStoreFactory
{
/// <summary>
/// Создаёт хранилище лимитов
/// </summary>
/// <returns>Готовый порт лимитов тенанта.</returns>
public ITenantLimitStore Create();
}
@@ -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;
/// <summary>
/// Фабрика EF-адаптера хранилища лимитов с дефолт-бюджетом из конфигурации
/// </summary>
public sealed class TenantLimitStoreFactory : ITenantLimitStoreFactory
{
private readonly DealDbContext _dbContext;
private readonly TokenLimitDefaults _defaults;
/// <summary>
/// Создаёт фабрику хранилища лимитов
/// </summary>
/// <param name="dbContext">Системный контекст (public-схема).</param>
/// <param name="defaults">Дефолт-параметры лениво создаваемой строки.</param>
public TenantLimitStoreFactory(DealDbContext dbContext, TokenLimitDefaults defaults)
{
ArgumentNullException.ThrowIfNull(dbContext);
ArgumentNullException.ThrowIfNull(defaults);
_dbContext = dbContext;
_defaults = defaults;
}
/// <inheritdoc />
public ITenantLimitStore Create() => new TenantLimitStore(_dbContext, _defaults);
}
@@ -0,0 +1,15 @@
using Deal.Modules.Settings.Application.Abstractions;
namespace Deal.Infrastructure.Security.Abstractions;
/// <summary>
/// Фабрика шифра секретов тенанта
/// </summary>
public interface ISecretCipherFactory
{
/// <summary>
/// Создаёт шифр секретов
/// </summary>
/// <returns>Готовый порт симметричного шифрования.</returns>
public ISecretCipher Create();
}
@@ -0,0 +1,25 @@
using Deal.Infrastructure.Security.Abstractions;
using Deal.Modules.Settings.Application.Abstractions;
namespace Deal.Infrastructure.Security.Services;
/// <summary>
/// Фабрика AES-256-GCM-шифра по ключу приложения
/// </summary>
public sealed class SecretCipherFactory : ISecretCipherFactory
{
private readonly EncryptionKeyProvider _keyProvider;
/// <summary>
/// Создаёт фабрику шифра
/// </summary>
/// <param name="keyProvider">Источник ключа шифрования приложения.</param>
public SecretCipherFactory(EncryptionKeyProvider keyProvider)
{
ArgumentNullException.ThrowIfNull(keyProvider);
_keyProvider = keyProvider;
}
/// <inheritdoc />
public ISecretCipher Create() => new AesGcmSecretCipher(_keyProvider.GetKey());
}
@@ -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<IInviteStore, InviteStore>();
services.AddScoped<ITenantLimitStore>(provider => new TenantLimitStore(
provider.GetRequiredService<DealDbContext>(),
tenantLimitDefaults ?? TokenBudgetDefaults.Default));
services.AddSingleton(tenantLimitDefaults ?? TokenBudgetDefaults.Default);
services.AddScoped<ITenantLimitStoreFactory, TenantLimitStoreFactory>();
services.AddScoped<ITenantLimitStore>(provider => provider.GetRequiredService<ITenantLimitStoreFactory>().Create());
services.AddScoped<IRateLimitCounterStore, RateLimitCounterStore>();
@@ -117,18 +120,11 @@ public static class ServiceCollectionExtensions
services.AddScoped<AiProviderConfigBuilder>();
services.AddScoped<GrpcAiClassifier>();
services.AddScoped<LocalAiClassifier>();
services.AddScoped<IAiClassifier>(provider => new BudgetedAiClassifier(
provider.GetRequiredService<GrpcAiClassifier>(),
provider.GetRequiredService<LocalAiClassifier>(),
provider.GetRequiredService<ITenantLimitStore>(),
provider.GetRequiredService<ITenantContext>(),
provider.GetRequiredService<ILogger<BudgetedAiClassifier>>()));
services.AddScoped<IAiClassifierFactory, AiClassifierFactory>();
services.AddScoped<IAiClassifier>(provider => provider.GetRequiredService<IAiClassifierFactory>().Create());
services.AddScoped<GrpcAiTools>();
services.AddScoped<IAiTools>(provider => new BudgetedAiTools(
provider.GetRequiredService<GrpcAiTools>(),
provider.GetRequiredService<ITenantLimitStore>(),
provider.GetRequiredService<ITenantContext>(),
provider.GetRequiredService<ILogger<BudgetedAiTools>>()));
services.AddScoped<IAiToolsFactory, AiToolsFactory>();
services.AddScoped<IAiTools>(provider => provider.GetRequiredService<IAiToolsFactory>().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<ISecretCipher>(new AesGcmSecretCipher(key));
services.AddSingleton(keyProvider);
services.AddSingleton<ISecretCipherFactory, SecretCipherFactory>();
services.AddSingleton<ISecretCipher>(provider => provider.GetRequiredService<ISecretCipherFactory>().Create());
return services;
}
}
@@ -21,8 +21,7 @@ public static class DiscoveryModuleRegistrar
services.AddScoped<DiscoveryBlacklistService>();
services.AddScoped<DiscoveryLogService>();
services.AddSingleton<DiscoverySearchErrorCounter>();
services.AddSingleton<IDiscoverySearchErrorCounter>(sp => sp.GetRequiredService<DiscoverySearchErrorCounter>());
services.AddSingleton<IDiscoverySearchErrorCounter, DiscoverySearchErrorCounter>();
services.AddScoped<DiscoveryEvaluator>();
services.AddScoped<IDiscoveryPacer, DiscoveryPacer>();
services.AddScoped(sp => new DiscoveryBanGuard(
@@ -31,7 +31,7 @@ public sealed partial class DiscoveryWorkerService
/// <param name="banGuard">Квоты дня/flood/стоп-кран (DiscoveryBanGuard).</param>
/// <param name="pacer">Паузы между авто-вступлениями (интерфейс — фейк в тестах).</param>
/// <param name="gateway">Гейт telegram-service (Search/Info/ReadForEval/Join/SetMonitor/Backfill).</param>
/// <param name="searchErrors">Singleton-счётчик ошибок ключей поиска; null — локальный (на инстанс воркера).</param>
/// <param name="searchErrors">Singleton-счётчик ошибок ключей поиска.</param>
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;
}
/// <summary>
@@ -443,7 +443,8 @@ public sealed class DiscoveryWorkerServiceTests
var pacer = Substitute.For<IDiscoveryPacer>();
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);
}
@@ -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;
/// <summary>
/// Тесты FileStorageFactory — выбор Local/MinIO по заполненности секции Storage:Minio.
/// </summary>
public sealed class FileStorageFactoryTests
{
// Локальный режим: относительно пустая секция Minio → LocalFileStorage.
[Fact]
public void Create_MinioNotConfigured_ReturnsLocalFileStorage()
{
var factory = new FileStorageFactory(
new StorageOptions(),
new LocalStorageRoot("/tmp/deal-attachments"),
NullLogger<MinioFileStorage>.Instance);
IFileStorage storage = factory.Create();
Assert.IsType<LocalFileStorage>(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<MinioFileStorage>.Instance);
IFileStorage storage = factory.Create();
Assert.IsType<MinioFileStorage>(storage);
}
}