Аудит: читаемые имена и «было → стало» вместо сырых данных #28

Merged
rust merged 8 commits from t25_audit_readable into main 2026-09-14 23:36:11 +03:00
19 changed files with 611 additions and 22 deletions
@@ -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<CardsService>();
CardDto unified = await cardsService.GetCardAsync(cardId, ct);
@@ -140,6 +140,7 @@ public static class ContainersEndpoints
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
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 });
}
@@ -135,6 +135,7 @@ public static class OperatorAnalyticsEndpoints
AnalyticsService analyticsService,
ITenantRepository tenantRepository,
IAuthStore authStore,
IAuditReferenceResolver referenceResolver,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
@@ -144,7 +145,7 @@ public static class OperatorAnalyticsEndpoints
AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct);
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(activity.Items, tenantRepository, authStore, ct);
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(activity.Items, tenantRepository, authStore, referenceResolver, ct);
return Results.Ok(new { items = view, total = activity.Total, limit = activity.Limit, offset = activity.Offset });
}
@@ -42,6 +42,7 @@ public static class OperatorAuditEndpoints
AuditService auditService,
ITenantRepository tenantRepository,
IAuthStore authStore,
IAuditReferenceResolver referenceResolver,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
@@ -54,7 +55,7 @@ public static class OperatorAuditEndpoints
eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset));
IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct);
int total = await auditService.CountAsync(filter, ct);
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(items, tenantRepository, authStore, ct);
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(items, tenantRepository, authStore, referenceResolver, ct);
return Results.Ok(new { items = view, total });
}
@@ -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);
/// <summary>
/// Регистрирует GET/PATCH /api/settings.
/// </summary>
@@ -72,9 +82,10 @@ public static class SettingsEndpoints
}
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
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<AuditChangeDto> BuildSettingsChanges(
PublicSettingsDto before,
PublicSettingsDto after,
IEnumerable<string> keys)
{
using JsonDocument beforeDoc = JsonDocument.Parse(JsonSerializer.Serialize(before, AuditJsonOptions));
using JsonDocument afterDoc = JsonDocument.Parse(JsonSerializer.Serialize(after, AuditJsonOptions));
var changes = new List<AuditChangeDto>();
var keyList = new List<string>();
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<string, JsonElement> body)
{
if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element))
+2
View File
@@ -165,6 +165,8 @@ builder.Services.AddSingleton<TelegramBackfillScheduler>();
builder.Services.AddScoped<AdminTickOrchestrator>();
builder.Services.AddScoped<IAuditReferenceResolver, AuditReferenceResolver>();
builder.Services.AddScoped<FtsMaintenance>();
builder.Services.AddSingleton<SseBroker>();
@@ -0,0 +1,133 @@
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
namespace Deal.Api.Services;
/// <summary>
/// Резолвер ссылок аудита на данные тенанта
/// </summary>
/// <param name="scopeFactory">Фабрика scope для чтения схемы тенанта.</param>
/// <param name="logger">Логгер сбоев разрешения ссылок.</param>
public sealed class AuditReferenceResolver(
IServiceScopeFactory scopeFactory,
ILogger<AuditReferenceResolver> logger) : IAuditReferenceResolver
{
async Task<IReadOnlyDictionary<string, string>> IAuditReferenceResolver.ResolveAsync(
IReadOnlyList<AuditRecordDto> records,
CancellationToken ct)
{
var names = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (IGrouping<Guid, AuditRecordDto> group in GroupByTenant(records))
{
await ResolveTenantAsync(group.Key, group, names, ct);
}
return names;
}
private static IEnumerable<IGrouping<Guid, AuditRecordDto>> GroupByTenant(IReadOnlyList<AuditRecordDto> records) =>
records
.Where(record => record.TenantId is not null)
.GroupBy(record => record.TenantId!.Value);
private async Task ResolveTenantAsync(
Guid tenantId,
IEnumerable<AuditRecordDto> records,
Dictionary<string, string> names,
CancellationToken ct)
{
var cardIds = new HashSet<string>(StringComparer.Ordinal);
var boardIds = new HashSet<string>(StringComparer.Ordinal);
foreach (AuditRecordDto record in records)
{
foreach (AuditChangeDto change in record.AuditChanges())
{
Collect(change.From, cardIds, boardIds);
Collect(change.To, cardIds, boardIds);
}
}
if (cardIds.Count == 0 && boardIds.Count == 0)
{
return;
}
await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope();
ITenantContext tenantContext = scope.ServiceProvider.GetRequiredService<ITenantContext>();
try
{
tenantContext.SetTenant(new TenantId(tenantId.ToString("N")));
ICardStore store = scope.ServiceProvider.GetRequiredService<ICardStore>();
await ResolveCardsAsync(store, cardIds, names, ct);
await ResolveContainersAsync(store, boardIds, names, ct);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Резолвер аудита: ссылки тенанта {TenantId} не разрешены", tenantId);
}
finally
{
tenantContext.Reset();
}
}
private static void Collect(
string? value,
HashSet<string> cardIds,
HashSet<string> boardIds)
{
if (string.IsNullOrEmpty(value))
{
return;
}
if (value.StartsWith(CardIds.CardPrefix, StringComparison.Ordinal))
{
cardIds.Add(value);
}
else if (value.StartsWith(KanbanIdPrefixes.Board, StringComparison.Ordinal))
{
boardIds.Add(value);
}
}
private static async Task ResolveCardsAsync(
ICardStore store,
HashSet<string> cardIds,
Dictionary<string, string> names,
CancellationToken ct)
{
foreach (string cardId in cardIds)
{
CardDto? card = await store.GetCardAsync(cardId, ct);
if (card is not null && !string.IsNullOrWhiteSpace(card.Title))
{
names[cardId] = card.Title;
}
}
}
private static async Task ResolveContainersAsync(
ICardStore store,
HashSet<string> boardIds,
Dictionary<string, string> names,
CancellationToken ct)
{
foreach (string boardId in boardIds)
{
ContainerDto? container = await store.GetContainerAsync(boardId, ct);
if (container is not null && !string.IsNullOrWhiteSpace(container.Name))
{
names[boardId] = container.Name;
}
}
}
}
+45 -1
View File
@@ -14,17 +14,20 @@ public static class AuditViewFactory
/// <param name="records">Записи аудита.</param>
/// <param name="tenantRepository">Реестр пространств для разрешения имён.</param>
/// <param name="authStore">Хранилище пользователей для разрешения логинов владельцев.</param>
/// <param name="referenceResolver">Резолвер ссылок на карточки и колонки.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Записи для чтения оператором.</returns>
public static async Task<IReadOnlyList<AuditRecordViewDto>> ProjectAsync(
IReadOnlyList<AuditRecordDto> records,
ITenantRepository tenantRepository,
IAuthStore authStore,
IAuditReferenceResolver referenceResolver,
CancellationToken ct)
{
Guid[] tenantIds = DistinctTenantIds(records);
IReadOnlyDictionary<Guid, string> names = await ResolveNamesAsync(tenantIds, tenantRepository, ct);
IReadOnlyDictionary<Guid, string> owners = await ResolveOwnerLoginsAsync(tenantIds, authStore, ct);
IReadOnlyDictionary<string, string> references = await referenceResolver.ResolveAsync(records, ct);
var view = new List<AuditRecordViewDto>(records.Count);
foreach (AuditRecordDto record in records)
@@ -40,7 +43,7 @@ public static class AuditViewFactory
ResolveUserName(record, owners),
tenantName,
record.Ip,
record.AuditChanges(),
ResolveChanges(record, references),
record.DetailJson,
record.At,
record.Id));
@@ -49,6 +52,47 @@ public static class AuditViewFactory
return view;
}
// Заменяет идентификаторы ссылок изменения значениями из карты имён и убирает пустые изменения.
// record: Запись аудита.
// references: Имена по идентификаторам ссылок.
// Возвращает: Изменения с читаемыми значениями ссылок без пар «без изменения».
private static IReadOnlyList<AuditChangeDto> ResolveChanges(
AuditRecordDto record,
IReadOnlyDictionary<string, string> references)
{
IReadOnlyList<AuditChangeDto> changes = record.AuditChanges();
var resolved = new List<AuditChangeDto>(changes.Count);
foreach (AuditChangeDto change in changes)
{
string? from = ResolveReference(references, change.From);
string? to = ResolveReference(references, change.To);
if (IsUnchanged(from, to))
{
continue;
}
resolved.Add(change with { From = from, To = to });
}
return resolved;
}
// Сравнивает значения «было» и «стало».
// from: Значение до изменения.
// to: Значение после изменения.
// Возвращает: True — значения заданы и совпадают.
private static bool IsUnchanged(string? from, string? to) =>
from is not null && to is not null && string.Equals(from, to, StringComparison.Ordinal);
// Подставляет имя вместо идентификатора ссылки, если оно известно.
// references: Имена по идентификаторам ссылок.
// value: Значение изменения.
// Возвращает: Имя ссылки либо исходное значение.
private static string? ResolveReference(
IReadOnlyDictionary<string, string> references,
string? value) =>
value is not null && references.TryGetValue(value, out string? name) ? name : value;
// Логин реального пользователя: владелец пространства, иначе логин/email из деталей события.
// record: Запись аудита.
// owners: Логины владельцев по идентификаторам пространств.
@@ -0,0 +1,19 @@
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Services;
/// <summary>
/// Разрешает идентификаторы ссылок аудита в читаемые имена
/// </summary>
public interface IAuditReferenceResolver
{
/// <summary>
/// Разрешает ссылки на карточки и колонки в их заголовки и имена
/// </summary>
/// <param name="records">Записи аудита выборки.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Имена по идентификаторам ссылок; неразрешённые идентификаторы отсутствуют.</returns>
public Task<IReadOnlyDictionary<string, string>> ResolveAsync(
IReadOnlyList<AuditRecordDto> records,
CancellationToken ct);
}
@@ -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);
}
}
@@ -6,4 +6,6 @@ namespace Deal.Modules.Cards.Application.Dtos;
/// Результат перехода карточки единым механизмом <see cref="ICardMover"/>.
/// </summary>
/// <param name="Error">Текст 400-ошибки либо null (успех).</param>
public sealed record CardMoveResultDto(string? Error);
/// <param name="From">Контейнер-источник после перехода; null — переход не выполнен.</param>
/// <param name="To">Контейнер-назначение после перехода; null — переход не выполнен.</param>
public sealed record CardMoveResultDto(string? Error, string? From = null, string? To = null);
@@ -22,6 +22,14 @@ public static class AuditRecordDtoExtensions
private const string OldPrefix = "old";
private const string NewPrefix = "new";
// Соответствие суффикса «old<X>» имени ключа нового значения в устаревшем формате (имена не совпадают).
private static readonly IReadOnlyDictionary<string, string> LegacyNewNameByOldSuffix =
new Dictionary<string, string>(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);
@@ -162,11 +183,12 @@ public static class AuditRecordDtoExtensions
// Приводит значение JSON к строке отображения.
// element: Значение JSON.
// Возвращает: Строковое представление либо null для JSON null.
// Возвращает: Строковое представление либо null для JSON null; массивы — списком через запятую.
private static string? Stringify(JsonElement element) => element.ValueKind switch
{
JsonValueKind.Null or JsonValueKind.Undefined => null,
JsonValueKind.String => element.GetString(),
JsonValueKind.Array => string.Join(", ", element.EnumerateArray().Select(Stringify)),
_ => element.GetRawText(),
};
@@ -0,0 +1,86 @@
using Deal.Api.Services;
using Deal.Infrastructure.Data;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.Tests.Unit.Support;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Юнит-тесты резолвера ссылок аудита
/// </summary>
public sealed class AuditReferenceResolverTests
{
private static readonly Guid TenantGuid = Guid.Parse("00000000-0000-0000-0000-000000000001");
[Fact]
public async Task Resolve_ResolvesCardTitleAndContainerName()
{
TestKanjStore kanban = new();
kanban.SeedCard(new CardDto { Id = "c_1", Title = "WPF заказ", Col = "b_1" });
kanban.SeedBoard(new ContainerDto { Id = "b_1", Name = "WPF" });
using ServiceProvider provider = BuildProvider(kanban);
var resolver = new AuditReferenceResolver(
provider.GetRequiredService<IServiceScopeFactory>(),
NullLogger<AuditReferenceResolver>.Instance);
var record = Record("""{"changes":[{"field":"cardId","to":"c_1"},{"field":"to","to":"b_1"}]}""");
IReadOnlyDictionary<string, string> names =
await ((IAuditReferenceResolver)resolver).ResolveAsync([record], CancellationToken.None);
Assert.Equal("WPF заказ", names["c_1"]);
Assert.Equal("WPF", names["b_1"]);
}
[Fact]
public async Task Resolve_UnknownIds_ReturnsOnlyKnownNames()
{
TestKanjStore kanban = new();
using ServiceProvider provider = BuildProvider(kanban);
var resolver = new AuditReferenceResolver(
provider.GetRequiredService<IServiceScopeFactory>(),
NullLogger<AuditReferenceResolver>.Instance);
var record = Record("""{"changes":[{"field":"cardId","to":"c_missing"}]}""");
IReadOnlyDictionary<string, string> names =
await ((IAuditReferenceResolver)resolver).ResolveAsync([record], CancellationToken.None);
Assert.Empty(names);
}
[Fact]
public async Task Resolve_WithoutTenant_ReturnsEmpty()
{
TestKanjStore kanban = new();
using ServiceProvider provider = BuildProvider(kanban);
var resolver = new AuditReferenceResolver(
provider.GetRequiredService<IServiceScopeFactory>(),
NullLogger<AuditReferenceResolver>.Instance);
var record = Record("""{"changes":[{"field":"cardId","to":"c_1"}]}""", tenantId: null);
IReadOnlyDictionary<string, string> names =
await ((IAuditReferenceResolver)resolver).ResolveAsync([record], CancellationToken.None);
Assert.Empty(names);
}
private static ServiceProvider BuildProvider(TestKanjStore kanban)
{
var services = new ServiceCollection();
services.AddSingleton<ITenantContext, TenantContext>();
services.AddSingleton(kanban.Store);
return services.BuildServiceProvider();
}
private static AuditRecordDto Record(string detailJson, Guid? tenantId = null) =>
new(
AuditEvents.CardMoved,
AuditActorTypes.Tenant,
ActorId: null,
TenantId: tenantId ?? TenantGuid,
Ip: null,
DetailJson: detailJson);
}
@@ -0,0 +1,59 @@
using Deal.Api.Services;
using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Юнит-тесты проекции записей аудита
/// </summary>
public sealed class AuditViewFactoryTests
{
[Fact]
public async Task Project_DropsUnchangedValues()
{
var record = new AuditRecordDto(
AuditEvents.TenantLimitChanged,
AuditActorTypes.Operator,
ActorId: null,
TenantId: null,
Ip: null,
DetailJson: """{"tenantId":"00000000-0000-0000-0000-000000000001","oldBudget":10000000,"oldPeriod":"month","budgetTokens":100000,"period":"month"}""");
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(
[record],
new TestTenantRepository().Repository,
new TestAuthStore().Store,
new TestAuditReferenceResolver(),
CancellationToken.None);
AuditRecordViewDto row = Assert.Single(view);
AuditChangeDto budget = Assert.Single(row.Changes);
Assert.Equal("budget", budget.Field);
Assert.Equal("10000000", budget.From);
Assert.Equal("100000", budget.To);
}
[Fact]
public async Task Project_KeepsChangedValues()
{
var record = new AuditRecordDto(
AuditEvents.SettingsUpdated,
AuditActorTypes.Tenant,
ActorId: null,
TenantId: null,
Ip: null,
DetailJson: """{"changes":[{"field":"minLen","from":"24","to":"42"}]}""");
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(
[record],
new TestTenantRepository().Repository,
new TestAuthStore().Store,
new TestAuditReferenceResolver(),
CancellationToken.None);
AuditChangeDto change = Assert.Single(Assert.Single(view).Changes);
Assert.Equal("24", change.From);
Assert.Equal("42", change.To);
}
}
@@ -57,6 +57,52 @@ 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<AuditChangeDto> 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_LegacyFieldsArray_RendersAsList()
{
var record = Record("""{"fields":["autoMonitorNew"]}""");
AuditChangeDto change = Assert.Single(record.AuditChanges());
Assert.Equal(AuditFields.Fields, change.Field);
Assert.Null(change.From);
Assert.Equal("autoMonitorNew", change.To);
}
[Fact]
public void AuditChanges_LegacyFieldsArray_JoinsMultipleKeys()
{
var record = Record("""{"fields":["autoMonitorNew","minLen"]}""");
AuditChangeDto change = Assert.Single(record.AuditChanges());
Assert.Equal("autoMonitorNew, minLen", change.To);
}
[Fact]
public void AuditChanges_InvalidJson_ReturnsEmpty()
{
@@ -212,6 +212,7 @@ internal static class OperatorAuthHttpHost
builder.Services.AddSingleton<IRateLimitCounterStore>(new TestRateLimitCounterStore().Store);
builder.Services.AddScoped<LoginAttemptGuard>();
builder.Services.AddScoped<SuspiciousActivityReporter>();
builder.Services.AddSingleton<IAuditReferenceResolver>(new TestAuditReferenceResolver());
WebApplication app = builder.Build();
app.UseMiddleware<SessionMiddleware>();
@@ -0,0 +1,15 @@
using Deal.Api.Services;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка резолвера ссылок аудита: без чтения данных тенанта
/// </summary>
public sealed class TestAuditReferenceResolver : IAuditReferenceResolver
{
public Task<IReadOnlyDictionary<string, string>> ResolveAsync(
IReadOnlyList<AuditRecordDto> records,
CancellationToken ct) =>
Task.FromResult<IReadOnlyDictionary<string, string>>(new Dictionary<string, string>());
}
@@ -23,6 +23,17 @@ const COLUMNS = [
const ACTOR_TONES = { operator: 'brand', tenant: 'neutral', system: 'warn' }
// Код поля «изменённые настройки» устаревшего формата деталей.
const FIELD_SETTINGS = 'fields'
// Поля-ссылки на контейнер и служебные контейнеры с человекочитаемыми именами.
const CONTAINER_FIELDS = new Set(['to', 'col', 'containerId'])
const CONTAINER_VALUE_KEYS = {
inbox: 'operator.columnValue.inbox',
archive: 'operator.columnValue.archive',
trash: 'operator.columnValue.trash',
}
// Строковые представления булевых значений в деталях события (как в JSON).
const TRUE_VALUE = 'true'
const FALSE_VALUE = 'false'
@@ -53,13 +64,37 @@ function valueLabel(field, raw) {
if (raw === null || raw === undefined || raw === '') return '—'
if (raw === TRUE_VALUE) return t('operator.da')
if (raw === FALSE_VALUE) return t('operator.net')
if (CONTAINER_FIELDS.has(field) && CONTAINER_VALUE_KEYS[raw]) return t(CONTAINER_VALUE_KEYS[raw])
return translate(`operator.value.${field}.${raw}`, raw)
}
// Список ключей настроек из устаревшего формата → русские названия через запятую.
function settingList(raw) {
return String(raw)
.split(',')
.map((key) => key.trim())
.filter(Boolean)
.map((key) => translate(`operator.setting.${key}`, key))
.join(', ')
}
function changesOf(row) {
return Array.isArray(row.changes) ? row.changes : []
}
// Строка изменения: «Поле: было → стало» либо «Поле: значение».
function changeLine(change) {
const field = fieldLabel(change.field)
if (change.field === FIELD_SETTINGS) {
return `${field}: ${settingList(change.to)}`
}
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 +134,7 @@ function pretty(json) {
class="mt-1.5 max-w-[460px] rounded-md bg-ink/70 border border-white/8 p-2"
>
<div v-if="changesOf(row).length" class="space-y-0.5">
<div v-for="(change, i) in changesOf(row)" :key="i" class="text-[11px] text-mid">
<span class="text-low">{{ fieldLabel(change.field) }}:</span>
<template v-if="hasFrom(change)">
<span class="text-low">{{ valueLabel(change.field, change.from) }}</span>
<span class="text-low/60"> </span>
</template>
<span class="text-hi">{{ valueLabel(change.field, change.to) }}</span>
</div>
<div v-for="(change, i) in changesOf(row)" :key="i" class="text-[11px] text-mid">{{ changeLine(change) }}</div>
</div>
<div v-else class="text-[11px] text-low">{{ $t('operator.dopolnitelnyh-parametrov-net') }}</div>
<button
+52 -1
View File
@@ -867,6 +867,7 @@ export const ru = {
"impersonation_stopped": "Завершён вход от имени пользователя"
},
"field": {
"id": "Идентификатор",
"login": "Логин",
"email": "Email",
"codeHash": "Код (хэш)",
@@ -878,7 +879,7 @@ export const ru = {
"status": "Статус",
"targetLogin": "Вход",
"cardId": "Карточка",
"to": "Куда",
"to": "Колонка",
"col": "Колонка",
"containerId": "Колонка",
"attempted": "Обработано",
@@ -891,6 +892,56 @@ export const ru = {
"phase": "Этап",
"dialogId": "Диалог"
},
"setting": {
"archiveAfterDays": "Срок до архива",
"archiveClearDays": "Очистка архива, дней",
"trashClearDays": "Очистка корзины, дней",
"minLen": "Минимальная длина сообщения",
"discJoinLimit": "Суточный лимит вступлений",
"discJoinDelayMin": "Мин. задержка вступления, сек",
"discJoinDelayMax": "Макс. задержка вступления, сек",
"discEvalSample": "Сообщений для оценки",
"discEvalThreshold": "Порог соответствия, %",
"excludeBudgetFrom": "Исключать бюджет от",
"excludeBudgetTo": "Исключать бюджет до",
"autoArchive": "Автоперенос в архив",
"aiEnabled": "Использовать ИИ",
"aiFilterEnabled": "ИИ-фильтр входящих",
"conversionOn": "Конвертация валют",
"remindersEnabled": "Напоминания",
"mlEnabled": "ML в пайплайне",
"blockResumes": "Блокировать резюме",
"budgetRequiredHire": "Требовать бюджет для вакансий",
"budgetRequiredOrder": "Требовать бюджет для фриланса",
"autoMonitorNew": "Проверять новые каналы автоматически",
"discPaused": "Поиск каналов на паузе",
"targetCurrency": "Целевая валюта",
"rateSource": "Источник курсов",
"aiProvider": "Провайдер ИИ",
"aiPrompt": "Промпт классификатора",
"aiFilterPrompt": "Промпт фильтра",
"cardPrompt": "Промпт структуры карточки",
"domainDescription": "Описание домена",
"wantedType": "Тип заявок",
"hireLabel": "Метка вакансии",
"orderLabel": "Метка фриланса",
"stopPhrases": "Стоп-фразы",
"domainKeywords": "Ключевые слова домена",
"hireMarkers": "Маркеры найма",
"levelTerms": "Термины уровней",
"resumeMarkers": "Маркеры резюме",
"excludeKeywords": "Исключающие слова",
"excludeLocations": "Исключающие локации",
"excludeTypes": "Исключающие типы",
"colState": "Состояние колонок",
"myPrompts": "Мои промпты",
"aiConfigs": "Провайдеры ИИ"
},
"columnValue": {
"inbox": "Неразобранное",
"archive": "Архив",
"trash": "Корзина"
},
"value": {
"status": {
"active": "Активен",