Показывать в аудите имена карточек и колонок вместо идентификаторов
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,42 @@ public static class AuditViewFactory
|
||||
return view;
|
||||
}
|
||||
|
||||
// Заменяет идентификаторы ссылок изменения значениями из карты имён.
|
||||
// record: Запись аудита.
|
||||
// references: Имена по идентификаторам ссылок.
|
||||
// Возвращает: Изменения с читаемыми значениями ссылок.
|
||||
private static IReadOnlyList<AuditChangeDto> ResolveChanges(
|
||||
AuditRecordDto record,
|
||||
IReadOnlyDictionary<string, string> references)
|
||||
{
|
||||
IReadOnlyList<AuditChangeDto> changes = record.AuditChanges();
|
||||
if (references.Count == 0 || changes.Count == 0)
|
||||
{
|
||||
return changes;
|
||||
}
|
||||
|
||||
var resolved = new List<AuditChangeDto>(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<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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
Reference in New Issue
Block a user