Saltar al contenido principal

Eventos de Almacenamiento

Vali-Blob incluye un sistema de eventos que permite reaccionar a operaciones de almacenamiento sin acoplar el código de notificación al código de almacenamiento. Los manejadores de eventos se ejecutan automáticamente después de cada operación.

Interfaz principal

public interface IStorageEventHandler<TEvent> where TEvent : IStorageEvent
{
Task HandleAsync(TEvent storageEvent, CancellationToken ct = default);
}

public interface IStorageEvent
{
string EventId { get; }
DateTimeOffset OccurredAt { get; }
}

Eventos disponibles

EventoCuándo se publica
FileUploadedEventDespués de una subida exitosa
FileUploadFailedEventCuando una subida falla por cualquier motivo
FileDownloadedEventDespués de una descarga exitosa
FileDeletedEventDespués de eliminar un archivo
FileCopiedEventDespués de copiar un archivo
FileMetadataUpdatedEventDespués de actualizar metadatos con SetMetadataAsync
VirusDetectedEventCuando el análisis de virus detecta malware
QuotaExceededEventCuando una subida falla por cuota superada
ResumableUploadCompletedEventCuando una subida reanudable finaliza correctamente
ResumableUploadAbortedEventCuando una subida reanudable se aborta

Estructura de los eventos

// Evento base con campos comunes
public abstract class StorageEventBase : IStorageEvent
{
public string EventId { get; } = Guid.NewGuid().ToString();
public DateTimeOffset OccurredAt { get; } = DateTimeOffset.UtcNow;
public string ProviderName { get; init; } = string.Empty;
}

// Subida exitosa
public class FileUploadedEvent : StorageEventBase
{
public required string Path { get; init; }
public required long SizeBytes { get; init; }
public string? ContentType { get; init; }
public string? Url { get; init; }
public bool WasDeduplicated { get; init; }
public IReadOnlyDictionary<string, string> Metadata { get; init; }
= new Dictionary<string, string>();
}

// Fallo de subida
public class FileUploadFailedEvent : StorageEventBase
{
public required string AttemptedPath { get; init; }
public required StorageErrorCode ErrorCode { get; init; }
public string? ErrorMessage { get; init; }
public Exception? Exception { get; init; }
}

// Virus detectado
public class VirusDetectedEvent : StorageEventBase
{
public required string AttemptedPath { get; init; }
public required string VirusName { get; init; }
public string? Details { get; init; }
}

// Cuota superada
public class QuotaExceededEvent : StorageEventBase
{
public required string AttemptedPath { get; init; }
public required long CurrentUsageBytes { get; init; }
public required long QuotaLimitBytes { get; init; }
public required long FileSizeBytes { get; init; }
}

Registro de manejadores

En Program.cs

builder.Services
.AddVali-Blob(o => o.DefaultProvider = "aws")
.AddProvider<AWSS3Provider>("aws", opts => { /* ... */ });

// Registrar manejadores de eventos
builder.Services.AddScoped<IStorageEventHandler<FileUploadedEvent>, AuditarSubidaHandler>();
builder.Services.AddScoped<IStorageEventHandler<FileUploadFailedEvent>, NotificarErrorHandler>();
builder.Services.AddScoped<IStorageEventHandler<VirusDetectedEvent>, AlertarVirusHandler>();
builder.Services.AddScoped<IStorageEventHandler<QuotaExceededEvent>, NotificarCuotaHandler>();

Implementación de manejadores

Auditoría de subidas

public class AuditarSubidaHandler(
IAuditService auditService,
ILogger<AuditarSubidaHandler> logger)
: IStorageEventHandler<FileUploadedEvent>
{
public async Task HandleAsync(FileUploadedEvent evento, CancellationToken ct)
{
logger.LogInformation(
"Archivo subido. Path={Path} Tamaño={Size}B Proveedor={Provider} Deduplicado={Dedup}",
evento.Path, evento.SizeBytes, evento.ProviderName, evento.WasDeduplicated);

await auditService.RegistrarAsync(new RegistroAuditoria
{
Accion = "ARCHIVO_SUBIDO",
Recurso = evento.Path,
Timestamp = evento.OccurredAt,
Detalles = new Dictionary<string, object>
{
["tamanoBytes"] = evento.SizeBytes,
["tipoContenido"] = evento.ContentType ?? "desconocido",
["proveedor"] = evento.ProviderName,
["deduplicado"] = evento.WasDeduplicated
}
}, ct);
}
}

Notificación de errores críticos

public class NotificarErrorHandler(
IEmailService email,
ILogger<NotificarErrorHandler> logger)
: IStorageEventHandler<FileUploadFailedEvent>
{
private static readonly HashSet<StorageErrorCode> ErroresCriticos =
[
StorageErrorCode.ProviderError,
StorageErrorCode.NetworkError,
StorageErrorCode.ConfigurationError
];

public async Task HandleAsync(FileUploadFailedEvent evento, CancellationToken ct)
{
// Solo notificar errores de infraestructura, no errores de negocio
if (!ErroresCriticos.Contains(evento.ErrorCode))
return;

logger.LogError(
evento.Exception,
"Error crítico de almacenamiento. Path={Path} Código={Code} Mensaje={Mensaje}",
evento.AttemptedPath, evento.ErrorCode, evento.ErrorMessage);

await email.EnviarAlertaAsync(
destinatario: "operaciones@empresa.com",
asunto: $"[Alerta] Error de almacenamiento: {evento.ErrorCode}",
cuerpo: $"Ruta: {evento.AttemptedPath}\n" +
$"Error: {evento.ErrorCode}\n" +
$"Mensaje: {evento.ErrorMessage}\n" +
$"Proveedor: {evento.ProviderName}\n" +
$"Timestamp: {evento.OccurredAt:R}",
ct);
}
}

Alerta de seguridad por virus

public class AlertarVirusHandler(
IAlertService alertas,
IRegistroSeguridad registroSeg)
: IStorageEventHandler<VirusDetectedEvent>
{
public async Task HandleAsync(VirusDetectedEvent evento, CancellationToken ct)
{
await registroSeg.RegistrarAmenazaAsync(new AmenazaSeguridad
{
Tipo = TipoAmenaza.Malware,
ArchivoAfectado = evento.AttemptedPath,
NombreAmenaza = evento.VirusName,
Detalles = evento.Details,
Timestamp = evento.OccurredAt,
Proveedor = evento.ProviderName
}, ct);

await alertas.EnviarAlertaCriticaAsync(
$"Malware bloqueado: {evento.VirusName} en intento de subida a {evento.AttemptedPath}",
nivel: NivelAlerta.Critico,
ct);
}
}

Notificación de cuota superada

public class NotificarCuotaHandler(INotificacionService notificaciones)
: IStorageEventHandler<QuotaExceededEvent>
{
public async Task HandleAsync(QuotaExceededEvent evento, CancellationToken ct)
{
var porcentajeUso = (double)evento.CurrentUsageBytes / evento.QuotaLimitBytes * 100;
var usadoMb = evento.CurrentUsageBytes / 1024.0 / 1024.0;
var limiteMb = evento.QuotaLimitBytes / 1024.0 / 1024.0;

await notificaciones.EnviarAsync(new Notificacion
{
Tipo = TipoNotificacion.Advertencia,
Titulo = "Cuota de almacenamiento superada",
Mensaje = $"Uso actual: {usadoMb:F0} MB de {limiteMb:F0} MB ({porcentajeUso:F1}%). " +
$"No se pudo subir el archivo solicitado.",
Proveedor = evento.ProviderName
}, ct);
}
}

Múltiples manejadores para el mismo evento

Puedes registrar varios manejadores para el mismo tipo de evento. Se ejecutan en el orden de registro:

// Los tres manejadores se ejecutan cada vez que se sube un archivo exitosamente
builder.Services.AddScoped<IStorageEventHandler<FileUploadedEvent>, AuditarSubidaHandler>();
builder.Services.AddScoped<IStorageEventHandler<FileUploadedEvent>, IndexarEnBuscadorHandler>();
builder.Services.AddScoped<IStorageEventHandler<FileUploadedEvent>, EnviarWebhookHandler>();
// Indexar en motor de búsqueda
public class IndexarEnBuscadorHandler(IBuscadorService buscador)
: IStorageEventHandler<FileUploadedEvent>
{
public async Task HandleAsync(FileUploadedEvent evento, CancellationToken ct)
{
await buscador.IndexarDocumentoAsync(new DocumentoIndexado
{
Id = evento.Path,
TipoContenido = evento.ContentType,
TamanoBytes = evento.SizeBytes,
FechaSubida = evento.OccurredAt,
Metadatos = evento.Metadata
}, ct);
}
}

Manejador genérico para todos los eventos

// Registrar logging para cualquier evento de almacenamiento
public class LoggingEventHandler(ILogger<LoggingEventHandler> logger)
: IStorageEventHandler<IStorageEvent>
{
public Task HandleAsync(IStorageEvent evento, CancellationToken ct)
{
var proveedor = evento is StorageEventBase base_ ? base_.ProviderName : "desconocido";

logger.LogDebug(
"Evento de almacenamiento: {TipoEvento} en {Timestamp} por proveedor {Proveedor}",
evento.GetType().Name,
evento.OccurredAt,
proveedor);

return Task.CompletedTask;
}
}

// Registro
builder.Services.AddScoped<IStorageEventHandler<IStorageEvent>, LoggingEventHandler>();
Información

Los manejadores de eventos se ejecutan en el mismo contexto de la operación que los desencadenó. Si un manejador lanza una excepción, Vali-Blob la captura, la registra como advertencia y continúa ejecutando los demás manejadores. Las excepciones en manejadores de eventos no afectan el resultado de la operación de almacenamiento original.

Consejo

Para operaciones costosas en manejadores de eventos (como enviar emails, llamar a APIs externas o procesar imágenes), encola el trabajo en un sistema de colas como Hangfire, MassTransit o Azure Service Bus. Esto evita que la latencia de la notificación afecte el tiempo de respuesta de la subida.