diplomail (Stage F): docs + edge-case tests + LibreTranslate recipe
Closes the documentation gaps from the freshly-audited diplomail implementation. FUNCTIONAL.md gains a §11 "Diplomatic mail" with the full user-facing story across all five stages, mirrored into FUNCTIONAL_ru.md as the project conventions require. A new backend/docs/diplomail-translator-setup.md captures the LibreTranslate operational recipe (Docker image, env wiring, manual smoke test, troubleshooting). The package README gains a "Multi-instance posture" note documenting the deliberate absence of FOR UPDATE in the worker pickup query — single-instance is safe today; multi-instance scaling will revisit the claim mechanism. Two small edge-case tests round things out: malformed LibreTranslate response bodies (single string, short array, empty array, missing field) must surface as errors so the worker falls back instead of crashing; and an empty translation queue must produce zero events on three consecutive Worker.Tick calls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,7 @@ field-level-валидация — всё это лежит в нижнеуро
|
||||
8. [Уведомления и почта](#8-уведомления-и-почта)
|
||||
9. [Гео-сигнал](#9-гео-сигнал)
|
||||
10. [Администрирование](#10-администрирование)
|
||||
11. [Дипломатическая почта](#11-дипломатическая-почта)
|
||||
|
||||
---
|
||||
|
||||
@@ -1193,3 +1194,220 @@ dead-letters и malformed notification-намерения. Они также м
|
||||
[ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox),
|
||||
[§12](ARCHITECTURE.md#12-notification-pipeline) и
|
||||
[Раздел 8](#8-уведомления-и-почта).
|
||||
|
||||
---
|
||||
|
||||
## 11. Дипломатическая почта
|
||||
|
||||
Сценарий описывает обмен сообщениями между игроками одной партии и
|
||||
адресные / широковещательные уведомления от администрации и
|
||||
владельца партии. Подсистема концептуально часть лобби (сообщения
|
||||
переживают рестарты движка), но видна только внутри игрового UI;
|
||||
в лобби виден лишь счётчик непрочитанного.
|
||||
|
||||
### 11.1 Состав
|
||||
|
||||
В составе: отправка персональной почты между активными участниками
|
||||
одной партии; ответы на персональную почту; чтение, отметка
|
||||
«прочитано» и soft-удаление своей входящей почты; адресные и
|
||||
широковещательные уведомления от админов и владельцев; платный
|
||||
broadcast от игроков; мультигеймовая admin-рассылка; ручная
|
||||
массовая чистка почты завершённых партий; авто-перевод тела
|
||||
сообщения на `preferred_language` получателя с кэшированием.
|
||||
|
||||
Вне состава: чат вне партии, групповые чаты с участниками разных
|
||||
партий, вложения, редактирование / отзыв сообщения,
|
||||
end-to-end-шифрование.
|
||||
|
||||
### 11.2 Модель сообщения
|
||||
|
||||
Каждая отправка порождает ровно одну строку в `diplomail_messages`
|
||||
плюс по одной строке на получателя в `diplomail_recipients`.
|
||||
Broadcast на N получателей — одно сообщение и N recipient-строк;
|
||||
строка перевода, если материализована, общая для всех получателей
|
||||
с одинаковым целевым языком.
|
||||
|
||||
`diplomail_messages.kind` — закрытое множество
|
||||
`{personal, admin}`. Персональные сообщения допускают ответ
|
||||
(получатель отправляет новое персональное сообщение);
|
||||
admin-сообщения не предполагают ответа — это уведомления о смене
|
||||
состояния или операторском действии. `sender_kind` — это
|
||||
`{player, admin, system}` и определяет роль отправителя: игрок-
|
||||
владелец партии (admin-уведомление от owner), site-администратор
|
||||
(admin-уведомление от оператора) или собственно автомат лобби
|
||||
(`game.paused`, `game.cancelled`, `membership.removed`,
|
||||
`membership.blocked`).
|
||||
|
||||
`broadcast_scope` фиксирует тип отправки: одному получателю
|
||||
(`single`), рассылка по одной партии (`game_broadcast`) или
|
||||
admin-рассылка по нескольким партиям (`multi_game_broadcast`).
|
||||
Получатели multi_game-рассылки видят отдельную, независимо
|
||||
удаляемую запись inbox в каждой адресованной партии.
|
||||
|
||||
Снимки сохраняются прямо в строках сообщения и получателя:
|
||||
`game_name`, `sender_username`, `sender_ip` и на стороне
|
||||
получателя — `recipient_user_name`, `recipient_race_name` и
|
||||
`recipient_preferred_language`. Они переживают переименование
|
||||
партии, отзыв членства, soft-delete аккаунта и итоговый
|
||||
bulk-purge — admin observability отрисовывается корректно даже
|
||||
после исчезновения «живых» строк.
|
||||
|
||||
Тела и subject — plain UTF-8 текст. Сервер не парсит, не санитайзит
|
||||
и не экранирует HTML; клиент рендерит тело через `textContent`.
|
||||
Максимум размера тела — `BACKEND_DIPLOMAIL_MAX_BODY_BYTES`
|
||||
(по умолчанию `4096`); максимум для subject —
|
||||
`BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (по умолчанию `256`).
|
||||
|
||||
### 11.3 Отправка почты
|
||||
|
||||
Персональная отправка требует активного членства в партии и от
|
||||
отправителя, и от получателя. Игроки free-tier отправляют одно
|
||||
персональное сообщение за запрос. Игрокам платных тиров доступен
|
||||
и игровой broadcast — одна отправка на всех остальных активных
|
||||
участников партии; ответы возвращаются автору broadcast.
|
||||
|
||||
Владельцы (приватных партий) и site-администраторы отправляют
|
||||
admin-уведомления. Endpoint владельца находится на user-поверхности
|
||||
(аутентификация по `X-User-ID`, проверка владельца в обработчике);
|
||||
endpoint администратора — на admin-поверхности (HTTP Basic). Оба
|
||||
принимают `target=user` (один получатель) или `target=all`
|
||||
(broadcast в одной партии). Site-администратору доступен
|
||||
дополнительный multi-game endpoint, принимающий
|
||||
`scope=selected` со списком game_id или `scope=all_running` —
|
||||
перебор всех партий в нетерминальных состояниях.
|
||||
|
||||
Состав получателей broadcast параметризуется полем `recipients`:
|
||||
`active` (по умолчанию), `active_and_removed` или `all_members`
|
||||
(включает блокированных, для аудит-уведомлений). Собственная
|
||||
recipient-строка автора broadcast не создаётся.
|
||||
|
||||
Player-broadcast от free-tier пользователя отклоняется кодом
|
||||
`403 forbidden`.
|
||||
|
||||
### 11.4 Получение почты
|
||||
|
||||
Получатель видит сообщение в своём inbox только после того, как
|
||||
асинхронный worker перевода обработал его (см.
|
||||
[§11.6](#116-перевод)). До этого строка невидима: не выводится в
|
||||
inbox-листинге, не учитывается в badge непрочитанного, push-событие
|
||||
не доставляется. Это исключает ситуацию «строка появилась, перевод
|
||||
не подъехал, badge мигает».
|
||||
|
||||
Badge непрочитанного в лобби агрегируется по партиям. Endpoint
|
||||
`/api/v1/user/lobby/mail/unread-counts` возвращает по одной записи
|
||||
на каждую партию с ненулевым unread плюс общий total; UI лобби
|
||||
отображает общий badge и плитки по партиям, не раскрывая самих
|
||||
сообщений.
|
||||
|
||||
Mark-read идемпотентен. Soft-удаление требует, чтобы сообщение уже
|
||||
было помечено прочитанным — клиент не может стереть неоткрытое
|
||||
сообщение. Soft-удаление действует только для одного получателя:
|
||||
строка самого сообщения переживает удаление вплоть до admin
|
||||
bulk-purge всей почты соответствующей партии.
|
||||
|
||||
Ответ message-detail содержит и оригинальное тело, и (если есть
|
||||
кэш) перевод; UI по умолчанию показывает перевод и предлагает
|
||||
переключение «показать оригинал».
|
||||
|
||||
### 11.5 Хуки жизненного цикла
|
||||
|
||||
Три транзитных перехода в лобби порождают system mail в inbox
|
||||
затронутых игроков:
|
||||
|
||||
- **Пауза / отмена игры.** Когда автомат партии проходит через
|
||||
`paused` или `cancelled`, лобби эмитит system-сообщение всем
|
||||
активным членам. Текст рендерится сервером по шаблону, чтобы
|
||||
игрок, открывший inbox позже, нашёл объяснение даже без
|
||||
одновременной push-сессии.
|
||||
- **Удаление / блокировка членства.** Сам-выход, удаление
|
||||
владельцем и admin-бан порождают system-сообщение только для
|
||||
затронутого игрока. Это письмо переживает переход членства в
|
||||
`removed` / `blocked` — игрок сохраняет к нему read-доступ
|
||||
навсегда (правило soft-доступа).
|
||||
|
||||
Будущее удаление по неактивности должно вызывать тот же publisher,
|
||||
чтобы объяснение дошло до затронутого игрока; README пакета
|
||||
прибивает этот контракт для следующего реализатора.
|
||||
|
||||
### 11.6 Перевод
|
||||
|
||||
`diplomail_messages.body_lang` заполняется на стороне сервера в
|
||||
момент отправки внутрипроцессным детектором языка, работающим
|
||||
только по телу. Subject наследует язык тела для ключа кэша
|
||||
перевода. Когда детектор не может уверенно классифицировать тело
|
||||
(слишком короткое, пустое, смешанные скрипты), значение —
|
||||
плейсхолдер BCP 47 `und` ("неопределённый"), и pipeline перевода
|
||||
обходится стороной — получатели видят оригинал.
|
||||
|
||||
Перевод выполняется асинхронно. Каждая recipient-строка содержит
|
||||
снимок `preferred_language` получателя плюс метку `available_at`.
|
||||
Получатель, чей язык совпадает с детектированным `body_lang` (или
|
||||
чей preferred_language пуст / язык тела — `und`), получает
|
||||
`available_at = now()` сразу при вставке, и push-событие
|
||||
отправляется в момент `POST`. Получатель с отличающимся языком
|
||||
вставляется с `available_at IS NULL` и ждёт worker.
|
||||
|
||||
Worker (`internal/diplomail.Worker`) тикает каждые
|
||||
`BACKEND_DIPLOMAIL_WORKER_INTERVAL` (по умолчанию `2s`) и
|
||||
обрабатывает по одной паре `(message_id, target_lang)` за тик. Он
|
||||
сначала смотрит в кэш переводов; на miss дёргает настроенный
|
||||
`Translator`. Дефолт production-сборки — LibreTranslate HTTP
|
||||
клиент; пустой `BACKEND_DIPLOMAIL_TRANSLATOR_URL` оставляет
|
||||
noop-translator, который доставляет сообщение в оригинале.
|
||||
|
||||
Исходы перевода:
|
||||
|
||||
- **Успех.** Строка в `diplomail_translations` создаётся (или
|
||||
переиспользуется, если параллельная попытка успела раньше),
|
||||
все pending-получатели пары переключаются на
|
||||
`available_at = now()`, и по каждому отправляется push.
|
||||
- **Неподдерживаемая пара языков** (HTTP 400 от LibreTranslate).
|
||||
Строка перевода не сохраняется; получатели доставляются с
|
||||
оригинальным телом. Последующие чтения возвращают оригинал.
|
||||
- **Транзитный сбой** (timeout, 5xx, network error). Счётчик
|
||||
попыток увеличивается, следующая попытка планируется по
|
||||
экспоненциальному backoff `1s → 2s → 4s → 8s → 16s`
|
||||
(с потолком 60s). После
|
||||
`BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS` (по умолчанию `5`)
|
||||
worker fallback'ит на оригинальное тело. Длительный отказ
|
||||
переводчика тормозит доставку максимум на ~30 секунд на пару
|
||||
до того, как получатель увидит оригинал.
|
||||
|
||||
Кэш переводов общий: broadcast на N получателей с одинаковым
|
||||
preferred_language порождает одну строку кэша и один вызов
|
||||
переводчика, не N.
|
||||
|
||||
### 11.7 Хранение и purge
|
||||
|
||||
Сообщения живут в `diplomail_messages`; per-recipient state — в
|
||||
`diplomail_recipients` с FK-каскадом на сообщение; переводы — в
|
||||
`diplomail_translations` тоже с каскадом. IP-адрес отправителя
|
||||
снимается из `X-Forwarded-For` (форвардит gateway) и хранится в
|
||||
сообщении для сохранения доказательств.
|
||||
|
||||
Автоматического retention нет. Admin bulk-purge endpoint удаляет
|
||||
все сообщения, чья партия завершилась более `older_than_years`
|
||||
лет назад (минимум `1`); каскад удаляет recipient- и
|
||||
translation-строки той же транзакцией.
|
||||
|
||||
### 11.8 Видимость для оператора
|
||||
|
||||
Admin-поверхность экспонирует постраничный листинг всех сообщений
|
||||
(`/api/v1/admin/mail/messages`) с фильтрами по `game_id`, `kind`
|
||||
и `sender_kind`. Bulk-purge endpoint
|
||||
(`/api/v1/admin/mail/cleanup`) принимает порог
|
||||
`older_than_years`. Per-game admin-отправки и multi-game
|
||||
broadcast'ы доступны через `/api/v1/admin/games/{game_id}/mail`
|
||||
и `/api/v1/admin/mail/broadcast`.
|
||||
|
||||
### 11.9 Перекрёстные ссылки
|
||||
|
||||
- Обзор пакета и карта стадий:
|
||||
[`backend/internal/diplomail/README.md`](../backend/internal/diplomail/README.md).
|
||||
- Рецепт развёртывания LibreTranslate для локальной разработки:
|
||||
[`backend/docs/diplomail-translator-setup.md`](../backend/docs/diplomail-translator-setup.md).
|
||||
- Детали хранения:
|
||||
[ARCHITECTURE.md §12.1](ARCHITECTURE.md#121-diplomatic-mail-subsystem).
|
||||
- Push-транспорт для событий доставки: [Раздел 7](#7-канал-push).
|
||||
- Notification-каталог: kind `diplomail.message.received`:
|
||||
[`backend/README.md` §10](../backend/README.md#10-notification-catalog).
|
||||
|
||||
Reference in New Issue
Block a user