Almacenamiento de archivos¶
Documentación técnica para el manejo de archivos en MinIO (S3-compatible).
Buckets definidos¶
| Bucket | Contenido | Visibilidad |
|---|---|---|
kyc-documents |
Documentos de identidad, selfies | Privado — solo con URL firmada |
package-photos |
Fotos de paquetes recibidos | Privado — solo con URL firmada |
order-evidence |
Capturas de órdenes, facturas | Privado — solo con URL firmada |
inspection-evidence |
Fotos y videos de inspecciones | Privado — solo con URL firmada |
incident-evidence |
Evidencias de incidencias y reclamos | Privado — solo con URL firmada |
return-evidence |
Evidencias de devoluciones | Privado — solo con URL firmada |
user-avatars |
Avatares de perfil | Privado — solo con URL firmada |
public-content |
Imágenes de tutoriales, contenido editorial | Público |
Regla: KYC nunca público
Ningún archivo en kyc-documents puede tener acceso público. Todo acceso requiere URL firmada con log de auditoría.
Modelo de datos¶
# core/models/file_attachment.py
class FileAttachment(models.Model):
class Visibility(models.TextChoices):
PRIVATE = 'private', 'Privado'
PUBLIC = 'public', 'Público'
class OwnerType(models.TextChoices):
USER = 'user', 'Usuario'
OPERATOR = 'operator', 'Operador'
SYSTEM = 'system', 'Sistema'
id = models.UUID(primary_key=True, default=uuid.uuid4)
owner_type = models.CharField(max_length=20, choices=OwnerType.choices)
owner_id = models.BigIntegerField() # user_id, operator_id
bucket = models.CharField(max_length=100)
object_key = models.CharField(max_length=500)
original_filename = models.CharField(max_length=255)
mime_type = models.CharField(max_length=100)
size_bytes = models.BigIntegerField()
checksum = models.CharField(max_length=64) # SHA-256
visibility = models.CharField(max_length=20, choices=Visibility.choices)
uploaded_by = models.BigIntegerField()
created_at = models.DateTimeField(auto_now_add=True)
deleted_at = models.DateTimeField(null=True)
class Meta:
indexes = [
models.Index(fields=['owner_type', 'owner_id']),
models.Index(fields=['bucket']),
models.Index(fields=['visibility']),
]
Modelo de log de acceso¶
# core/models/file_access_log.py
class FileAccessLog(models.Model):
id = models.BigAutoField(primary_key=True)
file = models.ForeignKey(FileAttachment, on_delete=models.CASCADE)
accessed_by = models.BigIntegerField() # user_id, operator_id
access_type = models.CharField(max_length=20) # read, download, upload
ip_address = models.GenericIPAddressField()
user_agent = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
Subida de archivos¶
Flujo: URL prefirmada¶
App FastAPI MinIO
│ │ │
│ POST /files/presigned-upload│ │
│ {bucket, filename, type} │ │
│────────────────────────────►│ │
│ │ │
│ ◄───────────────────────────│ {upload_url, object_key}│
│ │ │
│ PUT {upload_url} │ │
│ (directo a MinIO) │ │
│─────────────────────────────┼───────────────────────────►│
│ │ │
│ ◄───────────────────────────│ 200 OK │
│ │ │
│ POST /files/confirm-upload │ │
│ {object_key, metadata} │ │
│────────────────────────────►│ │
│ │ │
│ │ Crear FileAttachment │
│ │ + FileAccessLog │
│ │──────────────────────────►│
│ │ │
│ ◄───────────────────────────│ {file_id, status} │
Tipos de archivo permitidos¶
| Bucket | Tipos MIME | Tamaño máximo |
|---|---|---|
kyc-documents |
image/jpeg, image/png, application/pdf | 10 MB |
package-photos |
image/jpeg, image/png, video/mp4 | 50 MB |
order-evidence |
image/jpeg, image/png, application/pdf | 10 MB |
inspection-evidence |
image/jpeg, image/png, video/mp4 | 50 MB |
incident-evidence |
image/jpeg, image/png, video/mp4 | 50 MB |
return-evidence |
image/jpeg, image/png, video/mp4 | 50 MB |
user-avatars |
image/jpeg, image/png | 5 MB |
public-content |
image/jpeg, image/png, image/svg+xml | 20 MB |
Acceso a archivos privados¶
# core/services/file_service.py
def generate_presigned_read_url(file_id: int, requesting_user_id: int) -> str:
file = FileAttachment.objects.get(id=file_id)
# Verificar permisos
if file.visibility == FileAttachment.Visibility.PRIVATE:
if not (requesting_user_id == file.owner_id or is_operator(requesting_user_id)):
raise PermissionDenied()
# Generar URL con caducidad
url = minio_client.presigned_get_object(
bucket=file.bucket,
object_name=file.object_key,
expires=timedelta(minutes=5),
)
# Registrar acceso
FileAccessLog.objects.create(
file=file,
accessed_by=requesting_user_id,
access_type='read',
ip_address=get_client_ip(),
user_agent=get_user_agent(),
)
return url
Reglas de retención y eliminación¶
| Tipo de archivo | Retención | Eliminación |
|---|---|---|
| KYC documentos | Vida útil de la cuenta + 1 año | Física (borrar de MinIO + PostgreSQL) |
| Fotos de paquetes | 2 años | Física |
| Evidencias de inspección | 1 año | Lógica (deleted_at) |
| Evidencias de incidencias | 2 años | Física |
| Evidencias de devolución | 2 años | Física |
| Comprobantes de pago | 5 años (fiscal) | Física |
| Avatares | Mientras la cuenta exista | Física |
| Tutoriales (públicos) | Indefinido | Cuando se retire del sistema |
Validación de contenido¶
# core/validators/file_validator.py
ALLOWED_MIME_TYPES = {
'kyc-documents': ['image/jpeg', 'image/png', 'application/pdf'],
'package-photos': ['image/jpeg', 'image/png', 'video/mp4'],
'order-evidence': ['image/jpeg', 'image/png', 'application/pdf'],
'inspection-evidence': ['image/jpeg', 'image/png', 'video/mp4'],
'incident-evidence': ['image/jpeg', 'image/png', 'video/mp4'],
'return-evidence': ['image/jpeg', 'image/png', 'video/mp4'],
'user-avatars': ['image/jpeg', 'image/png'],
'public-content': ['image/jpeg', 'image/png', 'image/svg+xml'],
}
MAX_FILE_SIZES = {
'kyc-documents': 10 * 1024 * 1024, # 10 MB
'package-photos': 50 * 1024 * 1024, # 50 MB
'order-evidence': 10 * 1024 * 1024, # 10 MB
'inspection-evidence': 50 * 1024 * 1024, # 50 MB
'return-evidence': 50 * 1024 * 1024, # 50 MB
'user-avatars': 5 * 1024 * 1024, # 5 MB
'public-content': 20 * 1024 * 1024, # 20 MB
}
def validate_upload(bucket: str, mime_type: str, size_bytes: int) -> None:
if bucket not in ALLOWED_MIME_TYPES:
raise UnsupportedBucketError(bucket)
if mime_type not in ALLOWED_MIME_TYPES[bucket]:
raise UnsupportedFileTypeError(mime_type)
if size_bytes > MAX_FILE_SIZES[bucket]:
raise FileTooLargeError(size_bytes, MAX_FILE_SIZES[bucket])
CDN y optimización¶
public-contentpuede servirse a través de CDN (CloudFlare, etc.)- Imágenes de paquetes generar thumbnails de 200x200 para previews
- Videos de inspección no se transcodifican en MVP (se sirven como están)