Backend-реализация медиа-звонков: спека → код (messenger)
Инженерный гид сборки серверной части медиа-звонков/конференций/записи на реальном SDK Talent.Common.*
(.NET 9). Он проецирует нейтральные контракты домена/API (domain/*,
api/*) на конкретные проекты, агрегаты, EF-конфигурацию, CQRS-операции и
инфраструктурные адаптеры. Здесь допустимы имена Jitsi/SDK/EF и код-скетчи — это гид, не аналитическая спека.
Назначение. Раннбук для разработчика: «куда положить, что унаследовать, как замапить, какие операции». Контракт (сущности/поля/статусы/ошибки/события) — источник истины в domain/, api/, events/; последовательность работ и зависимости — в implementation-plan.md. Код ниже — иллюстративные скетчи, не готовые файлы.
Проектная раскладка
Области calls / conferences / recordings / internal ложатся в существующие проекты
src/services/messenger/ (README §Runtime):
| Область | Api | Application | Domain | Infrastructure | Background | Migrator |
|---|---|---|---|---|---|---|
1:1-звонки (calls) | контроллер CallsController | StartCall/Answer/Decline/End/ListCalls/MintCallMediaToken | CallSessionEntity + MediaRoom VO, CommunicationPolicy | репозиторий, JitsiTokenService | call-timeout-reaper | таблица call_sessions (+room_*) |
Конференции (conferences) | ConferencesController | StartConference/Join/Leave/End/Get/List/MintConfMediaToken | ConferenceSessionEntity + CallParticipantEntity (owned-child) | репозиторий, JitsiTokenService | conference-room-reaper | conference_sessions, call_participants |
Запись (recordings) | RecordingsController | StopRecording/Get/List/GetRecordingDownload | CallRecordingEntity, RecordingPolicy | FileStorageRecordingUploader, presign-адаптер | recording-finalize-retry | call_recordings |
Вебхуки (internal) | InternalJitsiController | JitsiReserveRoom/JitsiReleaseRoom/JitsiPresenceEvent/IngestRecording | те же агрегаты | HmacWebhookFilter, FileStorageRecordingUploader | — | — |
| realtime | SignalR MessengerHub | — | доменные события | realtime-dispatch (минтит MediaJoinModel) | realtime-dispatch | +session_kind в realtime_outbox |
| Kafka out | — | — | ConferenceEndedEvent/RecordingAvailableEvent | OutboxMessageEntity + dispatcher | — | outbox-таблицы |
RecordingPolicy и минтинг токена media-комнаты живут по README: policy — в Domain (доменный сервис),
минтинг — в Infrastructure (README §Api/Application/Infrastructure).
Агрегаты в коде (на SDK Talent.Common.*)
Агрегаты наследуют AggregateRoot<Guid>, поднимают доменные события через RaiseDomainEvent, коммитятся через
IUnitOfWork.SaveChanges. Доменное событие — record : DomainEvent. Бизнес-ошибка внутри метода не бросается,
а возвращается вызывающим хендлером как Result.Fail (метод агрегата может возвращать Result).
public sealed class ConferenceSessionEntity : AggregateRoot<Guid>
{
private readonly List<CallParticipantEntity> _participants = new();
public IReadOnlyCollection<CallParticipantEntity> Participants => _participants;
public MediaRoom Room { get; private set; } // owned VO
public ConferenceStatus Status { get; private set; }
public int ActiveParticipantCount { get; private set; }
public static ConferenceSessionEntity Start(Guid chatId, Guid startedBy,
CallMediaType media, MediaRoom room, string? title)
{
var conf = new ConferenceSessionEntity { Id = Guid.CreateVersion7(), /* … */ };
var moderator = CallParticipantEntity.JoinAsModerator(conf.Id, startedBy);
conf._participants.Add(moderator);
conf.ActiveParticipantCount = 1;
conf.RaiseDomainEvent(new ConferenceStartedEvent(conf.Id, chatId, startedBy)); // → outbox ConferenceInvited
return conf;
}
public Result Join(Guid userId, ParticipantRole role) { /* upsert строки, реактивация (INV-CPART-1) */
RaiseDomainEvent(new ConferenceParticipantJoinedEvent(Id, ChatId, userId, role)); return Result.Ok(); }
public Result Leave(Guid userId, ParticipantLeaveReason reason) { /* soft-leave; пересчёт count */
RaiseDomainEvent(new ConferenceParticipantLeftEvent(Id, ChatId, userId, reason));
if (ActiveParticipantCount == 0) End(ConferenceEndReason.LastParticipantLeft); return Result.Ok(); }
public void End(ConferenceEndReason reason) { /* Active → Ended */
RaiseDomainEvent(new ConferenceEndedEvent(Id, ChatId, DealId, reason)); } // доменное + интеграционное
}
public sealed record ConferenceStartedEvent(Guid ConferenceId, Guid ChatId, Guid StartedByUserId) : DomainEvent;
CallSessionEntity : AggregateRoot<Guid>получает owned VORoom(MediaRoom : ValueObject); методыStart/Answer/Decline/EndподнимаютCallStarted/Answered/Declined/Ended(см. call-session.md).CallParticipantEntity : Entity<Guid>— owned-child конференции: живёт в границах агрегата, отдельно не сохраняется, concurrency — по родителю (call-participant.md).CallRecordingEntity : AggregateRoot<Guid>— отдельный агрегат (переживает сессию):MarkNoticeAnnounced→Start(externalId)→MarkAvailable(fileId, size, duration, format)(raiseRecordingAvailableEvent— доменное + интеграционное) /Fail(reason)(call-recording.md).
public sealed class CallRecordingEntity : AggregateRoot<Guid>
{
public Result MarkAvailable(Guid fileId, string fileName, string contentType,
long sizeBytes, int durationSeconds, RecordingFormat format)
{
if (Status != RecordingStatus.Uploading) return Result.Fail(new Err("MSG_RECORDING_INVALID_STATE"));
Status = RecordingStatus.Available; FileId = fileId; /* … иммутабельно (INV-REC-5) */
RaiseDomainEvent(new RecordingAvailableEvent(Id, SessionKind, SessionId, ChatId, fileId, durationSeconds));
return Result.Ok();
}
}
EF-конфигурация
BaseDbContext<MessengerDbContext> + ConfigureOwnedTypes для MediaRoom; все enum — .HasConversion<string>();
partial unique-индексы и XOR-CHECK задаются во IEntityTypeConfiguration<T>. FK — Restrict (кроме owned-child
call_participants → Cascade).
public sealed class ConferenceSessionConfiguration : IEntityTypeConfiguration<ConferenceSessionEntity>
{
public void Configure(EntityTypeBuilder<ConferenceSessionEntity> b)
{
b.ToTable("conference_sessions", "messenger");
b.OwnsOne(x => x.Room, r => { r.Property(p => p.RoomName).HasColumnName("room_name").HasMaxLength(128);
r.Property(p => p.Domain).HasColumnName("room_domain").HasMaxLength(128); });
b.Property(x => x.Status).HasConversion<string>().HasMaxLength(16);
b.Property(x => x.MediaType).HasConversion<string>().HasMaxLength(8);
b.HasIndex(x => x.Status).HasDatabaseName("ix_conf_active")
.HasFilter("status in ('Provisioning','Active')").IsUnique().IncludeProperties(x => x.ChatId); // INV-CONF-2
b.HasOne<ChatEntity>().WithMany().HasForeignKey(x => x.ChatId).OnDelete(DeleteBehavior.Restrict);
b.OwnsMany(x => x.Participants, p => { // owned-child ростер
p.ToTable("call_participants", "messenger");
p.WithOwner().HasForeignKey(nameof(CallParticipantEntity.ConferenceSessionId));
p.Property(z => z.State).HasConversion<string>().HasMaxLength(16);
p.HasIndex("ConferenceSessionId", "UserId").IsUnique().HasDatabaseName("ux_call_participants_conf_user");
p.HasIndex("ConferenceSessionId").HasFilter("state = 'Joined'").HasDatabaseName("ix_call_participants_active");
});
}
}
CallSessionConfiguration— то жеOwnsOne(Room)+ partial uniqueix_call_activewherestatus in ('Ringing','Answered')(INV-CALL-5/6).CallRecordingConfiguration—HasCheckConstraint("ck_call_recordings_xor", …)(INV-REC-1), partial uniqueix_recording_activewherestatus in ('Pending','Recording','Uploading')(INV-REC-7), обе XOR-FK (CallSessionId/ConferenceSessionId)OnDelete(Restrict), все enum.HasConversion<string>()(полный DDL — ниже).- Tenant/version/lock-интерсепторы SDK подключены на уровне
MessengerDbContext(оптимистическая блокировка черезxminдля конференции/записи).
DDL-набросок и миграции
Новые таблицы + ALTER существующих (EF Core migration в Pin.Messenger.Migrator):
-- ALTER: 1:1-звонок получает media-комнату (owned VO MediaRoom)
ALTER TABLE messenger.call_sessions ADD COLUMN room_name varchar(128);
ALTER TABLE messenger.call_sessions ADD COLUMN room_domain varchar(128);
CREATE UNIQUE INDEX ix_call_active ON messenger.call_sessions (status)
WHERE status IN ('Ringing','Answered');
CREATE TABLE messenger.conference_sessions (
id uuid PRIMARY KEY, chat_id uuid NOT NULL REFERENCES messenger.chats(id),
deal_id uuid, deal_tenant_id uuid, media_type varchar(8) NOT NULL,
room_name varchar(128) NOT NULL, room_domain varchar(128) NOT NULL,
status varchar(16) NOT NULL, started_by_user_id uuid NOT NULL, title varchar(120),
active_participant_count int NOT NULL DEFAULT 0 CHECK (active_participant_count >= 0),
peak_participant_count int NOT NULL DEFAULT 0, last_activity_at timestamptz NOT NULL,
started_at timestamptz NOT NULL, ended_at timestamptz, end_reason varchar(24),
duration_seconds int CHECK (duration_seconds >= 0), created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL);
CREATE UNIQUE INDEX ix_conf_active ON messenger.conference_sessions (status)
WHERE status IN ('Provisioning','Active');
CREATE INDEX ix_conf_last_activity ON messenger.conference_sessions (last_activity_at);
CREATE TABLE messenger.call_participants (
id uuid PRIMARY KEY,
conference_session_id uuid NOT NULL REFERENCES messenger.conference_sessions(id) ON DELETE CASCADE,
user_id uuid NOT NULL, role varchar(24) NOT NULL, conference_role varchar(16) NOT NULL DEFAULT 'Speaker',
state varchar(16) NOT NULL, screen_share_state varchar(8) NOT NULL DEFAULT 'None',
invited_at timestamptz, joined_at timestamptz, left_at timestamptz, leave_reason varchar(24),
duration_seconds int CHECK (duration_seconds >= 0), created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL);
CREATE UNIQUE INDEX ux_call_participants_conf_user ON messenger.call_participants (conference_session_id, user_id);
CREATE INDEX ix_call_participants_active ON messenger.call_participants (conference_session_id) WHERE state = 'Joined';
CREATE TABLE messenger.call_recordings (
id uuid PRIMARY KEY, session_kind varchar(12) NOT NULL,
call_session_id uuid REFERENCES messenger.call_sessions(id),
conference_session_id uuid REFERENCES messenger.conference_sessions(id),
chat_id uuid, deal_id uuid, deal_tenant_id uuid, media_type varchar(8) NOT NULL,
status varchar(12) NOT NULL, trigger varchar(12) NOT NULL, initiated_by_user_id uuid,
notice_state varchar(12) NOT NULL DEFAULT 'Pending', announced_at timestamptz,
started_at timestamptz, stopped_at timestamptz, available_at timestamptz,
file_id uuid, file_name varchar(256), content_type varchar(128), size_bytes bigint,
duration_seconds int, format varchar(8), external_recording_id varchar(128),
failure_reason varchar(24), retention_until timestamptz, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
CONSTRAINT ck_call_recordings_xor CHECK ( -- INV-REC-1
(session_kind='Direct' AND call_session_id IS NOT NULL AND conference_session_id IS NULL) OR
(session_kind='Conference' AND conference_session_id IS NOT NULL AND call_session_id IS NULL)));
CREATE UNIQUE INDEX ix_recording_active ON messenger.call_recordings (coalesce(call_session_id, conference_session_id))
WHERE status IN ('Pending','Recording','Uploading');
CREATE INDEX ix_recording_external ON messenger.call_recordings (external_recording_id);
-- ALTER: realtime-outbox узнаёт форму media-payload
ALTER TABLE messenger.realtime_outbox ADD COLUMN session_kind varchar(12); -- nullable (не-медиа push)
Индексы ix_conf_deal_id/ix_recording_chat/ix_recording_retention — по домен-докам. Одна миграция на волну
media; сидер CALL_RECORDINGS — на стороне file storage (блокирующая предпосылка,
recording-pipeline.md).
CQRS-операции по эндпоинтам
Каждый HTTP-action / вебхук = IOperationRequest[<TResult>] + IOperationHandler, диспетч через
IOperationExecutor, результат — Result/Result<T>. Мутация коммитится IUnitOfWork; строка realtime_outbox
пишется в той же транзакции (persist-then-push); Kafka — через OutboxMessageEntity. Вебхуки идемпотентны
(IIdempotentRequest).
| Route | Operation | Мутируемый агрегат | Домен-события | Outbox / Kafka | Result.Fail |
|---|---|---|---|---|---|
POST /calls | StartCallCommand | CallSessionEntity (create Ringing) [+CallRecordingEntity] | CallStarted [+RecordingNoticeAnnounced] | CallInvited (SessionKind=Direct) | MSG_CALL_*, MSG_CALLS_DISABLED, MSG_RECORDING_PROVISION_FAILED |
POST /calls/{id}/answer | AnswerCallCommand | CallSessionEntity (→Answered) | CallAnswered | CallAnswered | MSG_CALL_INVALID_STATE, MSG_CALL_NOT_FOUND |
POST /calls/{id}/decline | DeclineCallCommand | CallSessionEntity (→Declined) | CallDeclined | CallDeclined | MSG_CALL_INVALID_STATE |
POST /calls/{id}/end | EndCallCommand | CallSessionEntity (→Ended) | CallEnded | CallEnded | MSG_CALL_INVALID_STATE |
GET /calls | ListCallsQuery | — (read Filter(PagingQuery)) | — | — | — |
POST /calls/{id}/media-token (Query) | MintCallMediaTokenQuery → MediaJoinModel | — (read + mint) | — | — (токен в ответе, не хранится) | MSG_CALL_INVALID_STATE |
POST /chats/{chatId}/conferences | StartConferenceCommand | ConferenceSessionEntity (+moderator CallParticipantEntity) [+recording] | ConferenceStarted, ParticipantJoined | ConferenceInvited (SessionKind=Conference) | MSG_CONFERENCE_*, MSG_CHAT_ACCESS_DENIED, MSG_CONFERENCES_DISABLED |
POST /conferences/{id}/join | JoinConferenceCommand → ConferenceModel | CallParticipantEntity (join/реактивация) | ConferenceParticipantJoined | ConferenceParticipantJoined | MSG_CHAT_ACCESS_DENIED, MSG_CONFERENCE_INVALID_STATE |
POST /conferences/{id}/leave | LeaveConferenceCommand | CallParticipantEntity (soft-leave) [+conf →Ended] | ConferenceParticipantLeft [+ConferenceEnded] | ConferenceParticipantLeft [+ConferenceEnded+Kafka] | MSG_CONFERENCE_INVALID_STATE |
POST /conferences/{id}/end | EndConferenceCommand (модератор) | ConferenceSessionEntity (→Ended) | ConferenceEnded (домен+интеграция) | ConferenceEnded + pin.messenger.conference-ended.v1 (key DealId) | MSG_CONFERENCE_INVALID_STATE, MSG_CHAT_ACCESS_DENIED |
POST /conferences/{id}/media-token | MintConferenceMediaTokenQuery → MediaJoinModel | — (read + mint) | — | — | MSG_CONFERENCE_INVALID_STATE |
GET /conferences/{id} | GetConferenceQuery | — | — | — | MSG_CHAT_ACCESS_DENIED |
GET /chats/{chatId}/conferences | ListConferencesQuery | — | — | — | MSG_CHAT_ACCESS_DENIED |
GET /calls/{id}/recordings | ListCallRecordingsQuery | — | — | — | — |
GET /conferences/{id}/recordings | ListConferenceRecordingsQuery | — | — | — | — |
GET /recordings/{id} | GetRecordingQuery | — | — | — | MSG_RECORDING_NOT_FOUND |
GET /recordings/{id}/download | GetRecordingDownloadQuery → presign (audit ReadSensitive) | — | — | — | MSG_RECORDING_NOT_FOUND, MSG_RECORDING_FILE_INVALID |
POST /conferences/{id}/recording/stop | StopRecordingCommand (модератор, .manage) | CallRecordingEntity (→Uploading) | RecordingStopped | RecordingStopped | MSG_RECORDING_INVALID_STATE |
POST /internal/jitsi/reservations | JitsiReserveRoomCommand (IIdempotentRequest) → JitsiReservationModel | ConferenceSessionEntity (Provisioning→Active) | — (admission) | — | MSG_JITSI_ROOM_UNKNOWN, MSG_*_INVALID_STATE |
DELETE /internal/jitsi/reservations/{roomName} | JitsiReleaseRoomCommand | ростер (→Left), LastActivityAt | — | MediaParticipant(Left) | — (unknown → no-op ack) |
POST /internal/jitsi/events | JitsiPresenceEventCommand (IIdempotentRequest) | CallParticipantEntity upsert / CallRecordingEntity (Pending→Recording) | ParticipantJoined/Left, ScreenShareChanged, RecordingStarted | MediaParticipant/ScreenShare/RecordingStarted | MSG_JITSI_SIGNATURE_INVALID, MSG_JITSI_ROOM_UNKNOWN |
POST /internal/call-recordings/{sessionId}/ingest | IngestRecordingCommand (IIdempotentRequest) | CallRecordingEntity (Uploading→Available/Failed) | RecordingAvailable (домен+интеграция) / RecordingFailed | RecordingAvailable + pin.messenger.call-recording-available.v1 (key SessionId) | MSG_RECORDING_FILE_MISSING, MSG_STORAGE_UNAVAILABLE |
Guard'ы состояния и уникальности — из домен-доков (INV-CALL-5, INV-CONF-2, INV-REC-7); детали алгоритмов — в api-доках соответствующих эндпоинтов.
RecordingPolicy (доменный сервис)
sealed-сервис в Domain; чистая функция без I/O. Вызывается в StartCallCommand/StartConferenceCommand при
провизии сессии (INV-REC-3) и считывается в reservation-вебхуке (флаг Recording).
public sealed class RecordingPolicy
{
/// <summary>Нужна ли авто-запись сессии по типу чата и виду сессии.</summary>
public bool ShouldAutoRecord(ChatKind chatKind, SessionKind sessionKind)
=> chatKind == ChatKind.Deal; // звонки/конференции в контексте сделки пишутся; Direct 1:1 — нет
}
Итог true при включённом RECORDINGS_ENABLED заводит CallRecordingEntity (Pending, Trigger=AutoPolicy,
InitiatedByUserId=null) и рассылает индикатор записи (NoticeState→Announced, INV-REC-4) до Recording.
JitsiTokenService (Infrastructure)
Минтит HS256-JWT входа в комнату (нейтрально — MediaJoinModel.AccessToken). Секрет JWT_APP_SECRET — из
vault через options; claims — iss/aud/sub/room/nbf/exp + context.user/features/group/tenant
(jitsi-jwt.md). Токен room-scoped, TTL ~4ч, не хранится.
public sealed class JitsiTokenService : IMediaTokenService
{
private readonly JitsiTokenOptions _opts; // AppId, Secret (vault), Domain, Kid, Ttl
public string Mint(Guid userId, string userName, string roomName, bool moderator,
MediaFeatureSet features, TimeSpan ttl)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_opts.Secret));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var ctx = new { user = new { id = userId, name = userName, moderator },
features = new { recording = features.Recording, screenSharing = features.ScreenSharing,
livestreaming = false, transcription = false, sip = false } };
var token = new JwtSecurityToken(issuer: _opts.AppId, audience: _opts.AppId,
claims: new[] { new Claim("room", roomName), new Claim("sub", _opts.Domain),
new Claim("context", JsonSerializer.Serialize(ctx)) },
notBefore: DateTime.UtcNow.AddSeconds(-30), expires: DateTime.UtcNow.Add(ttl), signingCredentials: creds);
token.Header["kid"] = _opts.Kid;
return new JwtSecurityTokenHandler().WriteToken(token); // никогда не логируется
}
}
Вызывается из realtime-dispatch (минтинг MediaJoinModel при доставке инвайта) и из REST-хендлеров
StartCall/Answer/StartConference/Join/MintMediaToken (когда дескриптор возвращается синхронно вызывающему).
TURN-креды (MediaIceServerEntry.Credential) минтятся аналогично из static-auth-secret coturn — тоже Sensitive.
Filestorage-адаптер (Infrastructure)
Ingest-эндпоинт принимает multipart-поток от jibri и потоково (memory-bounded) отдаёт в file storage —
без буферизации крупного файла в память. Предпочтителен streamed POST /api/files/upload-file/form; клиентский
SDK IFilesService.UploadFile(UploadFileRequest) применим для мелких, но требует MemoryStream — для записей
избегаем.
public sealed class FileStorageRecordingUploader
{
private readonly IFilesService _files; // Talent.FileStorage.Api.HttpClients
public async Task<Guid> Upload(Stream body, Guid tenantId, string fileName,
Guid videoContentTypeId, CancellationToken ct)
{
var req = new UploadFileRequest { TenantId = tenantId, CategoryCode = "CALL_RECORDINGS",
FileName = fileName, FileContentTypeId = videoContentTypeId,
FileContent = body /* поток, не MemoryStream для крупных */, IsDraft = false };
var result = await _files.UploadFile(req, ct); // объект-ключ {CategoryId}/{FileId}
return result.FileId; // → CallRecording.MarkAvailable(...)
}
}
Успешный FileId → CallRecordingEntity.MarkAvailable(...) + Kafka call-recording-available в одной
транзакции; сбой хранилища → MSG_STORAGE_UNAVAILABLE, запись остаётся Uploading для идемпотентного ретрая
(jibri-recording-ingest.md). Скачивание — CDN-presign GET (~1ч),
отдельный GetRecordingDownloadQuery (audit ReadSensitive).
Аутентификация internal-вебхуков (HMAC-фильтр)
ASP.NET action-filter/middleware перед InternalJitsiController: HMAC-SHA256 над сырым телом + X-Pin-Timestamp,
constant-time сравнение, replay-окно ≤ 5 мин; затем fail-closed сверка RoomName со стором MediaRoom.RoomName
(в хендлере). Секрет — из vault. Альтернатива — mTLS (тогда фильтр проверяет клиентский сертификат).
public sealed class HmacWebhookFilter : IAsyncActionFilter // регистрируется на [InternalJitsi]-действиях
{
private readonly IWebhookSecret _secret; // vault-backed
public async Task OnActionExecutionAsync(ActionExecutingContext c, ActionExecutionDelegate next)
{
var ts = c.HttpContext.Request.Headers["X-Pin-Timestamp"];
if (!DateTimeOffset.TryParse(ts, out var stamp) || DateTimeOffset.UtcNow - stamp > TimeSpan.FromMinutes(5))
{ c.Result = Unauthorized("MSG_JITSI_SIGNATURE_INVALID"); return; }
var raw = await ReadRawBodyBuffered(c.HttpContext.Request); // тело буферизуется для повторного чтения
var expected = HMACSHA256.HashData(_secret.Bytes, Encoding.UTF8.GetBytes(raw + ts));
var actual = Convert.FromHexString(c.HttpContext.Request.Headers["X-Pin-Signature"]);
if (!CryptographicOperations.FixedTimeEquals(expected, actual)) // constant-time
{ c.Result = Unauthorized("MSG_JITSI_SIGNATURE_INVALID"); return; }
await next();
}
}
Сверка RoomName — уже в хендлере (JitsiReserveRoom/JitsiPresenceEvent/IngestRecording): резолв сессии по
имени → точное совпадение с MediaRoom.RoomName, иначе MSG_JITSI_ROOM_UNKNOWN (fail-closed). Для ingest тело —
multipart-поток: HMAC считается по meta-части/заголовкам, файл-часть не буферизуется целиком.
Outbox → realtime-dispatch
RealtimeDispatchKind расширен media-значениями (CallInvited/ConferenceInvited/ConferenceParticipantJoined/
RecordingAvailable/…), таблица realtime_outbox получила nullable-колонку SessionKind
(realtime-dispatch.md). Диспетчер по SessionKind выбирает контракт и
адресата:
Direct→ 1:1-контракт (RealtimeCallInvite/RealtimeCallSignal) в группуuser:{TargetUserId};Conference→ групповой (RealtimeConferenceInvite/RealtimeMediaParticipant) вchat:{ChatId}.
Для приглашающих kind (CallInvited/ConferenceInvited/ConferenceStarted) диспетчер минтит
MediaJoinModel (JitsiTokenService.Mint + TURN) в момент доставки — короткоживущие секреты никогда не
пишутся в realtime_outbox (строка несёт только RoomName/reference-id); утечка строки не раскрывает доступ к
комнате. Kafka-события (conference-ended/call-recording-available) идут через OutboxMessageEntity +
dispatcher (FOR UPDATE SKIP LOCKED, LISTEN/NOTIFY); контракт IKafkaEvent со static ContractVersion/
TopicSettings, ключ партиционирования — DealId / SessionId.
Конвенции
sealedдля агрегатов/сервисов/конфигураций;record— только для доменных событий (: DomainEvent).- Все enum —
.HasConversion<string>()(хранениеtext), неизвестное значение →VALIDATION_FAILED. - Бизнес-ошибка —
Result.Fail(FluentResults), неthrow; коды — из api-доков (MSG_*). CancellationTokenна всех I/O (репозитории, file storage, hub, минтинг).- Собственные методы — без суффикса
Async(MarkAvailable,Mint,Start);Async— только у фреймворковых методов EF/HTTP. - Строгая типизация: без
object/json/dynamic/Dictionary<string,object>/JsonDocument; VO — owned types; payload-модели hub/webhook — типизированные (MediaJoinModel,JitsiFinalizeMeta, …). - XML-doc на публичных типах/членах; реализации —
<inheritdoc/>. - Namespace —
Pin.Messenger.<Aggregate>; топики —pin.messenger.<event>.
Обратные ссылки
Автоссылки: где используется этот документ.
- calls (4): Медиа-звонки и запись на Jitsi — интеграционный гид (messenger), Медиа-звонки на Jitsi: план поставки (playbook аналитик → разработчик), Jitsi: именование комнат, admission и presence, Jitsi: минтинг JWT входа в .NET
Связанные документы
- План/последовательность работ: implementation-plan.md; минтинг токена: jitsi-jwt.md; пайплайн записи: recording-pipeline.md; карта интеграции: README.
- Контракт (источник истины): домены call-session.md, conference-session.md, call-participant.md, call-recording.md; вебхуки api/internal/; realtime messenger-hub.md; события conference-ended.md, call-recording-available.md.
- SDK building-blocks: 11-backend-sdk; решение: ADR-0062, ADR-0056 simplicity-first.