Внедрить фабрики объектов вместо прямых new #14
@@ -103,6 +103,25 @@
|
|||||||
- Асинхронность: суффикс `Async`, `CancellationToken` пробрасывать до конца; `.Result` / `.Wait()`
|
- Асинхронность: суффикс `Async`, `CancellationToken` пробрасывать до конца; `.Result` / `.Wait()`
|
||||||
запрещены — только `await`.
|
запрещены — только `await`.
|
||||||
|
|
||||||
|
### 4.1. Фабрики и билдеры
|
||||||
|
|
||||||
|
- **Нетривиальные объекты с интерфейсом создаются только фабриками.** Реализация сервиса/адаптера,
|
||||||
|
у которого есть порт-интерфейс, не создаётся прямым `new` в прикладном коде или композиционном корне —
|
||||||
|
только внутри фабрики. Форма пары: `IXxxFactory` (порт фабрики) + `XxxFactory` (реализация), метод
|
||||||
|
`Create(...)` возвращает **интерфейс** готового объекта (`ISecretCipher`, `IFileStorage`, …).
|
||||||
|
- Фабрика сама регистрируется в DI (`AddScoped`/`AddSingleton<IXxxFactory, XxxFactory>()`) — контейнер
|
||||||
|
конструирует её без `new`; зависимости фабрики — тоже DI.
|
||||||
|
- **Билдер** (`IXxxBuilder`/`XxxBuilder`) добавляется, когда объект собирается итеративно из многих частей
|
||||||
|
или опций; фабрика делегирует сборку билдеру, а не повторяет её.
|
||||||
|
- Исключения из правила (прямой `new` допустим):
|
||||||
|
- DTO, рекорды, value-объекты, `Options`/`Settings`-снимки;
|
||||||
|
- исключения (`*Exception`) и примитивы/BCL-типы (`StringBuilder`, `NpgsqlConnection`, `MinioClient`, …);
|
||||||
|
- статические классы и хэлперы без состояния (фабрику для них не заводим);
|
||||||
|
- EF-конфигурации (`IEntityTypeConfiguration`) — это метаданные модели, а не прикладные объекты;
|
||||||
|
- обёртки ресурсов без порт-интерфейса (gRPC-соединения с `IDisposable`);
|
||||||
|
- объекты без порт-интерфейса, создаваемые контейнером (`AddScoped<Concrete>()`).
|
||||||
|
- Тесты могут конструировать проверяемый тип прямым `new` — это часть самого теста, а не прикладного кода.
|
||||||
|
|
||||||
## 5. Комментирование кода
|
## 5. Комментирование кода
|
||||||
|
|
||||||
Все комментарии — на русском языке.
|
Все комментарии — на русском языке.
|
||||||
@@ -123,6 +142,8 @@
|
|||||||
- **`<param>`/`<returns>`** — только если смысл не очевиден из имени/типа; не переписывать сигнатуру.
|
- **`<param>`/`<returns>`** — только если смысл не очевиден из имени/типа; не переписывать сигнатуру.
|
||||||
- **`<summary>` — только блочный.** Открывающий `<summary>` и закрывающий `</summary>` — **каждый на
|
- **`<summary>` — только блочный.** Открывающий `<summary>` и закрывающий `</summary>` — **каждый на
|
||||||
своей строке**; запись в одну строку (`/// <summary>текст</summary>`) **не допускается**. **[изм.]**
|
своей строке**; запись в одну строку (`/// <summary>текст</summary>`) **не допускается**. **[изм.]**
|
||||||
|
- **Конструкторы не документируем** — `<summary>`/`<param>` на них не нужны: назначение очевидно из
|
||||||
|
типа и сигнатуры. В частности, не документируем конструкторы классов, реализующих интерфейс. **[изм.]**
|
||||||
|
|
||||||
Правильно:
|
Правильно:
|
||||||
```csharp
|
```csharp
|
||||||
@@ -229,9 +250,12 @@
|
|||||||
|
|
||||||
## 11. Интерфейсы
|
## 11. Интерфейсы
|
||||||
|
|
||||||
- **Не дублировать `<summary>` интерфейса в реализации.** Если член объявлен в интерфейсе с XML-doc,
|
- **В реализациях интерфейсов XML-doc не пишем вообще.** Если тип или член объявлен в интерфейсе,
|
||||||
в классе-реализации достаточно `/// <inheritdoc/>` (или вообще ничего, если doc наследуется настройкой).
|
класс-реализация не документируется: ни `<summary>`, ни `<inheritdoc/>` (и ни `<param>` на
|
||||||
Текст описания пишется **один раз** — у интерфейса.
|
конструкторе). Описание живёт **один раз** — в интерфейсе; реализации вызываются только через порт.
|
||||||
|
Под этот запрет попадает и сам класс-реализация (его `<summary>` тоже лишний — есть у интерфейса).
|
||||||
|
- **XML-doc уместен только там, где нет интерфейса:** public-типы/члены без порта (статика, константы,
|
||||||
|
extension-классы), `protected`-члены и DTO/модели. **[изм. 2026-09-13, решение владельца]**
|
||||||
- **Явная реализация интерфейсов — по умолчанию** (`Task ICardStore.GetAsync(...)`). **[изм. 2026-09-11,
|
- **Явная реализация интерфейсов — по умолчанию** (`Task ICardStore.GetAsync(...)`). **[изм. 2026-09-11,
|
||||||
решение владельца]** Классы напрямую не вызываются — только через интерфейсы; исключения: DTO/модели
|
решение владельца]** Классы напрямую не вызываются — только через интерфейсы; исключения: DTO/модели
|
||||||
(напр. `Card` и семейство `I*Card`), хелперы, extension-классы. Весь прод-код уже переведён на явные
|
(напр. `Card` и семейство `I*Card`), хелперы, extension-классы. Весь прод-код уже переведён на явные
|
||||||
|
|||||||
@@ -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,38 @@
|
|||||||
|
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<BudgetedAiClassifier> _logger;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
IAiClassifier IAiClassifierFactory.Create() =>
|
||||||
|
new BudgetedAiClassifier(_paidClassifier, _localClassifier, _tenantLimits, _tenantContext, _logger);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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<BudgetedAiTools> _logger;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
IAiTools IAiToolsFactory.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,32 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
public sealed class FileStorageFactory : IFileStorageFactory
|
||||||
|
{
|
||||||
|
private readonly StorageOptions _options;
|
||||||
|
private readonly LocalStorageRoot _localRoot;
|
||||||
|
private readonly ILogger<MinioFileStorage> _minioLogger;
|
||||||
|
|
||||||
|
public FileStorageFactory(
|
||||||
|
StorageOptions options,
|
||||||
|
LocalStorageRoot localRoot,
|
||||||
|
ILogger<MinioFileStorage> minioLogger)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentNullException.ThrowIfNull(localRoot);
|
||||||
|
ArgumentNullException.ThrowIfNull(minioLogger);
|
||||||
|
_options = options;
|
||||||
|
_localRoot = localRoot;
|
||||||
|
_minioLogger = minioLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
IFileStorage IFileStorageFactory.Create() =>
|
||||||
|
_options.Minio.IsConfigured()
|
||||||
|
? new MinioFileStorage(_options.Minio, _minioLogger)
|
||||||
|
: new LocalFileStorage(_localRoot.Path);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Deal.Contracts.Integrations.Abstractions;
|
using Deal.Contracts.Integrations.Abstractions;
|
||||||
|
using Deal.Infrastructure.Integrations.Storage.Abstractions;
|
||||||
using Deal.Infrastructure.Integrations.Storage.Extensions;
|
using Deal.Infrastructure.Integrations.Storage.Extensions;
|
||||||
using Deal.Infrastructure.Integrations.Storage.Options;
|
using Deal.Infrastructure.Integrations.Storage.Options;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
@@ -48,16 +49,12 @@ public static class FileStorageRegistrar
|
|||||||
ArgumentNullException.ThrowIfNull(configuration);
|
ArgumentNullException.ThrowIfNull(configuration);
|
||||||
|
|
||||||
StorageOptions options = ReadOptions(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);
|
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;
|
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,22 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
public sealed class TenantLimitStoreFactory : ITenantLimitStoreFactory
|
||||||
|
{
|
||||||
|
private readonly DealDbContext _dbContext;
|
||||||
|
private readonly TokenLimitDefaults _defaults;
|
||||||
|
|
||||||
|
public TenantLimitStoreFactory(DealDbContext dbContext, TokenLimitDefaults defaults)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(dbContext);
|
||||||
|
ArgumentNullException.ThrowIfNull(defaults);
|
||||||
|
_dbContext = dbContext;
|
||||||
|
_defaults = defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
ITenantLimitStore ITenantLimitStoreFactory.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,17 @@
|
|||||||
|
using Deal.Infrastructure.Security.Abstractions;
|
||||||
|
using Deal.Modules.Settings.Application.Abstractions;
|
||||||
|
|
||||||
|
namespace Deal.Infrastructure.Security.Services;
|
||||||
|
|
||||||
|
public sealed class SecretCipherFactory : ISecretCipherFactory
|
||||||
|
{
|
||||||
|
private readonly EncryptionKeyProvider _keyProvider;
|
||||||
|
|
||||||
|
public SecretCipherFactory(EncryptionKeyProvider keyProvider)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(keyProvider);
|
||||||
|
_keyProvider = keyProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
ISecretCipher ISecretCipherFactory.Create() => new AesGcmSecretCipher(_keyProvider.GetKey());
|
||||||
|
}
|
||||||
@@ -5,8 +5,12 @@ using Deal.Infrastructure.Integrations.Options;
|
|||||||
using Deal.Infrastructure.Integrations.Services;
|
using Deal.Infrastructure.Integrations.Services;
|
||||||
using Deal.Infrastructure.Integrations.Sources;
|
using Deal.Infrastructure.Integrations.Sources;
|
||||||
using Deal.Infrastructure.Persistence;
|
using Deal.Infrastructure.Persistence;
|
||||||
|
using Deal.Infrastructure.Persistence.Abstractions;
|
||||||
using Deal.Infrastructure.Persistence.Repositories;
|
using Deal.Infrastructure.Persistence.Repositories;
|
||||||
|
using Deal.Infrastructure.Persistence.Services;
|
||||||
using Deal.Infrastructure.Security;
|
using Deal.Infrastructure.Security;
|
||||||
|
using Deal.Infrastructure.Security.Abstractions;
|
||||||
|
using Deal.Infrastructure.Security.Services;
|
||||||
using Deal.Infrastructure.Services;
|
using Deal.Infrastructure.Services;
|
||||||
using Deal.Infrastructure.Tenancy;
|
using Deal.Infrastructure.Tenancy;
|
||||||
using Deal.Modules.Cards.Application.Abstractions;
|
using Deal.Modules.Cards.Application.Abstractions;
|
||||||
@@ -20,7 +24,6 @@ using Deal.Modules.Tenants.Application.Abstractions;
|
|||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
using Deal.SharedKernel.Tenants.Abstractions;
|
using Deal.SharedKernel.Tenants.Abstractions;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Deal.Infrastructure;
|
namespace Deal.Infrastructure;
|
||||||
|
|
||||||
@@ -46,9 +49,9 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
services.AddScoped<IInviteStore, InviteStore>();
|
services.AddScoped<IInviteStore, InviteStore>();
|
||||||
|
|
||||||
services.AddScoped<ITenantLimitStore>(provider => new TenantLimitStore(
|
services.AddSingleton(tenantLimitDefaults ?? TokenBudgetDefaults.Default);
|
||||||
provider.GetRequiredService<DealDbContext>(),
|
services.AddScoped<ITenantLimitStoreFactory, TenantLimitStoreFactory>();
|
||||||
tenantLimitDefaults ?? TokenBudgetDefaults.Default));
|
services.AddScoped<ITenantLimitStore>(provider => provider.GetRequiredService<ITenantLimitStoreFactory>().Create());
|
||||||
|
|
||||||
services.AddScoped<IRateLimitCounterStore, RateLimitCounterStore>();
|
services.AddScoped<IRateLimitCounterStore, RateLimitCounterStore>();
|
||||||
|
|
||||||
@@ -117,18 +120,11 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<AiProviderConfigBuilder>();
|
services.AddScoped<AiProviderConfigBuilder>();
|
||||||
services.AddScoped<GrpcAiClassifier>();
|
services.AddScoped<GrpcAiClassifier>();
|
||||||
services.AddScoped<LocalAiClassifier>();
|
services.AddScoped<LocalAiClassifier>();
|
||||||
services.AddScoped<IAiClassifier>(provider => new BudgetedAiClassifier(
|
services.AddScoped<IAiClassifierFactory, AiClassifierFactory>();
|
||||||
provider.GetRequiredService<GrpcAiClassifier>(),
|
services.AddScoped<IAiClassifier>(provider => provider.GetRequiredService<IAiClassifierFactory>().Create());
|
||||||
provider.GetRequiredService<LocalAiClassifier>(),
|
|
||||||
provider.GetRequiredService<ITenantLimitStore>(),
|
|
||||||
provider.GetRequiredService<ITenantContext>(),
|
|
||||||
provider.GetRequiredService<ILogger<BudgetedAiClassifier>>()));
|
|
||||||
services.AddScoped<GrpcAiTools>();
|
services.AddScoped<GrpcAiTools>();
|
||||||
services.AddScoped<IAiTools>(provider => new BudgetedAiTools(
|
services.AddScoped<IAiToolsFactory, AiToolsFactory>();
|
||||||
provider.GetRequiredService<GrpcAiTools>(),
|
services.AddScoped<IAiTools>(provider => provider.GetRequiredService<IAiToolsFactory>().Create());
|
||||||
provider.GetRequiredService<ITenantLimitStore>(),
|
|
||||||
provider.GetRequiredService<ITenantContext>(),
|
|
||||||
provider.GetRequiredService<ILogger<BudgetedAiTools>>()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (telegramOptions.UseLocal)
|
if (telegramOptions.UseLocal)
|
||||||
@@ -152,8 +148,9 @@ public static class ServiceCollectionExtensions
|
|||||||
public static IServiceCollection AddDealSecurity(this IServiceCollection services, string contentRootPath)
|
public static IServiceCollection AddDealSecurity(this IServiceCollection services, string contentRootPath)
|
||||||
{
|
{
|
||||||
EncryptionKeyProvider keyProvider = new(contentRootPath);
|
EncryptionKeyProvider keyProvider = new(contentRootPath);
|
||||||
byte[] key = keyProvider.GetKey();
|
services.AddSingleton(keyProvider);
|
||||||
services.AddSingleton<ISecretCipher>(new AesGcmSecretCipher(key));
|
services.AddSingleton<ISecretCipherFactory, SecretCipherFactory>();
|
||||||
|
services.AddSingleton<ISecretCipher>(provider => provider.GetRequiredService<ISecretCipherFactory>().Create());
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ public static class DiscoveryModuleRegistrar
|
|||||||
services.AddScoped<DiscoveryBlacklistService>();
|
services.AddScoped<DiscoveryBlacklistService>();
|
||||||
services.AddScoped<DiscoveryLogService>();
|
services.AddScoped<DiscoveryLogService>();
|
||||||
|
|
||||||
services.AddSingleton<DiscoverySearchErrorCounter>();
|
services.AddSingleton<IDiscoverySearchErrorCounter, DiscoverySearchErrorCounter>();
|
||||||
services.AddSingleton<IDiscoverySearchErrorCounter>(sp => sp.GetRequiredService<DiscoverySearchErrorCounter>());
|
|
||||||
services.AddScoped<DiscoveryEvaluator>();
|
services.AddScoped<DiscoveryEvaluator>();
|
||||||
services.AddScoped<IDiscoveryPacer, DiscoveryPacer>();
|
services.AddScoped<IDiscoveryPacer, DiscoveryPacer>();
|
||||||
services.AddScoped(sp => new DiscoveryBanGuard(
|
services.AddScoped(sp => new DiscoveryBanGuard(
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public sealed partial class DiscoveryWorkerService
|
|||||||
/// <param name="banGuard">Квоты дня/flood/стоп-кран (DiscoveryBanGuard).</param>
|
/// <param name="banGuard">Квоты дня/flood/стоп-кран (DiscoveryBanGuard).</param>
|
||||||
/// <param name="pacer">Паузы между авто-вступлениями (интерфейс — фейк в тестах).</param>
|
/// <param name="pacer">Паузы между авто-вступлениями (интерфейс — фейк в тестах).</param>
|
||||||
/// <param name="gateway">Гейт telegram-service (Search/Info/ReadForEval/Join/SetMonitor/Backfill).</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(
|
public DiscoveryWorkerService(
|
||||||
IDiscoveryStore store,
|
IDiscoveryStore store,
|
||||||
DiscoveryTasksService tasks,
|
DiscoveryTasksService tasks,
|
||||||
@@ -41,7 +41,7 @@ public sealed partial class DiscoveryWorkerService
|
|||||||
DiscoveryBanGuard banGuard,
|
DiscoveryBanGuard banGuard,
|
||||||
IDiscoveryPacer pacer,
|
IDiscoveryPacer pacer,
|
||||||
ITelegramGateway gateway,
|
ITelegramGateway gateway,
|
||||||
IDiscoverySearchErrorCounter? searchErrors = null)
|
IDiscoverySearchErrorCounter searchErrors)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(store);
|
ArgumentNullException.ThrowIfNull(store);
|
||||||
ArgumentNullException.ThrowIfNull(tasks);
|
ArgumentNullException.ThrowIfNull(tasks);
|
||||||
@@ -51,6 +51,7 @@ public sealed partial class DiscoveryWorkerService
|
|||||||
ArgumentNullException.ThrowIfNull(banGuard);
|
ArgumentNullException.ThrowIfNull(banGuard);
|
||||||
ArgumentNullException.ThrowIfNull(pacer);
|
ArgumentNullException.ThrowIfNull(pacer);
|
||||||
ArgumentNullException.ThrowIfNull(gateway);
|
ArgumentNullException.ThrowIfNull(gateway);
|
||||||
|
ArgumentNullException.ThrowIfNull(searchErrors);
|
||||||
_store = store;
|
_store = store;
|
||||||
_tasks = tasks;
|
_tasks = tasks;
|
||||||
_candidates = candidates;
|
_candidates = candidates;
|
||||||
@@ -59,7 +60,7 @@ public sealed partial class DiscoveryWorkerService
|
|||||||
_banGuard = banGuard;
|
_banGuard = banGuard;
|
||||||
_pacer = pacer;
|
_pacer = pacer;
|
||||||
_gateway = gateway;
|
_gateway = gateway;
|
||||||
_searchErrors = searchErrors ?? new DiscoverySearchErrorCounter();
|
_searchErrors = searchErrors;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -443,7 +443,8 @@ public sealed class DiscoveryWorkerServiceTests
|
|||||||
var pacer = Substitute.For<IDiscoveryPacer>();
|
var pacer = Substitute.For<IDiscoveryPacer>();
|
||||||
var gateway = new TestDiscoveryGateway();
|
var gateway = new TestDiscoveryGateway();
|
||||||
var worker = new DiscoveryWorkerService(
|
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);
|
return new Fixture(store, settings, gateway, pacer, worker, tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Deal.Contracts.Integrations.Abstractions;
|
||||||
|
using Deal.Infrastructure.Integrations.Storage.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()
|
||||||
|
{
|
||||||
|
IFileStorageFactory 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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
IFileStorageFactory factory = new FileStorageFactory(
|
||||||
|
options,
|
||||||
|
new LocalStorageRoot("/tmp/deal-attachments"),
|
||||||
|
NullLogger<MinioFileStorage>.Instance);
|
||||||
|
|
||||||
|
IFileStorage storage = factory.Create();
|
||||||
|
|
||||||
|
Assert.IsType<MinioFileStorage>(storage);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ using Grpc.Net.Client;
|
|||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
|
||||||
@@ -57,15 +58,17 @@ internal static class TelegramIngressTestHost
|
|||||||
Func<GrpcChannel, Task> scenario,
|
Func<GrpcChannel, Task> scenario,
|
||||||
RateLimitOptions? rateLimitOptions = null)
|
RateLimitOptions? rateLimitOptions = null)
|
||||||
{
|
{
|
||||||
string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey);
|
|
||||||
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken);
|
|
||||||
|
|
||||||
WebApplication? app = null;
|
WebApplication? app = null;
|
||||||
GrpcChannel? channel = null;
|
GrpcChannel? channel = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
int port = TestPort.Allocate();
|
int port = TestPort.Allocate();
|
||||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||||
|
// Токен ингресса задаётся конфигурацией хоста (в приоритете над env) — без мутации процесса.
|
||||||
|
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
[ServiceTokenEnvKey] = serviceToken,
|
||||||
|
});
|
||||||
builder.WebHost.ConfigureKestrel(kestrel =>
|
builder.WebHost.ConfigureKestrel(kestrel =>
|
||||||
kestrel.Listen(IPAddress.Loopback, port, listen => listen.Protocols = HttpProtocols.Http2));
|
kestrel.Listen(IPAddress.Loopback, port, listen => listen.Protocols = HttpProtocols.Http2));
|
||||||
|
|
||||||
@@ -131,8 +134,6 @@ internal static class TelegramIngressTestHost
|
|||||||
await app.StopAsync();
|
await app.StopAsync();
|
||||||
await app.DisposeAsync();
|
await app.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user