Вынести коды параметров аудита и ключи JSON в константы

Магические строки кодов деталей аудита заменены каталогом AuditFields (68 мест), ключи элемента изменения и разбора JSON — константами, ключи хранилища ключей Telegram и геометрия маски — именованными константами.
This commit is contained in:
2026-09-13 20:32:11 +03:00
parent 6a88032414
commit 482b345438
18 changed files with 189 additions and 46 deletions
+5 -5
View File
@@ -68,7 +68,7 @@ public static class AuthEndpoints
ActorId: result.UserId,
TenantId: result.TenantId,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", NormalizeLogin(body.Login))])), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, NormalizeLogin(body.Login))])), ct);
return EndpointResults.Forbidden(TenantSuspendedDetail);
}
@@ -84,7 +84,7 @@ public static class AuthEndpoints
ActorId: null,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", attemptedLogin)])), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, attemptedLogin)])), ct);
}
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
@@ -98,7 +98,7 @@ public static class AuthEndpoints
ActorId: result.UserId,
TenantId: result.TenantId,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", result.Login)])), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, result.Login)])), ct);
SessionCookieWriter.Append(context, cookieOptions.Value, result.Token);
return Results.Ok(new { ok = true, login = result.Login });
@@ -125,12 +125,12 @@ public static class AuthEndpoints
ActorId: logout.OperatorId,
TenantId: logout.TenantId,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", logout.Login)])), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, logout.Login)])), ct);
}
if (user is not null)
{
await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, [AuditDetails.Set("login", user.Login)], ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, [AuditDetails.Set(AuditFields.Login, user.Login)], ct);
}
context.Response.Cookies.Delete(cookieName);
@@ -120,7 +120,7 @@ public static class CardDetailsEndpoints
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, [AuditDetails.Set("cardId", created.Id)], ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, [AuditDetails.Set(AuditFields.CardId, created.Id)], ct);
return await ReadCardAsync(context, created.Id, ct);
}
@@ -182,7 +182,7 @@ public static class CardsEndpoints
return EndpointResults.BadRequest(outcome.Error);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, [AuditDetails.Set("cardId", cardId), AuditDetails.Set("to", body.To)], ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, [AuditDetails.Set(AuditFields.CardId, cardId), AuditDetails.Set(AuditFields.Destination, body.To)], ct);
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto unified = await cardsService.GetCardAsync(cardId, ct);
@@ -202,7 +202,7 @@ public static class CardsEndpoints
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
await cardsService.TrashCardAsync(cardId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, [AuditDetails.Set("cardId", cardId)], ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, [AuditDetails.Set(AuditFields.CardId, cardId)], ct);
return Results.Ok(new { ok = true });
}
@@ -219,7 +219,7 @@ public static class CardsEndpoints
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
string col = await cardsService.RestoreCardAsync(cardId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, [AuditDetails.Set("cardId", cardId), AuditDetails.Set("col", col)], ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, [AuditDetails.Set(AuditFields.CardId, cardId), AuditDetails.Set(AuditFields.Column, col)], ct);
return Results.Ok(new { ok = true, col });
}
@@ -240,7 +240,7 @@ public static class CardsEndpoints
return EndpointResults.NotFound(CardNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, [AuditDetails.Set("cardId", cardId)], ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, [AuditDetails.Set(AuditFields.CardId, cardId)], ct);
return Results.Ok(new { ok = true });
}
@@ -284,7 +284,7 @@ public static class CardsEndpoints
return EndpointResults.NotFound(CardNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, [AuditDetails.Set("cardId", cardId)], ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, [AuditDetails.Set(AuditFields.CardId, cardId)], ct);
return Results.Ok(new { comments = result.Comments });
}
@@ -402,10 +402,10 @@ public static class CardsEndpoints
context,
AuditEvents.CardReclassified,
[
AuditDetails.Set("attempted", result.Attempted),
AuditDetails.Set("reclassified", result.Reclassified),
AuditDetails.Set("moved", result.Moved),
AuditDetails.Set("trashed", result.Trashed),
AuditDetails.Set(AuditFields.Attempted, result.Attempted),
AuditDetails.Set(AuditFields.Reclassified, result.Reclassified),
AuditDetails.Set(AuditFields.Moved, result.Moved),
AuditDetails.Set(AuditFields.Trashed, result.Trashed),
],
ct);
}
@@ -94,7 +94,7 @@ public static class ContainersEndpoints
Note: body.Note ?? string.Empty),
ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, [AuditDetails.Set("containerId", created.Id), AuditDetails.Set("name", created.Name)], ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, [AuditDetails.Set(AuditFields.ContainerId, created.Id), AuditDetails.Set(AuditFields.Name, created.Name)], ct);
return Results.Ok(new { id = created.Id });
}
@@ -153,7 +153,7 @@ public static class ContainersEndpoints
patchBody.Policy),
ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, [AuditDetails.Set("containerId", updated.Id)], ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, [AuditDetails.Set(AuditFields.ContainerId, updated.Id)], ct);
return Results.Ok(new { id = updated.Id });
}
@@ -171,7 +171,7 @@ public static class ContainersEndpoints
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ContainerDto accepted = await containers.AcceptSuggestedAsync(containerId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, [AuditDetails.Set("containerId", accepted.Id)], ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, [AuditDetails.Set(AuditFields.ContainerId, accepted.Id)], ct);
return Results.Ok(accepted);
}
@@ -189,7 +189,7 @@ public static class ContainersEndpoints
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
int moved = await containers.DeleteAsync(containerId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, [AuditDetails.Set("containerId", containerId)], ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, [AuditDetails.Set(AuditFields.ContainerId, containerId)], ct);
return Results.Ok(new { ok = true, movedToInbox = moved });
}
+1 -1
View File
@@ -70,7 +70,7 @@ public static class JoinEndpoint
Ip: ClientIp(context),
// Код инвайта — capability-токен: в аудит пишется только SHA-256-хэш (Security review).
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Set("email", result.Login), AuditDetails.Set("codeHash", SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty))])), ct);
[AuditDetails.Set(AuditFields.Email, result.Login), AuditDetails.Set(AuditFields.CodeHash, SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty))])), ct);
return Results.Ok(new { ok = true, login = result.Login });
}
@@ -64,7 +64,7 @@ public static class OperatorAuthEndpoints
ActorId: null,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", attemptedLogin)])), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, attemptedLogin)])), ct);
}
return EndpointResults.Unauthorized(InvalidCredentialsDetail);
@@ -78,7 +78,7 @@ public static class OperatorAuthEndpoints
ActorId: result.OperatorId,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("login", result.Login)])), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.Login, result.Login)])), ct);
SetOperatorSessionCookie(context, cookieOptions.Value, result.Token);
return Results.Ok(new { ok = true, login = result.Login });
@@ -100,7 +100,7 @@ public static class OperatorAuthEndpoints
if (operatorIdentity is not null)
{
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, [AuditDetails.Set("login", operatorIdentity.Login)], ct);
await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, [AuditDetails.Set(AuditFields.Login, operatorIdentity.Login)], ct);
}
return Results.Ok(new { ok = true });
@@ -93,7 +93,7 @@ public static class OperatorInvitesEndpoints
// Код инвайта — capability-токен (по нему активируется приглашение): в аудит пишется
// только его SHA-256-хэш, чтобы утечка ленты не давала рабочие коды (Security review).
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Set("email", result.Invite.Email), AuditDetails.Set("codeHash", SessionTokens.HashToken(result.Invite.Code))])), ct);
[AuditDetails.Set(AuditFields.Email, result.Invite.Email), AuditDetails.Set(AuditFields.CodeHash, SessionTokens.HashToken(result.Invite.Code))])), ct);
InviteDto invite = result.Invite;
return Results.Ok(new { invite.Code, invite.Email, invite.TenantId, invite.ExpiresAt, invite.Status });
@@ -128,7 +128,7 @@ public static class OperatorInvitesEndpoints
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Set("email", result.Invite!.Email), AuditDetails.Set("codeHash", SessionTokens.HashToken(result.Invite.Code))])), ct);
[AuditDetails.Set(AuditFields.Email, result.Invite!.Email), AuditDetails.Set(AuditFields.CodeHash, SessionTokens.HashToken(result.Invite.Code))])), ct);
return Results.Ok(new { ok = true });
}
@@ -175,7 +175,7 @@ public static class OperatorLimitsEndpoints
TenantId: id,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Change("budget", current.BudgetTokens, newBudget), AuditDetails.Change("period", current.Period, newPeriod)])), ct);
[AuditDetails.Change(AuditFields.Budget, current.BudgetTokens, newBudget), AuditDetails.Change(AuditFields.Period, current.Period, newPeriod)])), ct);
return Results.Ok(BuildDetailDto(tenant.Name, ownerLogin, updated));
}
@@ -129,7 +129,7 @@ public static class OperatorSettingsEndpoints
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Set("apiId", effectiveApiId), AuditDetails.Set("apiHashSet", true)])), ct);
[AuditDetails.Set(AuditFields.ApiId, effectiveApiId), AuditDetails.Set(AuditFields.ApiHashSet, true)])), ct);
TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct);
return Results.Ok(snapshot);
@@ -114,7 +114,7 @@ public static class OperatorTenantsEndpoints
TenantId: createdTenant.Id,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Set("name", createdTenant.Name), AuditDetails.Set("email", result.OwnerLogin)])), ct);
[AuditDetails.Set(AuditFields.Name, createdTenant.Name), AuditDetails.Set(AuditFields.Email, result.OwnerLogin)])), ct);
if (result.OwnerLogin is not null)
{
@@ -204,7 +204,7 @@ public static class OperatorTenantsEndpoints
TenantId: result.Tenant.Id,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(
[AuditDetails.Change("status", result.PreviousStatus, result.Tenant.Status)])), ct);
[AuditDetails.Change(AuditFields.Status, result.PreviousStatus, result.Tenant.Status)])), ct);
}
return Results.Ok(new { ok = true, status = result.Tenant.Status });
@@ -244,7 +244,7 @@ public static class OperatorTenantsEndpoints
ActorId: operatorIdentity.OperatorId,
TenantId: result.TenantId,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson([AuditDetails.Set("targetLogin", result.Login)])), ct);
DetailJson: AuditService.ToDetailJson([AuditDetails.Set(AuditFields.TargetLogin, result.Login)])), ct);
// Токен — это tenant-сессия (как после /api/auth/login): СТАВИМ ту же httpOnly-куку deal_session
// на ответ, чтобы браузер оператора сразу получил tenant-сессию (JS не может записать httpOnly-куку).
@@ -74,7 +74,7 @@ public static class SettingsEndpoints
SettingsService settingsService = context.RequestServices.GetRequiredService<SettingsService>();
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, [AuditDetails.Set("fields", string.Join(", ", body.Keys))], ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, [AuditDetails.Set(AuditFields.Fields, string.Join(", ", body.Keys))], ct);
if (ShouldScheduleRatesRefresh(body))
{
@@ -170,7 +170,7 @@ public static class TelegramEndpoints
TelegramAuthResultDto result = await gateway.StartQrAsync(apiId, keys.ApiHash, ct);
if (result.Phase == ReadyPhase)
{
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set("phase", result.Phase)], ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set(AuditFields.Phase, result.Phase)], ct);
}
return Results.Ok(new { phase = result.Phase, qrUrl = result.QrUrl ?? string.Empty });
@@ -197,7 +197,7 @@ public static class TelegramEndpoints
string phase = await gateway.SendCodeAsync((body.Code ?? string.Empty).Trim(), ct);
if (phase == ReadyPhase)
{
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set("phase", phase)], ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set(AuditFields.Phase, phase)], ct);
}
return Results.Ok(new { phase });
@@ -224,7 +224,7 @@ public static class TelegramEndpoints
string phase = await gateway.SendPasswordAsync(body.Password ?? string.Empty, ct);
if (phase == ReadyPhase)
{
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set("phase", phase)], ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, [AuditDetails.Set(AuditFields.Phase, phase)], ct);
}
return Results.Ok(new { phase });
@@ -343,7 +343,7 @@ public static class TelegramEndpoints
if (body.Enabled)
{
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, [AuditDetails.Set("all", true), AuditDetails.Set("count", result.Count)], ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, [AuditDetails.Set(AuditFields.All, true), AuditDetails.Set(AuditFields.Count, result.Count)], ct);
}
return Results.Ok(new { ok = true, count = result.Count, enabled = body.Enabled });
@@ -393,7 +393,7 @@ public static class TelegramEndpoints
if (result.Enabled)
{
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, [AuditDetails.Set("dialogId", dialog_id)], ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, [AuditDetails.Set(AuditFields.DialogId, dialog_id)], ct);
}
return Results.Ok(new { ok = true, enabled = result.Enabled });
@@ -62,7 +62,7 @@ public static class AuditViewFactory
return owner;
}
return record.DetailValue("login") ?? record.DetailValue("email");
return record.DetailValue(AuditFields.Login) ?? record.DetailValue(AuditFields.Email);
}
// Уникальные идентификаторы пространств выборки.
@@ -26,6 +26,15 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe
private const string EncryptedPrefix = "enc:";
// Ключи JSON значения telegramKeys (camelCase, как пишет SaveAsync).
private const string ApiIdProperty = "apiId";
private const string ApiHashProperty = "apiHash";
// Геометрия маски секрета: короткий (≤8) → «x…»; иначе «1234…5678».
private const int MaskShortMaxLength = 8;
private const int MaskShortVisibleChars = 1;
private const int MaskEdgeVisibleChars = 4;
// Опции JSON значения telegramKeys: camelCase (как пишет SaveAsync) + терпимость регистра.
private static readonly JsonSerializerOptions KeysJsonOptions = new()
{
@@ -49,8 +58,8 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
JsonElement root = document.RootElement;
string apiId = ReadString(root, "apiId");
string apiHash = ReadString(root, "apiHash");
string apiId = ReadString(root, ApiIdProperty);
string apiHash = ReadString(root, ApiHashProperty);
return new TgKeysSnapshot(apiId, cipher.Decrypt(apiHash));
}
catch (JsonException)
@@ -143,8 +152,11 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe
return value.Length switch
{
0 => string.Empty,
<= 8 => string.Concat(value.AsSpan(0, 1), MaskEllipsis),
_ => string.Concat(value.AsSpan(0, 4), MaskEllipsis, value.AsSpan(value.Length - 4)),
<= MaskShortMaxLength => string.Concat(value.AsSpan(0, MaskShortVisibleChars), MaskEllipsis),
_ => string.Concat(
value.AsSpan(0, MaskEdgeVisibleChars),
MaskEllipsis,
value.AsSpan(value.Length - MaskEdgeVisibleChars)),
};
}
}
@@ -10,6 +10,11 @@ public static class AuditRecordDtoExtensions
// Ключ служебного поля-контейнера изменений в деталях события.
private const string ChangesProperty = "changes";
// Ключи элемента изменения нового формата.
private const string FieldProperty = "field";
private const string FromProperty = "from";
private const string ToProperty = "to";
// Параметр события, дублирующий TenantId самой записи, — в изменения не попадает.
private const string TenantIdProperty = "tenantId";
@@ -105,14 +110,14 @@ public static class AuditRecordDtoExtensions
foreach (JsonElement item in changes.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object
|| !item.TryGetProperty("field", out JsonElement field)
|| !item.TryGetProperty(FieldProperty, out JsonElement field)
|| field.ValueKind != JsonValueKind.String)
{
continue;
}
string? from = item.TryGetProperty("from", out JsonElement fromElement) ? Stringify(fromElement) : null;
string? to = item.TryGetProperty("to", out JsonElement toElement) ? Stringify(toElement) : null;
string? from = item.TryGetProperty(FromProperty, out JsonElement fromElement) ? Stringify(fromElement) : null;
string? to = item.TryGetProperty(ToProperty, out JsonElement toElement) ? Stringify(toElement) : null;
result.Add(new AuditChangeDto(field.GetString()!, from, to));
}
@@ -7,6 +7,10 @@ namespace Deal.Modules.Tenants.Application.Models;
/// </summary>
public static class AuditDetails
{
// Строковые представления булевых значений (как в JSON).
private const string TrueLiteral = "true";
private const string FalseLiteral = "false";
/// <summary>
/// Задаёт параметр события
/// </summary>
@@ -33,7 +37,7 @@ public static class AuditDetails
{
null => null,
string text => text,
bool flag => flag ? "true" : "false",
bool flag => flag ? TrueLiteral : FalseLiteral,
DateTimeOffset moment => moment.ToString("O", CultureInfo.InvariantCulture),
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
_ => value.ToString(),
@@ -0,0 +1,122 @@
namespace Deal.Modules.Tenants.Application.Models;
/// <summary>
/// Каталог кодов параметров в деталях события аудита
/// </summary>
public static class AuditFields
{
/// <summary>
/// Логин пользователя
/// </summary>
public const string Login = "login";
/// <summary>
/// Email
/// </summary>
public const string Email = "email";
/// <summary>
/// Отпечаток (хэш) кода приглашения
/// </summary>
public const string CodeHash = "codeHash";
/// <summary>
/// Имя сущности (пользователь/колонка)
/// </summary>
public const string Name = "name";
/// <summary>
/// Статус
/// </summary>
public const string Status = "status";
/// <summary>
/// Бюджет токенов
/// </summary>
public const string Budget = "budget";
/// <summary>
/// Период лимита
/// </summary>
public const string Period = "period";
/// <summary>
/// Идентификатор приложения Telegram
/// </summary>
public const string ApiId = "apiId";
/// <summary>
/// Признак заданного секрета приложения Telegram
/// </summary>
public const string ApiHashSet = "apiHashSet";
/// <summary>
/// Логин, под которым выполнен вход от имени пользователя
/// </summary>
public const string TargetLogin = "targetLogin";
/// <summary>
/// Карточка
/// </summary>
public const string CardId = "cardId";
/// <summary>
/// Контейнер назначения карточки
/// </summary>
public const string Destination = "to";
/// <summary>
/// Колонка карточки
/// </summary>
public const string Column = "col";
/// <summary>
/// Контейнер (колонка)
/// </summary>
public const string ContainerId = "containerId";
/// <summary>
/// Число обработанных карточек
/// </summary>
public const string Attempted = "attempted";
/// <summary>
/// Число переклассифицированных карточек
/// </summary>
public const string Reclassified = "reclassified";
/// <summary>
/// Число перемещённых карточек
/// </summary>
public const string Moved = "moved";
/// <summary>
/// Число карточек, отправленных в корзину
/// </summary>
public const string Trashed = "trashed";
/// <summary>
/// Изменённые поля настроек
/// </summary>
public const string Fields = "fields";
/// <summary>
/// Признак действия «все каналы»
/// </summary>
public const string All = "all";
/// <summary>
/// Число каналов
/// </summary>
public const string Count = "count";
/// <summary>
/// Этап подключения Telegram
/// </summary>
public const string Phase = "phase";
/// <summary>
/// Диалог Telegram
/// </summary>
public const string DialogId = "dialogId";
}
@@ -340,7 +340,7 @@ public sealed class SuspiciousActivityService
// Извлекает логин из деталей записи (параметр login); повреждённые детали — null.
// record: Запись аудита.
// Возвращает: Логин либо null (деталей нет/параметр не задан).
private static string? ExtractLogin(AuditRecordDto record) => record.DetailValue("login");
private static string? ExtractLogin(AuditRecordDto record) => record.DetailValue(AuditFields.Login);
// Уровень находки по превышению порога (≥2× порог — high, иначе medium).
// count: Фактическое значение правила.