Uploads de arquivos em aplicações web modernas exigem fluidez, feedback imediato e processamento eficiente, especialmente quando há validações, geração de miniaturas ou outras etapas pesadas. Um fluxo completo costuma envolver três partes: envio do arquivo, atualização do estado em tempo real e uma esteira de processamento em segundo plano.
A combinação de Django (backend), HTMX (interatividade via HTML com requisições assíncronas) e Celery (tarefas assíncronas) permite construir esse tipo de experiência com pouca complexidade no frontend. O resultado é um sistema que aceita arrastar-e-soltar, exibe barra de progresso e executa processamento sem bloquear as requisições do servidor.
Visão geral da arquitetura: Django, HTMX e Celery
Django é um framework web em Python que oferece rotas, templates, formulários, autenticação e um ORM (camada de acesso a banco) já integrados. Isso torna o tratamento de uploads e validações mais direto, com proteção contra problemas comuns como CSRF (token anti-falsificação de requisição). O armazenamento do arquivo é feito com FileField, que salva o arquivo e registra o caminho no banco. A experiência do usuário melhora quando o envio e as atualizações de status ocorrem sem recarregar a página.
HTMX é uma biblioteca que adiciona atributos HTML capazes de disparar requisições assíncronas e trocar trechos da página com o HTML retornado pelo servidor. Em vez de criar uma API JSON completa e depois renderizar no navegador, o servidor devolve HTML pronto e o HTMX injeta no lugar certo. Isso reduz código JavaScript e mantém a lógica de apresentação concentrada no Django. Atualizações periódicas com polling (consulta a cada intervalo) podem ser feitas com gatilhos como hx-trigger.
Celery é uma fila de tarefas que executa trabalhos demorados fora do ciclo da requisição web. Um worker (processo separado) consome tarefas da fila usando um broker (intermediário), frequentemente o Redis. Assim, o upload é aceito rapidamente e o processamento ocorre em paralelo, atualizando o banco com progresso e status. Esse desenho evita travamentos em servidores web e melhora a escalabilidade.
Preparação do projeto e dependências
A base do projeto inclui o Django, a integração com HTMX via django-htmx, o Celery, o Redis e bibliotecas opcionais como Pillow para processamento de imagens. A instalação cria um ambiente consistente para lidar com arquivos, tarefas e renderização de templates. Também é necessário configurar MEDIA_ROOT e MEDIA_URL para armazenar e servir arquivos enviados em ambiente de desenvolvimento. Em produção, o conceito é o mesmo, mas o serviço de arquivos costuma ser separado.
Os comandos a seguir mostram a instalação e criação do projeto e do app responsável pelos uploads.
pip install django htmx django-htmx celery redis pillow
django-admin startproject fileupload_project
cd fileupload_project
python manage.py startapp uploads
As configurações essenciais incluem o app django_htmx e o middleware que detecta requisições HTMX, além das configurações de mídia e do Celery. O Redis é usado como broker e backend de resultados, simplificando o armazenamento do estado das tarefas. A configuração abaixo ilustra os pontos centrais em settings.py. Ajustes adicionais podem existir dependendo do projeto, mas esses são os blocos essenciais.
# fileupload_project/settings.py
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_htmx',
'uploads',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django_htmx.middleware.HtmxMiddleware',
]
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
Configuração do Celery no projeto
A integração do Celery com Django costuma ser feita com um arquivo celery.py no pacote do projeto, criando uma instância do Celery e carregando configurações do Django. A autodiscover (descoberta automática) permite que tarefas declaradas em tasks.py de cada app sejam registradas sem configuração manual. Esse padrão facilita crescimento do projeto sem acoplamento excessivo. Também é comum garantir que o Celery seja carregado quando o Django inicia.
A seguir está um exemplo completo do arquivo de configuração do Celery e da inicialização do pacote do projeto.
# fileupload_project/celery.py
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'fileupload_project.settings')
app = Celery('fileupload_project')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
# fileupload_project/__init__.py
from .celery import app as celery_app
__all__ = ('celery_app',)
Modelagem: persistindo arquivo, metadados, status e progresso
Um modelo de upload precisa registrar mais do que o arquivo, pois o sistema também controla estado, progresso e eventuais erros. O campo UUIDField fornece identificadores difíceis de adivinhar, reduzindo risco de enumeração. Os metadados principais incluem nome original, tamanho e tipo MIME (identificador do tipo de conteúdo, como image/png). O status permite distinguir entre pendente, processando, concluído e falhado.
O progresso em porcentagem facilita barras de progresso, mesmo que seja uma aproximação baseada em etapas. O campo de erro armazena mensagens úteis quando a tarefa falha. Também é valioso registrar timestamps como criação e finalização do processamento para auditoria e relatórios. O exemplo abaixo cobre esse conjunto de necessidades.
# uploads/models.py
from django.db import models
from django.contrib.auth.models import User
import uuid
class FileUpload(models.Model):
STATUS_CHOICES = [
('pending', 'Pendente'),
('processing', 'Processando'),
('completed', 'Concluído'),
('failed', 'Falhou'),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE)
file = models.FileField(upload_to='uploads/%Y/%m/%d/')
original_filename = models.CharField(max_length=255)
file_size = models.BigIntegerField()
mime_type = models.CharField(max_length=100)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
progress = models.IntegerField(default=0)
error_message = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
processed_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['user', 'created_at']),
models.Index(fields=['status']),
]
def __str__(self):
return f"{self.original_filename} - {self.status}"
Com o modelo definido, o banco deve ser atualizado com migrações para criar as tabelas e índices. Isso mantém a estrutura sincronizada com o código.
python manage.py makemigrations
python manage.py migrate
Formulário e validação de segurança no servidor
O uso de ModelForm centraliza validações e reduz repetição, além de se integrar ao sistema de erros do Django. A validação de tamanho impede arquivos excessivos que consumiriam disco e tempo de processamento. A validação de tipo MIME reduz risco de uploads inesperados, embora não substitua validação de conteúdo. Também é importante lembrar que validações no frontend são conveniência, mas a validação decisiva é sempre no servidor.
O formulário pode incluir atributos HTMX diretamente no widget para facilitar a submissão assíncrona e o envio multipart. O atributo hx-encoding como multipart/form-data é indispensável para upload de arquivos. O exemplo abaixo mantém a validação server-side e prepara o campo para integração com templates HTMX.
# uploads/forms.py
from django import forms
from .models import FileUpload
class FileUploadForm(forms.ModelForm):
class Meta:
model = FileUpload
fields = ['file']
widgets = {
'file': forms.FileInput(attrs={
'class': 'file-input',
'accept': 'image/*,.pdf,.doc,.docx',
})
}
def clean_file(self):
arquivo = self.cleaned_data.get('file')
if not arquivo:
return arquivo
limite_bytes = 10 * 1024 * 1024
if arquivo.size > limite_bytes:
raise forms.ValidationError('O arquivo não pode exceder 10MB.')
tipos_permitidos = [
'image/jpeg', 'image/png', 'image/gif',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
]
if arquivo.content_type not in tipos_permitidos:
raise forms.ValidationError('Tipo de arquivo não suportado.')
return arquivo
Views: upload assíncrono com retorno de fragmentos HTML
As views separam responsabilidades entre página inicial, recebimento do upload e consulta de progresso. A página inicial renderiza o formulário e a lista de uploads recentes. O endpoint de upload recebe POST com request.FILES, valida via formulário e cria o registro no banco. Em seguida, dispara a tarefa Celery usando delay, retornando um fragmento HTML com o item recém-criado.
A consulta de progresso retorna outro fragmento HTML, que será atualizado periodicamente pelo HTMX. O uso de login_required protege o recurso e evita vazamento de dados entre usuários. A consulta também filtra por usuário, impedindo que um ID válido acesse uploads de terceiros. O conjunto abaixo apresenta views completas e alinhadas ao fluxo.
# uploads/views.py
from django.shortcuts import render, get_object_or_404
from django.views.decorators.http import require_http_methods
from django.contrib.auth.decorators import login_required
from .forms import FileUploadForm
from .models import FileUpload
from .tasks import process_file_upload
@login_required
def upload_page(request):
form = FileUploadForm()
recent_uploads = FileUpload.objects.filter(user=request.user)[:10]
return render(request, 'uploads/upload_page.html', {
'form': form,
'recent_uploads': recent_uploads,
})
@login_required
@require_http_methods(["POST"])
def upload_file(request):
form = FileUploadForm(request.POST, request.FILES)
if form.is_valid():
upload = form.save(commit=False)
upload.user = request.user
arquivo = request.FILES['file']
upload.original_filename = arquivo.name
upload.file_size = arquivo.size
upload.mime_type = arquivo.content_type
upload.status = 'pending'
upload.progress = 0
upload.save()
process_file_upload.delay(str(upload.id))
return render(request, 'uploads/upload_item.html', {'upload': upload})
return render(request, 'uploads/upload_error.html', {'errors': form.errors})
@login_required
def upload_progress(request, upload_id):
upload = get_object_or_404(FileUpload, id=upload_id, user=request.user)
return render(request, 'uploads/progress_bar.html', {'upload': upload})
Tarefas Celery: pipeline de processamento e atualização de progresso
Uma tarefa Celery executa o processamento do arquivo e atualiza o banco com status e progresso. O status muda para processing no início e para completed no final, com tratamento de exceções para marcar failed. O progresso pode ser atualizado em etapas, o que é útil para pipelines com múltiplas fases. Em cenários reais, cada etapa pode representar validação avançada, extração de metadados, conversão e geração de derivados.
Ao lidar com imagens, o Pillow pode criar miniaturas, reduzindo custo de exibição em listagens. É importante atualizar o banco com frequência razoável para não gerar carga excessiva. Em caso de falha, a mensagem deve ser registrada em error_message para diagnóstico. O exemplo abaixo demonstra um pipeline simples com simulação de trabalho e geração de miniatura.
# uploads/tasks.py
from celery import shared_task
from django.utils import timezone
from django.db import transaction
from PIL import Image
import time
import os
from .models import FileUpload
@shared_task(bind=True, autoretry_for=(), retry_backoff=False)
def process_file_upload(self, upload_id: str):
upload = None
try:
upload = FileUpload.objects.get(id=upload_id)
upload.status = 'processing'
upload.progress = 0
upload.error_message = ''
upload.save(update_fields=['status', 'progress', 'error_message'])
for progresso in range(0, 101, 10):
time.sleep(0.3)
FileUpload.objects.filter(id=upload.id).update(progress=progresso)
upload.refresh_from_db()
if upload.mime_type.startswith('image/'):
_create_thumbnail(upload)
with transaction.atomic():
upload.status = 'completed'
upload.progress = 100
upload.processed_at = timezone.now()
upload.save(update_fields=['status', 'progress', 'processed_at'])
except Exception as exc:
if upload is not None:
upload.status = 'failed'
upload.error_message = str(exc)
upload.save(update_fields=['status', 'error_message'])
raise
def _create_thumbnail(upload: FileUpload) -> None:
caminho = upload.file.path
img = Image.open(caminho)
img.thumbnail((200, 200))
base, ext = os.path.splitext(caminho)
thumb_path = f"{base}_thumb{ext}"
img.save(thumb_path)
Templates com HTMX: zona de upload, itens e barra de progresso
O HTMX funciona declarando atributos no HTML que definem como e quando requisições serão feitas. O formulário de upload usa hx-post para enviar o arquivo, hx-encoding para multipart e hx-target para inserir o resultado em um contêiner. O servidor responde com HTML parcial, normalmente um item de lista contendo a barra de progresso. Esse padrão evita recarregar a página e mantém a renderização no backend.
A atualização do progresso é feita com uma requisição recorrente usando hx-get e hx-trigger="every 1s", substituindo o trecho com hx-swap="outerHTML". Quando o status chega a concluído ou falho, o próprio template pode exibir mensagens adequadas. A seguir estão templates completos e separáveis, que compõem a tela e os fragmentos atualizáveis. O carregamento do HTMX pode ser feito via arquivo estático local em projetos fechados, mas o mecanismo permanece o mesmo.
<!-- templates/uploads/upload_page.html -->
{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>Uploads</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<link rel="stylesheet" href="{% static 'uploads/style.css' %}">
</head>
<body>
<div class="container">
<div class="title">Envio de arquivos</div>
<div class="drop-zone" id="drop-zone">
<p>Arrastar e soltar ou clicar para selecionar</p>
<form id="upload-form"
hx-post="{% url 'upload_file' %}"
hx-encoding="multipart/form-data"
hx-target="#upload-results"
hx-swap="beforeend">
{% csrf_token %}
{{ form.file }}
</form>
</div>
<div id="upload-results" class="upload-results">
{% for upload in recent_uploads %}
{% include 'uploads/upload_item.html' %}
{% endfor %}
</div>
</div>
<script src="{% static 'uploads/drag-drop.js' %}"></script>
</body>
</html>
<!-- templates/uploads/upload_item.html -->
<div class="upload-item" id="upload-{{ upload.id }}">
<div class="upload-info">
<strong>{{ upload.original_filename }}</strong>
<span class="file-size">{{ upload.file_size|filesizeformat }}</span>
</div>
<div class="progress-container"
hx-get="{% url 'upload_progress' upload.id %}"
hx-trigger="every 1s"
hx-swap="outerHTML">
{% include 'uploads/progress_bar.html' %}
</div>
</div>
<!-- templates/uploads/progress_bar.html -->
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill status-{{ upload.status }}"
style="width: {{ upload.progress }}%"></div>
</div>
<div class="status-text">
{% if upload.status == 'completed' %}
Concluído
{% elif upload.status == 'failed' %}
Falhou: {{ upload.error_message }}
{% else %}
{{ upload.progress }}% - {{ upload.status }}
{% endif %}
</div>
</div>
O template de erro deve ser simples e retornar HTML parcial para ser inserido no mesmo contêiner de resultados. Isso mantém consistência visual e evita respostas em formatos diferentes.
<!-- templates/uploads/upload_error.html -->
<div class="upload-item">
<div class="status-text">
Falha de validação: {{ errors }}
</div>
</div>
Arrastar e soltar: JavaScript mínimo e integração com HTMX
O arrastar-e-soltar exige JavaScript porque o navegador não envia arquivos arrastados automaticamente para um formulário. A implementação pode ser pequena: capturar eventos de drag, aplicar estilos de destaque e, no drop, preencher o input de arquivo. Em seguida, é possível disparar a submissão do formulário usando o próprio HTMX. Esse código deve evitar comportamento padrão do navegador para impedir abertura do arquivo arrastado na janela.
O script abaixo adiciona suporte a clique para abrir seletor e drop para enviar. A seleção fica no input oculto e o HTMX envia o formulário sem recarregar a página. O resultado do upload aparece no contêiner configurado pelo hx-target. Essa abordagem mantém o JavaScript apenas como ponte para a API de arquivos do navegador.
// static/uploads/drag-drop.js
document.addEventListener('DOMContentLoaded', function () {
const dropZone = document.getElementById('drop-zone');
const fileInput = document.querySelector('.file-input');
const form = document.getElementById('upload-form');
dropZone.addEventListener('click', function () {
fileInput.click();
});
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (nomeEvento) {
dropZone.addEventListener(nomeEvento, function (e) {
e.preventDefault();
e.stopPropagation();
}, false);
});
['dragenter', 'dragover'].forEach(function (nomeEvento) {
dropZone.addEventListener(nomeEvento, function () {
dropZone.classList.add('drag-over');
}, false);
});
['dragleave', 'drop'].forEach(function (nomeEvento) {
dropZone.addEventListener(nomeEvento, function () {
dropZone.classList.remove('drag-over');
}, false);
});
dropZone.addEventListener('drop', function (e) {
const arquivos = e.dataTransfer.files;
if (!arquivos || arquivos.length === 0) return;
fileInput.files = arquivos;
htmx.trigger(form, 'submit');
});
});
Estilização: barra de progresso e estados visuais
A camada de estilos dá clareza ao estado do upload e melhora a leitura do progresso. A barra de progresso pode ter cores diferentes por status, permitindo identificar pendente, processando, concluído e falho. A zona de drop se beneficia de borda tracejada e destaque quando um arquivo é arrastado sobre ela. O layout em cartões facilita a listagem de múltiplos uploads.
O CSS abaixo cobre uma interface limpa com contêiner, drop-zone, itens e barra de progresso. As classes de status são combinadas com a renderização do template para alterar cor do preenchimento. O arquivo de estilo é estático e independe das regras do backend. A consistência visual também ajuda a identificar falhas rapidamente.
/* static/uploads/style.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, sans-serif;
background: #f5f7fb;
min-height: 100vh;
padding: 32px 16px;
}
.container {
max-width: 820px;
margin: 0 auto;
}
.title {
font-size: 28px;
font-weight: 700;
margin-bottom: 18px;
color: #1f2937;
}
.drop-zone {
background: #ffffff;
border: 2px dashed #cbd5e1;
border-radius: 12px;
padding: 48px 28px;
text-align: center;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease;
margin-bottom: 20px;
}
.drop-zone.drag-over {
border-color: #3b82f6;
background: #eff6ff;
}
.drop-zone p {
color: #475569;
font-size: 16px;
}
.file-input {
display: none;
}
.upload-results {
display: flex;
flex-direction: column;
gap: 12px;
}
.upload-item {
background: #ffffff;
border-radius: 10px;
padding: 16px;
border: 1px solid #e2e8f0;
}
.upload-info {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.file-size {
color: #64748b;
font-size: 13px;
}
.progress-bar {
height: 10px;
background: #e2e8f0;
border-radius: 999px;
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0%;
transition: width 0.25s ease;
}
.status-pending { background: #f59e0b; }
.status-processing { background: #3b82f6; }
.status-completed { background: #10b981; }
.status-failed { background: #ef4444; }
.status-text {
margin-top: 8px;
font-size: 13px;
color: #334155;
}
Rotas e integração final: URLs e media em desenvolvimento
As rotas conectam página, endpoint de upload e endpoint de progresso. A página inicial carrega o template completo e os endpoints retornam fragmentos HTML. O Django também precisa servir arquivos de mídia em desenvolvimento, o que é feito com static() no arquivo de URLs do projeto. Em produção, essa responsabilidade geralmente passa para outro serviço, mas a configuração de MEDIA permanece necessária.
A separação entre uploads/urls.py e fileupload_project/urls.py mantém organização e permite evolução do app. O endpoint de progresso recebe um UUID e retorna o estado do upload correspondente. O conjunto abaixo mostra a configuração completa de URLs.
# uploads/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('', views.upload_page, name='upload_page'),
path('upload/', views.upload_file, name='upload_file'),
path('progress/<uuid:upload_id>/', views.upload_progress, name='upload_progress'),
]
# fileupload_project/urls.py
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('uploads.urls')),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
Execução do sistema: servidor web, Redis e worker
O funcionamento depende de três processos: o servidor Django para receber requisições, o Redis para atuar como broker do Celery e o worker do Celery para executar tarefas. Separar processos evita que operações pesadas atrasem respostas HTTP. Em ambiente de desenvolvimento, cada serviço costuma rodar em um terminal. Em ambientes gerenciados, cada componente pode rodar em serviço próprio, mantendo a mesma lógica.
Os comandos a seguir iniciam os serviços essenciais do fluxo. Com tudo em execução, o upload retorna imediatamente e o progresso é atualizado conforme o worker avança. O polling do HTMX consulta o servidor até o status finalizar em concluído ou falho.
# Terminal 1
python manage.py runserver
# Terminal 2
redis-server
# Terminal 3
celery -A fileupload_project worker --loglevel=info
Cenários importantes: múltiplos arquivos, arquivos grandes e falhas
Uploads podem variar muito em tamanho e quantidade, e a solução precisa lidar com cenários comuns. Para múltiplos arquivos, o input pode aceitar múltipla seleção e o backend pode criar um registro por arquivo, disparando uma tarefa por item. Para arquivos grandes, o envio em chunks (partes) reduz impacto de interrupções de rede e melhora retomada, embora exija uma estratégia de recomposição no servidor. Para falhas, a persistência de status e mensagem de erro permite que a interface reflita claramente o problema.
O progresso exibido aqui é baseado em etapas de processamento e não no upload em si, pois o upload HTTP padrão não expõe progresso de envio ao servidor sem técnicas específicas. Ainda assim, esse modelo é efetivo para pipelines longos, como conversão de vídeo ou análise de documentos. Um sistema robusto também considera tentativas controladas, limites de taxa e validação de conteúdo real do arquivo. Em qualquer cenário, a regra central é manter o upload rápido e o trabalho pesado fora do ciclo de resposta HTTP.
Segurança e boas práticas para uploads
Uploads são uma superfície comum de ataque e exigem controles consistentes no servidor. A validação de tipo e tamanho reduz abusos, mas não elimina riscos, pois extensões e MIME podem ser forjados. É recomendável que arquivos sejam armazenados fora de diretórios executáveis e que nomes previsíveis sejam evitados, o que é atendido pelo uso de UUID no registro e pelo armazenamento gerenciado do Django. A proteção de CSRF deve permanecer ativa, incluindo em requisições HTMX.
Outra prática é restringir acesso ao status e ao arquivo ao usuário dono do upload, como feito no filtro por user. Em pipelines mais críticos, a verificação por assinatura mágica (conteúdo real do arquivo) e varredura por malware podem ser adicionadas antes do processamento. Limites de requisição e rate limiting reduzem ataques de negação de serviço. O registro detalhado de falhas também ajuda a detectar padrões maliciosos e problemas operacionais.
Conclusão
Um sistema completo de upload com Django, HTMX e Celery combina simplicidade de implementação com uma experiência moderna de interface. O envio do arquivo ocorre com retorno imediato, enquanto a atualização de progresso é feita por fragmentos HTML consultados periodicamente. O processamento pesado é deslocado para tarefas assíncronas, evitando bloqueio do servidor web. A persistência de status e erros no banco dá previsibilidade e transparência ao fluxo.
Essa arquitetura também cria uma base sólida para evoluções, como pipelines com múltiplas etapas, geração de derivados e aumento de escala com workers adicionais. A separação de responsabilidades entre requisição web, renderização parcial e execução em fila mantém o código organizado. Com validações server-side, controle de acesso e tratamento de falhas, o upload deixa de ser um ponto frágil e passa a ser um componente confiável do sistema. O resultado final é um fluxo com começo claro no envio, meio no acompanhamento do progresso e fim no processamento concluído ou na falha explicitamente registrada.