Перейти к основному содержимому

Resilience, FluentValidation и Audit — Практическое Руководство

Назначение

Документ описывает три cross-cutting concerns, обязательных для enterprise enterprise платформы:

  1. Resilience — Polly + Microsoft.Extensions.Http.Resilience для устойчивых HTTP/gRPC-вызовов к внешним провайдерам.
  2. FluentValidation в API — валидация request моделей на уровне HTTP перед MediatR.
  3. Audit.NET — глубокий аудит admin API: кто, что, когда, с каким результатом изменил.

Часть 1: Resilience

Зачем

Pin интегрируется с множеством внешних провайдеров: payment gateways, KYC providers, game aggregators, SMS gateways. Каждый может быть временно недоступен. Без resilience политики один недоступный провайдер роняет весь API.

Пакеты

<!-- В Talent.Common.Resilience.csproj -->
<PackageReference Include="Polly" Version="8.*" />
<PackageReference Include="Polly.Extensions" Version="8.*" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="8.*" />
<PackageReference Include="Microsoft.Extensions.Resilience" Version="8.*" />

Стандартные Pipeline Profiles

// Talent.Common.Resilience/Policies/PinResiliencePolicies.cs

public static class PinResiliencePolicies
{
/// <summary>
/// Standard retry для внешних HTTP-провайдеров (payment, KYC, SMS).
/// 3 попытки с exponential backoff: 1s, 2s, 4s.
/// Retry только на transient errors (5xx, timeout, network).
/// </summary>
public static ResiliencePipeline<HttpResponseMessage> ExternalProviderHttp(
IServiceProvider sp) =>
new ResiliencePipelineBuilder<HttpResponseMessage>()
.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Exponential,
Delay = TimeSpan.FromSeconds(1),
UseJitter = true,
ShouldHandle = static args =>
ValueTask.FromResult(
args.Outcome.Exception is not null ||
(int)args.Outcome.Result!.StatusCode >= 500 ||
args.Outcome.Result!.StatusCode == HttpStatusCode.RequestTimeout),
OnRetry = args =>
{
sp.GetRequiredService<ILogger<ResiliencePipeline>>().LogWarning(
"Retry {Attempt} for {OperationKey} after {Delay}ms",
args.AttemptNumber, args.Context.OperationKey,
args.RetryDelay.TotalMilliseconds);
return ValueTask.CompletedTask;
},
})
.AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
{
// После 5 failures в 30s — открываем circuit на 60s
FailureRatio = 0.5,
SamplingDuration = TimeSpan.FromSeconds(30),
MinimumThroughput = 5,
BreakDuration = TimeSpan.FromSeconds(60),
OnOpened = args =>
{
sp.GetRequiredService<ILogger<ResiliencePipeline>>().LogError(
"Circuit OPENED for {OperationKey}", args.Context.OperationKey);
return ValueTask.CompletedTask;
},
})
.AddTimeout(TimeSpan.FromSeconds(10))
.Build();

/// <summary>
/// Легковесный pipeline для internal service-to-service HTTP (нет circuit breaker).
/// </summary>
public static ResiliencePipeline<HttpResponseMessage> InternalServiceHttp() =>
new ResiliencePipelineBuilder<HttpResponseMessage>()
.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 2,
BackoffType = DelayBackoffType.Linear,
Delay = TimeSpan.FromMilliseconds(300),
UseJitter = true,
})
.AddTimeout(TimeSpan.FromSeconds(5))
.Build();

/// <summary>
/// Pipeline для idempotent-safe операций, которые можно повторить безопасно.
/// Используется в IDecision CheckPort, IRiskScoringPort.
/// </summary>
public static ResiliencePipeline IdempotentPort() =>
new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Exponential,
Delay = TimeSpan.FromMilliseconds(500),
UseJitter = true,
})
.AddTimeout(TimeSpan.FromSeconds(8))
.Build();
}

Регистрация HttpClient с Resilience

// Talent.Common.Resilience/DependencyInjection/AddPinResilience.cs

public static IPinInfrastructureBuilder AddPinResilience(
this IPinInfrastructureBuilder builder)
{
// Регистрируем named resilience pipelines в DI (Polly.Extensions)
builder.Services.AddResiliencePipeline<string, HttpResponseMessage>(
"external-provider",
(pipelineBuilder, context) =>
{
var logger = context.ServiceProvider
.GetRequiredService<ILogger<ResiliencePipeline>>();
// Используем Microsoft.Extensions.Http.Resilience стандартные расширения
pipelineBuilder
.AddStandardHedging() // hedging для latency-sensitive вызовов
.AddStandardResilienceHandler(); // retry + circuit breaker + timeout
});

return builder;
}

// Подключение к конкретному HttpClient адаптера:
// Pin.Accounting.Infrastructure/DependencyInjection/AccountingInfrastructureModule.cs

services.AddHttpClient<IStripePaymentAdapter, StripePaymentAdapter>(client =>
{
client.BaseAddress = new Uri(configuration["Stripe:BaseUrl"]!);
client.DefaultRequestHeaders.Add("Authorization",
$"Bearer {configuration["Stripe:ApiKey"]}");
})
.AddResilienceHandler("stripe", pipeline =>
{
pipeline
.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
})
.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
{
SamplingDuration = TimeSpan.FromSeconds(30),
BreakDuration = TimeSpan.FromSeconds(60),
FailureRatio = 0.5,
MinimumThroughput = 5,
})
.AddTimeout(TimeSpan.FromSeconds(15));
});

Resilience в Application Ports

// Application port с resilience через Scrutor decorator:
// Pin.Accounting.Infrastructure/Adapters/Resilient/ResilientDecision CheckPort.cs

public sealed class ResilientDecision CheckPort : IDecision CheckPort
{
private readonly IDecision CheckPort _inner;
private readonly ResiliencePipeline _pipeline;
private readonly ILogger<ResilientDecision CheckPort> _logger;

public async Task<Decision CheckResult> Check(
UserId userId, CancellationToken ct)
{
return await _pipeline.ExecuteAsync(
async token =>
{
_logger.LogDebug("Checking decision check for user {UserId}", userId);
return await _inner.Check(userId, token);
},
ct);
}
}

// Регистрация через Scrutor:
services.Decorate<IDecision CheckPort, ResilientDecision CheckPort>();

Часть 2: FluentValidation в API

Два уровня валидации

УровеньГдеЧто проверяет
API ValidationController/Filter (FluentValidation)Формат, тип, диапазон, обязательные поля — до MediatR
Business ValidationMediatR ValidationBehavior (FluentValidation)Бизнес-правила, зависящие от command контекста

API валидация дешевле: не открывает transaction, не лезет в БД.

Регистрация

// Pin.Kernel.AspNetCore / PinApiBuilder — глобально для всех сервисов

services.AddFluentValidationAutoValidation(config =>
{
config.DisableDataAnnotationsValidation = true; // только FluentValidation
});

// Сканируем все assembly сервиса
services.AddValidatorsFromAssemblyContaining<CreateWithdrawalRequestValidator>(
lifetime: ServiceLifetime.Scoped);

API Request Validator

// Pin.Accounting.Api/Withdrawals/CreateWithdrawalRequestValidator.cs

// Это API-level validator — проверяет только формат запроса
public sealed class CreateWithdrawalRequestValidator
: AbstractValidator<CreateWithdrawalRequest>
{
public CreateWithdrawalRequestValidator()
{
RuleFor(x => x.Amount)
.GreaterThan(0).WithErrorCode("amount-must-be-positive")
.LessThanOrEqualTo(1_000_000m).WithErrorCode("amount-exceeds-api-limit")
.WithName("amount");

RuleFor(x => x.CurrencyId)
.NotEmpty().WithErrorCode("currency-required")
.Length(3).WithErrorCode("currency-must-be-3-chars")
.Matches("^[A-Z]{3}$").WithErrorCode("currency-invalid-format");

RuleFor(x => x.PaymentMethodId)
.NotEmpty().WithErrorCode("payment-method-required");

// Условная валидация: address только для банковского перевода
When(x => x.Type == WithdrawalType.BankTransfer, () =>
{
RuleFor(x => x.BankAccountIban)
.NotEmpty().WithErrorCode("iban-required-for-bank-transfer")
.Matches(@"^[A-Z]{2}\d{2}[A-Z0-9]{4}\d{7}([A-Z0-9]?){0,16}$")
.WithErrorCode("iban-invalid-format");
});
}
}

Стандартный Validation Error Response

// При провале validation FluentValidation AutoValidation возвращает 400:
// {
// "type": "validation-error",
// "title": "One or more validation errors occurred.",
// "status": 400,
// "errors": {
// "amount": ["amount-must-be-positive"],
// "currencyId": ["currency-must-be-3-chars"]
// }
// }

// Кастомный ProblemDetails mapper (в Pin.Kernel.AspNetCore):
public sealed class PinValidationProblemDetailsFactory : IValidationProblemDetailsFactory
{
public ProblemDetails Create(ValidationResult result)
=> new ValidationProblemDetails(
result.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key.ToCamelCase(),
g => g.Select(e => e.ErrorCode).ToArray()))
{
Status = StatusCodes.Status400BadRequest,
Type = "validation-error",
};
}

Часть 3: Audit.NET (thepirat000)

Зачем Audit.NET

Audit.NET предоставляет декларативный аудит через атрибуты и AuditScope: кто, когда, с каких параметров вызвал метод, что изменилось, каков результат. В отличие от кастомного AuditBehavior в MediatR, Audit.NET ориентирован на HTTP actions в admin API — детальный лог изменений с before/after для compliance.

Пакеты

<!-- В Talent.Common.Audit.csproj -->
<PackageReference Include="Audit.NET" Version="25.*" />
<PackageReference Include="Audit.NET.EntityFramework" Version="25.*" />
<PackageReference Include="Audit.WebApi" Version="25.*" />

Конфигурация Audit.NET

// Talent.Common.Audit/AuditNetConfiguration.cs

public static class AuditNetConfiguration
{
public static void Configure(IServiceProvider sp)
{
// DataProvider: пишем в PostgreSQL таблицу audit_events
Audit.Core.Configuration.Setup()
.UseEntityFramework(config => config
.AuditTypeMapper(t => typeof(AuditEvent))
.AuditEntityAction<AuditEvent>((auditScope, auditEvent, auditEntity) =>
{
auditEntity.TenantId = GetCurrentTenantId(sp);
auditEntity.ActorId = GetCurrentActorId(sp);
auditEntity.ActorType = GetCurrentActorType(sp);
auditEntity.IpAddress = GetCurrentIp(sp);
auditEntity.UserAgent = GetCurrentUserAgent(sp);
auditEntity.EventJson = auditEvent.ToJson();
})
.IgnoreMatchedProperties(true));

// Глобальные custom fields — добавляются в каждый AuditEvent
Audit.Core.Configuration.AddCustomAction(ActionType.OnScopeCreated, scope =>
{
var identity = sp.GetService<IIdentityContextProvider>()?.TryGetCurrent();
scope.SetCustomField("tenantId", identity?.TenantId?.ToString());
scope.SetCustomField("actorId", identity?.ActorId);
scope.SetCustomField("actorType", identity?.ActorType.ToString());
});
}
}

// EF-сущность для хранения audit events:
public sealed class AuditEvent
{
public long Id { get; set; }
public string EventType { get; set; } = default!; // "Action", "EFCore", "Custom"
public string Action { get; set; } = default!; // "PUT /admin/users/{id}/kyc"
public string? TenantId { get; set; }
public string? ActorId { get; set; }
public string? ActorType { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public string EventJson { get; set; } = default!; // полный AuditEvent JSON
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public int DurationMs { get; set; }
public DateTimeOffset StartedAt { get; set; }
}

Аудит Admin API Actions через Атрибут

// Talent.Common.Audit/Attributes/AuditApiActionAttribute.cs

// Декларативный аудит через Audit.WebApi:
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public sealed class AuditApiActionAttribute : AuditApiAttribute
{
public AuditApiActionAttribute(string eventType) : base()
{
EventTypeName = eventType;
IncludeHeaders = false; // не логируем Authorization header
IncludeResponseBody = true;
IncludeRequestBody = true;
}
}

// Применение на controller action:
[HttpPost("{userId}/kyc/approve")]
[Authorize(Policy = "AdminKycApprover")]
[AuditApiAction("admin.kyc.approve")]
public async Task<IActionResult> ApproveKyc(
Guid userId,
ApproveKycRequest request,
CancellationToken ct)
{
var result = await _mediator.Send(
new ApproveKycCommand(new UserId(userId), request.Notes), ct);
return result.ToActionResult();
}

// Audit.NET автоматически логирует:
// {
// "EventType": "admin.kyc.approve",
// "StartDate": "2026-05-10T14:23:01Z",
// "EndDate": "2026-05-10T14:23:01.254Z",
// "Duration": 254,
// "HttpRequest": {
// "Method": "POST",
// "Url": "/api/admin/users/abc/kyc/approve",
// "Body": { "notes": "Documents verified" }
// },
// "HttpResponse": { "StatusCode": 200 },
// "CustomFields": { "tenantId": "...", "actorId": "...", "actorType": "Admin" }
// }

EF Core Change Tracking Audit

// Talent.Common.Audit/EF/PinAuditDbContext.cs

// Включить EF audit для конкретного DbContext:
public sealed class AdminDbContext : AuditDbContext
{
// AuditDbContext из Audit.NET.EntityFramework автоматически логирует
// все INSERT/UPDATE/DELETE с before/after values

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Исключаем sensitive таблицы из EF audit (используем только Audit.WebApi для них)
AuditDataProvider = new PinEfAuditDataProvider();
}
}

Что Аудировать vs Что Логировать

УровеньИнструментЧто записываетRetention
Структурированный логSerilog + OTELRequest/response metrics, correlation, performance30 дней
MediatR AuditBehaviorCustom behaviorCommand/query name, actor, result (success/fail)90 дней
Audit.NET WebApi[AuditApiAction]Полный request body, response, actor snapshot2 года
Audit.NET EFAuditDbContextbefore/after EF entity changes2 года
Compliance ExportExport RegistryRegulatory packages7 лет

Правила Audit.NET:

  • [AuditApiAction] — только admin API endpoints с mutation (PUT/POST/DELETE/PATCH).
  • Никогда не аудировать raw credentials, токены, CVV, пароли — маскировать перед записью.
  • EF audit — только для financial и compliance entities (Withdrawal, KYC, UserIncentive, CommissionPayout).
  • Audit storage — отдельная PostgreSQL schema audit, не mixed с operational data.
  • Audit records — append-only, без UPDATE/DELETE.

Регистрация в Сервисе

// Pin.Admin.Api/Startup/AdminWebStartup.cs

protected override void ConfigureInfrastructure(IPinInfrastructureBuilder builder)
{
builder
.AddAccountingInfrastructure()
.AddPinResilience()
.AddPinAuditNet(options =>
{
options.DataProvider = AuditDataProvider.PostgreSql;
options.EnableEfAudit = true;
options.EnableWebApiAudit = true;
options.ExcludedRoutes = ["/health/*", "/metrics"];
});
}

// AddPinAuditNet extension:
public static IPinInfrastructureBuilder AddPinAuditNet(
this IPinInfrastructureBuilder builder,
Action<AuditNetOptions> configure)
{
var options = new AuditNetOptions();
configure(options);

// Конфигурируем Audit.NET глобально (singleton)
builder.Services.AddSingleton<IAuditNetConfigurator, AuditNetConfigurator>();
builder.Services.AddHostedService<AuditNetStartupService>();

// Audit.WebApi middleware
if (options.EnableWebApiAudit)
builder.Services.AddMvc(mvc =>
mvc.Filters.Add<AuditApiGlobalFilter>());

return builder;
}

Ссылки

Обратные ссылки

Автоссылки: где используется этот документ.