Показывать реального пользователя в аудите и лимитах

Добавлен батч-резолв логинов владельцев пространств (IAuthStore.FindOwnerLoginsByTenantIdsAsync). Ответы аудита дополнены userName (владелец пространства либо логин попытки), ответы лимитов — ownerLogin; имя пространства остаётся вторичным.
This commit is contained in:
2026-09-13 20:23:12 +03:00
parent c46bbe9332
commit a99bb4a713
7 changed files with 111 additions and 23 deletions
@@ -134,6 +134,7 @@ public static class OperatorAnalyticsEndpoints
HttpContext context,
AnalyticsService analyticsService,
ITenantRepository tenantRepository,
IAuthStore authStore,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
@@ -143,7 +144,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, ct);
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(activity.Items, tenantRepository, authStore, ct);
return Results.Ok(new { items = view, total = activity.Total, limit = activity.Limit, offset = activity.Offset });
}
@@ -41,6 +41,7 @@ public static class OperatorAuditEndpoints
HttpContext context,
AuditService auditService,
ITenantRepository tenantRepository,
IAuthStore authStore,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
@@ -53,7 +54,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, ct);
IReadOnlyList<AuditRecordViewDto> view = await AuditViewFactory.ProjectAsync(items, tenantRepository, authStore, ct);
return Results.Ok(new { items = view, total });
}
@@ -58,6 +58,7 @@ public static class OperatorLimitsEndpoints
private static async Task<IResult> ListSummaryAsync(
HttpContext context,
ITenantRepository tenantRepository,
IAuthStore authStore,
ITenantLimitStore limitStore,
CancellationToken ct)
{
@@ -68,6 +69,8 @@ public static class OperatorLimitsEndpoints
}
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
IReadOnlyDictionary<Guid, string> owners = await authStore.FindOwnerLoginsByTenantIdsAsync(
tenants.Select(tenant => tenant.Id).ToArray(), ct);
var items = new List<object>(tenants.Count);
foreach (TenantRecordDto tenant in tenants)
{
@@ -75,6 +78,7 @@ public static class OperatorLimitsEndpoints
items.Add(new
{
tenantId = tenant.Id,
ownerLogin = owners.GetValueOrDefault(tenant.Id),
name = tenant.Name,
budget = state.BudgetTokens,
period = state.Period,
@@ -92,6 +96,7 @@ public static class OperatorLimitsEndpoints
Guid id,
HttpContext context,
ITenantRepository tenantRepository,
IAuthStore authStore,
ITenantLimitStore limitStore,
CancellationToken ct)
{
@@ -108,7 +113,7 @@ public static class OperatorLimitsEndpoints
}
BudgetStateDto state = await limitStore.GetStateAsync(id, ct);
return Results.Ok(BuildDetailDto(tenant.Name, state));
return Results.Ok(BuildDetailDto(tenant.Name, await ResolveOwnerLoginAsync(authStore, id, ct), state));
}
// PATCH /api/operator/tenants/{id}/limit: смена бюджета/периода (сброс флагов + аудит tenant_limit_changed).
@@ -117,6 +122,7 @@ public static class OperatorLimitsEndpoints
OperatorLimitUpdateRequest? body,
HttpContext context,
ITenantRepository tenantRepository,
IAuthStore authStore,
ITenantLimitStore limitStore,
AuditService auditService,
CancellationToken ct)
@@ -154,10 +160,11 @@ public static class OperatorLimitsEndpoints
BudgetStateDto current = await limitStore.GetStateAsync(id, ct);
long newBudget = body.Budget ?? current.BudgetTokens;
string newPeriod = body.Period ?? current.Period;
string? ownerLogin = await ResolveOwnerLoginAsync(authStore, id, ct);
if (newBudget == current.BudgetTokens && newPeriod == current.Period)
{
// Идемпотентный повторный PATCH: без изменения хранилища и без дубля аудита.
return Results.Ok(BuildDetailDto(tenant.Name, current));
return Results.Ok(BuildDetailDto(tenant.Name, ownerLogin, current));
}
BudgetStateDto updated = await limitStore.UpdateBudgetAsync(id, newBudget, newPeriod, ct);
@@ -170,7 +177,7 @@ public static class OperatorLimitsEndpoints
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Change("budget", current.BudgetTokens, newBudget), AuditDetails.Change("period", current.Period, newPeriod)])), ct);
return Results.Ok(BuildDetailDto(tenant.Name, updated));
return Results.Ok(BuildDetailDto(tenant.Name, ownerLogin, updated));
}
/// <summary>
@@ -191,11 +198,13 @@ public static class OperatorLimitsEndpoints
// Форма деталей лимита тенанта (GET и ответ PATCH — единая).
// name: Имя тенанта (реестр).
// ownerLogin: Логин владельца пространства; null, если пользователей нет.
// state: Состояние бюджета (после ленивого reset).
// Возвращает: Объект ответа: лимит + расход + флаги порогов + статус тенанта.
private static object BuildDetailDto(string name, BudgetStateDto state) => new
private static object BuildDetailDto(string name, string? ownerLogin, BudgetStateDto state) => new
{
tenantId = state.TenantId,
ownerLogin,
name,
status = state.Status,
allowed = state.Allowed,
@@ -209,6 +218,17 @@ public static class OperatorLimitsEndpoints
notifiedExhausted = state.NotifiedExhausted,
};
// Логин владельца пространства (первый пользователь по времени создания).
// authStore: Хранилище пользователей.
// tenantId: Идентификатор пространства.
// ct: Токен отмены.
// Возвращает: Логин либо null.
private static async Task<string?> ResolveOwnerLoginAsync(IAuthStore authStore, Guid tenantId, CancellationToken ct)
{
IReadOnlyDictionary<Guid, string> owners = await authStore.FindOwnerLoginsByTenantIdsAsync([tenantId], ct);
return owners.GetValueOrDefault(tenantId);
}
// IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
+44 -16
View File
@@ -9,18 +9,23 @@ namespace Deal.Api.Services;
public static class AuditViewFactory
{
/// <summary>
/// Дополняет записи аудита именами пользователей и разбирает изменения деталей
/// Дополняет записи аудита реальными пользователями и разбирает изменения деталей
/// </summary>
/// <param name="records">Записи аудита.</param>
/// <param name="tenantRepository">Реестр пользователей для разрешения имён.</param>
/// <param name="tenantRepository">Реестр пространств для разрешения имён.</param>
/// <param name="authStore">Хранилище пользователей для разрешения логинов владельцев.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Записи для чтения оператором.</returns>
public static async Task<IReadOnlyList<AuditRecordViewDto>> ProjectAsync(
IReadOnlyList<AuditRecordDto> records,
ITenantRepository tenantRepository,
IAuthStore authStore,
CancellationToken ct)
{
IReadOnlyDictionary<Guid, string> names = await ResolveNamesAsync(records, tenantRepository, ct);
Guid[] tenantIds = DistinctTenantIds(records);
IReadOnlyDictionary<Guid, string> names = await ResolveNamesAsync(tenantIds, tenantRepository, ct);
IReadOnlyDictionary<Guid, string> owners = await ResolveOwnerLoginsAsync(tenantIds, authStore, ct);
var view = new List<AuditRecordViewDto>(records.Count);
foreach (AuditRecordDto record in records)
{
@@ -32,6 +37,7 @@ public static class AuditViewFactory
record.ActorType,
record.ActorId,
record.TenantId,
ResolveUserName(record, owners),
tenantName,
record.Ip,
record.AuditChanges(),
@@ -43,24 +49,46 @@ public static class AuditViewFactory
return view;
}
// Разрешает имена пользователей одной выборкой по уникальным идентификаторам.
// records: Записи аудита.
// tenantRepository: Реестр пользователей.
// ct: Токен отмены.
// Возвращает: Словарь id → имя.
private static async Task<IReadOnlyDictionary<Guid, string>> ResolveNamesAsync(
IReadOnlyList<AuditRecordDto> records,
ITenantRepository tenantRepository,
CancellationToken ct)
// Логин реального пользователя: владелец пространства, иначе логин/email из деталей события.
// record: Запись аудита.
// owners: Логины владельцев по идентификаторам пространств.
// Возвращает: Логин либо null.
private static string? ResolveUserName(
AuditRecordDto record,
IReadOnlyDictionary<Guid, string> owners)
{
Guid[] ids = records
if (record.TenantId is { } tenantId && owners.TryGetValue(tenantId, out string? owner))
{
return owner;
}
return record.DetailValue("login") ?? record.DetailValue("email");
}
// Уникальные идентификаторы пространств выборки.
private static Guid[] DistinctTenantIds(IReadOnlyList<AuditRecordDto> records) =>
records
.Select(record => record.TenantId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToArray();
return ids.Length == 0
// Разрешает имена пространств выборкой по уникальным идентификаторам.
private static async Task<IReadOnlyDictionary<Guid, string>> ResolveNamesAsync(
Guid[] tenantIds,
ITenantRepository tenantRepository,
CancellationToken ct) =>
tenantIds.Length == 0
? new Dictionary<Guid, string>()
: await tenantRepository.FindNamesByIdsAsync(ids, ct);
}
: await tenantRepository.FindNamesByIdsAsync(tenantIds, ct);
// Разрешает логины владельцев пространств выборкой по уникальным идентификаторам.
private static async Task<IReadOnlyDictionary<Guid, string>> ResolveOwnerLoginsAsync(
Guid[] tenantIds,
IAuthStore authStore,
CancellationToken ct) =>
tenantIds.Length == 0
? new Dictionary<Guid, string>()
: await authStore.FindOwnerLoginsByTenantIdsAsync(tenantIds, ct);
}
@@ -67,6 +67,32 @@ public sealed class AuthStore(DealDbContext dbContext) : IAuthStore
return entities;
}
public async Task<IReadOnlyDictionary<Guid, string>> FindOwnerLoginsByTenantIdsAsync(
IReadOnlyCollection<Guid> tenantIds,
CancellationToken ct)
{
if (tenantIds.Count == 0)
{
return new Dictionary<Guid, string>();
}
var users = await dbContext.Users
.AsNoTracking()
.Where(u => tenantIds.Contains(u.TenantId))
.OrderBy(u => u.CreatedAt)
.ThenBy(u => u.Login)
.Select(u => new { u.TenantId, u.Login })
.ToListAsync(ct);
var owners = new Dictionary<Guid, string>();
foreach (var user in users)
{
owners.TryAdd(user.TenantId, user.Login);
}
return owners;
}
/// <inheritdoc />
public async Task CreateSessionAsync(SessionDto session, CancellationToken ct)
{
@@ -41,6 +41,16 @@ public interface IAuthStore
/// <returns>Пользователи тенанта, упорядоченные по времени создания (пусто — пользователей нет).</returns>
public Task<IReadOnlyList<UserIdentityDto>> ListUsersByTenantIdAsync(Guid tenantId, CancellationToken ct);
/// <summary>
/// Логины владельцев тенантов
/// </summary>
/// <param name="tenantIds">Идентификаторы тенантов.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Словарь tenantId → логин владельца (первый пользователь по времени создания); тенанты без пользователей не включаются.</returns>
public Task<IReadOnlyDictionary<Guid, string>> FindOwnerLoginsByTenantIdsAsync(
IReadOnlyCollection<Guid> tenantIds,
CancellationToken ct);
/// <summary>
/// Сохраняет новую сессию.
/// </summary>
@@ -7,7 +7,8 @@ namespace Deal.Modules.Tenants.Application.Models;
/// <param name="ActorType">Тип актора — константа <c>AuditActorTypes</c>.</param>
/// <param name="ActorId">Идентификатор актора; null, если актор неизвестен.</param>
/// <param name="TenantId">Идентификатор пользователя события; null для операторских/системных событий.</param>
/// <param name="TenantName">Имя пользователя события; null, если не разрешено.</param>
/// <param name="UserName">Логин реального пользователя (владелец пространства либо логин попытки); null, если не разрешён.</param>
/// <param name="TenantName">Имя пространства события; null, если не разрешено.</param>
/// <param name="Ip">IP-адрес клиента (без порта); null для серверных действий.</param>
/// <param name="Changes">Человекочитаемые изменения параметров события.</param>
/// <param name="DetailJson">Сырые детали события в JSON; null, если деталей нет.</param>
@@ -18,6 +19,7 @@ public sealed record AuditRecordViewDto(
string ActorType,
Guid? ActorId,
Guid? TenantId,
string? UserName,
string? TenantName,
string? Ip,
IReadOnlyList<AuditChangeDto> Changes,