using System.Text.Json; using Deal.Api.Extensions; using Deal.Api.Services; using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Services; using Deal.Modules.Tenants.Application.Models; using Deal.SharedKernel.Utilities; namespace Deal.Api.Endpoints; /// /// HTTP-эндпоинты настроек тенанта /// public static class SettingsEndpoints { private const string ApiGroupPrefix = "/api"; private const string SettingsPath = "/settings"; private const string SettingsOpenApiTag = "settings"; private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом"; // Максимальная длина значения в деталях аудита (промпты/списки бывают длинными). private const int MaxAuditValueLength = 120; // Пометка для составных настроек, чьи значения в аудит не пишутся (секреты, снимки состояния). private const string ChangedMarker = "изменено"; // Опции сериализации снимка настроек в деталях аудита (camelCase, как на wire). private static readonly JsonSerializerOptions AuditJsonOptions = new(JsonSerializerDefaults.Web); /// /// Регистрирует 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); } Dictionary? body; try { body = await JsonSerializer.DeserializeAsync>( context.Request.Body, options: null, cancellationToken: ct); } catch (JsonException) { return EndpointResults.BadRequest(InvalidBodyDetail); } if (body is null) { return EndpointResults.BadRequest(InvalidBodyDetail); } SettingsService settingsService = context.RequestServices.GetRequiredService(); PublicSettingsDto before = await settingsService.GetPublicAsync(ct); PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, BuildSettingsChanges(before, result, body.Keys), ct); if (ShouldScheduleRatesRefresh(body)) { context.RequestServices.GetRequiredService().Schedule(); } return Results.Ok(result); } // Строит изменения настроек «поле: было → стало» по применённому PATCH. // before: Снимок настроек до применения. // after: Снимок настроек после применения. // keys: Ключи PATCH-тела (изменённые). // Возвращает: Изменения по каждому ключу (составные — пометкой «изменено»). private static IReadOnlyList BuildSettingsChanges( PublicSettingsDto before, PublicSettingsDto after, IEnumerable keys) { using JsonDocument beforeDoc = JsonDocument.Parse(JsonSerializer.Serialize(before, AuditJsonOptions)); using JsonDocument afterDoc = JsonDocument.Parse(JsonSerializer.Serialize(after, AuditJsonOptions)); var changes = new List(); var keyList = new List(); foreach (string key in keys) { keyList.Add(key); SettingKind? kind = SettingsKeys.FindPublicKind(key); if (kind is SettingKind.Dict or SettingKind.MyPrompts) { changes.Add(AuditDetails.Set(key, ChangedMarker)); continue; } changes.Add(AuditDetails.Change( key, ReadSettingText(beforeDoc.RootElement, key), ReadSettingText(afterDoc.RootElement, key))); } return changes.Count > 0 ? changes : [AuditDetails.Set(AuditFields.Fields, string.Join(", ", keyList))]; } // Читает значение ключа снимка настроек как текст для деталей аудита. private static string? ReadSettingText(JsonElement root, string key) => root.TryGetProperty(key, out JsonElement value) ? ToText(value) : null; // Приводит значение JSON к строке отображения (длинные значения обрезаются). private static string? ToText(JsonElement value) => value.ValueKind switch { JsonValueKind.Null or JsonValueKind.Undefined => null, JsonValueKind.String => Truncate(value.GetString()), JsonValueKind.True => BoolText.True, JsonValueKind.False => BoolText.False, JsonValueKind.Array => Truncate(string.Join(", ", value.EnumerateArray().Select(ToText))), _ => Truncate(value.GetRawText()), }; // Обрезает длинное значение деталей аудита. private static string? Truncate(string? value) => value is not null && value.Length > MaxAuditValueLength ? value[..MaxAuditValueLength] + "…" : value; private static bool ShouldScheduleRatesRefresh(Dictionary body) { if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element)) { return false; } return element.ValueKind switch { JsonValueKind.String => !string.IsNullOrEmpty(element.GetString()), JsonValueKind.Null => false, _ => true, }; } }