Аудит: читаемые имена и «было → стало» вместо сырых данных #28
@@ -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))
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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": "Активен",
|
||||
|
||||
Reference in New Issue
Block a user