From f31989a133044dcf63977b675b3035fe625f01e4 Mon Sep 17 00:00:00 2001 From: stepan Date: Sun, 13 Sep 2026 21:08:15 +0300 Subject: [PATCH 1/8] =?UTF-8?q?=D0=9F=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=B2=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82?= =?UTF-8?q?=D0=B5=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=C2=AB=D0=B1=D1=8B=D0=BB=D0=BE=20=E2=86=92=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Перемещение карточки пишет прежнюю и новую колонку (CardMoveResultDto.From/To), изменение колонки — переименование «было → стало», разбор старого плоского формата спаривает oldBudget/budgetTokens и oldPeriod/period. Добавлены тесты разбора. --- src/core/Deal.Api/Endpoints/CardsEndpoints.cs | 9 +++++- .../Deal.Api/Endpoints/ContainersEndpoints.cs | 9 +++++- .../Deal.Infrastructure/Services/CardMover.cs | 2 +- .../Application/Dtos/CardMoveResultDto.cs | 4 ++- .../Extensions/AuditRecordDtoExtensions.cs | 29 ++++++++++++++++--- .../Tenants/AuditRecordDtoExtensionsTests.cs | 24 +++++++++++++++ 6 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs index 216139a..0b56d94 100644 --- a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs @@ -182,7 +182,14 @@ public static class CardsEndpoints return EndpointResults.BadRequest(outcome.Error); } - await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, [AuditDetails.Set(AuditFields.CardId, cardId), AuditDetails.Set(AuditFields.Destination, body.To)], ct); + await AuditAppender.AppendTenantAsync( + context, + AuditEvents.CardMoved, + [ + AuditDetails.Set(AuditFields.CardId, cardId), + AuditDetails.Change(AuditFields.ContainerId, outcome.From, outcome.To), + ], + ct); CardsService cardsService = context.RequestServices.GetRequiredService(); CardDto unified = await cardsService.GetCardAsync(cardId, ct); diff --git a/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs b/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs index 5bc70ec..4dc32ba 100644 --- a/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs @@ -140,6 +140,7 @@ public static class ContainersEndpoints } ContainersService containers = context.RequestServices.GetRequiredService(); + ContainerDto before = await containers.GetAsync(containerId, ct); ContainerDto updated = await containers.PatchAsync( containerId, new ContainerPatchDto( @@ -153,7 +154,13 @@ public static class ContainersEndpoints patchBody.Policy), ct); - await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, [AuditDetails.Set(AuditFields.ContainerId, updated.Id)], ct); + await AuditAppender.AppendTenantAsync( + context, + AuditEvents.ContainerUpdated, + string.Equals(before.Name, updated.Name, StringComparison.Ordinal) + ? [AuditDetails.Set(AuditFields.ContainerId, updated.Id)] + : [AuditDetails.Change(AuditFields.Name, before.Name, updated.Name)], + ct); return Results.Ok(new { id = updated.Id }); } diff --git a/src/core/Deal.Infrastructure/Services/CardMover.cs b/src/core/Deal.Infrastructure/Services/CardMover.cs index 1f59b4c..e7d5459 100644 --- a/src/core/Deal.Infrastructure/Services/CardMover.cs +++ b/src/core/Deal.Infrastructure/Services/CardMover.cs @@ -22,6 +22,6 @@ public sealed class CardMover(CardsService cardsService) : ICardMover CardResultDto result = CardsDefaultContainers.Contains(toContainerId) ? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct) : await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct); - return new CardMoveResultDto(result.Error); + return new CardMoveResultDto(result.Error, From: result.Card?.PrevCol, To: result.Card?.Col); } } diff --git a/src/core/Deal.Modules.Cards/Application/Dtos/CardMoveResultDto.cs b/src/core/Deal.Modules.Cards/Application/Dtos/CardMoveResultDto.cs index 06bfa6a..65f7e01 100644 --- a/src/core/Deal.Modules.Cards/Application/Dtos/CardMoveResultDto.cs +++ b/src/core/Deal.Modules.Cards/Application/Dtos/CardMoveResultDto.cs @@ -6,4 +6,6 @@ namespace Deal.Modules.Cards.Application.Dtos; /// Результат перехода карточки единым механизмом . /// /// Текст 400-ошибки либо null (успех). -public sealed record CardMoveResultDto(string? Error); +/// Контейнер-источник после перехода; null — переход не выполнен. +/// Контейнер-назначение после перехода; null — переход не выполнен. +public sealed record CardMoveResultDto(string? Error, string? From = null, string? To = null); diff --git a/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs b/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs index 445bb56..b0879fd 100644 --- a/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs +++ b/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs @@ -22,6 +22,14 @@ public static class AuditRecordDtoExtensions private const string OldPrefix = "old"; private const string NewPrefix = "new"; + // Соответствие суффикса «old» имени ключа нового значения в устаревшем формате (имена не совпадают). + private static readonly IReadOnlyDictionary LegacyNewNameByOldSuffix = + new Dictionary(StringComparer.Ordinal) + { + ["Budget"] = "budgetTokens", + ["Period"] = "period", + }; + // События аудита «неудачный вход» (тенант/оператор). private static readonly string[] FailedLoginEvents = { @@ -140,10 +148,23 @@ public static class AuditRecordDtoExtensions continue; } - string? pairedNewName = property.Name.StartsWith(OldPrefix, StringComparison.Ordinal) - && property.Name.Length > OldPrefix.Length - ? NewPrefix + property.Name[OldPrefix.Length..] - : null; + string? pairedNewName = null; + if (property.Name.StartsWith(OldPrefix, StringComparison.Ordinal) + && property.Name.Length > OldPrefix.Length) + { + string suffix = property.Name[OldPrefix.Length..]; + string defaultNewName = NewPrefix + suffix; + if (LegacyNewNameByOldSuffix.TryGetValue(suffix, out string? mapped) + && root.TryGetProperty(mapped, out JsonElement mappedValue)) + { + consumed.Add(mapped); + result.Add(new AuditChangeDto(LowerFirst(suffix), Stringify(property.Value), Stringify(mappedValue))); + continue; + } + + pairedNewName = root.TryGetProperty(defaultNewName, out _) ? defaultNewName : null; + } + if (pairedNewName is not null && root.TryGetProperty(pairedNewName, out JsonElement newValue)) { consumed.Add(pairedNewName); diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditRecordDtoExtensionsTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditRecordDtoExtensionsTests.cs index eba20e8..f9bd186 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditRecordDtoExtensionsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditRecordDtoExtensionsTests.cs @@ -57,6 +57,30 @@ public sealed class AuditRecordDtoExtensionsTests }); } + [Fact] + public void AuditChanges_LegacyLimitChange_PairsBudgetAndPeriod() + { + var record = Record( + """{"tenantId":"6f3c0d1e-2b4a-4c8d-9e0f-1a2b3c4d5e6f","oldBudget":100000,"oldPeriod":"month","budgetTokens":1000000,"period":"month"}"""); + + IReadOnlyList changes = record.AuditChanges(); + + Assert.Collection( + changes, + budget => + { + Assert.Equal("budget", budget.Field); + Assert.Equal("100000", budget.From); + Assert.Equal("1000000", budget.To); + }, + period => + { + Assert.Equal("period", period.Field); + Assert.Equal("month", period.From); + Assert.Equal("month", period.To); + }); + } + [Fact] public void AuditChanges_InvalidJson_ReturnsEmpty() { From 905129effa8041180b4669e95b19fe706e864e26 Mon Sep 17 00:00:00 2001 From: stepan Date: Sun, 13 Sep 2026 21:08:15 +0300 Subject: [PATCH 2/8] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D1=8C=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8F=20=D0=B8=D0=B4=D0=B5=D0=BD=D1=82=D0=B8=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=D1=82=D0=BE=D1=80=D0=B0=20=D0=B2=20=D0=B0?= =?UTF-8?q?=D1=83=D0=B4=D0=B8=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/i18n/locales/ru.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/frontend/src/i18n/locales/ru.js b/src/frontend/src/i18n/locales/ru.js index 1e331b8..c7f2c20 100644 --- a/src/frontend/src/i18n/locales/ru.js +++ b/src/frontend/src/i18n/locales/ru.js @@ -867,6 +867,7 @@ export const ru = { "impersonation_stopped": "Завершён вход от имени пользователя" }, "field": { + "id": "Идентификатор", "login": "Логин", "email": "Email", "codeHash": "Код (хэш)", From d0e202fbc6cf0bf0170e86bc99175f90d6f28d06 Mon Sep 17 00:00:00 2001 From: stepan Date: Sun, 13 Sep 2026 21:21:02 +0300 Subject: [PATCH 3/8] =?UTF-8?q?=D0=9F=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=B2=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82?= =?UTF-8?q?=D0=B5=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D1=91=D0=BD=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settings_updated пишет по каждому ключу «поле: было → стало» (скаляры и списки; секреты и снимки состояния — пометкой «изменено»), длинные значения обрезаются. --- .../Deal.Api/Endpoints/SettingsEndpoints.cs | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) 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)) From 37b7524665b779677480667989a63c23a6bc089f Mon Sep 17 00:00:00 2001 From: stepan Date: Sun, 13 Sep 2026 21:21:02 +0300 Subject: [PATCH 4/8] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE=D0=B1=D0=B5=D0=BB=20=D0=B2=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B5=20=D0=B8=D0=B7=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/operator/AuditTable.vue | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/frontend/src/components/operator/AuditTable.vue b/src/frontend/src/components/operator/AuditTable.vue index 3d7632f..4394d29 100644 --- a/src/frontend/src/components/operator/AuditTable.vue +++ b/src/frontend/src/components/operator/AuditTable.vue @@ -60,6 +60,15 @@ function changesOf(row) { return Array.isArray(row.changes) ? row.changes : [] } +// Строка изменения: «Поле: было → стало» либо «Поле: значение». +function changeLine(change) { + const field = fieldLabel(change.field) + const to = valueLabel(change.field, change.to) + return hasFrom(change) + ? `${field}: ${valueLabel(change.field, change.from)} → ${to}` + : `${field}: ${to}` +} + // Вторая строка колонки пользователя: имя пространства, иначе id. function tenantSubline(row) { return row.userName && row.tenantName ? row.tenantName : row.tenantId || '—' @@ -99,14 +108,7 @@ function pretty(json) { class="mt-1.5 max-w-[460px] rounded-md bg-ink/70 border border-white/8 p-2" >
-
- {{ fieldLabel(change.field) }}: - - {{ valueLabel(change.field, change.to) }} -
+
{{ changeLine(change) }}
{{ $t('operator.dopolnitelnyh-parametrov-net') }}