using System.Text.Json; using Deal.Api; using Deal.Api.Dtos; using Deal.Api.Services; using Deal.Api.Extensions; using Deal.Api.Models; using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Registrars; using Deal.Modules.Settings.Application.Services; using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Endpoints; /// /// HTTP-эндпоинты настроек тенанта: GET/PATCH /api/settings (api-map §3.4 L146–147, §4.6). /// /// /// GET — публичный снимок настроек (дефолты + переопределения, маски секретов, providers — Ruling 3); /// PATCH — произвольный JSON-объект публичных полей §4.6, ответ — полный снимок после применения /// (фронт затирает локальный state ответом — store.js). Оба эндпоинта требуют сессию: /// 401 {"detail":"Требуется авторизация"} (Ruling 10). Мягкая семантика: невалидное поле PATCH /// просто не применяется; жёсткая ошибка — только тело не JSON-объект (400). /// Побочные эффекты прототипа L186–192: PATCH с полем rateSource запускает фоновое /// обновление кэша курсов (, Ruling 6); пересчёт карточек при смене /// targetCurrency/conversionOn выполняет сам SettingsService через порт /// (реализация — ConversionRecomputer модуля Kanban, Ruling 7, Task 12). /// /// SettingsService резолвится из RequestServices ВНУТРИ обработчика после проверки сессии, а не /// параметром эндпоинта: DI-биндинг параметров выполняется до тела обработчика, а зависимость /// сервиса — scoped TenantDbContext, опции которого строятся по tenant-контексту запроса /// (без сессии контекст не разрешим — ошибка конфигурации). Так запрос без сессии получает 401, /// а не 500 при резолве. /// /// public static class SettingsEndpoints { private const string ApiGroupPrefix = "/api"; private const string SettingsPath = "/settings"; private const string SettingsOpenApiTag = "settings"; private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом"; /// /// Регистрирует GET/PATCH /api/settings. /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup(ApiGroupPrefix).WithTags(SettingsOpenApiTag); group.MapGet(SettingsPath, GetSettingsAsync); group.MapPatch(SettingsPath, PatchSettingsAsync); return app; } // GET /api/settings: публичный снимок настроек текущего тенанта. private static async Task GetSettingsAsync(HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } SettingsService settingsService = context.RequestServices.GetRequiredService(); return Results.Ok(await settingsService.GetPublicAsync(ct)); } // PATCH /api/settings: частичное обновление настроек; ответ — полный снимок после применения. private static async Task PatchSettingsAsync(HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } // Произвольный JSON-объект: ключи тела — публичные ключи §4.6 (как их шлёт фронт). Dictionary? body; try { body = await JsonSerializer.DeserializeAsync>( context.Request.Body, options: null, cancellationToken: ct); } catch (JsonException) { // Не-JSON или не-объект целиком — ошибка запроса: 400 + detail // (в прототипе FastAPI на такое тело — 422). return EndpointResults.BadRequest(InvalidBodyDetail); } if (body is null) { return EndpointResults.BadRequest(InvalidBodyDetail); } SettingsService settingsService = context.RequestServices.GetRequiredService(); PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct); // Аудит сохранения настроек (этап 10, T1): только имена полей — значения (в т.ч. секреты) не пишутся. await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct); // Смена источника курсов в PATCH (settings_routes.py L188–189) — фоновое обновление кэша // курсов (Ruling 6, Task 8). RefreshAsync читает уже сохранённую настройку rateSource. if (ShouldScheduleRatesRefresh(body)) { context.RequestServices.GetRequiredService().Schedule(); } return Results.Ok(result); } // Запускать ли фоновый refresh курсов после PATCH (семантика if body.get("rateSource") L188). // body: Тело PATCH — публичные ключи §4.6. // Возвращает: True — поле rateSource передано «правдивым» значением (не null/пустая строка). private static bool ShouldScheduleRatesRefresh(Dictionary body) { if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element)) { return false; } // JSON-булево/число в python «правдивы» и запускают refresh; пустая строка/null — нет. return element.ValueKind switch { JsonValueKind.String => !string.IsNullOrEmpty(element.GetString()), JsonValueKind.Null => false, _ => true, }; } }