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

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):

ОбластьApiApplicationDomainInfrastructureBackgroundMigrator
1:1-звонки (calls)контроллер CallsControllerStartCall/Answer/Decline/End/ListCalls/MintCallMediaTokenCallSessionEntity + MediaRoom VO, CommunicationPolicyрепозиторий, JitsiTokenServicecall-timeout-reaperтаблица call_sessions (+room_*)
Конференции (conferences)ConferencesControllerStartConference/Join/Leave/End/Get/List/MintConfMediaTokenConferenceSessionEntity + CallParticipantEntity (owned-child)репозиторий, JitsiTokenServiceconference-room-reaperconference_sessions, call_participants
Запись (recordings)RecordingsControllerStopRecording/Get/List/GetRecordingDownloadCallRecordingEntity, RecordingPolicyFileStorageRecordingUploader, presign-адаптерrecording-finalize-retrycall_recordings
Вебхуки (internal)InternalJitsiControllerJitsiReserveRoom/JitsiReleaseRoom/JitsiPresenceEvent/IngestRecordingте же агрегатыHmacWebhookFilter, FileStorageRecordingUploader
realtimeSignalR MessengerHubдоменные событияrealtime-dispatch (минтит MediaJoinModel)realtime-dispatch+session_kind в realtime_outbox
Kafka outConferenceEndedEvent/RecordingAvailableEventOutboxMessageEntity + dispatcheroutbox-таблицы

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 VO Room (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> — отдельный агрегат (переживает сессию): MarkNoticeAnnouncedStart(externalId)MarkAvailable(fileId, size, duration, format) (raise RecordingAvailableEvent — доменное + интеграционное) / 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_participantsCascade).

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 unique ix_call_active where status in ('Ringing','Answered') (INV-CALL-5/6).
  • CallRecordingConfigurationHasCheckConstraint("ck_call_recordings_xor", …) (INV-REC-1), partial unique ix_recording_active where status 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).

RouteOperationМутируемый агрегатДомен-событияOutbox / KafkaResult.Fail
POST /callsStartCallCommandCallSessionEntity (create Ringing) [+CallRecordingEntity]CallStarted [+RecordingNoticeAnnounced]CallInvited (SessionKind=Direct)MSG_CALL_*, MSG_CALLS_DISABLED, MSG_RECORDING_PROVISION_FAILED
POST /calls/{id}/answerAnswerCallCommandCallSessionEntity (→Answered)CallAnsweredCallAnsweredMSG_CALL_INVALID_STATE, MSG_CALL_NOT_FOUND
POST /calls/{id}/declineDeclineCallCommandCallSessionEntity (→Declined)CallDeclinedCallDeclinedMSG_CALL_INVALID_STATE
POST /calls/{id}/endEndCallCommandCallSessionEntity (→Ended)CallEndedCallEndedMSG_CALL_INVALID_STATE
GET /callsListCallsQuery— (read Filter(PagingQuery))
POST /calls/{id}/media-token (Query)MintCallMediaTokenQueryMediaJoinModel— (read + mint)— (токен в ответе, не хранится)MSG_CALL_INVALID_STATE
POST /chats/{chatId}/conferencesStartConferenceCommandConferenceSessionEntity (+moderator CallParticipantEntity) [+recording]ConferenceStarted, ParticipantJoinedConferenceInvited (SessionKind=Conference)MSG_CONFERENCE_*, MSG_CHAT_ACCESS_DENIED, MSG_CONFERENCES_DISABLED
POST /conferences/{id}/joinJoinConferenceCommandConferenceModelCallParticipantEntity (join/реактивация)ConferenceParticipantJoinedConferenceParticipantJoinedMSG_CHAT_ACCESS_DENIED, MSG_CONFERENCE_INVALID_STATE
POST /conferences/{id}/leaveLeaveConferenceCommandCallParticipantEntity (soft-leave) [+conf →Ended]ConferenceParticipantLeft [+ConferenceEnded]ConferenceParticipantLeft [+ConferenceEnded+Kafka]MSG_CONFERENCE_INVALID_STATE
POST /conferences/{id}/endEndConferenceCommand (модератор)ConferenceSessionEntity (→Ended)ConferenceEnded (домен+интеграция)ConferenceEnded + pin.messenger.conference-ended.v1 (key DealId)MSG_CONFERENCE_INVALID_STATE, MSG_CHAT_ACCESS_DENIED
POST /conferences/{id}/media-tokenMintConferenceMediaTokenQueryMediaJoinModel— (read + mint)MSG_CONFERENCE_INVALID_STATE
GET /conferences/{id}GetConferenceQueryMSG_CHAT_ACCESS_DENIED
GET /chats/{chatId}/conferencesListConferencesQueryMSG_CHAT_ACCESS_DENIED
GET /calls/{id}/recordingsListCallRecordingsQuery
GET /conferences/{id}/recordingsListConferenceRecordingsQuery
GET /recordings/{id}GetRecordingQueryMSG_RECORDING_NOT_FOUND
GET /recordings/{id}/downloadGetRecordingDownloadQuery → presign (audit ReadSensitive)MSG_RECORDING_NOT_FOUND, MSG_RECORDING_FILE_INVALID
POST /conferences/{id}/recording/stopStopRecordingCommand (модератор, .manage)CallRecordingEntity (→Uploading)RecordingStoppedRecordingStoppedMSG_RECORDING_INVALID_STATE
POST /internal/jitsi/reservationsJitsiReserveRoomCommand (IIdempotentRequest) → JitsiReservationModelConferenceSessionEntity (Provisioning→Active)— (admission)MSG_JITSI_ROOM_UNKNOWN, MSG_*_INVALID_STATE
DELETE /internal/jitsi/reservations/{roomName}JitsiReleaseRoomCommandростер (→Left), LastActivityAtMediaParticipant(Left)— (unknown → no-op ack)
POST /internal/jitsi/eventsJitsiPresenceEventCommand (IIdempotentRequest)CallParticipantEntity upsert / CallRecordingEntity (Pending→Recording)ParticipantJoined/Left, ScreenShareChanged, RecordingStartedMediaParticipant/ScreenShare/RecordingStartedMSG_JITSI_SIGNATURE_INVALID, MSG_JITSI_ROOM_UNKNOWN
POST /internal/call-recordings/{sessionId}/ingestIngestRecordingCommand (IIdempotentRequest)CallRecordingEntity (Uploading→Available/Failed)RecordingAvailable (домен+интеграция) / RecordingFailedRecordingAvailable + 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(...)
}
}

Успешный FileIdCallRecordingEntity.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>.

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

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

Связанные документы