179 lines
14 KiB
Markdown
179 lines
14 KiB
Markdown
# WebSocket и realtime
|
||
|
||
EUSRR использует одно авторизованное WebSocket-соединение на вкладку приложения:
|
||
|
||
```text
|
||
POST /api/auth/ws-ticket/ → ws://localhost:8000/ws/?ticket=ONE_TIME_TICKET
|
||
```
|
||
|
||
Отдельных `/ws/chat/`, `/ws/chats/`, `/ws/notifications/` и `/ws/realtime/` в текущем routing нет.
|
||
|
||
## Источники истины
|
||
|
||
- ASGI composition: `backend/eusrr_backend/asgi.py`
|
||
- JWT middleware: `backend/eusrr_backend/channels_jwt.py`
|
||
- routing: `backend/realtime/routing.py`
|
||
- user consumer: `backend/realtime/consumers.py`
|
||
- chat protocol: `backend/communications/consumers.py`
|
||
- machine-readable contract: `backend/docs/architecture/websocket-asyncapi.yaml`
|
||
- singleton frontend connection: `frontend/src/lib/websocketManager.ts`
|
||
- chat adapter поверх singleton: `frontend/src/hooks/useWebSocket.ts`
|
||
- calendar realtime publisher: `backend/scheduling/realtime.py`
|
||
- commerce realtime publisher: `backend/commerce/realtime.py`
|
||
- tests: `backend/tests/realtime/`
|
||
|
||
AsyncAPI доступен через `/api/ws/schema/` и `/api/ws/docs/`. При расхождении с этим guide ориентируйтесь на consumer и AsyncAPI schema.
|
||
|
||
## Запуск
|
||
|
||
```bash
|
||
cd backend
|
||
daphne -b 0.0.0.0 -p 8000 eusrr_backend.asgi:application
|
||
```
|
||
|
||
Для Redis-backed channel layer должны быть доступны `REDIS_HOST` и `REDIS_PORT`. Docker image проекта также запускает Daphne на порту 8000.
|
||
|
||
Frontend:
|
||
|
||
```dotenv
|
||
NEXT_PUBLIC_BACKEND_URL=http://localhost:8000
|
||
NEXT_PUBLIC_WS_URL=ws://localhost:8000/ws/
|
||
```
|
||
|
||
`NEXT_PUBLIC_WS_URL` — полный endpoint. Если переменная отсутствует, URL строится из `NEXT_PUBLIC_BACKEND_URL` с заменой `http` → `ws` или `https` → `wss` и добавлением `/ws/`.
|
||
|
||
## Аутентификация
|
||
|
||
Browser сначала выполняет авторизованный `POST /api/auth/ws-ticket/`, затем передаёт полученный одноразовый билет как `?ticket=...`. Билет действует 30 секунд, подписан сервером и может быть использован только один раз. Поэтому долгоживущий JWT не попадает в URL WebSocket handshake.
|
||
|
||
Non-browser clients могут передать `Authorization: Bearer <access_token>`. Legacy `?token=<access_token>` по умолчанию отключён; временно включить его можно только явной настройкой `WEBSOCKET_ALLOW_QUERY_JWT=true`.
|
||
|
||
ASGI проверяет `Origin` через `AllowedHostsOriginValidator`. Если frontend и WebSocket работают на разных host, разрешённые frontend origins задаются явно через `WEBSOCKET_ALLOWED_ORIGINS=https://app.example.com`. JWT и ticket также проверяют связанную `UserAuthSession`; отзыв сессии немедленно закрывает принадлежащее ей соединение кодом `4401`.
|
||
|
||
## Подписки consumer-а
|
||
|
||
После connect `UserConsumer`:
|
||
|
||
- подписывается только на доступные активные `chat_{id}`;
|
||
- подписывается на `user_{user_id}`;
|
||
- подписывается на `notifications_{user_id}`;
|
||
- подписывается на общий read-only канал статусов commerce-интеграций;
|
||
- запускает server ping каждые 20 секунд.
|
||
|
||
Изменение `ChatMembership` синхронизируется с уже открытыми соединениями: при добавлении consumer входит в группу, при отзыве доступа немедленно выходит из неё и закрывает активный чат.
|
||
|
||
`active_chat_id` определяет, получает соединение полное chat event или только обновление списка. `open_chat` регистрирует только presence и не меняет read state. Курсор сообщения и связанные notifications отмечаются прочитанными одной HTTP-операцией при загрузке видимой истории или через `POST /chats/{id}/mark-read/`; результат рассылается по WebSocket другим вкладкам. Presence дополнительно публикуется в `ActiveChatSession` и продлевается heartbeat, чтобы подавлять нативный Web Push для уже открытого чата. Событие состояния notification по WebSocket доставляется во все сессии и само по себе не означает прочтение.
|
||
|
||
## Команды клиента
|
||
|
||
| `action` | Обязательные данные | Назначение |
|
||
| --- | --- | --- |
|
||
| `open_chat` | `chat_id` | Активировать чат; optional `load_history` |
|
||
| `close_chat` | `chat_id` | Закрыть активный чат |
|
||
| `send_message` | `content` или `text`; optional `mentioned_user_ids` | Отправить текст в активный чат |
|
||
| `edit_message` | `message_id`, `content`; optional `mentioned_user_ids` | Изменить своё сообщение |
|
||
| `delete_message` | `message_id` | Soft-delete своего сообщения |
|
||
| `add_reaction` | `message_id`, `emoji` | Добавить реакцию |
|
||
| `remove_reaction` | `message_id`, `emoji` | Удалить реакцию |
|
||
| `typing` | — | Начать typing для активного чата |
|
||
| `stop_typing` | — | Остановить typing |
|
||
| `vote_poll` | `poll_id`, `option_ids` | Изменить голос в опросе активного чата |
|
||
| `mark_read` | `chat_id` | Deprecated; используйте HTTP mark-read action |
|
||
| `calendar_subscribe` | `subscription_id`, `start`, `end` | Подписаться на snapshot событий диапазона; optional `calendar_id`, `scope` |
|
||
| `calendar_unsubscribe` | `subscription_id` | Закрыть подписку диапазона календаря |
|
||
| `pong` | optional `timestamp` | Ответ на server heartbeat |
|
||
|
||
Пример:
|
||
|
||
```json
|
||
{"action":"open_chat","chat_id":42,"load_history":false}
|
||
```
|
||
|
||
`mentioned_user_ids` — массив максимум из 50 ID, выбранных autocomplete-ом. Consumer проверяет, что каждый пользователь имеет доступ к активному чату, и связывает ID только с реально присутствующим в тексте `@Полное имя`. События с сериализованным сообщением возвращают поле `mentions` в виде массива `{user_id, name, start, end}`. Если поле не передано при редактировании старым клиентом, существующие упоминания сохраняются и их диапазоны пересчитываются по новому тексту.
|
||
|
||
## События сервера
|
||
|
||
Основные публичные `type`:
|
||
|
||
- connection: `ping`, `error`;
|
||
- access lifecycle: `chat_access_changed`;
|
||
- chat lifecycle: `chat_opened`, `chat_closed`, `initial_messages`;
|
||
- messages: `new_message`, `list_update`, `message_edited`, `message_deleted`;
|
||
- reactions: `reaction_added`, `reaction_removed`;
|
||
- typing: `typing_start`, `typing_stop`;
|
||
- polls/read receipts: `poll_update`, `marked_read`;
|
||
- notifications: `notification`, `unread_count`, `notification_read`, `notifications_read_all`;
|
||
- domain events: `procurement_update`, `task_board_update`, `calendar_update`, `commerce_connection_updated`;
|
||
- calendar stream: `calendar_snapshot`, `calendar_error`.
|
||
|
||
`commerce_connection_updated` содержит безопасный срез состояния подключения и
|
||
последнего запуска синхронизации без credentials. Экран управления применяет
|
||
его к локальному состоянию вместо опроса `/api/v1/commerce/connections/` каждые
|
||
2,5 секунды. При недоступном WebSocket остаётся REST fallback раз в 30 секунд;
|
||
при живом соединении периодический запрос не выполняется.
|
||
|
||
`calendar_subscribe` открывает независимую подписку на диапазон до 400 дней. Одно соединение поддерживает до восьми подписок, поэтому большой и боковой календари могут иметь разные диапазоны. Backend проверяет доступ пользователя, разворачивает recurring occurrences и отправляет `calendar_snapshot` сразу после подписки и после связанных изменений. `scope=all` применяется только для staff/superuser.
|
||
|
||
`calendar_update` остаётся компактным доменным событием для синхронизации списка календарей. Сами видимые события поступают в `calendar_snapshot`; REST-загрузка используется frontend только как fallback, если WebSocket недоступен. Создание, изменение, удаление, импорт и экспорт календаря остаются HTTP-командами.
|
||
|
||
Channel-layer handler names вроде `chat_message`, `notification_message` и `notification_count_update` — внутренний Python protocol. Frontend должен обрабатывать JSON `type`, отправленный consumer-ом, а не имена внутренних handlers.
|
||
|
||
## Frontend lifecycle
|
||
|
||
`websocketManager` поддерживает один shared connection, пока есть message/status subscribers. Он:
|
||
|
||
- подключается только при наличии auth-сессии и получает новый одноразовый ticket на каждую попытку;
|
||
- отключается после очистки auth-сессии;
|
||
- синхронизирует active chat после reconnect;
|
||
- отвечает `pong`, контролирует heartbeat и закрывает зависшее соединение;
|
||
- использует exponential backoff с jitter, после пяти быстрых попыток продолжает восстановление раз в минуту;
|
||
- немедленно повторяет подключение после browser `online` и возвращения вкладки;
|
||
- закрывает socket, когда исчезает последний subscriber.
|
||
|
||
Не создавайте WebSocket напрямую в page/component. Chat-specific hook подписывается на shared manager и не владеет отдельным соединением.
|
||
|
||
После reconnect frontend повторяет активные календарные подписки. Если snapshot не приходит за три секунды или исчерпаны попытки reconnect, выполняется HTTP fallback для текущего диапазона.
|
||
Чаты синхронизируют сообщения после локального watermark, список чатов перечитывается, а задачи и список календарей выполняют контрольную REST-синхронизацию. Поэтому WebSocket используется как сигнал ускорения, а не как единственный источник истины.
|
||
|
||
## Изменение протокола
|
||
|
||
1. Добавьте handler/action в consumer.
|
||
2. Проверьте permission и принадлежность объекта пользователю.
|
||
3. Определите channel group и поведение для active/inactive screen.
|
||
4. Обновите `websocket-asyncapi.yaml`.
|
||
5. Обновите frontend type guard/handler.
|
||
6. Добавьте consumer test и, при необходимости, multi-session test.
|
||
7. Выполните OpenAPI/AsyncAPI documentation tests.
|
||
|
||
## Проверка
|
||
|
||
```bash
|
||
cd backend
|
||
pytest tests/realtime tests/api/test_documentation.py -q
|
||
```
|
||
|
||
Ручная проверка через `wscat`:
|
||
|
||
```bash
|
||
TICKET=$(curl -s -X POST http://localhost:8000/api/auth/ws-ticket/ \
|
||
-H 'Authorization: Bearer ACCESS_TOKEN' | jq -r .ticket)
|
||
wscat -c "ws://localhost:8000/ws/?ticket=${TICKET}" \
|
||
-H 'Origin: http://localhost:3000'
|
||
```
|
||
|
||
После подключения server отправляет `ping`; client должен ответить `{"action":"pong"}`. При отсутствии ответа в течение трёх интервалов соединение закрывается кодом `4408`.
|
||
|
||
## Production proxy
|
||
|
||
Reverse proxy должен:
|
||
|
||
- передавать `/ws/` в ASGI server;
|
||
- поддерживать HTTP/1.1 Upgrade и `Connection: upgrade`;
|
||
- не буферизовать WebSocket;
|
||
- иметь idle timeout больше 20 секунд;
|
||
- маршрутизировать все worker-ы к общему Redis channel layer;
|
||
- передавать корректные `Host`/forwarded headers.
|
||
- не подменять и не отбрасывать допустимый `Origin`.
|
||
|
||
`1006` — общий abnormal closure, а не самостоятельная причина. Сначала проверяйте close code/log consumer-а, JWT/session, proxy Upgrade, доступность Redis и совпадение endpoint-а.
|