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

Первый коммит: модульный монолит ядра (.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,89 @@
using Deal.Api.Http;
using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// HTTP-эндпоинты курсов валют: GET /api/rates, POST /api/rates/refresh (Ruling 8, api-map §3.4 L149150).
/// </summary>
/// <remarks>
/// «Только для Settings-экрана» (Ruling 8): фронт читает курсы на boot (store.js L571581) и обновляет
/// по кнопке (refreshRates L18431848). GET — текущий кэш (ratesCache) или дефолт-мок; при протухании/
/// смене источника/отсутствии кэша (Ruling 6) фоново запускает RefreshAsync через
/// <see cref="RatesRefreshScheduler"/> и отвечает текущим кэшем (план Task 8 L317318). POST — синхронный
/// refresh 1:1 с прототипом: <c>{ok: bool, rates: {base, rates, source, updatedAt}}</c> (ok=false при сбое
/// ЦБ, кэш не тронут). Оба требуют сессию: 401 {detail} (Ruling 10). Резолв scoped-зависимостей — через
/// RequestServices ПОСЛЕ проверки сессии (как SettingsEndpoints/AiCheckEndpoint: ISettingsStore требует
/// tenant-контекст запроса).
/// </remarks>
public static class RatesEndpoints
{
// Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
private const string ApiGroupPrefix = "/api";
// Путь текущих курсов (GET).
private const string RatesPath = "/rates";
// Путь принудительного обновления (POST).
private const string RatesRefreshPath = "/rates/refresh";
// OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py).
private const string OpenApiTag = "settings";
/// <summary>
/// Регистрирует GET /api/rates и POST /api/rates/refresh.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapRatesEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag);
group.MapGet(RatesPath, GetRatesAsync);
group.MapPost(RatesRefreshPath, RefreshRatesAsync);
return app;
}
// GET /api/rates: текущий кэш курсов тенанта (+ ленивый фоновый refresh при необходимости).
private static async Task<IResult> GetRatesAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
// Резолв после 401-гейта: RatesService — scoped на TenantDbContext (tenant-контекст запроса).
RatesService ratesService = context.RequestServices.GetRequiredService<RatesService>();
RatesDto current = await ratesService.GetAsync(ct);
// Ленивое обновление (Ruling 6, план Task 8): протухший кэш / смена источника / нет кэша —
// фоновый RefreshAsync в отдельном scope; ответ — текущий кэш.
if (await ratesService.ShouldFetchAsync(ct))
{
context.RequestServices.GetRequiredService<RatesRefreshScheduler>().Schedule();
}
return Results.Ok(current);
}
// POST /api/rates/refresh: принудительное обновление; ответ {ok, rates} (1:1 settings_routes.py L229232).
private static async Task<IResult> RefreshRatesAsync(HttpContext context, CancellationToken ct)
{
if (!HasUser(context))
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
RatesService ratesService = context.RequestServices.GetRequiredService<RatesService>();
// Синхронно: refresh по текущей настройке rateSource; при сбое ЦБ ok=false и кэш не тронут.
bool ok = await ratesService.RefreshAsync(ct);
RatesDto current = await ratesService.GetAsync(ct);
return Results.Ok(new { ok, rates = current });
}
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
// context: Контекст запроса.
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
}