Confirmar Carga (Caso de Uso)
Autor: Iker Acevedo¶
Fecha creacion: 2026-09-21
Estado: desarrollo
Caso de uso: ConfirmarCargaUseCase¶
Ubicación: Aplicacion/CasosUso/ConfirmarCargaUseCase.cs
Lo invoca: Function.cs — POST /reportes-analytics/confirmar-carga/{cargaId}
¿Qué hace?¶
El corazón del módulo. Toma una carga PENDIENTE ya creada por
SolicitarCargaUseCase, descarga el HTML que el cliente ya
subió a S3, lo valida de verdad (tamaño real, UTF-8, las 8 reglas de seguridad),
lo comprime, lo publica en la key definitiva, y actualiza ReportesAnalytics — todo con
verificación de integridad en cada paso.
EjecutarAsync(sub, cargaId, leerRequest, logger, ct)
1. AutorizacionUsuario.ExigirRolAsync(sub, usuarios, ct) 401
2. Guid.TryParseExact(cargaId) 400 CARGA_ID_INVALIDO
3. cargas.ObtenerAsync(cargaId) 404 CARGA_NO_EXISTE
4. carga.IdentidadOrigen != caller.Sub 403 CARGA_AJENA
5. carga.Estado != PENDIENTE || !cargas.AdquirirAsync(...) 409 CARGA_NO_PENDIENTE
(compare-and-set: dueno + PENDIENTE + EnProcesamiento != true)
--- a partir de aca, esta carga es exclusiva de esta invocacion ---
try
6. ahora >= FechaSolicitud + 10min 410 CARGA_EXPIRADA
7. leerRequest() + ValidarMetadata() 400 METADATOS_INVALIDOS
8. si carga.ReporteId, reportes.ExisteAsync 404 REPORTE_NO_EXISTE
9. storage.DescargarStagingAsync(cargaId) limite real de bytes
10. objeto.Bytes.Length > 15 MiB -> ValidacionException 422 TAMANO_EXCEDIDO
11. SHA-256 sobre los bytes crudos
12. hash != objeto.Sha256 (checksum de transferencia S3) 500 INTEGRIDAD_INVALIDA
13. UTF8Encoding(throwOnInvalid).GetString 422 UTF8_INVALIDO
14. validador.Validar(html, nombreArchivo) 422 con Hallazgos
15. gzip = compresion.Comprimir(bytes)
compresion.DescomprimirVerificado(gzip, hash) roundtrip local
16. key = reportes-analytics/{reporteId}/{versionId}.html.gz
storage.GuardarVersionAsync(key, base64(gzip)) If-None-Match:*
17. relee esa MISMA key recien escrita
compresion.DescomprimirVerificado(releido, hash) segunda verificacion
18. arma VersionReporte + ReporteAnalytics
19. reportes.PublicarAsync(reporte, nuevo: carga.ReporteId==null) $push atomico
20. cargas.FinalizarAsync(cargaId, COMPLETADA, ...)
21. devuelve ConfirmarCargaResponse(true, ReporteId, VersionId)
catch ValidacionException
cargas.FinalizarAsync(cargaId, RECHAZADA, hallazgos, ...)
devuelve ConfirmarCargaResponse(false, Hallazgos: ...) 422, no relanza
catch ApiException con StatusCode < 500
cargas.FinalizarAsync(cargaId, RECHAZADA, ...)
relanza lo maneja Function.cs
catch cualquier otra excepcion
cargas.RegistrarErrorAsync(cargaId, REVISION_REQUERIDA) fallo ambiguo
relanza
finally
storage.BorrarStagingAsync(cargaId) SIEMPRE, con token propio
si falla el borrado -> 503 LIMPIEZA_PENDIENTE
Por qué la toma de la carga es un compare-and-set¶
if (carga.Estado != "PENDIENTE" || !await cargas.AdquirirAsync(cargaId, caller.Sub, ct))
throw new ApiException(409, "CARGA_NO_PENDIENTE");
AdquirirAsync (CargaRepository) hace un único UpdateOne con filtro
_id == cargaId AND IdentidadOrigen == propietario AND Estado == PENDIENTE AND EnProcesamiento != true.
Si dos peticiones de confirmación llegan casi al mismo tiempo para la misma carga (doble
click, reintento de red del cliente), solo una consigue ModifiedCount == 1 — la otra
recibe 409 sin haber tocado S3 ni Mongo más allá de esa comparación. Sin esto, dos
invocaciones concurrentes podrían pisarse la limpieza del staging o publicar la misma
versión dos veces.
Por qué se verifica el hash tres veces¶
- Al descargar de staging: contra el checksum de transferencia que S3 devuelve
(
ChecksumSHA256), para detectar corrupción en la bajada. - Al comprimir localmente:
DescomprimirVerificado(gzip, hash)descomprime el gzip recién generado y confirma que da exactamente los mismos bytes — antes de gastar una escritura a S3 con algo potencialmente corrupto. - Al releer la versión ya subida: se vuelve a descargar la key definitiva inmediatamente después de escribirla y se descomprime de nuevo — para confirmar que lo que quedó persistido en S3 es exactamente lo que se pretendía, no una escritura parcial o corrompida en tránsito.
Ninguna de las tres reemplaza a las otras: cada una cubre un tramo distinto de la cadena (bajada, procesamiento local, subida).
Por qué la limpieza de staging corre en un finally con su propio token¶
finally
{
using var limpieza = new CancellationTokenSource(TimeSpan.FromSeconds(4));
try { await storage.BorrarStagingAsync(cargaId, limpieza.Token); }
catch { throw new ApiException(503, "LIMPIEZA_PENDIENTE"); }
}
El token de cancelación general de la petición puede estar ya vencido o cancelado
cuando se llega al finally (por ejemplo, si el cliente cortó la conexión). Usar un
CancellationTokenSource propio de 4 segundos garantiza que el intento de borrado
siempre corra, independiente de qué pasó antes. Si el borrado falla, se responde 503
LIMPIEZA_PENDIENTE — pero la publicación ya puede haber quedado COMPLETADA: un 503 acá
no significa que la operación de negocio falló, solo que quedó basura en staging por
limpiar.
Fallos ambiguos: por qué no se reintenta sola¶
catch (Exception ex)
{
...
await cargas.RegistrarErrorAsync(cargaId, "REVISION_REQUERIDA", registro.Token);
throw;
}
Una excepción no prevista entre el PutObject a S3 y el $push en Mongo deja abierta
la duda de si el objeto quedó escrito pero el documento no, o viceversa. Reintentar
automáticamente ahí arriesga publicar la misma versión dos veces o dejar un objeto
huérfano sin registro. En vez de eso, la carga queda PENDIENTE con
EnProcesamiento=true y ErrorProcesamiento=REVISION_REQUERIDA — visible para
conciliación manual, sin bloquear otras cargas del mismo usuario.
Observaciones¶
ValidarMetadatacorre después de descargar el staging, no antes — se prioriza fallar por contenido (lo que más tiempo de CPU cuesta) solo si los metadatos ya son válidos, evitando gastar la descarga de S3 en una petición que de todos modos iba a rechazarse por metadatos.- El
Estadofinal de una carga rechazada por contenido esRECHAZADA, igual que una rechazada porApiExceptionde negocio — no se distingue en el campoEstado, solo enHallazgos(presente) vs. ausente. - Ver dominio/modelos.md para el detalle completo de
CargaReporteAnalytics,ReporteAnalyticsyVersionReporte.