Загрузка, замена и удаление файлов
Файловая операция соединяет HTTP, storage и SQL. Handler разбирает multipart, action проверяет владельца и управляет изменениями, mapper формирует API metadata. SQL-транзакция не откатывает запись или удаление файла; порядок действий и компенсацию нужно определить отдельно.
1. Зафиксировать контракт операции
Перед реализацией найдите OpenAPI, Laravel FormRequest/action/observer либо существующий Go-сценарий. Зафиксируйте:
| Область | Что определить |
|---|---|
| Вход | Multipart field, обязательность, размер, MIME, дополнительные поля и enum типа файла |
| Доступ | Родительская сущность, владелец, разрешённое состояние и публичность файла |
| Хранение | Disk, ключ объекта, путь в SQL, имя оригинала и имя в storage |
| Ответ | Статус, nullable metadata, path, root_path, url |
| Повтор и отказ | Замена, повторное удаление, отсутствующие ID, обязательная или best-effort очистка |
Правки HTTP-контракта вносят в resources/openapi/ с последующим elc ensi-gog server. Сгенерированные поля DTO и маршруты вручную не меняют; Rules() дорабатывают в сохраняемой генератором части request — см. генератор.
2. Выбрать диск и согласовать пути
В текущей конфигурации CMS/OMS используются диски ensi и ensi_protected. У них разные root и URL: например, ensi_protected имеет root /var/data/protected/ и URL из ENSI_PROTECTED_DISK_URL. Значения нужно сверять с config/filesystems.go и монтированием томов окружения.
Не смешивайте четыре представления пути:
| Представление | Назначение |
|---|---|
| Путь в SQL | Стабильный относительный идентификатор, принятый в домене |
| Ключ storage | Аргумент PutFileAs, Get или Delete, относительно выбранного disk |
| Физический путь | Root драйвера плюс ключ объекта; зависит от окружения |
| API metadata | Публичные path, root_path, url по контракту; не аргументы драйвера по умолчанию |
Конкретный пример из OMS: после сохранения относительного пути order_files/ab/cd/name.pdf helper защищённых файлов добавляет домен orders:
SQL path: order_files/ab/cd/name.pdf
Storage disk: ensi_protected
Storage key: orders/order_files/ab/cd/name.pdf
Root драйвера: /var/data/protected/
Физический путь при таком root:
/var/data/protected/orders/order_files/ab/cd/name.pdf
API root_path: protected/orders/order_files/ab/cd/name.pdf
API url: <ENSI_PROTECTED_DISK_URL>/orders/order_files/ab/cd/name.pdf
StoreProtected и DeleteProtected находятся в app/common/files/protected.go OMS. Они используют одинаковый domain prefix; это helper конкретного сервиса, наличие которого нужно проверить перед переносом в другой сервис. DescribeProtected описывает metadata и не проверяет существование объекта или право доступа к нему.
В CMS PublicFromPath(path, "cms") формирует metadata: root_path начинается с public/cms, URL строится через Storage().Disk("ensi").Url("cms/" + path). Helper не записывает файл и не добавляет этот prefix к операциям удаления. Согласуйте фактический ключ записи с root, публикацией тома и URL, затем проверьте чтение и удаление того же объекта. Само наличие правильной строки URL ещё не подтверждает доступность файла.
Название защищённого disk не обеспечивает авторизацию. Проверьте, каким маршрутом или файловым шлюзом выдаётся содержимое и где ограничен доступ.
Имя файла и границы каталога
Используйте существующий генератор случайного имени и схему подкаталогов. Оригинальное имя можно хранить отдельно для ответа или скачивания; оно не должно задавать storage key. В текущих helpers hash имени выбирает подкаталоги, а не подтверждает целостность содержимого.
Пути задаёт сервер: не допускайте абсолютный путь, .. и выход из каталога через пользовательские параметры. path.Join, TrimLeft и нормализация разделителей сами по себе не проверяют принадлежность пути разрешённому root.
3. Разобрать и проверить multipart
Файл читается через ctx.Request().File("file"). Обработайте ошибку извлечения и отсутствие файла, затем передавайте filesystem.File в action. Правила обычного JSON request не заменяют multipart validation.
Проверки должны соответствовать контракту:
- обязательность поля и поддерживаемый тип вложения;
- размер с обработкой ошибки его определения и согласованными единицами;
- MIME и содержимое в пределах требований операции; клиентское расширение не подтверждает тип;
- существование родителя, ownership и допустимость загрузки в его состоянии.
Не превращайте ошибку Size() или MimeType() в успешную проверку с нулевым значением. Для размера ровно на границе и на единицу больше нужны отдельные случаи. В OMS правило max:2048 для file соответствует принятому ограничению 2 MiB; ручная проверка должна совпадать с ним.
Для обращений к storage используйте контекст операции. Пример фрагмента записи после валидации:
stored, err := facades.Storage().Disk("ensi").WithContext(ctx).
PutFileAs(directory, source, filename)
if err != nil {
return err
}
if stored == "" {
return fmt.Errorf("storage returned an empty file path")
}
Здесь directory и filename подготовлены сервером, source — проверенный filesystem.File, а окружающая функция возвращает error. Сохранение metadata и компенсация остаются частью action. Возвращённый драйвером ключ нельзя автоматически считать путём для SQL, если доменное соглашение исключает prefix.
4. Согласовать storage и SQL
Для новой операции, когда контракт допускает такой порядок, замену можно организовать следующим образом:
- Загрузить и проверить сущность; запомнить старый путь.
- Записать новый файл под новым ключом. До успешной записи не менять SQL metadata.
- Сохранить новую metadata и завершить SQL-транзакцию.
- При отказе SQL удалить новый объект как компенсацию; сохранить исходную ошибку и результат cleanup.
- После успешного commit удалить старый объект по принятой политике очистки.
Эта последовательность сохраняет старый файл до commit, но не делает SQL и storage атомарными. Между шагами возможны сбой процесса и конкурентная замена. Определите повтор, поиск осиротевших объектов и защиту от удаления файла другого обновления. Если action вызывается внутри внешней SQL-транзакции, его возврат ещё не означает commit: очистка должна учитывать и её результат.
| Отказ | Ожидаемое состояние и проверка |
|---|---|
| Запись нового файла | Прежняя SQL metadata и старый объект сохранены |
| SQL после успешной записи | Новый объект удалён либо ошибка cleanup зафиксирована для восстановления |
| Удаление старого после commit | Новая metadata остаётся действительной; отказ очистки обрабатывается по принятой политике |
| Сбой между шагами | Повтор или фоновая очистка не удаляет актуальный объект |
Не теряйте исходную ошибку при отказе компенсации. Там, где обе ошибки должны возвращаться вызывающему коду, можно объединить их через errors.Join; публичный ответ формируется по правилам ошибок. При best-effort cleanup сохраните принятую наблюдаемость и способ последующей очистки.
При портировании сначала воспроизведите согласованную семантику исходного сервиса. Например, OrderFilesAction.Delete в OMS сохраняет best-effort удаление storage из Laravel observer. Это конкретное поведение; превращение его в обязательный отказ или изменение момента удаления требует отдельного решения.
Текущие upload actions также нужно читать вместе с обработкой отказов: в CMS замена banner удаляет прежний файл до SQL-сохранения, в OMS ошибка очистки после неудачного Create не возвращается. Эти места полезны для изучения вызовов и metadata, но не являются готовым образцом безопасной замены для нового сценария.
5. Удалить только разрешённые файлы
Перед удалением получите весь набор и проверьте принадлежность каждого файла родителю из URI. Определите поведение для пустого списка, дубликатов, отсутствующих ID и частично найденного набора. Отсутствие SQL-строки может означать 404 или идемпотентный успех — это решает контракт.
Сначала проверьте весь набор, затем выполняйте разрушительные эффекты. Нельзя удалить первый файл и только после этого обнаружить чужого владельца у второго. Передавайте в Delete ключ нужного disk, а не URL, физический путь или root_path из API.
Выберите порядок удаления metadata и объекта с учётом ошибок обоих шагов. Удалённый файл не восстановится при SQL rollback. Если физическое удаление best-effort, тест должен подтверждать именно эту политику и предусмотренный способ очистки.
6. Проверить сценарий без рабочего storage
Компонентный тест отправляет настоящий multipart через API, проверяет ответ по OpenAPI, перечитывает SQL и утверждает вызовы storage. Для проверки записи байтов используйте временный каталог и локальный disk; для сценария отказа или ownership удобнее строгий mock.
В CMS есть tests.MockStorage(t). Он заменяет binding и восстанавливает его через cleanup, сохраняя реальные HTTP и ORM. Helper рассчитан на последовательный тест с одним запросом: maintenance middleware вызывает Exists("framework/maintenance.json") на каждом HTTP-запросе. Для нескольких запросов настройте соответствующие ожидания.
| Сценарий | Что проверить |
|---|---|
| Upload | Поле multipart, путь/имя, содержимое или аргументы записи, SQL и DTO |
| Validation | Отсутствующий файл, неверный тип/размер, границы; нет изменений SQL и storage |
| Ownership | Чужой родитель и смешанный набор ID; нет разрушительных вызовов |
| Replacement | Старый объект, новый объект и metadata после успеха и каждого отказа |
| Delete | Точный disk/key, SQL-состояние, повтор и отсутствие объекта |
| Cleanup failure | Сохранена исходная ошибка и соблюдена политика восстановления |
Exists() == true в mock не доказывает наличие байтов. Успешный upload JSON не доказывает, что последующее чтение или удаление обращается к тому же ключу. Изолируйте binding, конфигурацию, временные файлы и fake registry; общие singleton bindings не допускают t.Parallel().
Исходные примеры
- CMS: public metadata и storage mock.
- OMS: protected helpers — согласование домена, ключа и metadata.
- OMS: файлы заказа и тесты attach.
Используйте implement-file-storage-operation и test-component-behavior из .agents/skills. Детали SQL и тестового harness — в изменяющих операциях и автотестах.
Пример узкой проверки из корня OMS Go:
elc go test ./app/modules/orders/tests/order_files --env=.env.testing -count=1
elc mise run check:env
elc go build -o build/app