Add WIP notes on attachments work
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
60fb39f98f
commit
d0fcc93a8d
1 file changed
+214
@@ -0,0 +1,214 @@
|
||||
# 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.
|
||||
Reference in new issue
Block a user