Показывать реального пользователя в аудите и лимитах
Добавлен батч-резолв логинов владельцев пространств (IAuthStore.FindOwnerLoginsByTenantIdsAsync). Ответы аудита дополнены userName (владелец пространства либо логин попытки), ответы лимитов — ownerLogin; имя пространства остаётся вторичным.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user