Показывать в аудите имена карточек и колонок вместо идентификаторов

This commit is contained in:
2026-09-13 21:39:03 +03:00
parent f7459069d1
commit fb964f9b4f
9 changed files with 300 additions and 3 deletions
@@ -135,6 +135,7 @@ public static class OperatorAnalyticsEndpoints
AnalyticsService analyticsService, AnalyticsService analyticsService,
ITenantRepository tenantRepository, ITenantRepository tenantRepository,
IAuthStore authStore, IAuthStore authStore,
IAuditReferenceResolver referenceResolver,
CancellationToken ct) CancellationToken ct)
{ {
if (context.GetCurrentOperator() is null) if (context.GetCurrentOperator() is null)
@@ -144,7 +145,7 @@ public static class OperatorAnalyticsEndpoints
AnalyticsActivityDto activity = await analyticsService.ActivityAsync( AnalyticsActivityDto activity = await analyticsService.ActivityAsync(
eventType, actorType, actorId, tenantId, from, to, limit, offset, ct); 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 }); 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, AuditService auditService,
ITenantRepository tenantRepository, ITenantRepository tenantRepository,
IAuthStore authStore, IAuthStore authStore,
IAuditReferenceResolver referenceResolver,
CancellationToken ct) CancellationToken ct)
{ {
var operatorIdentity = context.GetCurrentOperator(); var operatorIdentity = context.GetCurrentOperator();
@@ -54,7 +55,7 @@ public static class OperatorAuditEndpoints
eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset)); eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset));
IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct); IReadOnlyList<AuditRecordDto> items = await auditService.QueryAsync(filter, ct);
int total = await auditService.CountAsync(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 }); return Results.Ok(new { items = view, total });
} }
+2
View File
@@ -165,6 +165,8 @@ builder.Services.AddSingleton<TelegramBackfillScheduler>();
builder.Services.AddScoped<AdminTickOrchestrator>(); builder.Services.AddScoped<AdminTickOrchestrator>();
builder.Services.AddScoped<IAuditReferenceResolver, AuditReferenceResolver>();
builder.Services.AddScoped<FtsMaintenance>(); builder.Services.AddScoped<FtsMaintenance>();
builder.Services.AddSingleton<SseBroker>(); 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;
}
}
}
}
+40 -1
View File
@@ -14,17 +14,20 @@ public static class AuditViewFactory
/// <param name="records">Записи аудита.</param> /// <param name="records">Записи аудита.</param>
/// <param name="tenantRepository">Реестр пространств для разрешения имён.</param> /// <param name="tenantRepository">Реестр пространств для разрешения имён.</param>
/// <param name="authStore">Хранилище пользователей для разрешения логинов владельцев.</param> /// <param name="authStore">Хранилище пользователей для разрешения логинов владельцев.</param>
/// <param name="referenceResolver">Резолвер ссылок на карточки и колонки.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Записи для чтения оператором.</returns> /// <returns>Записи для чтения оператором.</returns>
public static async Task<IReadOnlyList<AuditRecordViewDto>> ProjectAsync( public static async Task<IReadOnlyList<AuditRecordViewDto>> ProjectAsync(
IReadOnlyList<AuditRecordDto> records, IReadOnlyList<AuditRecordDto> records,
ITenantRepository tenantRepository, ITenantRepository tenantRepository,
IAuthStore authStore, IAuthStore authStore,
IAuditReferenceResolver referenceResolver,
CancellationToken ct) CancellationToken ct)
{ {
Guid[] tenantIds = DistinctTenantIds(records); Guid[] tenantIds = DistinctTenantIds(records);
IReadOnlyDictionary<Guid, string> names = await ResolveNamesAsync(tenantIds, tenantRepository, ct); IReadOnlyDictionary<Guid, string> names = await ResolveNamesAsync(tenantIds, tenantRepository, ct);
IReadOnlyDictionary<Guid, string> owners = await ResolveOwnerLoginsAsync(tenantIds, authStore, 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); var view = new List<AuditRecordViewDto>(records.Count);
foreach (AuditRecordDto record in records) foreach (AuditRecordDto record in records)
@@ -40,7 +43,7 @@ public static class AuditViewFactory
ResolveUserName(record, owners), ResolveUserName(record, owners),
tenantName, tenantName,
record.Ip, record.Ip,
record.AuditChanges(), ResolveChanges(record, references),
record.DetailJson, record.DetailJson,
record.At, record.At,
record.Id)); record.Id));
@@ -49,6 +52,42 @@ public static class AuditViewFactory
return view; 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 из деталей события. // Логин реального пользователя: владелец пространства, иначе логин/email из деталей события.
// record: Запись аудита. // record: Запись аудита.
// owners: Логины владельцев по идентификаторам пространств. // 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.AddSingleton<IRateLimitCounterStore>(new TestRateLimitCounterStore().Store);
builder.Services.AddScoped<LoginAttemptGuard>(); builder.Services.AddScoped<LoginAttemptGuard>();
builder.Services.AddScoped<SuspiciousActivityReporter>(); builder.Services.AddScoped<SuspiciousActivityReporter>();
builder.Services.AddSingleton<IAuditReferenceResolver>(new TestAuditReferenceResolver());
WebApplication app = builder.Build(); WebApplication app = builder.Build();
app.UseMiddleware<SessionMiddleware>(); 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>());
}