using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Models; using Deal.Infrastructure.Integrations.Exceptions; using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Models; using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Models; using Microsoft.Extensions.Logging; namespace Deal.Infrastructure.Integrations.Services; /// /// Декоратор бюджетного гейта порта /// public sealed class BudgetedAiClassifier : IAiClassifier { // Текст ошибки вызова вне tenant-контекста (гейт читает лимиты по тенанту). private const string NoTenantContextText = "BudgetedAiClassifier запрошен вне tenant-контекста (ITenantContext.TenantId == null/не-Guid)."; // Причина запрета в логе (общий текст для исчерпания и приостановки). private const string GateDeniedLogText = "бюджет исчерпан или тенант приостановлен"; private readonly IAiClassifier _paidClassifier; private readonly LocalAiClassifier _localClassifier; private readonly ITenantLimitStore _tenantLimits; private readonly ITenantContext _tenantContext; private readonly ILogger _logger; /// /// Создаёт декоратор бюджетного гейта классификатора. /// /// Платный исполнитель (gRPC-адаптер ai-service; вызывается только при Allowed). /// Бесплатный локальный разбор/фильтр (fallback при запрете гейта). /// Хранилище лимитов бюджета (public.tenant_limits; источник гейта). /// Контекст текущего тенанта (AsyncLocal; tenantId запроса). /// Логгер переходов на локальный путь. public BudgetedAiClassifier( IAiClassifier 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; } /// async Task IAiClassifier.FilterAsync(string text, CancellationToken ct) { if (await IsPaidAllowedAsync(ct)) { return await _paidClassifier.FilterAsync(text, ct); } _logger.LogDebug( "ИИ-фильтр: {Reason} — Local-пропуск (тенант {TenantId})", GateDeniedLogText, TenantIdForLog()); return await _localClassifier.FilterAsync(text, ct); } /// async Task IAiClassifier.ClassifyAsync(string text, CancellationToken ct) { if (await IsPaidAllowedAsync(ct)) { return await _paidClassifier.ClassifyAsync(text, ct); } _logger.LogDebug( "ИИ-классификация: {Reason} — Local-разбор (тенант {TenantId})", GateDeniedLogText, TenantIdForLog()); return await _localClassifier.ClassifyAsync(text, ct); } private async Task IsPaidAllowedAsync(CancellationToken ct) { BudgetStateDto state = await _tenantLimits.GetStateAsync(RequireTenantId(), ct); return state.Allowed; } // Текущий тенант scope как Guid строки public.tenants (гейт читает лимиты по тенанту). // Возвращает: Идентификатор тенанта (Guid). // Исключение InvalidOperationException: Вызов вне tenant-контекста или не-Guid формат id. private Guid RequireTenantId() { TenantId? tenantId = _tenantContext.TenantId; if (tenantId is null || !Guid.TryParse(tenantId.Value.Value, out Guid id)) { throw new InvalidOperationException(NoTenantContextText); } return id; } // Id тенанта для лога («-» вне контекста — недостижимо после RequireTenantId). // Возвращает: Строка id тенанта. private string TenantIdForLog() => _tenantContext.TenantId?.Value ?? "-"; }