Saltar a contenido

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-content puede 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)