Показывать в аудите имена карточек и колонок вместо идентификаторов
This commit is contained in:
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user