SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Декоратор бюджетного гейта порта <see cref="IAiTools"/>
|
||||
/// </summary>
|
||||
public sealed class BudgetedAiTools : IAiTools
|
||||
{
|
||||
private const string ExhaustedKeywordsError = "ИИ-бюджет исчерпан — генерация ключевых слов недоступна";
|
||||
|
||||
private const string SuspendedKeywordsError = "Тенант приостановлен — генерация ключевых слов недоступна";
|
||||
|
||||
private const string ExhaustedFitError = "ИИ-бюджет исчерпан — обработка в локальном режиме";
|
||||
|
||||
private const string SuspendedFitError = "Тенант приостановлен — ИИ-оценка заморожена";
|
||||
|
||||
private readonly IAiTools _paidTools;
|
||||
private readonly ITenantLimitStore _tenantLimits;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ILogger<BudgetedAiTools> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт декоратор бюджетного гейта ИИ-инструментов.
|
||||
/// </summary>
|
||||
/// <param name="paidTools">Платный исполнитель (gRPC-адаптер ai-service; вызывается только при Allowed).</param>
|
||||
/// <param name="tenantLimits">Хранилище лимитов бюджета (public.tenant_limits; источник гейта).</param>
|
||||
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal; tenantId запроса).</param>
|
||||
/// <param name="logger">Логгер переходов на локальный путь.</param>
|
||||
public BudgetedAiTools(
|
||||
IAiTools 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 />
|
||||
async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
|
||||
{
|
||||
BudgetStateDto state = await GateStateAsync(ct);
|
||||
if (state.Allowed)
|
||||
{
|
||||
return await _paidTools.GenerateKeywordsAsync(description, ct);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"generate-keywords: {Reason} — мягкая ошибка (тенант {TenantId})",
|
||||
DenyLogText(state),
|
||||
TenantIdForLog());
|
||||
return new AiGenerateKeywordsResultDto(
|
||||
Ok: false,
|
||||
Keywords: Array.Empty<string>(),
|
||||
Error: state.Status == TenantStatuses.Suspended ? SuspendedKeywordsError : ExhaustedKeywordsError);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
|
||||
string text,
|
||||
string description,
|
||||
IReadOnlyCollection<string> keywords,
|
||||
CancellationToken ct)
|
||||
{
|
||||
BudgetStateDto state = await GateStateAsync(ct);
|
||||
if (state.Allowed)
|
||||
{
|
||||
return await _paidTools.EvaluateFitAsync(text, description, keywords, ct);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"evaluate-fit: {Reason} — эвристика (тенант {TenantId})",
|
||||
DenyLogText(state),
|
||||
TenantIdForLog());
|
||||
throw new AiUnavailableException(
|
||||
state.Status == TenantStatuses.Suspended ? SuspendedFitError : ExhaustedFitError);
|
||||
}
|
||||
|
||||
private async Task<BudgetStateDto> GateStateAsync(CancellationToken ct)
|
||||
=> await _tenantLimits.GetStateAsync(RequireTenantId(), ct);
|
||||
|
||||
// Причина запрета в логе: приостановка и исчерпание различаются (короткая строка без секретов).
|
||||
// state: Состояние бюджета тенанта.
|
||||
// Возвращает: Текст причины.
|
||||
private static string DenyLogText(BudgetStateDto state)
|
||||
=> state.Status == TenantStatuses.Suspended ? "тенант приостановлен" : "ИИ-бюджет исчерпан";
|
||||
|
||||
// Текущий тенант 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(
|
||||
"BudgetedAiTools запрошен вне tenant-контекста (ITenantContext.TenantId == null/не-Guid).");
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
// Id тенанта для лога («-» вне контекста — недостижимо после RequireTenantId).
|
||||
// Возвращает: Строка id тенанта.
|
||||
private string TenantIdForLog() => _tenantContext.TenantId?.Value ?? "-";
|
||||
}
|
||||
Reference in New Issue
Block a user