222 lines
9.6 KiB
Markdown
222 lines
9.6 KiB
Markdown
# Личные сообщения (DM): как это устроено
|
||
|
||
## Коротко (для быстрого понимания)
|
||
|
||
Личные сообщения в SHiNE сейчас работают как пара **подписанных клиентом блоков** в формате `SHiNE_dm2`:
|
||
|
||
- тип `1` — входящее сообщение для собеседника;
|
||
- тип `2` — исходящая копия того же сообщения для автора.
|
||
|
||
Оба блока отправляются вместе одной операцией (`SendMessagePair` / `ReceiveOutcomingMessage`) и либо сохраняются оба, либо не сохраняются вовсе.
|
||
Дальше сервер доставляет их по активным сессиям целевого логина событием `SignedMessageArrived`, а клиент подтверждает доставку на конкретную сессию через `AckSessionDelivery`.
|
||
|
||
Подтверждение прочтения также идёт парой блоков:
|
||
|
||
- тип `3` — «прочитано» для исходящего сообщения автора;
|
||
- тип `4` — зеркальная копия для второй стороны.
|
||
|
||
UI чата строится на этих типах: текстовые сообщения (1/2), read-receipt (3/4), непрочитанные, галочки и история.
|
||
|
||
---
|
||
|
||
## Подробно
|
||
|
||
## 1) Общая схема потока
|
||
|
||
1. Клиент формирует текст сообщения и строит **2 подписанных блока** (`type=1` и `type=2`) с одинаковыми `fromLogin/toLogin/timeMs/nonce`.
|
||
2. Клиент отправляет оба блока в одном RPC: `SendMessagePair` (алиас: `ReceiveOutcomingMessage`).
|
||
3. Сервер:
|
||
- парсит оба блока;
|
||
- валидирует пару;
|
||
- проверяет существование `from/to` пользователей и подписи;
|
||
- атомарно сохраняет пару в `signed_messages_v2`.
|
||
4. Сервер доставляет блоки в активные сессии целевого логина событием `SignedMessageArrived`.
|
||
5. Клиент, получив событие, кладёт сообщение в локальный чат и отправляет `AckSessionDelivery(messageKey)`.
|
||
6. При открытии чата клиент отправляет read-receipt (пара `type=3/4`) для непрочитанных входящих.
|
||
|
||
## 2) Формат signed DM-блока (`SHiNE_dm2`)
|
||
|
||
Префикс: `SHiNE_dm2` (ASCII).
|
||
|
||
Далее поля (big-endian):
|
||
|
||
1. `toLoginLen` (`u8`) + `toLogin` (ASCII, 1..60);
|
||
2. `fromLoginLen` (`u8`) + `fromLogin` (ASCII, 1..60);
|
||
3. `timeMs` (`u64`);
|
||
4. `nonce` (`u32`);
|
||
5. `messageType` (`u16`);
|
||
6. `payloadLen` (`u16`);
|
||
7. `payloadBytes` (`1..4096`);
|
||
8. `signature` (`64 bytes`, Ed25519).
|
||
|
||
Ограничения:
|
||
|
||
- полный пакет: до `8192` байт;
|
||
- `messageType` сейчас допустим только `1..4`.
|
||
|
||
## 3) Типы DM-сообщений
|
||
|
||
- `1` (`TYPE_INCOMING_TEXT`) — входящий текст для получателя.
|
||
- `2` (`TYPE_OUTGOING_COPY`) — исходящая копия в истории автора.
|
||
- `3` (`TYPE_READ_INCOMING`) — read-receipt (входящий тип для пары квитанции).
|
||
- `4` (`TYPE_READ_OUTGOING_COPY`) — зеркальная копия read-receipt.
|
||
|
||
Правило пары:
|
||
|
||
- первый блок должен быть нечётным (`1` или `3`);
|
||
- второй должен быть ровно `+1` (`2` или `4`);
|
||
- ключевые поля пары совпадают: `toLogin/fromLogin/timeMs/nonce`.
|
||
|
||
## 4) Ключи сообщений
|
||
|
||
- `baseKey = from|to|timeMs|nonce`
|
||
- `messageKey = baseKey|messageType`
|
||
|
||
Эти ключи используются:
|
||
|
||
- для дедупликации;
|
||
- для связи read-receipt с исходным сообщением;
|
||
- для ACK доставки по сессии.
|
||
|
||
## 5) RPC и события
|
||
|
||
## `SendMessagePair` (алиас `ReceiveOutcomingMessage`)
|
||
|
||
Запрос:
|
||
|
||
```json
|
||
{
|
||
"op": "SendMessagePair",
|
||
"requestId": "req-1",
|
||
"payload": {
|
||
"incomingBlobB64": "<base64 signed block type 1 or 3>",
|
||
"outgoingBlobB64": "<base64 signed block type 2 or 4>"
|
||
}
|
||
}
|
||
```
|
||
|
||
Успешный ответ:
|
||
|
||
```json
|
||
{
|
||
"op": "SendMessagePair",
|
||
"requestId": "req-1",
|
||
"status": 200,
|
||
"ok": true,
|
||
"payload": {
|
||
"baseKey": "from|to|time|nonce",
|
||
"incomingKey": "from|to|time|nonce|1",
|
||
"outgoingKey": "from|to|time|nonce|2",
|
||
"deliveredWsSessions": 2,
|
||
"deliveredWebPushSessions": 1
|
||
}
|
||
}
|
||
```
|
||
|
||
## `SignedMessageArrived` (server event)
|
||
|
||
Событие в сессию получателя содержит:
|
||
|
||
- `messageKey`, `baseKey`;
|
||
- `fromLogin`, `toLogin`, `targetLogin`;
|
||
- `messageType`, `timeMs`, `nonce`;
|
||
- `blobB64`;
|
||
- `backlog` (признак догрузки из очереди).
|
||
|
||
## `AckSessionDelivery`
|
||
|
||
Запрос:
|
||
|
||
```json
|
||
{
|
||
"op": "AckSessionDelivery",
|
||
"requestId": "ack-1",
|
||
"payload": {
|
||
"messageKey": "from|to|time|nonce|1"
|
||
}
|
||
}
|
||
```
|
||
|
||
Ответ: `status=200`, echo `messageKey`.
|
||
|
||
## 6) Хранение на сервере (SQLite)
|
||
|
||
Основные таблицы:
|
||
|
||
1. `signed_messages_v2` — сами DM-блоки типов `1/2/3/4`:
|
||
- `message_key` (PK),
|
||
- `base_key`,
|
||
- `target_login`,
|
||
- `from_login`, `to_login`,
|
||
- `time_ms`, `nonce`, `message_type`,
|
||
- `raw_block`,
|
||
- `source_api`, `origin_session_id`,
|
||
- `receipt_ref_base_key`, `receipt_ref_type`.
|
||
2. `signed_message_session_delivery` — доставка по сессиям:
|
||
- составной PK `(message_key, session_id)`,
|
||
- `delivered` (0/1),
|
||
- `delivered_at_ms`, `created_at_ms`.
|
||
|
||
Примечание: историческая таблица `signed_direct_messages_history` в БД присутствует как legacy-слой, но текущий рабочий поток DM v2 опирается на `signed_messages_v2` + `signed_message_session_delivery`.
|
||
|
||
## 7) Доставка и backlog
|
||
|
||
- При сохранении пары сервер пытается сразу доставить в онлайн-сессии.
|
||
- Для офлайн/недоступных сессий остаётся pending-запись доставки.
|
||
- При подключении сессии сервер догружает pending (`listPendingForSession`) и шлёт `SignedMessageArrived(backlog=true)`.
|
||
- После получения клиент должен отправить `AckSessionDelivery`, чтобы отметить `delivered=1`.
|
||
|
||
## 8) Read-receipt логика
|
||
|
||
Когда клиент открывает чат:
|
||
|
||
1. ищет входящие `messageType=1` без `readReceiptSent`;
|
||
2. для каждого отправляет read-receipt как пару `type=3/4`;
|
||
3. после успешной отправки помечает `readReceiptSent`.
|
||
|
||
Сервер для read-receipt хранит ссылку на исходное сообщение:
|
||
|
||
- `receipt_ref_base_key`;
|
||
- `receipt_ref_type`.
|
||
|
||
Есть уникальность, чтобы не плодить дубликаты receipt на один и тот же `baseKey` для одного `target_login`.
|
||
|
||
## 9) Логика UI-клиента
|
||
|
||
В UI:
|
||
|
||
- чат хранится в `state.chats[chatId]`;
|
||
- `chatId` для `type=1` — `fromLogin`, для `type=2` — `toLogin`;
|
||
- непрочитанные считаются по `from='in' && unread=true`;
|
||
- доставка/прочтение исходящих:
|
||
- `firstTick` — сообщение принято в парный поток,
|
||
- `secondTick` — пришло подтверждение прочтения;
|
||
- при открытии диалога UI автопрокручивает ленту в самый низ;
|
||
- после отправки нового сообщения UI сразу автопрокручивает ленту вниз, чтобы новое сообщение было в зоне видимости;
|
||
- сообщения дополнительно кешируются в IndexedDB (`shine-ui-messages-v1`, store `messages`).
|
||
|
||
## 10) Инварианты (обязательно соблюдать при доработках)
|
||
|
||
1. Пара блоков (1/2 или 3/4) должна оставаться атомарной.
|
||
2. `messageKey`/`baseKey` формат должен быть совместим с текущей логикой дедупликации и receipt.
|
||
3. Доставка должна оставаться **по сессиям** с явным `AckSessionDelivery`.
|
||
4. Read-receipt не должен отправляться многократно на один и тот же `baseKey`.
|
||
5. Любые изменения DM-логики в коде должны сразу отражаться в этом документе.
|
||
|
||
## 11) Ключевые файлы реализации
|
||
|
||
- UI:
|
||
- `shine-UI/js/services/auth-service.js`
|
||
- `shine-UI/js/app.js`
|
||
- `shine-UI/js/state.js`
|
||
- `shine-UI/js/pages/chat-view.js`
|
||
- Сервер:
|
||
- `shine-server-net-protocol/.../messages/SignedMessageBlock.java`
|
||
- `shine-server-net-protocol/.../messages/SignedMessagesCore.java`
|
||
- `shine-server-net-protocol/.../messages/Net_SendMessagePair_Handler.java`
|
||
- `shine-server-net-protocol/.../messages/SignedMessagesRealtime.java`
|
||
- `shine-server-net-protocol/.../messages/Net_AckSessionDelivery_Handler.java`
|
||
- БД:
|
||
- `shine-server-db/src/main/java/shine/db/DatabaseInitializer.java`
|
||
- `shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java`
|