Перейти к основному содержимому

Загрузка, замена и удаление файлов

Файловая операция соединяет 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​

Для новой операции, когда контракт допускает такой порядок, замену можно организовать следующим образом:

  1. Загрузить и проверить сущность; запомнить старый путь.
  2. Записать новый файл под новым ключом. До успешной записи не менять SQL metadata.
  3. Сохранить новую metadata и завершить SQL-транзакцию.
  4. При отказе SQL удалить новый объект как компенсацию; сохранить исходную ошибку и результат cleanup.
  5. После успешного 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().

Исходные примеры​

Используйте 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