diff --git a/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs b/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs
index 7944ea9..3270eea 100644
--- a/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs
@@ -5,6 +5,7 @@ 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;
@@ -18,6 +19,15 @@ public static class SettingsEndpoints
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.
///
@@ -72,9 +82,10 @@ public static class SettingsEndpoints
}
SettingsService settingsService = context.RequestServices.GetRequiredService();
+ PublicSettingsDto before = await settingsService.GetPublicAsync(ct);
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
- await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, [AuditDetails.Set(AuditFields.Fields, string.Join(", ", body.Keys))], ct);
+ await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, BuildSettingsChanges(before, result, body.Keys), ct);
if (ShouldScheduleRatesRefresh(body))
{
@@ -84,6 +95,60 @@ public static class SettingsEndpoints
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 or SettingKind.AiConfigs)
+ {
+ 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))