diff --git a/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs index 603d299..3d09c6d 100644 --- a/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs @@ -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 view = await AuditViewFactory.ProjectAsync(activity.Items, tenantRepository, authStore, ct); + IReadOnlyList 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 }); } diff --git a/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs index 2112281..df99e34 100644 --- a/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs @@ -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 items = await auditService.QueryAsync(filter, ct); int total = await auditService.CountAsync(filter, ct); - IReadOnlyList view = await AuditViewFactory.ProjectAsync(items, tenantRepository, authStore, ct); + IReadOnlyList view = await AuditViewFactory.ProjectAsync(items, tenantRepository, authStore, referenceResolver, ct); return Results.Ok(new { items = view, total }); } diff --git a/src/core/Deal.Api/Program.cs b/src/core/Deal.Api/Program.cs index a1eb42e..f683551 100644 --- a/src/core/Deal.Api/Program.cs +++ b/src/core/Deal.Api/Program.cs @@ -165,6 +165,8 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/src/core/Deal.Api/Services/AuditReferenceResolver.cs b/src/core/Deal.Api/Services/AuditReferenceResolver.cs new file mode 100644 index 0000000..ede6cd0 --- /dev/null +++ b/src/core/Deal.Api/Services/AuditReferenceResolver.cs @@ -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; + +/// +/// Резолвер ссылок аудита на данные тенанта +/// +/// Фабрика scope для чтения схемы тенанта. +/// Логгер сбоев разрешения ссылок. +public sealed class AuditReferenceResolver( + IServiceScopeFactory scopeFactory, + ILogger logger) : IAuditReferenceResolver +{ + async Task> IAuditReferenceResolver.ResolveAsync( + IReadOnlyList records, + CancellationToken ct) + { + var names = new Dictionary(StringComparer.Ordinal); + foreach (IGrouping group in GroupByTenant(records)) + { + await ResolveTenantAsync(group.Key, group, names, ct); + } + + return names; + } + + private static IEnumerable> GroupByTenant(IReadOnlyList records) => + records + .Where(record => record.TenantId is not null) + .GroupBy(record => record.TenantId!.Value); + + private async Task ResolveTenantAsync( + Guid tenantId, + IEnumerable records, + Dictionary names, + CancellationToken ct) + { + var cardIds = new HashSet(StringComparer.Ordinal); + var boardIds = new HashSet(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(); + try + { + tenantContext.SetTenant(new TenantId(tenantId.ToString("N"))); + ICardStore store = scope.ServiceProvider.GetRequiredService(); + 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 cardIds, + HashSet 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 cardIds, + Dictionary 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 boardIds, + Dictionary 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; + } + } + } +} diff --git a/src/core/Deal.Api/Services/AuditViewFactory.cs b/src/core/Deal.Api/Services/AuditViewFactory.cs index ca547e8..c24a679 100644 --- a/src/core/Deal.Api/Services/AuditViewFactory.cs +++ b/src/core/Deal.Api/Services/AuditViewFactory.cs @@ -14,17 +14,20 @@ public static class AuditViewFactory /// Записи аудита. /// Реестр пространств для разрешения имён. /// Хранилище пользователей для разрешения логинов владельцев. + /// Резолвер ссылок на карточки и колонки. /// Токен отмены. /// Записи для чтения оператором. public static async Task> ProjectAsync( IReadOnlyList records, ITenantRepository tenantRepository, IAuthStore authStore, + IAuditReferenceResolver referenceResolver, CancellationToken ct) { Guid[] tenantIds = DistinctTenantIds(records); IReadOnlyDictionary names = await ResolveNamesAsync(tenantIds, tenantRepository, ct); IReadOnlyDictionary owners = await ResolveOwnerLoginsAsync(tenantIds, authStore, ct); + IReadOnlyDictionary references = await referenceResolver.ResolveAsync(records, ct); var view = new List(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,42 @@ public static class AuditViewFactory return view; } + // Заменяет идентификаторы ссылок изменения значениями из карты имён. + // record: Запись аудита. + // references: Имена по идентификаторам ссылок. + // Возвращает: Изменения с читаемыми значениями ссылок. + private static IReadOnlyList ResolveChanges( + AuditRecordDto record, + IReadOnlyDictionary references) + { + IReadOnlyList changes = record.AuditChanges(); + if (references.Count == 0 || changes.Count == 0) + { + return changes; + } + + var resolved = new List(changes.Count); + foreach (AuditChangeDto change in changes) + { + resolved.Add(change with + { + From = ResolveReference(references, change.From), + To = ResolveReference(references, change.To), + }); + } + + return resolved; + } + + // Подставляет имя вместо идентификатора ссылки, если оно известно. + // references: Имена по идентификаторам ссылок. + // value: Значение изменения. + // Возвращает: Имя ссылки либо исходное значение. + private static string? ResolveReference( + IReadOnlyDictionary references, + string? value) => + value is not null && references.TryGetValue(value, out string? name) ? name : value; + // Логин реального пользователя: владелец пространства, иначе логин/email из деталей события. // record: Запись аудита. // owners: Логины владельцев по идентификаторам пространств. diff --git a/src/core/Deal.Api/Services/IAuditReferenceResolver.cs b/src/core/Deal.Api/Services/IAuditReferenceResolver.cs new file mode 100644 index 0000000..6d288a1 --- /dev/null +++ b/src/core/Deal.Api/Services/IAuditReferenceResolver.cs @@ -0,0 +1,19 @@ +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Api.Services; + +/// +/// Разрешает идентификаторы ссылок аудита в читаемые имена +/// +public interface IAuditReferenceResolver +{ + /// + /// Разрешает ссылки на карточки и колонки в их заголовки и имена + /// + /// Записи аудита выборки. + /// Токен отмены. + /// Имена по идентификаторам ссылок; неразрешённые идентификаторы отсутствуют. + public Task> ResolveAsync( + IReadOnlyList records, + CancellationToken ct); +} diff --git a/src/core/tests/Deal.Tests.Unit/Api/AuditReferenceResolverTests.cs b/src/core/tests/Deal.Tests.Unit/Api/AuditReferenceResolverTests.cs new file mode 100644 index 0000000..41928ea --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Api/AuditReferenceResolverTests.cs @@ -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; + +/// +/// Юнит-тесты резолвера ссылок аудита +/// +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(), + NullLogger.Instance); + + var record = Record("""{"changes":[{"field":"cardId","to":"c_1"},{"field":"to","to":"b_1"}]}"""); + IReadOnlyDictionary 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(), + NullLogger.Instance); + + var record = Record("""{"changes":[{"field":"cardId","to":"c_missing"}]}"""); + IReadOnlyDictionary 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(), + NullLogger.Instance); + + var record = Record("""{"changes":[{"field":"cardId","to":"c_1"}]}""", tenantId: null); + IReadOnlyDictionary names = + await ((IAuditReferenceResolver)resolver).ResolveAsync([record], CancellationToken.None); + + Assert.Empty(names); + } + + private static ServiceProvider BuildProvider(TestKanjStore kanban) + { + var services = new ServiceCollection(); + services.AddSingleton(); + 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); +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs index 79c569f..d603551 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs @@ -212,6 +212,7 @@ internal static class OperatorAuthHttpHost builder.Services.AddSingleton(new TestRateLimitCounterStore().Store); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddSingleton(new TestAuditReferenceResolver()); WebApplication app = builder.Build(); app.UseMiddleware(); diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestAuditReferenceResolver.cs b/src/core/tests/Deal.Tests.Unit/Support/TestAuditReferenceResolver.cs new file mode 100644 index 0000000..4b37fb9 --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/TestAuditReferenceResolver.cs @@ -0,0 +1,15 @@ +using Deal.Api.Services; +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Tests.Unit.Support; + +/// +/// Подставка резолвера ссылок аудита: без чтения данных тенанта +/// +public sealed class TestAuditReferenceResolver : IAuditReferenceResolver +{ + public Task> ResolveAsync( + IReadOnlyList records, + CancellationToken ct) => + Task.FromResult>(new Dictionary()); +}