Updated ImageService to use default_storage.url() instead of manually
constructing URLs. This ensures images displayed on the frontend correctly
include the tenant_id in the path, enabling proper file access within
multi-tenant environment.
Changes:
- ImageService.get_url() now delegates to default_storage.url()
- All image URLs now include /media/tenants/{tenant_id}/ path
- Ensures consistent behavior with TenantAwareFileSystemStorage
- Frontend photos now display correctly with tenant isolation
Result:
- Thumbnail URLs: /media/tenants/papa/products/4/28/thumb.webp
- Medium URLs: /media/tenants/papa/products/4/28/medium.webp
- Large URLs: /media/tenants/papa/products/4/28/large.webp
- Original URLs: /media/tenants/papa/products/4/28/original.jpg
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
240 lines
11 KiB
Markdown
240 lines
11 KiB
Markdown
# Решение: Изоляция фотографий товаров между тенантами
|
||
|
||
## Проблема
|
||
|
||
Фотографии товаров одного тенанта перезаписывали фотографии другого тенанта. Два разных тенанта с товарами ID=1 использовали одни и те же файлы:
|
||
```
|
||
media/products/1/1/original.jpg ← перезатиралось для каждого тенанта
|
||
```
|
||
|
||
## Решение: Tenant-Aware FileSystemStorage
|
||
|
||
Реализована полная изоляция файлов между тенантами через custom Django storage backend.
|
||
|
||
### Архитектура
|
||
|
||
**На диске (физическое хранилище):**
|
||
```
|
||
media/tenants/{tenant_id}/products/{entity_id}/{photo_id}/{size}.ext
|
||
media/tenants/{tenant_id}/kits/{entity_id}/{photo_id}/{size}.ext
|
||
media/tenants/{tenant_id}/categories/{entity_id}/{photo_id}/{size}.ext
|
||
```
|
||
|
||
**В базе данных (для экономии и мобильности):**
|
||
```
|
||
products/{entity_id}/{photo_id}/{size}.ext
|
||
kits/{entity_id}/{photo_id}/{size}.ext
|
||
categories/{entity_id}/{photo_id}/{size}.ext
|
||
```
|
||
|
||
Tenant ID добавляется/удаляется автоматически при работе с файлами.
|
||
|
||
## Реализованные изменения
|
||
|
||
### 1. Создан Custom Storage Backend
|
||
|
||
**Файл:** `products/utils/storage.py`
|
||
|
||
Класс `TenantAwareFileSystemStorage` расширяет стандартный Django FileSystemStorage:
|
||
|
||
- `_get_tenant_id()` - Получает ID текущего тенанта из контекста django-tenants
|
||
- `_get_tenant_path()` - Добавляет tenant_id в начало пути
|
||
- `get_available_name()` - Проверяет уникальность на диске, но возвращает путь БЕЗ tenant_id для БД
|
||
- `_save()` - Сохраняет файл с tenant_id на диск, но возвращает путь БЕЗ tenant_id для БД
|
||
- `_open()` - Открывает файл, добавляя tenant_id если необходимо (критично для Celery!)
|
||
- `path()` - Преобразует относительные пути в полные системные пути с tenant_id
|
||
- `delete()` - Удаляет файлы с проверкой принадлежности тенанту (безопасность)
|
||
- `exists()` - Проверяет существование с валидацией тенанта
|
||
- `url()` - Генерирует URL с проверкой безопасности
|
||
|
||
**Безопасность:** Storage предотвращает доступ к файлам других тенантов и выбрасывает исключение при попытке кросс-тенантного доступа.
|
||
|
||
### 2. Обновлена конфигурация Django
|
||
|
||
**Файл:** `myproject/settings.py`
|
||
|
||
```python
|
||
DEFAULT_FILE_STORAGE = 'products.utils.storage.TenantAwareFileSystemStorage'
|
||
```
|
||
|
||
### 3. Обновлены модели фотографий
|
||
|
||
**Файл:** `products/models/photos.py`
|
||
|
||
- Заменены жесткие `upload_to='products/temp/'` на callable функции
|
||
- Функции генерируют пути БЕЗ tenant_id (добавляется автоматически storage)
|
||
- Добавлены комментарии о мультитенантности в docstring каждого класса
|
||
|
||
Функции upload_to:
|
||
- `get_product_photo_upload_path()` → `products/temp/{filename}`
|
||
- `get_kit_photo_upload_path()` → `kits/temp/{filename}`
|
||
- `get_category_photo_upload_path()` → `categories/temp/{filename}`
|
||
|
||
### 4. Обновлены утилиты обработки фотографий
|
||
|
||
**Файлы:**
|
||
- `products/utils/image_processor.py` - Добавлены комментарии о мультитенантности
|
||
- `products/utils/image_service.py` - Добавлены комментарии о структуре путей
|
||
- `products/tasks.py` - Обновлены комментарии о мультитенантности в Celery задачах
|
||
|
||
Важно: Эти файлы работают как есть благодаря архитектуре storage!
|
||
|
||
### 5. Созданы комплексные тесты
|
||
|
||
**Файл:** `products/tests/test_multi_tenant_photos.py`
|
||
|
||
Тесты проверяют:
|
||
- ✅ Что пути в БД не содержат tenant_id (для мобильности)
|
||
- ✅ Что пути на диске содержат tenant_id (для изоляции)
|
||
- ✅ Что фотографии разных тенантов сохраняются в разные места
|
||
- ✅ Что storage отказывает в доступе к файлам других тенантов
|
||
- ✅ Что storage настроен в settings
|
||
- ✅ Что качество фото устанавливается корректно
|
||
|
||
```bash
|
||
# Запуск тестов
|
||
cd myproject
|
||
python manage.py test products.tests.test_multi_tenant_photos -v 2
|
||
```
|
||
|
||
**Результат:** Все 5 тестов проходят успешно ✅
|
||
|
||
## Как это работает
|
||
|
||
### Сценарий загрузки фото
|
||
|
||
1. **Пользователь загружает фото в tenant1**
|
||
- Django создает `ProductPhoto` объект
|
||
- Пользователь указывает файл (temporary)
|
||
|
||
2. **BasePhoto.save() срабатывает**
|
||
- Проверяет контекст (connection.schema_name = 'tenant_1')
|
||
- Запускает Celery задачу для асинхронной обработки
|
||
|
||
3. **ImageField сохраняет файл**
|
||
- Вызывает `TenantAwareFileSystemStorage._save()`
|
||
- Storage:
|
||
- Добавляет tenant_id: `tenants/tenant_1/products/temp/image.jpg`
|
||
- Сохраняет на диск: `media/tenants/tenant_1/products/temp/image.jpg`
|
||
- Возвращает БД путь БЕЗ tenant_id: `products/temp/image.jpg`
|
||
- Django сохраняет в БД: `products/temp/image.jpg`
|
||
|
||
4. **Celery обрабатывает фото в фоне**
|
||
- Активирует schema: `connection.set_schema('tenant_1')`
|
||
- Читает фото из БД (путь `products/temp/image.jpg`)
|
||
- Storage автоматически добавляет tenant_id при чтении
|
||
- Обрабатывает и создает размеры
|
||
- Сохраняет обработанные файлы
|
||
- Обновляет БД с путем: `products/{entity_id}/{photo_id}/original.jpg`
|
||
|
||
5. **Когда пользователь заходит в Tenant2**
|
||
- Товар с ID=1 в tenant2 имеет разные фото
|
||
- Файлы хранятся в: `media/tenants/tenant_2/products/1/{photo_id}/`
|
||
- Не пересекаются с tenant1!
|
||
|
||
## Безопасность
|
||
|
||
### Защита от кросс-тенантного доступа
|
||
|
||
Storage проверяет tenant_id при операциях чтения/удаления:
|
||
|
||
```python
|
||
def delete(self, name):
|
||
tenant_id = self._get_tenant_id()
|
||
if not name.startswith(f"tenants/{tenant_id}/"):
|
||
raise RuntimeError(f"Cannot delete file - belongs to different tenant")
|
||
```
|
||
|
||
Если пользователь попытается обратиться к файлу другого тенанта - получит исключение.
|
||
|
||
## Преимущества решения
|
||
|
||
✅ **Полная изоляция** - Файлы разных тенантов физически разделены
|
||
✅ **Безопасность** - Storage предотвращает кросс-тенантный доступ
|
||
✅ **Чистота БД** - Пути в БД не содержат tenant_id (более мобильно)
|
||
✅ **Минимум изменений** - ImageProcessor и ImageService работают без изменений
|
||
✅ **Асинхронность** - Celery полностью поддерживает мультитенантность
|
||
✅ **Масштабируемость** - Готово к переходу на S3 в будущем
|
||
✅ **Протестировано** - 5 комплексных тестов проходят успешно
|
||
|
||
## Путь к облаку (S3)
|
||
|
||
В будущем очень легко перейти на S3 хранилище:
|
||
|
||
```python
|
||
# Просто замените одну строку в settings.py:
|
||
|
||
# Текущая конфигурация
|
||
DEFAULT_FILE_STORAGE = 'products.utils.storage.TenantAwareFileSystemStorage'
|
||
|
||
# Облачное хранилище (S3)
|
||
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
|
||
|
||
# Структура путей остается той же!
|
||
# S3: s3://bucket/tenants/{tenant_id}/products/{entity_id}/{photo_id}/{size}.ext
|
||
```
|
||
|
||
Структура paths останется идентичной - нужны только зависимости и конфигурация подключения к S3.
|
||
|
||
## Отладка
|
||
|
||
### Логирование
|
||
|
||
Все операции логируются с префиксом `[Storage]` для удобства отладки:
|
||
|
||
```
|
||
[Storage] Extracted tenant_id=tenant_1 from schema=tenant_1
|
||
[Storage] get_available_name: products/temp/image.jpg → checking disk with: tenants/tenant_1/products/temp/image.jpg
|
||
[Storage] Stripped tenant prefix: tenants/tenant_1/products/temp/image_lKjH.jpg → products/temp/image_lKjH.jpg
|
||
[Storage] _save: products/temp/image.jpg → tenants/tenant_1/products/temp/image.jpg
|
||
```
|
||
|
||
### Проверка структуры файлов
|
||
|
||
```bash
|
||
# На диске файлы организованы так:
|
||
media/
|
||
├── tenants/
|
||
│ ├── tenant_1/
|
||
│ │ └── products/
|
||
│ │ └── temp/
|
||
│ │ └── image.jpg
|
||
│ └── tenant_2/
|
||
│ └── products/
|
||
│ └── temp/
|
||
│ └── image.jpg
|
||
```
|
||
|
||
## Миграция (если были старые фото)
|
||
|
||
Для проекта указано - начинаем с чистого листа, без миграции старых фото.
|
||
|
||
Если в будущем понадобится мигрировать старые данные:
|
||
1. Напишите management команду для перемещения файлов
|
||
2. Обновите пути в БД
|
||
3. Используйте storage для добавления tenant_id в пути
|
||
|
||
## Контрольный список
|
||
|
||
- ✅ Custom storage backend создан
|
||
- ✅ Settings обновлены
|
||
- ✅ Модели фотографий обновлены
|
||
- ✅ Комментарии добавлены во все утилиты
|
||
- ✅ Тесты написаны и проходят
|
||
- ✅ Безопасность валидирована
|
||
- ✅ Документация готова
|
||
|
||
## Следующие шаги
|
||
|
||
Когда проект вырастет:
|
||
|
||
1. **S3 миграция** - замените storage backend на S3
|
||
2. **CDN** - настройте CloudFront для ускорения доставки
|
||
3. **Бэкапы** - настройте S3 versioning и lifecycle policies
|
||
4. **Мониторинг** - добавьте метрики для отслеживания использования storage
|
||
|
||
---
|
||
|
||
**Дата:** 2025-11-23
|
||
**Статус:** ✅ Готово к продакшену
|