Files
Deal/src/telegram-service/Deal.Telegram/Core/CoreIngressClient.cs
T
Rustam Khalimov 9e07568ddd Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
2026-09-11 02:50:17 +03:00

128 lines
6.0 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Deal.Grpc.Hosting;
using Deal.Grpc.Telegram;
using Deal.Telegram.Sessions;
using Grpc.Core;
using Grpc.Net.Client;
namespace Deal.Telegram.Core;
/// <summary>
/// Исходящий gRPC-канал в ядро: клиент Deal.Grpc.Telegram.IngressService (план Task 10, Core/
/// CoreIngressClient.cs; Ruling 1/7). Каждый RPC несёт metadata tenant-id + service-token (Ruling 1);
/// принадлежность сообщений тенанту — только по metadata (ядро не доверяет полю). Сбой связи →
/// <see cref="Sessions.SessionException"/> UNAVAILABLE «Ядро недоступно…» с логом: буфер недоставленных
/// сообщений не ведётся — неподтверждённые сообщения не помечаются прочитанными и догоняются
/// realtime_sweep (упущенное после рестарта/разрыва, Ruling 7/план Task 10).
/// </summary>
public sealed class CoreIngressClient : ICoreIngressClient
{
/// <summary>
/// Ключ gRPC-metadata с id тенанта (зеркало интерцепторов, Ruling 1).
/// </summary>
public const string TenantIdMetadataKey = "tenant-id";
/// <summary>
/// Ключ gRPC-metadata с service-token (зеркало интерцепторов, Ruling 1).
/// </summary>
public const string ServiceTokenMetadataKey = "service-token";
private readonly CoreIngressOptions _options;
private readonly ILogger<CoreIngressClient> _logger;
private readonly MtlsCertificates? _mtlsCertificates;
private readonly object _channelGate = new();
private IngressService.IngressServiceClient? _client;
private GrpcChannel? _channel;
/// <summary>
/// Создаёт клиент ингресса ядра.
/// </summary>
/// <param name="options">Конфигурация (адрес из env, service-token).</param>
/// <param name="logger">Логгер.</param>
/// <param name="mtlsCertificates">Сертификаты mTLS (Ruling 6, Task 13): null — plaintext-канал (dev).</param>
public CoreIngressClient(CoreIngressOptions options, ILogger<CoreIngressClient> logger, MtlsCertificates? mtlsCertificates = null)
{
_options = options;
_logger = logger;
_mtlsCertificates = mtlsCertificates;
}
/// <inheritdoc />
public async Task<PushMessageReply> PushMessageAsync(string tenantId, PushMessageRequest message, CancellationToken cancellationToken)
{
IngressService.IngressServiceClient client = GetClient();
try
{
return await client.PushMessageAsync(message, CallOptions(tenantId)).ResponseAsync.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
throw Fail("PushMessage", exception);
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> SyncDialogsAsync(string tenantId, IReadOnlyList<DialogEntry> entries, CancellationToken cancellationToken)
{
IngressService.IngressServiceClient client = GetClient();
var request = new SyncDialogsRequest();
request.Entries.AddRange(entries);
try
{
SyncDialogsReply reply = await client.SyncDialogsAsync(request, CallOptions(tenantId)).ResponseAsync.WaitAsync(cancellationToken).ConfigureAwait(false);
return reply.MonitoredIds;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
throw Fail("SyncDialogs", exception);
}
}
// Лениво создаёт gRPC-канал к ядру (адрес неизменен на время жизни процесса).
private IngressService.IngressServiceClient GetClient()
{
lock (_channelGate)
{
if (_client is null)
{
// mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским
// сертификатом и проверяет CA ядра; dev — plaintext-канал (Ruling 2 этапа 6).
if (_mtlsCertificates is not null)
{
_channel = GrpcChannel.ForAddress(
_options.IngressEndpoint,
new GrpcChannelOptions { HttpHandler = _mtlsCertificates.CreateClientHttpHandler() });
}
else
{
_channel = GrpcChannel.ForAddress(_options.IngressEndpoint);
}
_client = new IngressService.IngressServiceClient(_channel);
}
return _client;
}
}
// Metadata вызова (tenant-id + service-token) и deadline (Ruling 1).
// tenantId: Id тенанта.
private CallOptions CallOptions(string tenantId)
{
var metadata = new Metadata
{
{ TenantIdMetadataKey, tenantId },
{ ServiceTokenMetadataKey, _options.ServiceToken },
};
return new CallOptions(metadata, deadline: DateTime.UtcNow.AddSeconds(CoreIngressOptions.RpcTimeoutSeconds));
}
// Переводит сбой вызова в SessionException (UNAVAILABLE) со структурированным логом.
// action: Действие (PushMessage/SyncDialogs) для лога аудита (Ruling 13).
// exception: Исключение вызова.
private SessionException Fail(string action, Exception exception)
{
_logger.LogWarning(exception, "Аудит: ингресс ядра {Action} → сбой ({Endpoint})", action, _options.IngressEndpoint);
return new SessionException(StatusCode.Unavailable, SessionErrorMessages.IngressUnavailable, exception);
}
}