215 lines
12 KiB
Markdown
215 lines
12 KiB
Markdown
# 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 API `File`;
|
||
- 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.
|