Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user