Первоначальная версия VidConf
This commit is contained in:
0
docs/db/.gitkeep
Normal file
0
docs/db/.gitkeep
Normal file
664
docs/db/schema.md
Normal file
664
docs/db/schema.md
Normal file
@@ -0,0 +1,664 @@
|
||||
# Схема БД
|
||||
|
||||
## ER-диаграмма
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
USERS ||--o{ CONFERENCES : owns
|
||||
USERS ||--o{ CONFERENCE_PARTICIPANTS : joins
|
||||
USERS ||--o{ CONFERENCE_INVITEES : invites
|
||||
USERS ||--o{ PHRASES : speaks
|
||||
USERS ||--o{ CHAT_MESSAGES : "sends (author)"
|
||||
USERS ||--o{ EMAIL_VERIFICATION_TOKENS : receives
|
||||
CONFERENCES ||--o{ CONFERENCE_INVITEES : "has invitees"
|
||||
CONFERENCES ||--o{ CONFERENCE_SESSIONS : "has sessions"
|
||||
CONFERENCES ||--o{ GUEST_ACCESS : "may allow"
|
||||
CONFERENCES ||--o{ EMAIL_DELIVERIES : "invitations"
|
||||
CONFERENCE_SESSIONS ||--o{ CONFERENCE_PARTICIPANTS : tracks
|
||||
CONFERENCE_SESSIONS ||--o{ PHRASES : contains
|
||||
CONFERENCE_SESSIONS ||--o{ CHAT_MESSAGES : contains
|
||||
CONFERENCE_SESSIONS ||--o{ EMAIL_DELIVERIES : "summaries"
|
||||
GUEST_ACCESS ||--o{ CONFERENCE_PARTICIPANTS : "may join"
|
||||
GUEST_ACCESS ||--o{ CHAT_MESSAGES : "sends (author)"
|
||||
LIVEKIT_WEBHOOK_EVENTS ||--o{ CONFERENCE_SESSIONS : "triggers"
|
||||
INSTANCE_SETTINGS ||--|| CONFERENCES : "configure"
|
||||
TEAMS ||--o{ USERS : "groups"
|
||||
|
||||
USERS {
|
||||
uuid id PK
|
||||
string email UK
|
||||
string name_user
|
||||
text password_hash
|
||||
string role "admin|user"
|
||||
boolean email_verified
|
||||
boolean is_blocked
|
||||
uuid team_id FK "nullable"
|
||||
string avatar_path "nullable"
|
||||
datetime created_at
|
||||
}
|
||||
|
||||
EMAIL_VERIFICATION_TOKENS {
|
||||
uuid id PK
|
||||
uuid user_id FK
|
||||
string token_hash UK
|
||||
datetime expires_at
|
||||
datetime used_at "nullable"
|
||||
datetime created_at
|
||||
}
|
||||
|
||||
CONFERENCES {
|
||||
uuid id PK
|
||||
string number UK "9 digits"
|
||||
string slug UK "base64url"
|
||||
string title "nullable"
|
||||
uuid owner_id FK "nullable"
|
||||
string status "enum: scheduled|active|ended"
|
||||
boolean is_pinned
|
||||
boolean is_closed
|
||||
text password_hash "nullable"
|
||||
datetime scheduled_at "nullable"
|
||||
integer duration_minutes "nullable"
|
||||
jsonb recurrence "nullable"
|
||||
datetime ended_at "nullable"
|
||||
datetime created_at
|
||||
}
|
||||
|
||||
CONFERENCE_INVITEES {
|
||||
uuid id PK
|
||||
uuid conference_id FK
|
||||
uuid user_id FK "nullable, для зарег. пользователей"
|
||||
string email "nullable, для внешних"
|
||||
datetime created_at
|
||||
"CHECK: ровно один из user_id/email"
|
||||
"UNIQUE: (conference_id, user_id)"
|
||||
"UNIQUE: (conference_id, lower(email))"
|
||||
}
|
||||
|
||||
CONFERENCE_SESSIONS {
|
||||
uuid id PK
|
||||
uuid conference_id FK
|
||||
string title "nullable"
|
||||
datetime t_start
|
||||
datetime t_end "nullable"
|
||||
text summary_data "nullable"
|
||||
string pipeline_status "enum"
|
||||
datetime created_at
|
||||
}
|
||||
|
||||
GUEST_ACCESS {
|
||||
uuid id PK
|
||||
uuid conference_id FK
|
||||
string display_name
|
||||
string email "nullable"
|
||||
datetime created_at
|
||||
}
|
||||
|
||||
CONFERENCE_PARTICIPANTS {
|
||||
uuid id PK
|
||||
uuid session_id FK
|
||||
uuid user_id FK "nullable"
|
||||
uuid guest_id FK "nullable"
|
||||
datetime joined_at
|
||||
datetime left_at "nullable"
|
||||
}
|
||||
|
||||
SESSION_AUDIO_TRACKS {
|
||||
uuid id PK
|
||||
uuid session_id FK
|
||||
uuid participant_id FK
|
||||
string track_sid
|
||||
string egress_id "nullable"
|
||||
text file_path "nullable"
|
||||
string status "enum"
|
||||
datetime started_at
|
||||
datetime ended_at "nullable"
|
||||
jsonb segments "nullable"
|
||||
}
|
||||
|
||||
PHRASES {
|
||||
bigint id PK "Identity"
|
||||
uuid participant_id FK
|
||||
uuid session_id FK
|
||||
text data
|
||||
datetime t_start
|
||||
datetime t_end
|
||||
}
|
||||
|
||||
CHAT_MESSAGES {
|
||||
bigint id PK "Identity"
|
||||
uuid session_id FK
|
||||
uuid user_id FK "nullable"
|
||||
uuid guest_access_id FK "nullable"
|
||||
string author_name "255 chars"
|
||||
text text
|
||||
datetime created_at
|
||||
}
|
||||
|
||||
LIVEKIT_WEBHOOK_EVENTS {
|
||||
string event_id PK
|
||||
string event_type
|
||||
datetime received_at
|
||||
}
|
||||
|
||||
INSTANCE_SETTINGS {
|
||||
string key PK
|
||||
jsonb value
|
||||
datetime updated_at
|
||||
}
|
||||
|
||||
EMAIL_DELIVERIES {
|
||||
uuid id PK
|
||||
uuid session_id FK "nullable"
|
||||
uuid conference_id FK "nullable"
|
||||
string recipient_email
|
||||
string kind "summary|invitation"
|
||||
datetime sent_at
|
||||
}
|
||||
```
|
||||
|
||||
## Таблицы
|
||||
|
||||
### users
|
||||
Зарегистрированные пользователи приложения.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|---------|
|
||||
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
|
||||
| `email` | VARCHAR(255) | Уникальный адрес электронной почты |
|
||||
| `name_user` | VARCHAR(255) | Имя пользователя |
|
||||
| `password_hash` | TEXT | Argon2-хэш пароля |
|
||||
| `role` | VARCHAR(16) | 'admin' или 'user'; проверка CHECK |
|
||||
| `email_verified` | BOOLEAN | Флаг подтверждения почты |
|
||||
| `is_blocked` | BOOLEAN | Флаг блокировки администратором; заблокированный пользователь получает 401 немедленно, без ожидания истечения access-токена |
|
||||
| `team_id` | UUID | FK → teams, nullable; `ON DELETE SET NULL` — удаление команды не удаляет пользователей |
|
||||
| `avatar_path` | VARCHAR(512) | Путь к загруженному аватару (относительно `MEDIA_ROOT`); `NULL` — заглушка с инициалами на фронте; пример: `avatars/550e8400-e29b-41d4-a716-446655440000.jpg` |
|
||||
| `created_at` | TIMESTAMPTZ | UTC-время создания |
|
||||
|
||||
### teams
|
||||
Справочник команд для группировки пользователей.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|---------|
|
||||
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
|
||||
| `name` | VARCHAR(255) | Название команды; уникально |
|
||||
| `created_at` | TIMESTAMPTZ | UTC-время создания |
|
||||
|
||||
**Логика:** удаление команды не каскадирует на пользователей — `users.team_id` просто обнуляется (`ON DELETE SET NULL`); привязка необязательна.
|
||||
|
||||
### email_verification_tokens
|
||||
Одноразовые токены подтверждения email при регистрации.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|---------|
|
||||
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
|
||||
| `user_id` | UUID | FK → users; CASCADE удаление |
|
||||
| `token_hash` | TEXT | SHA256-хэш одноразового токена (hex); уникален |
|
||||
| `expires_at` | TIMESTAMPTZ | Срок действия (UTC); по умолчанию 24 часа от создания |
|
||||
| `used_at` | TIMESTAMPTZ | Время использования; NULL если не подтверждён |
|
||||
| `created_at` | TIMESTAMPTZ | UTC-время создания |
|
||||
|
||||
**Ограничения:**
|
||||
- `UNIQUE(token_hash)` — уникальный индекс на хэш токена
|
||||
- `INDEX(user_id)` — для быстрого поиска токенов пользователя
|
||||
|
||||
**Логика:**
|
||||
- При регистрации создаётся новый токен; клиент получает открытый токен в письме
|
||||
- Сервер хранит только SHA256-хэш (токен не может быть восстановлен из БД)
|
||||
- Первое использование (POST /verify-email) заполняет `used_at`, устанавливает `user.email_verified=true`
|
||||
- Повторное использование того же токена → 400 (used_at IS NOT NULL)
|
||||
|
||||
### conferences
|
||||
Конференция — постоянная сущность с номером и постоянной ссылкой (slug). Может быть мгновенной или плановой с повторением (ADR-001).
|
||||
|
||||
**Миграция:** Таблица `conferences` переименована в `conference_sessions` (см. ниже); новая таблица `conferences` создана миграцией `f418dd65e7b1`.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|---------|
|
||||
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
|
||||
| `number` | VARCHAR(9) | 9 десятичных цифр, первая 1..9, уникален; генерируется с retry при коллизии |
|
||||
| `slug` | VARCHAR(22) | `secrets.token_urlsafe(8)` — 11 base64url-символов; используется как имя комнаты в LiveKit и часть URL `/j/{slug}` |
|
||||
| `title` | VARCHAR(255) | Опциональное название конференции |
|
||||
| `owner_id` | UUID | FK → users; SET NULL при удалении пользователя; может быть NULL для исторических данных |
|
||||
| `status` | ENUM | `scheduled` (плановая, время ещё не наступило), `active` (идёт сейчас), `ended` (завершена; история остаётся) |
|
||||
| `is_pinned` | BOOLEAN | Закреплена ли (видна в «Моих конференциях», может повторяться) |
|
||||
| `is_closed` | BOOLEAN | Требуется ли пароль для входа |
|
||||
| `password_hash` | TEXT | Argon2-хэш пароля (только если is_closed=true); CHECK: `is_closed=false OR password_hash IS NOT NULL` |
|
||||
| `scheduled_at` | TIMESTAMPTZ | Время запуска (UTC); NULL для мгновенных |
|
||||
| `duration_minutes` | INT | Ожидаемая длительность в минутах; опционально, для информации |
|
||||
| `recurrence` | JSONB | RecurrenceRule (JSON с type/weekdays/day_of_month/interval_days/anchor_date/time_local/timezone/duration_minutes); только если is_pinned=true; CHECK: `recurrence IS NULL OR is_pinned = true` |
|
||||
| `ended_at` | TIMESTAMPTZ | Время завершения (UTC); заполняется при статусе → ended |
|
||||
| `summary_recipients` | VARCHAR(16) | Переопределение рассылки саммари для конференции: `'all'` (всем участникам и гостям с email), `'owner'` (только организатору), NULL (используется дефолт инстанса) |
|
||||
| `ics_sequence` | INT | Счётчик изменений расписания: растёт при правке `scheduled_at`, `duration_minutes`, `recurrence`, `title`; пишется в VEVENT SEQUENCE для обновления приглашений календарными клиентами |
|
||||
| `created_at` | TIMESTAMPTZ | UTC-время создания |
|
||||
|
||||
**Ограничения:**
|
||||
- `UNIQUE(number)` — уникальность номера
|
||||
- `UNIQUE(slug)` — уникальность постоянной ссылки
|
||||
- CHECK: `is_closed = false OR password_hash IS NOT NULL`
|
||||
- CHECK: `recurrence IS NULL OR is_pinned = true`
|
||||
|
||||
### conference_sessions
|
||||
Один запуск конференции, единица AI-пайплайна пост-обработки.
|
||||
|
||||
**Миграция:** До миграции `f418dd65e7b1` это была таблица `conferences`; переименована в `conference_sessions`, связь `conference_id` добавлена.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|---------|
|
||||
| `id` | UUID | Primary key |
|
||||
| `conference_id` | UUID | FK → conferences; CASCADE удаление |
|
||||
| `title` | VARCHAR(255) | Снапшот названия конференции на момент запуска (опционально) |
|
||||
| `t_start` | TIMESTAMPTZ | Время начала сеанса (UTC) |
|
||||
| `t_end` | TIMESTAMPTZ | Время окончания сеанса (UTC); опционально, заполняется при завершении |
|
||||
| `summary_data` | TEXT | JSON со сводкой (заполняется после summarize-шага); опционально |
|
||||
| `pipeline_status` | ENUM | Статус пайплайна: `recording` → `transcribing` → `summarizing` → `notified` / `failed` |
|
||||
| `created_at` | TIMESTAMPTZ | UTC-время создания |
|
||||
|
||||
**Индексы:**
|
||||
- `(conference_id, t_start)` для поиска сеансов конференции
|
||||
- `(pipeline_status)` для поиска активных обработок
|
||||
|
||||
Статус-машина пост-обработки живёт в этой таблице (см. ADR-001).
|
||||
|
||||
### guest_access
|
||||
Гость, представившийся при входе в конференцию без аутентификации (ADR-001, п.6).
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|---------|
|
||||
| `id` | UUID | Primary key |
|
||||
| `conference_id` | UUID | FK → conferences; CASCADE удаление |
|
||||
| `display_name` | VARCHAR(255) | Отображаемое имя гостя (обязательно) |
|
||||
| `email` | VARCHAR(320) | Адрес электронной почты (опционально; для рассылки саммари) |
|
||||
| `created_at` | TIMESTAMPTZ | UTC-время создания записи (при первом входе гостем) |
|
||||
|
||||
**Назначение:**
|
||||
- Гость входит в конференцию без аккаунта (публичный эндпоинт `/guest-join`)
|
||||
- LiveKit identity: `guest:{guest_access.id}`
|
||||
- Email не передаётся в LiveKit (PII — только в БД)
|
||||
|
||||
### conference_participants
|
||||
Отслеживает присутствие участника (пользователя ИЛИ гостя) в сеансе конференции (ADR-001, п.6).
|
||||
|
||||
**Миграция:** Колонка `conference_id` переименована в `session_id` (`f418dd65e7b1`); `user_id` стал nullable; добавлены `guest_id` и CHECK для ровно одного identity.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|---------|
|
||||
| `id` | UUID | Primary key |
|
||||
| `session_id` | UUID | FK → conference_sessions; CASCADE удаление |
|
||||
| `user_id` | UUID | FK → users; NULL для гостей; nullable |
|
||||
| `guest_id` | UUID | FK → guest_access; NULL для зарегистрированных; nullable |
|
||||
| `joined_at` | TIMESTAMPTZ | Время входа (UTC) |
|
||||
| `left_at` | TIMESTAMPTZ | Время выхода (UTC); опционально |
|
||||
|
||||
**Ограничения:**
|
||||
- CHECK: `(user_id IS NOT NULL)::int + (guest_id IS NOT NULL)::int = 1` — ровно одно из user_id/guest_id заполнено
|
||||
|
||||
**Индексы:**
|
||||
- `(session_id)` для поиска участников сеанса
|
||||
|
||||
### conference_invitees
|
||||
|
||||
Приглашённые на конференцию участники (зарегистрированные пользователи или внешние email'ы) — ADR-003.
|
||||
|
||||
**Миграция:** `d87681e12784`
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|---------|
|
||||
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
|
||||
| `conference_id` | UUID | FK → conferences; CASCADE удаление |
|
||||
| `user_id` | UUID | FK → users; nullable; CASCADE удаление; заполнено для зарегистрированных пользователей |
|
||||
| `email` | VARCHAR(255) | Email для внешних приглашённых; nullable; нормализуется в lower-case |
|
||||
| `created_at` | TIMESTAMPTZ | UTC-время создания приглашения |
|
||||
|
||||
**Ограничения:**
|
||||
- CHECK: `(user_id IS NOT NULL)::int + (email IS NOT NULL)::int = 1` — ровно один из user_id/email должен быть заполнен (единственная identity приглашённого)
|
||||
- **UNIQUE INDEX `uq_conference_invitees_user`** на `(conference_id, user_id)` WHERE `user_id IS NOT NULL` — один приглашённый пользователь на конференцию
|
||||
- **UNIQUE INDEX `uq_conference_invitees_email`** на `(conference_id, lower(email))` WHERE `email IS NOT NULL` — один внешний email на конференцию (регистронезависимо)
|
||||
|
||||
**Назначение:**
|
||||
- Хранение состава приглашённых участников, заданного организатором при создании/редактировании конференции
|
||||
- **Организатор НЕ хранится в этой таблице** — он выводится из `conferences.owner_id` и всегда в составе (инвариант обеспечен конструктивно, без триггеров)
|
||||
- **Не путать с `conference_participants`** — та таблица фиксирует ФАКТИЧЕСКОЕ присутствие в сеансе; эта — планируемый состав
|
||||
|
||||
**Семантика:**
|
||||
- Дедупликация при POST/PATCH конференции: если организатор передан в массиве participants, он автоматически вычищается (не хранится дважды)
|
||||
- Внешние приглашённые (email) при входе по ссылке создают запись в `guest_access`, которая затем связывается с `conference_participants` сеанса
|
||||
|
||||
### session_audio_tracks
|
||||
|
||||
Аудиодорожка сеанса, записанная LiveKit Track Egress. Каждая строка соответствует одному аудиотреку (микрофону) одного участника.
|
||||
|
||||
**Таблица используется для:**
|
||||
- Маппинга файлов записи к участникам сеанса (включая гостей без user_id)
|
||||
- Отслеживания статуса транскрибации каждого трека (recording → recorded → transcribed / failed)
|
||||
- Хранения сегментов Whisper в JSONB (`segments`) как промежуточного артефакта пайплайна
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|---------|
|
||||
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
|
||||
| `session_id` | UUID | FK → conference_sessions; CASCADE удаление |
|
||||
| `participant_id` | UUID | FK → conference_participants; CASCADE удаление; NOT NULL (атрибуция к участнику сеанса, см. ADR-002) |
|
||||
| `track_sid` | VARCHAR(64) | ID трека в LiveKit (уникален в пределах сеанса с session_id) |
|
||||
| `egress_id` | VARCHAR(64) | ID egress-задачи в LiveKit; заполняется при старте egress; может быть NULL |
|
||||
| `file_path` | TEXT | Абсолютный путь к файлу на диске (например, `/recordings/{session_id}/{participant_id}_{track_sid}.ogg`); заполняется при завершении egress |
|
||||
| `status` | ENUM | Статус трека: `recording` (egress активен) → `recorded` (файл готов) → `transcribed` (Whisper завершил) / `failed` (ошибка на любом шаге) |
|
||||
| `started_at` | TIMESTAMPTZ | UTC-время старта egress (из EgressInfo) |
|
||||
| `ended_at` | TIMESTAMPTZ | UTC-время завершения egress; NULL пока трек в статусе `recording` |
|
||||
| `segments` | JSONB | Список объектов `{start: float, end: float, text: string}` — результат faster-whisper для этого трека; NULL пока не транскрибирован |
|
||||
|
||||
**Ограничения:**
|
||||
- `UNIQUE(session_id, track_sid)` — один трек на сеанс; дедупликация при повторных `track_published`
|
||||
- Индексы: `(session_id)`, `(session_id, status)` для поиска активных и готовых треков
|
||||
|
||||
**Инвариант:**
|
||||
- Независимый статус трека (отдельно от `conference_sessions.pipeline_status`)
|
||||
- Тайминги сегментов в `segments` — относительно начала файла (не UTC); абсолютный расчёт: `session.t_start + timedelta(seconds=segment.start)`
|
||||
|
||||
### phrases
|
||||
|
||||
Восстановленные транскрипт-фразы (результат faster-whisper + реконструкция по алгоритму ТЗ §1.3).
|
||||
|
||||
**Изменение:** Колонка `user_id` заменена на `participant_id` (FK → conference_participants), чтобы атрибутировать фразы гостям без user_id (ADR-002).
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|---------|
|
||||
| `id` | BIGINT | Primary key, Identity(always=True) |
|
||||
| `participant_id` | UUID | FK → conference_participants; CASCADE удаление; NOT NULL; атрибуция к участнику сеанса |
|
||||
| `session_id` | UUID | FK → conference_sessions; CASCADE удаление |
|
||||
| `data` | TEXT | Текст фразы (результат `build_phrases()` — может быть несколько сегментов Whisper одного спикера подряд) |
|
||||
| `t_start` | TIMESTAMPTZ | Начало фразы (UTC); рассчитывается как `session.t_start + (segment.start + track_offset)` |
|
||||
| `t_end` | TIMESTAMPTZ | Конец фразы (UTC) |
|
||||
|
||||
**Индексы:**
|
||||
- `(session_id, t_start)` для хронологического поиска фраз в сеансе
|
||||
|
||||
**Как получить пользователя/гостя фразы:**
|
||||
```sql
|
||||
SELECT p.data, u.name_user, g.display_name
|
||||
FROM phrases p
|
||||
JOIN conference_participants cp ON p.participant_id = cp.id
|
||||
LEFT JOIN users u ON cp.user_id = u.id
|
||||
LEFT JOIN guest_access g ON cp.guest_id = g.id
|
||||
WHERE p.session_id = $1
|
||||
ORDER BY p.t_start;
|
||||
```
|
||||
|
||||
### chat_messages
|
||||
Текстовые сообщения, отправленные во время сеанса конференции (WS чат).
|
||||
|
||||
**Миграции:**
|
||||
- `f418dd65e7b1`: колонка `conference_id` переименована в `session_id`
|
||||
- `504791847d4f`: добавлены `guest_access_id` и `author_name` для поддержки гостевых авторов
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|---------|
|
||||
| `id` | BIGINT | Primary key, Identity(always=True), уникален в пределах инстанса |
|
||||
| `session_id` | UUID | FK → conference_sessions; CASCADE удаление |
|
||||
| `user_id` | UUID | FK → users; nullable; заполнено для авторов-пользователей |
|
||||
| `guest_access_id` | UUID | FK → guest_access; nullable; CASCADE удаление; заполнено для авторов-гостей |
|
||||
| `author_name` | VARCHAR(255) | Снапшот отображаемого имени автора из LiveKit access-токена на момент отправки; NOT NULL (переживает переименование пользователя или удаление гостевой записи) |
|
||||
| `text` | TEXT | Текст сообщения (1..2000 символов) |
|
||||
| `created_at` | TIMESTAMPTZ | UTC-время создания |
|
||||
|
||||
**Ограничения:**
|
||||
- CHECK: `user_id IS NOT NULL OR guest_access_id IS NOT NULL` — ровно один из user_id/guest_access_id должен быть заполнен (аналогично `conference_participants`, ADR-001 п.6)
|
||||
|
||||
**Индексы:**
|
||||
- `(session_id, created_at)` для хронологического поиска сообщений в сеансе
|
||||
|
||||
**Назначение:** Хранение истории чата конференции для отдачи последних N сообщений при подключении нового клиента; публикация в Redis pub/sub для broadcast остальным клиентам
|
||||
|
||||
### instance_settings
|
||||
Настройки инстанса как key-value хранилище (JSONB). Бутстрап из `config/plugins.yaml` при старте backend'а (однократный, идемпотентный импорт).
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|---------|
|
||||
| `key` | TEXT | Primary key; имя настройки (`transcriber`, `summarizer`, `chat`, `ai_level`, `summary_recipients`, `display_timezone`, `registration_team_choice`, `registration_email_domain`) |
|
||||
| `value` | JSONB | Значение настройки (структура зависит от ключа) |
|
||||
| `updated_at` | TIMESTAMPTZ | UTC-время последнего обновления |
|
||||
|
||||
**Назначение:**
|
||||
- Централизованное хранилище конфигурации инстанса (переносит дефолты из `config/plugins.yaml` в БД)
|
||||
- Административный интерфейс может менять настройки без рестарта приложения
|
||||
- Воркеры Celery читают конфигурацию на старте каждой задачи (fallback на YAML при пустой таблице)
|
||||
|
||||
**Ключи и типы значений (примеры):**
|
||||
```json
|
||||
{
|
||||
"transcriber": {"enabled": true, "provider": "faster-whisper-cpu", "language": "ru", ...},
|
||||
"summarizer": {"enabled": true, "provider": "qwen-local", "chunk_minutes": 20, ...},
|
||||
"chat": {"enabled": false},
|
||||
"ai_level": {"level": "min"},
|
||||
"summary_recipients": {"mode": "all"},
|
||||
"display_timezone": {"tz": "Europe/Moscow"},
|
||||
"registration_team_choice": {"enabled": true},
|
||||
"registration_email_domain": {"enabled": true, "domain": "example.com"}
|
||||
}
|
||||
```
|
||||
|
||||
### email_deliveries
|
||||
Журнал отправленных писем (саммари и приглашения). Идемпотентность рассылки саммари и отслеживание доставки.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|---------|
|
||||
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
|
||||
| `session_id` | UUID | FK → conference_sessions; NULL для приглашений; заполняется для саммари |
|
||||
| `conference_id` | UUID | FK → conferences; NULL для саммари; заполняется для приглашений |
|
||||
| `recipient_email` | VARCHAR(320) | Email получателя (хранится в `lower()` — нормализованный) |
|
||||
| `kind` | VARCHAR(16) | Тип письма: `'summary'` (саммари сеанса), `'invitation'` (.ics приглашение) |
|
||||
| `sent_at` | TIMESTAMPTZ | UTC-время отправки (server_default=now()) |
|
||||
|
||||
**Ограничения:**
|
||||
- CHECK: `kind IN ('summary', 'invitation')`
|
||||
- CHECK: `(kind = 'summary') = (session_id IS NOT NULL)` — саммари обязана иметь session_id
|
||||
- CHECK: `(kind = 'invitation') = (conference_id IS NOT NULL)` — приглашение обязано иметь conference_id
|
||||
- **UNIQUE INDEX `uq_email_deliveries_summary`** на `(session_id, recipient_email)` WHERE `kind = 'summary'` — гарантирует, что один адресат получит саммри сеанса один раз (идемпотентность повторной отправки)
|
||||
- INDEX `ix_email_deliveries_conference` на `conference_id` для быстрого поиска истории приглашений
|
||||
|
||||
**Назначение:**
|
||||
- **Для саммари:** гарантирует идемпотентность — повторный запуск `notify_session` отправляет письмо только тем адресатам, которые ещё не в таблице
|
||||
- **Для приглашений:** журнал, без unique (изменение расписания конференции переслёт приглашение, допуская дублирование в истории)
|
||||
|
||||
### livekit_webhook_events
|
||||
Журнал идемпотентной доставки webhook-событий от LiveKit.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|---------|
|
||||
| `event_id` | VARCHAR(255) | Primary key (уникальный ID события от LiveKit) |
|
||||
| `event_type` | VARCHAR(64) | Тип события: `room_started`, `participant_joined`, `participant_left`, `room_finished` и др. |
|
||||
| `received_at` | TIMESTAMPTZ | UTC-время получения события |
|
||||
|
||||
**Назначение:**
|
||||
- Дедупликация: каждое webhook-событие от LiveKit имеет уникальный `event_id`
|
||||
- При получении события выполняется `INSERT ... ON CONFLICT DO NOTHING` по primary key `event_id`
|
||||
- Повторная доставка того же события отклоняется на уровне БД; ответ 200 всё равно отправляется (идемпотентность)
|
||||
|
||||
## Ключевые решения архитектуры
|
||||
|
||||
### UUID как primary key
|
||||
Все таблицы, кроме `phrases` и `chat_messages`, используют `UUID` генерируемые функцией `gen_random_uuid()`. Это обеспечивает:
|
||||
- Глобальную уникальность без синхронизации
|
||||
- Предсказуемую нагрузку на индексы
|
||||
- Возможность распределённых вставок
|
||||
|
||||
### BIGINT Identity для phrases и chat_messages
|
||||
Высокочастотные таблицы используют `BIGINT Identity(always=True)` — быстрее и проще для очередей обработки фраз/сообщений.
|
||||
|
||||
### Номер и slug конференции (9 цифр + base64url)
|
||||
- **Номер:** 9 десятичных цифр, первая 1..9 (энтропия ≈2^29.75); генерируется с retry при коллизии; задача перебора при rate limit ≈60+ дней; резолв отвечает единообразным 404 (см. ADR-001, п.4)
|
||||
- **Slug:** `secrets.token_urlsafe(8)` — 11 base64url-символов (64 бита энтропии); используется как имя комнаты в LiveKit и часть URL `/j/{slug}`
|
||||
|
||||
### Расширение btree_gist (не используется)
|
||||
**Миграция `f418dd65e7b1`:** расширение `btree_gist` больше не используется (EXCLUDE constraint на `room_bookings` снят с удалением таблицы, см. ADR-001). Расширение остаётся установленным в БД (безвредно, миграция обратима).
|
||||
|
||||
### Все временные метки в UTC
|
||||
Все столбцы `DateTime(timezone=True)` хранят время в UTC на сервере. Конвертация в локальное время происходит на клиенте на основе часового пояса браузера. iCalendar-экспорт (`.ics`) включает `VTIMEZONE`.
|
||||
|
||||
### Enum pipeline_status
|
||||
|
||||
Статус пост-обработки сеанса (единица пайплайна) отслеживает жизненный цикл от записи через транскрибацию и суммаризацию до уведомления или ошибки.
|
||||
|
||||
**Состояния машины:**
|
||||
|
||||
```
|
||||
recording → transcribing → summarizing → notified / failed
|
||||
```
|
||||
|
||||
| Статус | Описание | Переход | Ответственный |
|
||||
|--------|---------|---------|---------------|
|
||||
| `recording` | Сеанс идёт, LiveKit Egress записывает треки | Автоматический при `t_start` сеанса | Backend (создание сеанса) |
|
||||
| `transcribing` | Транскрибация в процессе | При `run_pipeline()`, после завершения egress | Воркер `run_pipeline` |
|
||||
| `summarizing` | Суммаризация в процессе | После успешной транскрибации и реконструкции фраз | Воркер `run_pipeline` |
|
||||
| `notified` | Уведомление отправлено | После успешной суммаризации и отправки email | Воркер уведомлений |
|
||||
| `failed` | Ошибка на одном из шагов (пайплайн остановлен) | При сбое воркера или если все треки провалены | Воркер `run_pipeline` |
|
||||
|
||||
**Идемпотентность:** каждый шаг пайплайна проверяет текущий `pipeline_status` перед началом и пропускает уже завершённые шаги (guard от повторных запусков).
|
||||
|
||||
**Пример восстановления после сбоя:**
|
||||
- Воркер транскрибации упал посеридине обработки треков
|
||||
- При повторном запуске `run_pipeline(session_id)` проверяет `pipeline_status` = `transcribing`
|
||||
- Пропускает уже транскрибированные треки (у которых `segments IS NOT NULL`)
|
||||
- Завершает оставшиеся треки и переходит в `summarizing`
|
||||
|
||||
### Идемпотентность через статусы
|
||||
Каждый шаг пайплайна (транскрибация, суммаризация, уведомление) должен быть идемпотентным: повторное выполнение не приведёт к дублированию или ошибкам. Это достигается через проверку текущего `pipeline_status` перед началом шага.
|
||||
|
||||
---
|
||||
|
||||
## Миграции
|
||||
|
||||
### Миграция d87681e12784
|
||||
|
||||
**Участники конференций и аватары пользователей:**
|
||||
|
||||
1. **Новая таблица `conference_invitees`** (ADR-003) — приглашённые участники конференции
|
||||
- `id` (UUID PK), `conference_id` (FK), `user_id` (nullable FK), `email` (nullable VARCHAR), `created_at`
|
||||
- CHECK: ровно одна identity (`user_id` ИЛИ `email`)
|
||||
- Частичные UNIQUE индексы по (conference_id, user_id) и (conference_id, lower(email))
|
||||
- Организатор не хранится в таблице (выводится из `conferences.owner_id`)
|
||||
|
||||
2. **Колонка в `users`:** `avatar_path` (VARCHAR(512), nullable) — путь к загруженному аватару
|
||||
- Пример: `avatars/550e8400-e29b-41d4-a716-446655440000.jpg`
|
||||
- NULL → заглушка с инициалами на фронте
|
||||
- Удаление аватара вычищает файл с диска и обнуляет поле
|
||||
|
||||
**SQL:**
|
||||
```sql
|
||||
CREATE TABLE conference_invitees (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
conference_id UUID NOT NULL REFERENCES conferences(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
email VARCHAR(255),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK ((user_id IS NOT NULL)::int + (email IS NOT NULL)::int = 1)
|
||||
);
|
||||
CREATE UNIQUE INDEX uq_conference_invitees_user
|
||||
ON conference_invitees(conference_id, user_id) WHERE user_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX uq_conference_invitees_email
|
||||
ON conference_invitees(conference_id, lower(email)) WHERE email IS NOT NULL;
|
||||
|
||||
ALTER TABLE users ADD COLUMN avatar_path VARCHAR(512);
|
||||
```
|
||||
|
||||
### Миграция 504791847d4f
|
||||
|
||||
**Поддержка гостевых авторов в чате:**
|
||||
|
||||
1. **Колонки в `chat_messages`:**
|
||||
- `guest_access_id` (UUID, nullable) — FK → `guest_access.id`, `ON DELETE CASCADE`; заполнено для авторов-гостей
|
||||
- `author_name` (VARCHAR(255), NOT NULL) — снапшот имени автора из LiveKit access-токена на момент отправки; добавляется nullable, backfill из `users.name_user` для уже существующих строк (все они с `user_id`), затем ужесточается до `NOT NULL`
|
||||
|
||||
2. **Изменение `chat_messages.user_id`:**
|
||||
- Переходит в nullable (вместо NOT NULL), т.к. автором может быть гость
|
||||
|
||||
3. **Check-констрейнт:**
|
||||
- `ck_chat_messages_author` — `user_id IS NOT NULL OR guest_access_id IS NOT NULL` (ровно один из двух должен быть заполнен)
|
||||
|
||||
**Назначение:** WS-чат поддерживает как пользователей, так и гостей как авторов сообщений, с сохранением снапшота имени (для истории и чата).
|
||||
|
||||
### Миграция 9d37822e4513
|
||||
|
||||
**Справочник команд:**
|
||||
|
||||
1. **Новая таблица `teams`** — `id` (UUID PK), `name` (уникально), `created_at`
|
||||
2. **Колонка в `users`:** `team_id` (UUID, nullable) — FK → `teams.id`, `ON DELETE SET NULL`
|
||||
|
||||
**SQL:**
|
||||
```sql
|
||||
CREATE TABLE teams (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE users ADD COLUMN team_id UUID;
|
||||
ALTER TABLE users ADD CONSTRAINT fk_users_team_id_teams
|
||||
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE SET NULL;
|
||||
```
|
||||
|
||||
### Миграция 5970bf64fc43
|
||||
|
||||
**Настройки инстанса и уведомления:**
|
||||
|
||||
1. **Новая таблица `instance_settings`** — key-value хранилище настроек (JSONB) с бутстрапом из `config/plugins.yaml`
|
||||
2. **Новая таблица `email_deliveries`** — идемпотентность саммари (уникальный частичный индекс по `(session_id, recipient_email)`) и журнал приглашений
|
||||
3. **Колонки в `conferences`:**
|
||||
- `summary_recipients` (nullable TEXT, CHECK) — переопределение рассылки ('all', 'owner', или NULL=дефолт)
|
||||
- `ics_sequence` (INT, default 0) — счётчик для VEVENT SEQUENCE
|
||||
4. **Колонка в `users`:**
|
||||
- `is_blocked` (BOOLEAN, default false) — блокировка администратором
|
||||
|
||||
**SQL:**
|
||||
```sql
|
||||
CREATE TABLE instance_settings (key TEXT PRIMARY KEY, value JSONB, updated_at TIMESTAMPTZ DEFAULT now());
|
||||
CREATE TABLE email_deliveries (...);
|
||||
ALTER TABLE conferences ADD COLUMN summary_recipients VARCHAR(16) CHECK (...);
|
||||
ALTER TABLE conferences ADD COLUMN ics_sequence INT DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN is_blocked BOOLEAN DEFAULT false;
|
||||
```
|
||||
|
||||
### Миграция 88aa676ac140
|
||||
|
||||
**Таблица `session_audio_tracks` и атрибуция фраз (ADR-002):**
|
||||
|
||||
1. **Новая таблица `session_audio_tracks`** — аудиотреки сеанса с независимым статусом и сегментами Whisper
|
||||
2. **Изменение `phrases`: `user_id` → `participant_id`** — атрибуция к участнику сеанса (FK `conference_participants.id`), чтобы работать с гостями без user_id
|
||||
|
||||
**Данные (в продакшене):** сохраняются через данные участников (`conference_participants`) и сеансов.
|
||||
|
||||
### Миграция f418dd65e7b1
|
||||
|
||||
**Переход на динамические конференции (ADR-001):**
|
||||
|
||||
1. **Таблица `conferences` → `conference_sessions`** (переименование + новые колонки)
|
||||
- Сохранены все данные
|
||||
- Добавлена `conference_id` (FK → новая таблица conferences)
|
||||
- `pipeline_status` остаётся (семантика не меняется)
|
||||
|
||||
2. **Новая таблица `conferences`**
|
||||
- Постоянные сущности (номер, slug, владелец, статус, recurrence)
|
||||
- Связь с `conference_sessions` (один ко многим, CASCADE удаление)
|
||||
|
||||
3. **Новая таблица `guest_access`**
|
||||
- Гости, представившиеся при входе
|
||||
- display_name обязателен, email опционален
|
||||
|
||||
4. **Колонки фраз и сообщений: `conference_id` → `session_id`**
|
||||
- `phrases.conference_id` → `phrases.session_id`
|
||||
- `chat_messages.conference_id` → `chat_messages.session_id`
|
||||
- `conference_participants.conference_id` → `conference_participants.session_id`
|
||||
|
||||
5. **Таблицы удалены**
|
||||
- `rooms` (комнаты больше не предустановленные)
|
||||
- `room_bookings` (бронирования заменены динамическими конференциями)
|
||||
- `booking_participants` (доступ теперь по паролю, не по списку)
|
||||
|
||||
6. **CHECK-ограничения в новой таблице conferences**
|
||||
- `is_closed = false OR password_hash IS NOT NULL`
|
||||
- `recurrence IS NULL OR is_pinned = true`
|
||||
|
||||
7. **CHECK-ограничение в conference_participants**
|
||||
- `(user_id IS NOT NULL)::int + (guest_id IS NOT NULL)::int = 1` — ровно один из user_id/guest_id
|
||||
|
||||
8. **Индексы**
|
||||
- `(conference_id, t_start)` на conference_sessions
|
||||
- `(pipeline_status)` на conference_sessions
|
||||
- `(session_id)` на conference_participants
|
||||
Reference in New Issue
Block a user