← Integraciones externas

Sesión 43 · Semana 22

Ficheros y comunicación externa

Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado comprobar una dependencia externa y su degradación. En Servidor continúas la implementación del mismo producto.

Se explica

25 minutos · explicación y demostración

Tu aplicación ya consulta un proveedor y controla sus fallos. Hoy recibirá archivos y emitirá avisos externos. Multipart permite enviar un archivo junto con otros datos; un webhook es una petición hacia una URL receptora. Separarás el éxito del guardado del resultado del aviso.

El transporte binario: multipart/form-data

Hasta ahora todas nuestras peticiones enviaban texto estructurado en formato JSON. Sin embargo, un fichero (un PDF con especificaciones, una captura de un bug en PNG o un informe de obra) es una secuencia de bytes binarios.

Para transmitir simultáneamente datos JSON y flujos binarios, el protocolo HTTP utiliza el estándar multipart/form-data (RFC 7578):

  • El cuerpo de la petición se divide en bloques independientes delimitados por una cadena frontera (boundary).
  • Cada bloque tiene sus propias cabeceras Content-Disposition y Content-Type, seguidas de los bytes correspondientes.

Los tres vectores de ataque en la subida de ficheros

Aceptar ficheros del exterior es una de las puertas de entrada más peligrosas en una aplicación web. Un atacante intentará explotar tres vectores clásicos:

Vector de ataque Cómo opera el atacante Consecuencia Contramedida obligatoria
1 · Salto de directorio (Path Traversal) Envía un fichero con nombre manipulado: ../../../../etc/shadow o ../../app.jar. Sobrescribe ficheros críticos del sistema operativo o binarios de la aplicación. Nunca usar el nombre original en el disco. Generar un nombre aleatorio con UUID.randomUUID() y guardar el nombre original solo como metadato en la base de datos.
2 · Ejecución remota de código (RCE) Sube un archivo con código ejecutable (malware.jsp, script.sh) a una carpeta estática pública. El servidor web ejecuta el script directamente con permisos del sistema, dando control total al atacante. Almacenar los ficheros fuera del classpath y del directorio web. Servirlos exclusivamente a través de un endpoint de descarga controlado por Java.
3 · Denegación de servicio por espacio (Zip Bomb) Sube ficheros gigantescos de cientos de gigabytes o miles de ficheros simultáneos. Agota el espacio en disco de la máquina o satura la memoria RAM del servidor. Configurar límites estrictos en Spring Boot (max-file-size: 5MB) y validar extensiones/MIME permitidos en el servicio.

La ley del almacenamiento seguro

El disco almacena UUIDs opacos; la base de datos almacena los nombres reales.

Los ficheros subidos deben residir en un directorio externo configurable (ej: /var/uploads/), inaccesible mediante URL directa, y servirse siempre a través de un controlador que verifique la autenticación del usuario.

El problema de la doble escritura y la frontera transaccional

Imagina este caso de uso en nuestro gestor de proyectos:

  • Cuando un usuario crea una tarea de prioridad CRÍTICA, el sistema debe:
    1. Guardar la tarea en PostgreSQL (operación ACID local).
    2. Notificar a un sistema externo (enviar un correo SMTP o emitir un webhook HTTP hacia un canal de Discord/Slack de soporte).

Si implementas esto de forma síncrona dentro del método del servicio:

// ANTIPATRÓN: Acoplamiento síncrono de efectos secundarios
@Transactional
public TareaResponse crearTarea(TareaRequest request) {
    Tarea tarea = tareaRepository.save(new Tarea(...)); // Paso 1: Base de datos

    webhookClient.notificarAlerta(tarea); // Paso 2: Red externa síncrona (¡PELIGRO!)

    return mapearResponse(tarea);
}

Este código contiene dos defectos arquitectónicos gravísimos:

  1. Latencia acumulada: El cliente web se queda esperando en blanco mientras el servidor contacta con Slack o el servidor de correo. Si la red remota tarda 5 segundos, la API tarda 5 segundos.
  2. Inconsistencia transaccional:
    • Si la llamada a Slack falla con una excepción, Spring hace rollback en PostgreSQL: la tarea no se guarda porque Slack estaba caído.
    • Si la base de datos hace commit pero la notificación falla después, ¿cómo sabes qué se notificó y qué no?

El principio de desacoplamiento de efectos secundarios

Las notificaciones externas son efectos secundarios; nunca deben bloquear la transacción principal de negocio.

La persistencia en base de datos debe confirmarse primero. Una vez garantizado el commit, los efectos secundarios se disparan de forma asíncrona mediante Eventos de Dominio.

Arquitectura de Eventos de Dominio en Spring

Para resolver este problema con elegancia, Spring proporciona un bus de eventos en memoria:

Eventos desacoplados con @TransactionalEventListener
  1. 1. Controlador recibe petición
  2. 2. Servicio guarda Tarea en DB
  3. 3. Publica TareaCreadaEvent
  4. 4. Commit de la Transacción local (DB asegurada)
  5. 5. Listener en hilo @Async envía Webhook en background
  • ApplicationEventPublisher: Publica un objeto de evento inmutable (record).
  • @TransactionalEventListener(phase = AFTER_COMMIT): Garantiza que el evento solo se procesará después de que la transacción de base de datos se haya confirmado con éxito. Si la base de datos falla, la notificación externa jamás se envía.
  • @Async: Ejecuta el listener en un pool de hilos independiente en segundo plano, liberando al hilo de Tomcat inmediatamente.

Se trabaja

140 minutos · implementación guiada sobre el proyecto propio

Paso 1 · Retomar el proyecto y preparar la comprobación

  1. Abre la entidad que recibirá adjuntos, su servicio y las reglas de permisos. Prepara archivos ficticios pequeños, uno de tipo permitido y otro rechazado.
  2. Localiza la configuración del directorio de almacenamiento y la URL receptora de pruebas. Usa un receptor de desarrollo para los avisos.
  3. Anota qué información del adjunto guardarás en la base de datos y cómo comprobarás quién puede descargarlo.

Paso 2 · Subida y descarga segura de adjuntos

Después de crear Adjunto, crea repository/AdjuntoRepository.java. Es el repositorio que utilizarán los controladores y el servicio integrado de la siguiente sesión:

package com.ejemplo.gestor.repository;

import com.ejemplo.gestor.model.Adjunto;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface AdjuntoRepository extends JpaRepository<Adjunto, Long> {
    List<Adjunto> findByTareaId(Long tareaId);
}

Si el manejador global captura Exception, añade también un método específico para ResponseStatusException: de lo contrario convertiría sus 404 o 409 en 500. Importa org.springframework.web.server.ResponseStatusException y conserva los demás métodos del manejador.

@ExceptionHandler(ResponseStatusException.class)
public ProblemDetail estadoConocido(ResponseStatusException ex) {
    return ProblemDetail.forStatusAndDetail(ex.getStatusCode(), ex.getReason());
}

Para el archivo vacío o de tipo rechazado, utiliza una excepción propia de validación o el IllegalArgumentException del ejemplo y tradúcela expresamente a 400; no cambies todos los errores inesperados a 400.

Configura primero los límites multipart y el directorio local. Crea la entidad Adjunto conservando su relación obligatoria con Tarea, su repositorio y AlmacenamientoService. Después incorpora las operaciones al servicio del dominio y conecta el controlador; los datos de la petición deben validarse antes de escribir el archivo. Reutiliza las comprobaciones de propiedad de la UD9 en subida y descarga. El nombre de almacenamiento lo genera el servidor; el nombre original se utiliza solo como metadato visible.

# Límite máximo por fichero individual (5 MB)
spring.servlet.multipart.max-file-size=5MB
# Límite máximo por petición completa (10 MB)
spring.servlet.multipart.max-request-size=10MB

# Directorio de almacenamiento externo en disco
app.almacenamiento.directorio-subidas=./almacenamiento/adjuntos

La base de datos almacena la trazabilidad y la relación con la tarea:

package com.ejemplo.gestor.model;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "adjuntos")
public class Adjunto {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String nombreOriginal;

    @Column(nullable = false, unique = true)
    private String nombreAlmacenado; // UUID generado (ej: "a4f8b1c2-9e3d.pdf")

    @Column(nullable = false)
    private String contentType; // "application/pdf", "image/png"

    @Column(nullable = false)
    private long tamanoBytes;

    @Column(nullable = false)
    private LocalDateTime fechaSubida = LocalDateTime.now();

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "tarea_id", nullable = false)
    private Tarea tarea;

    // Constructores, getters y setters
    public Adjunto() {}

    public Adjunto(String nombreOriginal, String nombreAlmacenado, String contentType, long tamanoBytes, Tarea tarea) {
        this.nombreOriginal = nombreOriginal;
        this.nombreAlmacenado = nombreAlmacenado;
        this.contentType = contentType;
        this.tamanoBytes = tamanoBytes;
        this.tarea = tarea;
    }

    public Long getId() { return id; }
    public String getNombreOriginal() { return nombreOriginal; }
    public String getNombreAlmacenado() { return nombreAlmacenado; }
    public String getContentType() { return contentType; }
    public long getTamanoBytes() { return tamanoBytes; }
}

Este servicio valida el fichero, genera el UUID y escribe los bytes en el disco con control estricto:

package com.ejemplo.gestor.service;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import jakarta.annotation.PostConstruct;
import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.file.*;
import java.util.List;
import java.util.UUID;

@Service
public class AlmacenamientoService {

    @Value("${app.almacenamiento.directorio-subidas:./almacenamiento/adjuntos}")
    private String directorioSubidas;

    private Path rutaAlmacenamiento;

    private static final List<String> TIPOS_PERMITIDOS = List.of(
        "application/pdf", "image/png", "image/jpeg", "text/plain"
    );

    @PostConstruct
    public void inicializar() {
        try {
            this.rutaAlmacenamiento = Paths.get(directorioSubidas).toAbsolutePath().normalize();
            Files.createDirectories(this.rutaAlmacenamiento);
        } catch (IOException ex) {
            throw new RuntimeException("No se pudo inicializar la carpeta de subidas en: " + directorioSubidas, ex);
        }
    }

    public String guardarFichero(MultipartFile archivo) {
        if (archivo == null || archivo.isEmpty()) {
            throw new IllegalArgumentException("El archivo no puede estar vacío");
        }

        // Validación estricta de tipo MIME
        String contentType = archivo.getContentType();
        if (contentType == null || !TIPOS_PERMITIDOS.contains(contentType.toLowerCase())) {
            throw new IllegalArgumentException("Tipo de archivo no permitido: " + contentType + ". Permitidos: " + TIPOS_PERMITIDOS);
        }

        // Extracción segura de la extensión
        String nombreOriginal = archivo.getOriginalFilename();
        String extension = "";
        if (nombreOriginal != null && nombreOriginal.contains(".")) {
            extension = nombreOriginal.substring(nombreOriginal.lastIndexOf(".")).toLowerCase();
        }

        // Generamos un nombre UUID para evitar colisiones y ataques de Path Traversal
        String nombreSeguro = UUID.randomUUID() + extension;
        Path destino = this.rutaAlmacenamiento.resolve(nombreSeguro).normalize();

        // Verificación de seguridad anti Path Traversal
        if (!destino.startsWith(this.rutaAlmacenamiento)) {
            throw new SecurityException("Intento de almacenamiento fuera de la ruta permitida");
        }

        try {
            Files.copy(archivo.getInputStream(), destino, StandardCopyOption.REPLACE_EXISTING);
            return nombreSeguro;
        } catch (IOException ex) {
            throw new RuntimeException("Error al escribir el archivo en disco", ex);
        }
    }

    public Resource cargarComoRecurso(String nombreAlmacenado) {
        try {
            Path archivo = this.rutaAlmacenamiento.resolve(nombreAlmacenado).normalize();
            Resource recurso = new UrlResource(archivo.toUri());

            if (recurso.exists() && recurso.isReadable()) {
                return recurso;
            } else {
                throw new RuntimeException("El archivo no existe o no se puede leer: " + nombreAlmacenado);
            }
        } catch (MalformedURLException ex) {
            throw new RuntimeException("Ruta de archivo malformada", ex);
        }
    }
}
package com.ejemplo.gestor.controller;

import com.ejemplo.gestor.model.Adjunto;
import com.ejemplo.gestor.repository.AdjuntoRepository;
import com.ejemplo.gestor.repository.TareaRepository;
import com.ejemplo.gestor.service.AlmacenamientoService;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/api/v1")
public class AdjuntoController {

    private final AlmacenamientoService almacenamientoService;
    private final AdjuntoRepository adjuntoRepository;
    private final TareaRepository tareaRepository;

    public AdjuntoController(AlmacenamientoService almacenamientoService,
                             AdjuntoRepository adjuntoRepository,
                             TareaRepository tareaRepository) {
        this.almacenamientoService = almacenamientoService;
        this.adjuntoRepository = adjuntoRepository;
        this.tareaRepository = tareaRepository;
    }

    @PostMapping(value = "/tareas/{id}/adjuntos", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    @PreAuthorize("hasAnyRole('DESARROLLADOR', 'JEFE_PROYECTO', 'ADMINISTRADOR')")
    public ResponseEntity<Void> subirAdjunto(
            @PathVariable Long id,
            @RequestParam("archivo") MultipartFile archivo) {

        var tarea = tareaRepository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("Tarea no encontrada"));

        String nombreAlmacenado = almacenamientoService.guardarFichero(archivo);

        Adjunto adjunto = new Adjunto(
            archivo.getOriginalFilename(),
            nombreAlmacenado,
            archivo.getContentType(),
            archivo.getSize(),
            tarea
        );
        adjuntoRepository.save(adjunto);

        return ResponseEntity.status(201).build();
    }

    @GetMapping("/adjuntos/{id}/descargar")
    @PreAuthorize("isAuthenticated()")
    public ResponseEntity<Resource> descargarAdjunto(@PathVariable Long id) {
        Adjunto adjunto = adjuntoRepository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("Adjunto no encontrado"));

        Resource recurso = almacenamientoService.cargarComoRecurso(adjunto.getNombreAlmacenado());

        // Cabecera Content-Disposition: attachment fuerza al navegador a descargarlo con su nombre original
        return ResponseEntity.ok()
            .contentType(MediaType.parseMediaType(adjunto.getContentType()))
            .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + adjunto.getNombreOriginal() + "\"")
            .body(recurso);
    }
}

Paso 3 · Pruebas de subida y descarga en Bruno

  1. Subida de fichero mediante Bruno:
    • Crea una petición POST http://localhost:8080/api/v1/tareas/1/adjuntos.
    • En la pestaña Auth de la petición, introduce un Bearer Token válido con rol DESARROLLADOR.
    • En la pestaña Body, selecciona Multipart Form.
    • Añade el campo con nombre archivo, selecciona el tipo File y escoge un archivo PDF o PNG real de tu ordenador.
    • Envía la petición y comprueba que responde 201 Created.
  2. Inspección forense del disco:
    • Abre tu explorador de archivos y entra en la carpeta almacenamiento/adjuntos.
    • Comprueba que se ha creado un archivo como 8e2a1b9c-4f12-411a-a45b-76b9e28f30c1.pdf.
    • El nombre original no está en el disco: el sistema es completamente inmune a Path Traversal.
  3. Descarga autorizada:
    • Lanza GET http://localhost:8080/api/v1/adjuntos/1/descargar con cabecera Authorization: Bearer <token>.
    • Comprueba que la respuesta devuelve los bytes binarios y la cabecera: Content-Disposition: attachment; filename="especificaciones-proyecto.pdf".
  4. Prueba de seguridad (Fichero malicioso o no permitido):
    • Intenta subir un script prueba.sh o un ejecutable .exe.
    • Resultado esperado: Error 400 Bad Request con mensaje “Tipo de archivo no permitido”. El archivo es rechazado y nada se escribe en el disco.

Paso 4 · Listar los adjuntos de una tarea

Crea dto/AdjuntoResponse.java. En AdjuntoRepository declara una consulta por id de tarea, carga primero la tarea y comprueba el permiso para verla. Transforma cada adjunto a ese DTO y construye la URL de descarga con el id del adjunto, no con el id de la tarea. Añade el GET al controlador existente y compruébalo con una tarea sin adjuntos y otra con dos. Usa cada URL recibida y verifica que descarga el archivo correspondiente.

Correo, servicio externo o webhook

Paso 5 · Webhooks asíncronos con Eventos de Dominio

Preparar el receptor de prueba. Crea tools/webhook-prueba.py con este contenido y ejecútalo en otra terminal con python tools/webhook-prueba.py. Deja el proceso abierto; el listener Java llamará a http://localhost:9090/post. Usa exclusivamente datos ficticios.

from http.server import BaseHTTPRequestHandler, HTTPServer

class Receptor(BaseHTTPRequestHandler):
    def do_POST(self):
        contenido = self.rfile.read(int(self.headers.get("Content-Length", "0")))
        print(contenido.decode("utf-8"), flush=True)
        self.send_response(204)
        self.end_headers()

HTTPServer(("127.0.0.1", 9090), Receptor).serve_forever()

Primero crea una tarea de prioridad alta y observa el POST en esa terminal. Después detén solo el receptor con Ctrl+C y repite: la tarea debe conservarse y el fallo del aviso aparecer en los logs. Vuelve a iniciarlo al terminar. El listener mostrado registra el fallo, pero no implementa una cola de reintentos.

Crea en orden AsyncConfig, el record del evento y el listener. Después añade ApplicationEventPublisher al constructor existente de TareaService, conservando sus otros colaboradores. Dentro del método transaccional de alta, guarda primero, toma el id devuelto y publica el evento con los datos necesarios; no copies un método abreviado que omita el guardado. Usa una prioridad aceptada por tu validador para activar el ejemplo de aviso. Configura la URL de un receptor local de pruebas antes de activar el envío.

package com.ejemplo.gestor.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
@EnableAsync // Habilita la anotación @Async
public class AsyncConfig {

    @Bean(name = "notificacionesExecutor")
    public Executor notificacionesExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(50);
        executor.setThreadNamePrefix("notif-thread-");
        executor.initialize();
        return executor;
    }
}

Creamos un registro inmutable que transporta los datos mínimos necesarios:

package com.ejemplo.gestor.event;

public record TareaCriticaCreadaEvent(
    Long tareaId,
    String titulo,
    String prioridad,
    String proyectoNombre,
    String creadoPor
) {}

En TareaService añade ApplicationEventPublisher como campo y parámetro del constructor existente; importa org.springframework.context.ApplicationEventPublisher y el evento anterior. Conserva los repositorios y las reglas actuales. En el método transaccional de alta, después de guardar y antes del return, inserta este bloque. Aquí tarea es la entidad devuelta por save y usuarioAutenticado es el username recibido del principal del controlador; pásalo como argumento si tu método todavía no lo recibía.

if ("alta".equals(tarea.getPrioridad())) {
    eventPublisher.publishEvent(new TareaCriticaCreadaEvent(
        tarea.getId(), tarea.getTitulo(), tarea.getPrioridad(),
        tarea.getProyecto().getNombre(), usuarioAutenticado));
}

La prioridad sigue siendo String con valores baja, media y alta. No crees un enum CRITICA solo para copiar el aviso. El nombre del evento identifica el caso que hemos decidido notificar.

El listener se ejecuta en segundo plano solo tras el commit de la base de datos:

package com.ejemplo.gestor.listener;

import com.ejemplo.gestor.event.TareaCriticaCreadaEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.web.client.RestClient;

import java.util.Map;

@Component
public class NotificacionWebhookListener {

    private static final Logger log = LoggerFactory.getLogger(NotificacionWebhookListener.class);
    private final RestClient webhookRestClient;

    public NotificacionWebhookListener(RestClient.Builder restClientBuilder,
            @org.springframework.beans.factory.annotation.Value("${app.webhook.base-url:http://localhost:9090}") String baseUrl) {
        // En un entorno real se apunta a una URL configurable de Slack/Discord o Webhook de terceros
        this.webhookRestClient = restClientBuilder
            .baseUrl(baseUrl)
            .build();
    }

    @Async("notificacionesExecutor")
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void alCrearTareaCritica(TareaCriticaCreadaEvent evento) {
        log.info("[{}] Procesando notificación asíncrona para tarea crítica #{}: {}",
            Thread.currentThread().getName(), evento.tareaId(), evento.titulo());

        try {
            // Emitimos la petición POST hacia el webhook externo
            webhookRestClient.post()
                .uri("/post")
                .body(Map.of(
                    "alerta", "TAREA CRÍTICA REGISTRADA",
                    "id", evento.tareaId(),
                    "titulo", evento.titulo(),
                    "proyecto", evento.proyectoNombre(),
                    "responsable", evento.creadoPor()
                ))
                .retrieve()
                .toBodilessEntity();

            log.info("[{}] Notificación de webhook enviada con éxito para tarea #{}",
                Thread.currentThread().getName(), evento.tareaId());

        } catch (Exception ex) {
            // El fallo externo se registra en auditoría sin afectar al usuario
            log.error("[{}] Error al enviar webhook para tarea #{}: {}. Revisar el envío fallido. Este ejemplo no programa reintentos.",
                Thread.currentThread().getName(), evento.tareaId(), ex.getMessage());
        }
    }
}

Paso 6 · Inspección de hilos y tiempos en Bruno

  1. Lanza la creación de una tarea crítica: POST http://localhost:8080/api/v1/proyectos/1/tareas
    {
      "titulo": "Servidor principal caído en producción",
      "prioridad": "CRITICA"
    }
  2. Comprueba el tiempo de respuesta en Bruno: El cliente recibe código 201 Created en 18 ms. La experiencia de usuario es instantánea.
  3. Inspecciona la consola de Spring Boot:
    23:45:10.102 INFO  [http-nio-8080-exec-1] c.e.p.service.TareaService : Tarea #42 guardada en PostgreSQL
    23:45:10.120 INFO  [notif-thread-1] c.e.p.l.NotificacionWebhookListener : [notif-thread-1] Procesando notificación asíncrona para tarea crítica #42: Servidor principal caído
    23:45:10.450 INFO  [notif-thread-1] c.e.p.l.NotificacionWebhookListener : [notif-thread-1] Notificación de webhook enviada con éxito para tarea #42
    Observa los nombres de los hilos:
    • El hilo de Tomcat http-nio-8080-exec-1 guardó en la base de datos y respondió al cliente en 18 ms.
    • El hilo notif-thread-1 procesó el webhook en segundo plano durante 330 ms sin que el usuario sufriera ninguna espera.

Paso 7 · Si algo no sale como dice el guion

Síntoma Causa casi segura Qué mirar
El listener se ejecuta pero la tarea no está en la base de datos Se está escuchando antes del commit @TransactionalEventListener(phase = AFTER_COMMIT), no @EventListener a secas
El listener no se ejecuta nunca Falta habilitar la asincronía @EnableAsync en una clase de configuración; sin ella, @Async es decoración
El listener corre en el hilo de la petición y la ralentiza Falta @Async, o la llamada es interna Si el evento se publica desde el mismo bean que lo escucha, el proxy no interviene
El webhook falla y se pierde la tarea El listener está dentro de la transacción Con AFTER_COMMIT esto no puede pasar: la tarea ya está guardada pase lo que pase
El webhook falla y nadie se entera La excepción muere en el hilo asíncrono Un @Async sin try/catch traga el error en silencio: registra siempre el fallo

Paso 8 · Notificación simulada por correo electrónico

Añade un segundo listener que simule el envío de un correo de alerta:

  1. Crea NotificacionEmailListener.
  2. Escucha el mismo evento TareaCriticaCreadaEvent con @Async y @TransactionalEventListener(phase = AFTER_COMMIT).
  3. Simula la redacción del mensaje y registra en logs el destinatario y el asunto.
  4. Comprueba que un único evento dispara tanto el webhook como el correo sin que ninguno espere al otro.
  5. Demuestra que el desacoplamiento funciona de verdad, que es toda la razón de ser de la sesión: haz que el listener del webhook lance una excepción a propósito, crea una tarea crítica y comprueba tres cosas a la vez:
    • la tarea está en la base de datos,
    • el cliente recibió su 201 Created sin enterarse de nada,
    • y el listener del correo se ejecutó igualmente. Si alguna de las tres falla, tu notificación no está desacoplada: está escondida dentro de la transacción.
  6. Mide el tiempo de respuesta del alta con y sin los listeners activos. Deben ser prácticamente iguales. Si el alta tarda más al añadir notificaciones, el @Async no está actuando y estás haciendo esperar al usuario a que se envíe un correo.
  7. Anota el agujero que queda abierto, porque te lo van a preguntar en la defensa: si el servidor se apaga entre el commit y la ejecución del listener, la notificación se pierde y nadie lo sabe. Es exactamente el problema que resuelve el patrón Outbox del reto.
Cómo saber que lo has terminado
Un evento dispara dos listeners independientes; un fallo en uno no afecta al otro ni al alta; el tiempo de respuesta del endpoint no cambia al añadirlos; y sabes explicar en qué caso concreto una notificación se perdería.

Paso 9 · Comprobar y registrar el resultado del proyecto

  1. Sube, lista y descarga un archivo autorizado; compara su contenido y prueba tamaño, tipo e identidad no permitidos.
  2. Simula un fallo del receptor externo y verifica la política prevista: el dato confirmado no debe desaparecer porque un aviso posterior haya fallado. Registra ese fallo de forma comprobable.

Ampliación si has completado el trabajo

Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.

Reto · Validación de firmas mágicas binarias (Magic Bytes)

Un atacante avanzado puede renombrar un ejecutable virus.exe a informe.pdf.

  • Si tu servidor solo comprueba la extensión o la cabecera Content-Type enviada por el cliente, el fichero será aceptado porque el navegador reporta lo que la extensión sugiere.

Investiga cómo inspeccionar los Magic Bytes del flujo binario:

  1. ¿Cuáles son los primeros 4 bytes característicos de un archivo PDF legítimo (%PDF / 0x25 0x50 0x44 0x46) y de una imagen PNG (0x89 0x50 0x4E 0x47)?
  2. Integra la librería Apache Tika o implementa una comprobación directa de los primeros bytes de archivo.getInputStream() para verificar el tipo real antes de escribir en disco.
Objetivo mínimoConfiguración de límites multipart y servicio de almacenamiento local con UUIDs operativos.
Si lo tienesSubida y descarga autorizada con Spring Security, metadatos en PostgreSQL y Content-Disposition.
RetoValidación profunda de tipos de archivo mediante inspección de firmas mágicas (*Magic Bytes*).
Ver respuestas

1 · Porque el cliente puede enviar nombres maliciosos con secuencias de salto de directorio (../../) para sobrescribir archivos del sistema o inyectar código ejecutable.

2 · La cabecera Content-Disposition: attachment; filename="nombre.ext".

3 · spring.servlet.multipart.max-file-size y spring.servlet.multipart.max-request-size.

4 · Porque la extensión puede ser alterada trivialmente por el usuario (ej: renombrar un script .sh a .pdf) eludiendo la comprobación si no se valida el MIME o los magic bytes.

Reto · El patrón Outbox para garantizar entrega (Transactional Outbox)

Si el servidor se apaga repentinamente justo después de hacer commit en la base de datos pero antes de que el hilo asíncrono ejecute el webhook, la notificación se pierde para siempre.

Investiga el patrón Transactional Outbox:

  1. ¿Por qué las arquitecturas de microservicios guardan la notificación en una tabla local mensajes_pendientes dentro de la misma transacción que la tarea?
  2. ¿Cómo lee un proceso programado (@Scheduled) esa tabla periódicamente para enviar los webhooks y marcar su estado como ENVIADO?
Objetivo mínimoConfiguración de @EnableAsync, evento de dominio y listener desacoplado.
Si lo tienesListener con @TransactionalEventListener(phase = AFTER_COMMIT) y llamada a webhook con RestClient.
RetoDiseño conceptual del patrón Transactional Outbox para tolerancia a fallos y reintentos.
Ver respuestas

1 · Porque mantiene la conexión de base de datos y los bloqueos de filas abiertos durante todo el tiempo que tarda la red externa, reduciendo drásticamente la concurrencia y arriesgando rollbacks indebidos.

2 · Garantiza que el evento solo se ejecutará si la transacción de base de datos se confirmó con éxito; si hubo un error previo o un rollback, el listener no se dispara.

3 · Nada; el usuario ya recibió su respuesta 201 Created hace tiempo porque el listener se ejecuta en un hilo separado desacoplado del ciclo de vida de la petición HTTP.

4 · Para controlar el tamaño de la cola, limitar el número máximo de hilos concurrentes y evitar que un aluvión de notificaciones consuma toda la memoria de la máquina.

Cierre

15 minutos · resultado comprobable y explicación individual

Al terminar la sesión:

Un archivo no permitido se rechaza y una persona sin permisos no descarga un adjunto ajeno.

Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.