Перейти к содержанию

Модель закрытого файлового архива

Внутреннему порталу компании нужен раздел «Архив»: сотрудник загружает фотографию документа, видит состояние обработки и скачивает подготовленную копию. Начнём с модели данных. Если сначала сделать публичную папку с файлами, позднее будет трудно добавить права, квоты и удаление. Поэтому уже на первом шаге отделим карточку архива от содержимого файла и от разрешения получить эти байты.

Серия рассчитана на знакомство с JavaScript, HTTP и SQL. Наш API использует Node.js, PostgreSQL хранит карточки, а S3-совместимое хранилище появится отдельным слоем. В скачиваемом состоянии lesson-01 есть исходники сервера и начальная схема. Они подготовлены как учебный проект; схема и сервер здесь не выполнялись. Ответы ниже описывают ожидаемый контракт, а не запись запущенного приложения.

Карточка, объект и разрешение

Карточка отвечает на вопросы «чей файл», «как его назвать» и «готов ли результат». Объект содержит последовательность байтов. Разрешение отвечает на третий вопрос: «может ли этот запрос получить этот объект сейчас». Ни один слой не заменяет остальные. Наличие объекта в бакете не означает, что файл уже принят приложением; знание идентификатора карточки не даёт права на чтение.

Рассмотрим сотрудницу Алису и фотографию receipt.png. Имя нужно интерфейсу, но не становится путём хранения. Позднее сервер создаст случайный ключ вида incoming/<uuid>/<uuid>. Здесь нет каталогов файловой системы: ключ является именем объекта внутри бакета. Разделители помогают сгруппировать операции очистки, но не образуют границу доступа. Граница доступа определяется учётными данными хранилища и проверками приложения.

В базе сохраняем owner_id отдельно от id. UUID уменьшает вероятность случайного угадывания, однако владельца всё равно придётся проверять при каждой операции. Запрос по одному id нельзя считать авторизацией. Если Боб получит UUID Алисы из скриншота, карточка должна оставаться для него недоступной. Такой пример полезно держать в голове ещё до написания маршрутов: он показывает разницу между идентификацией и правом доступа.

В учебной схеме предусмотрены поля всей будущей последовательности. Это начальная схема пустой лабораторной базы, а не набор миграций для действующего сервиса. Она позволяет сравнивать состояния уроков, не выполняя изменения базы между страницами. Пока сервер не использует большинство полей: задачи обработки, подписанные ссылки и удаление добавляются последовательно в следующих состояниях.

Полный файл schema.sql состояния 01:

CREATE TABLE archive_files (
 id uuid PRIMARY KEY, owner_id text NOT NULL, name text NOT NULL,
 declared_type text NOT NULL, expected_bytes integer NOT NULL,
 status text NOT NULL CHECK(status IN
 ('pending','queued','processing','ready','rejected','deleting','deleted')),
 source_key text UNIQUE NOT NULL, source_version text,
 derived_key text, derived_version text, sha256 text,
 upload_expires timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
 expires_at timestamptz NOT NULL DEFAULT now() + interval '90 days',
 lease_token uuid, lease_until timestamptz, attempts integer NOT NULL DEFAULT 0,
 available_at timestamptz NOT NULL DEFAULT now(), error_code text,
 delete_after timestamptz,
 CHECK(expected_bytes BETWEEN 1 AND 8388608)
);
CREATE INDEX archive_queue ON archive_files(available_at,created_at)
 WHERE status IN ('queued','processing');
CREATE TABLE archive_audit (
 id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
 file_id uuid NOT NULL, event text NOT NULL, happened_at timestamptz DEFAULT now()
);

Ограничение CHECK для размера — последний рубеж согласованности. Оно предотвращает запись заведомо невозможного значения даже при ошибке вызывающего кода. Но база не знает настоящей длины объекта в S3. Поэтому expected_bytes намеренно обозначает заявленный размер; подтверждение позже проверит размер объекта, а обработчик проверит прочитанные байты. Смешение заявленных и установленных фактов часто создаёт ошибочные отчёты о безопасности загрузки.

Состояния одной загрузки

После резервирования карточка становится pending: приложение разрешило попытку загрузки, но ещё не подтвердило её. Состояние queued означает, что конкретная версия объекта найдена и поставлена в очередь. Затем worker переводит запись в processing. Только успешное декодирование и создание производного изображения приводят к ready. Отказ обработки получает rejected, а удаление проходит через deleting к deleted.

Это не украшение интерфейса. Каждое состояние ограничивает допустимые действия. Для pending нельзя выдать download; для deleting нельзя начать новую обработку; для ready повторное подтверждение не должно заменить принятую версию. Сформулированные переходы позже появятся в условиях SQL. Тогда состояние защищает от одновременных запросов, а не просто отображает подпись рядом с фотографией.

В записи присутствуют две пары key/version: исходная и производная. Ключ может получить новое содержимое при повторной загрузке, а versionId обозначает конкретную версию. Нам нужен воспроизводимый ответ на вопрос «какие байты приняли». Поэтому обработка и чтение будут использовать закреплённую версию, даже если в течение срока upload-разрешения клиент ещё раз отправит объект по тому же ключу.

Журнал archive_audit хранит факт перехода, UUID и время. Он не дублирует пользовательское имя и не сохраняет подписанную ссылку. Такая запись помогает объяснить последовательность событий без распространения содержимого документа. При этом журнал пока не является защищённым юридическим аудитом: права базы и срок хранения журнала потребуют отдельной эксплуатационной политики.

Первый HTTP-контракт

На первом этапе сервер отвечает только на /health. Это позволяет отделить запуск процесса от работы будущих зависимостей. Успешный ответ health говорит, что обработчик HTTP способен ответить; он ещё не подтверждает доступность PostgreSQL или закрытого бакета. Для готовности обслуживать загрузки позднее потребуется проверка конкретных ресурсов. Пока отсутствие таких операций честно отражает границу состояния проекта.

Полный файл server.mjs состояния 01:

import { createServer } from 'node:http';
import { db } from './db.mjs';
import { send,json,Failure,fileId } from './http.mjs';
const server=createServer(async(req,res)=>{
  try {
    const path=new URL(req.url,'http://localhost').pathname;
    if(req.method==='GET' && path==='/health') return send(res,200,{service:'archive'});
    throw new Failure(404,'route_not_found');
  } catch(error) { send(res,error.status||500,{error:error.code||'internal_error'}); }
});
server.listen(Number(process.env.PORT||8080),'127.0.0.1');
process.on('SIGTERM',()=>{server.close(async()=>{await db.end();});});

Ожидаемый ответ на GET /health содержит service: "archive". Для неизвестного маршрута ожидается JSON с route_not_found и статусом 404. Сервер слушает 127.0.0.1, поэтому учебный процесс доступен локально. Открывать его во внешнюю сеть до появления авторизации нельзя: дальнейшие примеры исходят из закрытой лаборатории, а не из уже защищённого публичного выпуска.

Обратите внимание на Cache-Control: no-store в общей функции ответа. Позднее некоторые JSON-ответы будут содержать временное разрешение на чтение. Их нельзя оставлять в обычном публичном кеше. Для самих байтов правила кеширования задаются независимо в хранилище; заголовок ответа API не управляет всеми следующими запросами браузера автоматически.

При завершении сервер прекращает принимать соединения и закрывает пул базы. Это управление ресурсами, а не операция удаления файлов. Пул не должен создаваться для каждого HTTP-запроса: такой подход быстро расходует соединения и усложняет объяснение задержек. В нашем проекте один модуль db.mjs принадлежит одному процессу. Отдельный worker позднее получит собственный процесс и собственный пул.

Теперь вы можете проследить судьбу фотографии без привязки к экрану загрузки: карточка резервирует намерение, объект хранит байты, версия закрепляет факт, состояние ограничивает операции. Следующий шаг задаст проверяемую политику заявки. Полезный вариант для самостоятельного разбора — назвать действие, которое допускается для pending, но должно быть запрещено для deleted, и объяснить, где эту границу проверять.

Следующий урок: Политика размера и типа файла

Оглавление серии · Исходники всех состояний.

Документация: PostgreSQL: ограничения, Node.js 22.14.0.