Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -0,0 +1,116 @@
using Deal.Api.Events;
using Deal.Api.Http;
namespace Deal.Api.Endpoints;
/// <summary>
/// SSE-поток событий канбана: GET /api/events (Ruling 5; прототип events_routes.py L1538).
/// </summary>
/// <remarks>
/// Открывает <c>text/event-stream</c> с подпиской на канал тенанта сессии (singleton SseBroker).
/// События пишутся по мере поступления; при тишине 15 с отправляется ping-комментарий ": ping" —
/// соединение держится (переподключение EventSource, api.js L62104). Завершение — по отвалу клиента
/// (CancellationToken = RequestAborted); отписка — в finally. Без сессии — 401 {detail} (Ruling 10,
/// паттерн остальных эндпоинтов). Заголовки: Content-Type text/event-stream, Cache-Control: no-cache,
/// X-Accel-Buffering: no (запрет буферизации прокси, иначе ping/события задерживаются).
/// </remarks>
public static class EventsEndpoint
{
// Путь потока (роутер events, events_routes.py L12: prefix="/api").
private const string EventsPath = "/api/events";
// OpenAPI-тег группы (в прототипе — роутер events_routes.py).
private const string OpenApiTag = "events";
// Тип контента потока (events_routes.py L32).
private const string EventStreamContentType = "text/event-stream";
// Директива кеширования: поток не кешируется (events_routes.py L34).
private const string NoCacheHeaderValue = "no-cache";
// Отключение буферизации ответа nginx-прокси (events_routes.py L35).
private const string NoBufferingHeaderValue = "no";
// Ping-комментарий: строки протокола SSE, начинающиеся с ':', клиент игнорирует.
private const string PingComment = ": ping\n\n";
// Интервал ping при тишине: держим соединение (events_routes.py L24: timeout=15).
private static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(15);
/// <summary>
/// Регистрирует GET /api/events.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapEventsEndpoint(this IEndpointRouteBuilder app)
{
app.MapGet(EventsPath, StreamEventsAsync).WithTags(OpenApiTag);
return app;
}
// GET /api/events: поток text/event-stream канала тенанта сессии.
// context: Контекст запроса (сессия — HttpContext.Items).
// ct: Отмена запроса: клиент отвалился — завершаем поток и отписываемся.
private static async Task StreamEventsAsync(HttpContext context, CancellationToken ct)
{
CurrentUser? user = context.GetCurrentUser();
if (user is null)
{
await EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail).ExecuteAsync(context);
return;
}
SseBroker broker = context.RequestServices.GetRequiredService<SseBroker>();
SseSubscription subscription = broker.Subscribe(user.TenantId);
try
{
HttpResponse response = context.Response;
response.ContentType = EventStreamContentType;
response.Headers["Cache-Control"] = NoCacheHeaderValue;
response.Headers["X-Accel-Buffering"] = NoBufferingHeaderValue;
while (true)
{
using var pingTimeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
pingTimeout.CancelAfter(PingInterval);
try
{
await subscription.Events.WaitToReadAsync(pingTimeout.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// Тишина 15 с — ping держит соединение; отмену клиента ловит внешний catch.
await WriteFrameAsync(response, PingComment, ct);
continue;
}
while (subscription.Events.TryRead(out SseEvent? sseEvent))
{
await WriteFrameAsync(response, sseEvent.RenderFrame(), ct);
}
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Клиент закрыл соединение — штатное завершение потока.
}
catch (IOException)
{
// Сброс соединения клиентом (закрытая вкладка/обрыв сети): ответ уже не доставить.
}
finally
{
broker.Unsubscribe(user.TenantId, subscription.Id);
}
}
// Пишет frame в поток ответа и сбрасывает буфер — события уходят сразу (не пачкой).
// response: Ответ (stream уже начат).
// frame: Frame протокола SSE.
// ct: Токен отмены запроса.
private static async Task WriteFrameAsync(HttpResponse response, string frame, CancellationToken ct)
{
await response.WriteAsync(frame, ct);
await response.Body.FlushAsync(ct);
}
}