12 KiB
WIP — вложения (2026-10-02)
Сделано за сегодня: модуль file (хранение файлов), модуль issue-attachment
(вложения задач) и вложения в комментариях. Всё в ветке feature/attachments
(не запушена), миграции к БД не применялись.
Архитектура
Метаданные файла хранятся отдельно от того, к чему файл прикреплён:
file ←── issue_attachment (issueId, fileId) — модуль issue-attachment
←── comment_attachment (commentId, fileId) — модуль comment
fileничего не знает о владельцах. Новый владелец = своя таблица связей- регистрация в
FileOwnerRegistry; самfileпри этом не меняется.
- регистрация в
- Удаление задачи/комментария удаляет связи каскадом в БД, а файл без единой связи (сирота) удаляет GC.
- Флоу для клиента в два шага: загрузить файл → прикрепить по
fileId.
Почему не полиморфная связь ownerType + ownerId: нет FK, нет каскадов, разные
типы ключей (Issue.id — Int, остальные — uuid).
Миграции
| Миграция | Что |
|---|---|
20261002045355_file |
таблица file |
20261002050156_issue_attachment |
таблица issue_attachment |
20261002060755_comment_attachment |
таблица comment_attachment |
Собраны через prisma migrate diff без подключения к БД. Применятся при деплое
(prisma migrate deploy в CMD Dockerfile).
Модуль file — src/modules/file/
API
| Метод | Путь | Что |
|---|---|---|
| POST | /api/files |
загрузка, multipart, поле file → FileDto |
| GET | /api/files/:id/download |
скачивание (поток) |
| DELETE | /api/files/:id |
удалить файл (только загрузивший), 204 |
FileDto: { id, fileName, mimeType, size, uploaderId, createdAt }.
storageKey наружу не отдаётся.
Устройство
- domain:
StoredFile— неFile, чтобы не путать с глобальным Web APIFile;- VO
FileName: убирает путь и управляющие символы, обрезает до 255 с сохранением расширения; - порты
IFileStorage,IFileTypeDetector.
- application:
- use-case'ы upload / download / delete / get-files;
FileAccessService,FileOwnerRegistry,FileGcService.
- infrastructure:
FilePrismaRepository;- хранилища
LocalFileStorage(dev/тесты) иS3FileStorage(S3 / RustFS / MinIO), выбираются вfile-storage.factory.ts; FileTypeDetectorнаfile-type.
- presentation:
FileController, хелперcontentDisposition(RFC 5987, кириллица), GraphQL-типFile.
Загрузка
- multer пишет во временный файл (
os.tmpdir()), а не в память. Лимит —FILE_MAX_SIZE_MB, при превышении Nest отвечает 413. defParamCharset: 'utf8'— иначе multer ломает кириллицу в именах файлов.- MIME определяется по содержимому (magic bytes). Нераспознанное
(HTML, SVG, текст) сохраняется как
application/octet-stream. - Ключ в хранилище —
files/{randomUUID}. Имя от пользователя в ключ не попадает. - Порядок записи: объект → строка в БД. Если запись строки упала, объект удаляется сразу.
Скачивание и безопасность
- Inline отдаются только
image/png|jpeg|gif|webp, всё остальное — какattachment. ПлюсX-Content-Type-Options: nosniff— защита от stored XSS. - Файл виден загрузившему и всем, кому виден хоть один его владелец (задача, комментарий). Иначе 404, чтобы не раскрывать существование файла.
TransformResponseInterceptorпропускаетStreamableFile. Без этого обёртка{data, code}ломала скачивание.
Прикрепление (для модулей-владельцев)
FileAccessService.getAttachableFile(id, userId): прикрепить можно только свой файл. Невидимый файл → 404, чужой → 403. Иначе файл, увиденный через одну задачу, можно было бы перенести в другую.GetFilesUseCase— метаданные файлов по id, для раскрытия связей в DTO.
GC файлов-сирот
FileGcService,@Cron(EVERY_HOUR). ПодключёнScheduleModuleвapp.module.ts.- Удаляет файлы старше
FILE_ORPHAN_TTL_HOURS(по умолчанию 24), на которые не ссылается ни один зарегистрированный владелец. - Порядок удаления: объект → строка. Удаление объекта идемпотентно, поэтому
при сбое следующий запуск доделает работу. Тот же порядок в
DeleteFileUseCase. - Безопасен при нескольких инстансах приложения.
⚠️ Новый владелец файлов обязан зарегистрироваться в
FileOwnerRegistry(onModuleInit), иначе GC удалит его файлы как сирот.
Модуль issue-attachment — src/modules/issue-attachment/
Отдельный модуль, как comment: зависит от issue, а issue о нём не знает.
Иначе получался бы цикл issue ↔ attachment.
API
| Метод | Путь | Что |
|---|---|---|
| GET | /api/issues/:issueId/attachments |
список → IssueAttachmentDto[] |
| POST | /api/issues/:issueId/attachments |
{ fileId } → IssueAttachmentDto |
| DELETE | /api/issues/:issueId/attachments/:fileId |
открепить, 204 (файл удалит GC) |
IssueAttachmentDto: { issueId, file: FileDto, attachedAt }.
GraphQL: поле IssueDetail.attachments. Его добавляет IssueAttachmentResolver
через @ResolveField, issue-модуль не тронут. В списке задач (Issue) этого
поля нет, чтобы не было N+1.
Правила
- Прикреплять и откреплять может любой, кому видна задача (то же правило, что для правки задачи), но прикрепить можно только свой файл.
- Повторное прикрепление идемпотентно (
upsert). - Если задачу или файл удалили между проверкой и вставкой (FK violation), ответ 404.
IssueAttachmentFileOwnerServiceрегистрируется в реестре. Видимость файла проверяется черезIIssueRepository.findById, поэтому правило «reporter/assignee» живёт в одном месте.
Вложения в комментариях — модуль comment
Вложения — часть агрегата Comment (fileIds). Отдельных эндпоинтов нет:
POST /api/issues/:issueId/comments { "content": "...", "fileIds": ["…"] }
PATCH /api/comments/:id { "content": "...", "fileIds": [...] }
- Ответы.
CommentDto.attachments: FileDto[]— в create, update, списке и SSE-событиях. - Правка.
fileIdsпри update задаёт набор целиком; не передан — вложения не меняются.- Синхронизация связей идёт в одной транзакции с правкой текста.
- Изменение только набора файлов тоже считается правкой
(
editedAt,comment.updated). - Проверяются только добавленные файлы.
- Ограничения: максимум
Comment.MAX_ATTACHMENTS = 10, дубли убираются. - Порядок — по времени загрузки. id файлов — uuid v7, поэтому сортировка по строке совпадает с порядком загрузки.
- Мягкое удаление комментария снимает связи, файлы забирает GC. В ответе
у удалённого комментария
attachments: []. CommentAttachmentFileOwnerServiceрегистрируется в реестре. Файл виден тем, кому видна задача неудалённого комментария с этим файлом.CommentAuthorExpanderServiceпереименован вCommentExpanderService: он раскрывает и авторов, и файлы (параллельно).
Прочие изменения
- Зависимости:
@aws-sdk/client-s3,file-type,@nestjs/schedule; dev —@types/multer. tsconfig.json:multerдобавлен вtypes, иначе не виденExpress.Multer.File..gitignore/.dockerignore: добавлен/storage— каталог локального хранилища.
Переменные окружения
FILE_STORAGE=s3 # local (по умолчанию) | s3
FILE_STORAGE_LOCAL_DIR=./storage
FILE_MAX_SIZE_MB=25
FILE_ORPHAN_TTL_HOURS=24
S3_ENDPOINT=http://rustfs:9000 # для AWS можно не задавать
S3_REGION=us-east-1
S3_BUCKET=ulitka
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_FORCE_PATH_STYLE=true # для RustFS/MinIO
В .env были закомментированы RUSTFS_*. Адаптер использует общие S3_*.
Проверено
- 154 unit-теста проходят. Типы, линтер и сборка чистые.
- Приложение стартует, DI резолвится, новые маршруты без сессии отвечают 401.
В
schema.gqlпоявилисьFile,IssueAttachment,IssueDetail.attachments. - Не проверено: сценарий целиком с авторизацией, реальной БД и S3 (upload → attach → download → GC).
TODO / открытые вопросы
- Перед деплоем: создать бакет в RustFS (приложение его не создаёт),
прописать
FILE_STORAGE=s3иS3_*на сервере. - Прогнать e2e: загрузка → прикрепление → скачивание другим участником → открепление → GC.
- Влить
feature/attachmentsвmain. Push вmainзапускает деплой — сначала бакет и переменные окружения. - Лимит числа вложений у задачи (у комментария — 10).
- SSE-события для вложений задачи (по образцу
CommentEventsService). - Комментарий только с файлами, без текста? Сейчас текст обязателен.
- Вложения комментариев в GraphQL — когда появятся комментарии в GraphQL.
- GC при большом числе файлов: сейчас каждый запуск проходит по всем старым
файлам. При сотнях тысяч файлов — переписать на
NOT EXISTS. - Маловероятная гонка: прикрепление файла, загруженного больше 24 ч назад, ровно в момент прохода GC может его потерять.
- Позже: отдавать скачивание redirect'ом на presigned GET вместо стрима через Node.