Deal.Api/Http -> Services/Models/Extensions; Contracts/Integrations и SharedKernel/Tenants -> Abstractions/Models; extension-классы telegram/ml -> Extensions. namespace/using/FQN мигрированы, using дедуплицированы.
138 lines
7.2 KiB
C#
138 lines
7.2 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// HTTP-эндпоинты настроек тенанта: GET/PATCH /api/settings (api-map §3.4 L146–147, §4.6).
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// GET — публичный снимок настроек (дефолты + переопределения, маски секретов, providers — Ruling 3);
|
||
/// PATCH — произвольный JSON-объект публичных полей §4.6, ответ — полный снимок после применения
|
||
/// (фронт затирает локальный state ответом — store.js). Оба эндпоинта требуют сессию:
|
||
/// 401 {"detail":"Требуется авторизация"} (Ruling 10). Мягкая семантика: невалидное поле PATCH
|
||
/// просто не применяется; жёсткая ошибка — только тело не JSON-объект (400).
|
||
/// Побочные эффекты прототипа L186–192: PATCH с полем rateSource запускает фоновое
|
||
/// обновление кэша курсов (<see cref="RatesRefreshScheduler"/>, Ruling 6); пересчёт карточек при смене
|
||
/// targetCurrency/conversionOn выполняет сам SettingsService через порт <see cref="IRatesChangedListener"/>
|
||
/// (реализация — ConversionRecomputer модуля Kanban, Ruling 7, Task 12).
|
||
/// <para>
|
||
/// SettingsService резолвится из RequestServices ВНУТРИ обработчика после проверки сессии, а не
|
||
/// параметром эндпоинта: DI-биндинг параметров выполняется до тела обработчика, а зависимость
|
||
/// сервиса — scoped TenantDbContext, опции которого строятся по tenant-контексту запроса
|
||
/// (без сессии контекст не разрешим — ошибка конфигурации). Так запрос без сессии получает 401,
|
||
/// а не 500 при резолве.
|
||
/// </para>
|
||
/// </remarks>
|
||
public static class SettingsEndpoints
|
||
{
|
||
private const string ApiGroupPrefix = "/api";
|
||
private const string SettingsPath = "/settings";
|
||
private const string SettingsOpenApiTag = "settings";
|
||
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
|
||
|
||
/// <summary>
|
||
/// Регистрирует GET/PATCH /api/settings.
|
||
/// </summary>
|
||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||
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<IResult> GetSettingsAsync(HttpContext context, CancellationToken ct)
|
||
{
|
||
if (!context.HasUser())
|
||
{
|
||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||
}
|
||
|
||
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
|
||
return Results.Ok(await settingsService.GetPublicAsync(ct));
|
||
}
|
||
|
||
// PATCH /api/settings: частичное обновление настроек; ответ — полный снимок после применения.
|
||
private static async Task<IResult> PatchSettingsAsync(HttpContext context, CancellationToken ct)
|
||
{
|
||
if (!context.HasUser())
|
||
{
|
||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||
}
|
||
|
||
// Произвольный JSON-объект: ключи тела — публичные ключи §4.6 (как их шлёт фронт).
|
||
Dictionary<string, JsonElement>? body;
|
||
try
|
||
{
|
||
body = await JsonSerializer.DeserializeAsync<Dictionary<string, JsonElement>>(
|
||
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<SettingsService>();
|
||
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<RatesRefreshScheduler>().Schedule();
|
||
}
|
||
|
||
return Results.Ok(result);
|
||
}
|
||
|
||
// Запускать ли фоновый refresh курсов после PATCH (семантика if body.get("rateSource") L188).
|
||
// body: Тело PATCH — публичные ключи §4.6.
|
||
// Возвращает: True — поле rateSource передано «правдивым» значением (не null/пустая строка).
|
||
private static bool ShouldScheduleRatesRefresh(Dictionary<string, JsonElement> 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,
|
||
};
|
||
}
|
||
}
|