Вынести условия-предикаты в extension-методы
HasUser из 13 endpoint-файлов сведён в AuthHelpers.HasUser; 15 приватных предикатов заменены extension-методами с удалением дублирующих приватных методов: IsCommunicationFailure, IsTransportFailure, IsPrivateEndpoint, IsConfigured, IsTrue, IsExpired, IsFailedLogin/IsSuccessfulLogin, HasChanges, HasBudget, HasAnyTerm, IsCurrencyLetter, IsEmojiCodePoint, ContainsFooterHint, IsTypeLabel.
This commit is contained in:
@@ -1,132 +1,128 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Api;
|
||||
using Deal.Api.Http;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
|
||||
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 (!HasUser(context))
|
||||
{
|
||||
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 (!HasUser(context))
|
||||
{
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// Разрешена ли сессия запроса (SessionMiddleware наполняет CurrentUser и tenant-контекст).
|
||||
// context: Контекст запроса.
|
||||
private static bool HasUser(HttpContext context) => context.GetCurrentUser() is not null;
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Deal.Api;
|
||||
using Deal.Api.Http;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user