using Deal.Api.Http; using Deal.Modules.Settings.Application; using Deal.Modules.Settings.Application.Models; namespace Deal.Api.Endpoints; /// /// HTTP-эндпоинты курсов валют: GET /api/rates, POST /api/rates/refresh (Ruling 8, api-map §3.4 L149–150). /// /// /// «Только для Settings-экрана» (Ruling 8): фронт читает курсы на boot (store.js L571–581) и обновляет /// по кнопке (refreshRates L1843–1848). GET — текущий кэш (ratesCache) или дефолт-мок; при протухании/ /// смене источника/отсутствии кэша (Ruling 6) фоново запускает RefreshAsync через /// и отвечает текущим кэшем (план Task 8 L317–318). POST — синхронный /// refresh 1:1 с прототипом: {ok: bool, rates: {base, rates, source, updatedAt}} (ok=false при сбое /// ЦБ, кэш не тронут). Оба требуют сессию: 401 {detail} (Ruling 10). Резолв scoped-зависимостей — через /// RequestServices ПОСЛЕ проверки сессии (как SettingsEndpoints/AiCheckEndpoint: ISettingsStore требует /// tenant-контекст запроса). /// 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"; /// /// Регистрирует GET /api/rates и POST /api/rates/refresh. /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. 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 GetRatesAsync(HttpContext context, CancellationToken ct) { if (!HasUser(context)) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } // Резолв после 401-гейта: RatesService — scoped на TenantDbContext (tenant-контекст запроса). RatesService ratesService = context.RequestServices.GetRequiredService(); RatesDto current = await ratesService.GetAsync(ct); // Ленивое обновление (Ruling 6, план Task 8): протухший кэш / смена источника / нет кэша — // фоновый RefreshAsync в отдельном scope; ответ — текущий кэш. if (await ratesService.ShouldFetchAsync(ct)) { context.RequestServices.GetRequiredService().Schedule(); } return Results.Ok(current); } // POST /api/rates/refresh: принудительное обновление; ответ {ok, rates} (1:1 settings_routes.py L229–232). private static async Task RefreshRatesAsync(HttpContext context, CancellationToken ct) { if (!HasUser(context)) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } RatesService ratesService = context.RequestServices.GetRequiredService(); // Синхронно: 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; }