Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
128 lines
6.0 KiB
C#
128 lines
6.0 KiB
C#
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);
|
||
}
|
||
}
|