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

Предусловия в file storage для хранения записей звонков

Чек-лист для разработчика file storage (talent.microservices.filestorage): что нужно завести, чтобы messenger мог сохранять записи звонков/конференций (jibri → ingest → file storage). Пока этих справочных данных нет — ingest-эндпоинт записи завершится ошибкой, и CallRecordingEntity не дойдёт до Available.

Дом сущности записи и пайплайн — call-recording.md, recording-pipeline.md. Здесь — только предусловия в чужом сервисе.

Зачем

Загрузка файла в file storage требует, чтобы существовали:

  • категория файлов (FileCategoryEntity) с кодом CALL_RECORDINGS — байты записей нельзя класть в общую MEDIA/CHAT_ATTACHMENTS, у записей своя политика хранения/доступа и свой путь в бакете;
  • типы контента (FileContentTypeEntity) для видео/аудио контейнеров записей (video/mp4, video/webm, video/x-matroska, на будущее audio/mpeg) — сейчас в справочнике только архивы/документы/картинки (id 1..19), видео нет, и UploadFileRequest.FileContentTypeId не на что указать.

Категории уникальны по Code, типы контента — по Id (smallint) и по паре MIME/расширение. Новые записи идут строго следующими свободными идентификаторами, без «дыр» и переиспользования занятых id.

Порядок шагов ниже — рабочий: константы → сиды → миграция → проверка.


Шаг 1. Код категории CALL_RECORDINGS

Файл: Talent.FileStorage.Constants/FileCategoryCodeConstants.cs (актуальный источник кодов). Добавить свойство рядом с существующими (Media, ChatAttachments, ...):

/// <summary>
/// Записи звонков и конференций (Jitsi/jibri)
/// </summary>
public static string CallRecordings { get; } = "CALL_RECORDINGS";

Второй файл Talent.FileStorage.Api.HttpClients/FileCategoryCodeConstants.cs помечен [Obsolete] («Используйте Talent.FileStorage.Constants...») — дублировать в него не нужно; новый код использует только версию из Constants. Если по каким-то причинам deprecated-копия ещё в ходу у потребителей, добавьте туда такое же свойство, но целевое состояние — единый источник в Constants.

Шаг 2. Сид категории FileCategoryEntity

Файл: Talent.FileStorage.DataBase/Seeders/FileCategoryEntitySeeder.cs. Добавить элемент в массив Entities по образцу существующих (например, CHAT_ATTACHMENTS):

new FileCategoryEntity()
{
Id = Guid.Parse("A1E7C4D2-0F3B-4C9A-9E11-7C2D5B8F0A64"), // новый уникальный Guid, сгенерировать свой
Name = "Записи звонков",
Code = "CALL_RECORDINGS",
CreatedAt = EntitySeederConstants.DefaultCreatedAt,
Description = "Записи звонков и конференций мессенджера (Jitsi/jibri)",
DefaultBucketId = BucketEntitySeeder.DefaultId, // тот же засиженный бакет 7BEDBFF3-...-1C3762851946
Path = "CallRecordings",
},

Обязательные поля берутся из FileCategoryEntity (Talent.FileStorage.DataBase/Entities/FileCategoryEntity.cs): Id, Name, Code, Path, DefaultBucketId, CreatedAt (+ опциональный Description). Path определяет префикс объектов записей внутри бакета. DefaultBucketId — существующий бакет BucketEntitySeeder.DefaultId (Talent.FileStorage.DataBase/Seeders/BucketEntitySeeder.cs); отдельный бакет под записи заводить не обязательно.

Новый Guid выше — placeholder: сгенерируйте собственный и зафиксируйте одно и то же значение в сиде и в миграции (Шаг 4), иначе идемпотентность сида/миграции сломается.

Шаг 3. Типы контента для видео/аудио

Занятые id — 1..19 (Gif = 19). Следующие свободные — начиная с 20.

3.1. Enum группы

Файл: Talent.FileStorage.DataBase/Enums/FileContentTypeGroup.cs. Группы Video нет — добавить в конец (значения сохраняются как int, поэтому только в хвост, без сдвига существующих):

public enum FileContentTypeGroup
{
None, // 0
Images, // 1
Archives, // 2
Documents, // 3
Application, // 4
Video // 5 ← новое
// Audio // 6 ← опционально, если .mp3 выделять в отдельную группу
}

audio/mpeg (.mp3) семантически — аудио; допустимо положить его в Video (медиа-контейнер записи) либо завести Audio = 6. Рекомендация: Video для всех четырёх сейчас, Audio — когда появится реальный audio-only сценарий.

3.2. Константы типов контента

Файл: Talent.FileStorage.Constants/FileContentTypeConstants.cs. Добавить свойства после Gif (Id = 19) и включить их в приватный массив AllContentTypes (иначе GetContentTypeInfoBy* их не найдёт):

/// <summary> video/mp4 </summary>
public static FileContentTypeInfo Mp4 { get; } = new() { Id = 20, MimeType = "video/mp4", FileTypeExtensions = ".mp4" };

/// <summary> video/webm </summary>
public static FileContentTypeInfo Webm { get; } = new() { Id = 21, MimeType = "video/webm", FileTypeExtensions = ".webm" };

/// <summary> video/x-matroska </summary>
public static FileContentTypeInfo Mkv { get; } = new() { Id = 22, MimeType = "video/x-matroska", FileTypeExtensions = ".mkv" };

/// <summary> audio/mpeg (на будущее — audio-only записи) </summary>
public static FileContentTypeInfo Mp3 { get; } = new() { Id = 23, MimeType = "audio/mpeg", FileTypeExtensions = ".mp3" };
private static readonly FileContentTypeInfo[] AllContentTypes = new[]
{
Default, Pdf, /* ... */ Webp, Gif,
Mp4, Webm, Mkv, Mp3, // ← добавить
};

3.3. Сид типов контента

Файл: Talent.FileStorage.DataBase/Seeders/FileContentTypeEntitySeeder.cs. Добавить записи в массив Entities после Gif (Id = 19), группа Video:

new FileContentTypeEntity()
{
Id = 20,
FileTypeName = "video/mp4",
FileTypeExtensions = ".mp4",
CreatedAt = EntitySeederConstants.DefaultCreatedAt,
Group = FileContentTypeGroup.Video
},
new FileContentTypeEntity()
{
Id = 21,
FileTypeName = "video/webm",
FileTypeExtensions = ".webm",
CreatedAt = EntitySeederConstants.DefaultCreatedAt,
Group = FileContentTypeGroup.Video
},
new FileContentTypeEntity()
{
Id = 22,
FileTypeName = "video/x-matroska",
FileTypeExtensions = ".mkv",
CreatedAt = EntitySeederConstants.DefaultCreatedAt,
Group = FileContentTypeGroup.Video
},
new FileContentTypeEntity()
{
Id = 23,
FileTypeName = "audio/mpeg",
FileTypeExtensions = ".mp3",
CreatedAt = EntitySeederConstants.DefaultCreatedAt,
Group = FileContentTypeGroup.Video // либо FileContentTypeGroup.Audio
},

FileTypeName — это MIME-тип (см. существующие записи: "image/png" и т.п.), Group пишется как int.

Шаг 4. EF-миграция

После правки сидов сгенерировать миграцию (схема БД — TalentFileStorage):

dotnet ef migrations add AddCallRecordingsCategoryAndVideoContentTypes \
--project Talent.FileStorage.DataBase \
--startup-project Talent.FileStorage.Migrator

(startup-проект — тот, что применяет миграции: Talent.FileStorage.Migrator; при иной конфигурации — Talent.FileStorage.Api.)

Что попадёт в Up() (образцы — Migrations/20240816080839_AddedNewCategories.cs для категории и Migrations/20251119085927_AddedFileContentTypeEntity.cs для типов контента):

  • InsertData в TalentFileStorage.FileCategoryEntity — одна строка CALL_RECORDINGS (колонки Id, Name, Code, Path, Description, DefaultBucketId, CreatedAt);
  • InsertData в TalentFileStorage.FileContentTypeEntity — четыре строки (id 20..23, Group = 5);
  • обратный Down() — соответствующие DeleteData по Id (Guid категории и id 20..23).

Проверить, что сгенерированный InsertData использует тот же Guid категории, что и сид (Шаг 2). Применение — штатным прогоном мигратора при деплое (Talent.FileStorage.Migrator), после существующих миграций; порядок определяется таймстампом в имени файла миграции.

Шаг 5 (рекомендация). Стрим-загрузка крупных записей

Клиент SDK IFilesService.UploadFile (Talent.FileStorage.Api.HttpClients/Services/Files/IFilesService.cs) принимает UploadFileRequest.FileContent типа MemoryStream — то есть весь файл держится в RAM. Записи конференций — это десятки/сотни МБ; при нескольких параллельных выгрузках это риск OOM на стороне messenger.

Два варианта (не взаимоисключающие):

  1. messenger стримит напрямую в multipart-эндпоинт. POST /api/files/upload-file/form (Talent.FileStorage.Api/Features/FilesController.cs, метод UploadFileFromForm) принимает IFormFile и сконфигурирован под стрим/ограниченную память: [RequestFormLimits(BufferBody = false, MemoryBufferThreshold = 128MB, MultipartBodyLengthLimit = 1GB)], [RequestSizeLimit(1GB)], [DisableRequestTimeout]. То есть messenger проксирует байты записи multipart-потоком, минуя MemoryStream SDK-клиента. Плюс — работает уже сейчас; минус — не типобезопасный SDK-контракт, ручной multipart.
  2. file storage добавляет Stream-перегрузку. Ввести в IFilesService метод, принимающий Stream (а не MemoryStream), и пробрасывать поток до провайдера без полной буферизации в память. Плюс — чистый SDK-контракт для потребителей; минус — работа на стороне file storage.

Риск, если не делать ничего: под нагрузкой (несколько одновременно завершившихся записей) буферизация в MemoryStream даёт всплеск RAM пропорционально суммарному размеру файлов.

Проверка: как этим пользуется ingest в messenger

Внутренний эндпоинт messenger POST /internal/call-recordings/{sessionId}/ingest (../api/internal/) после приёма байтов от jibri вызывает загрузку в file storage:

var result = await filesService.UploadFile(
new IFilesService.UploadFileRequest
{
TenantId = tenantId,
CategoryCode = FileCategoryCodeConstants.CallRecordings, // "CALL_RECORDINGS" (Шаг 1)
FileName = $"call-{sessionId}-{DateTime.UtcNow:yyyyMMddHHmmss}.mp4",
FileContentTypeId = FileContentTypeConstants.Mp4.Id, // 20 (Шаг 3); .webm → 21, .mkv → 22
FileContent = content, // MemoryStream (или см. Шаг 5)
Comment = null,
IsDraft = false,
},
cancellationToken);
  • CategoryCode обязан существовать (Шаги 1–2), FileContentTypeId — существовать в справочнике (Шаг 3), иначе загрузка вернёт Result.Fail и запись не станет Available.
  • Успех → UploadFileRequestResult.FileId. Этот FileId messenger сохраняет в CallRecordingEntity (Uploading → Available, вместе с SizeBytes/DurationSeconds/Format).
  • Объектный ключ в хранилище — {CategoryId}/{FileId} (в бакете категории CALL_RECORDINGS).
  • Скачивание позже — CDN presigned GET (IFilesService.GetDownloadFileUrlFileDownloadLink = {CdnUrl}/{FilePath}), ссылка живёт ~1 час; отдаётся клиентам через recordings-эндпоинты messenger (GET /recordings/{id}/download).

Порядок скачивания и download-роуты — см. ../api/recordings/; формат ключа/пресайна — jitsi-deployment.md и доменный call-recording.md.

Обратные ссылки

Автоссылки: где используется этот документ.