generated from VLADIMIR/template
606 lines
56 KiB
Markdown
606 lines
56 KiB
Markdown
# Пакет игры, версия 2 — ED-031
|
||
|
||
Пакет — самостоятельный ZIP для одной игры до старта. Сборка получает готовый
|
||
снимок данных и файлы; чтение не обращается к сети, БД или хранилищу приложения.
|
||
Реализация: `internal/services/game_package_service`.
|
||
|
||
## Контракт
|
||
|
||
| Запись | Содержание |
|
||
| --- | --- |
|
||
| `manifest.json` | `version=2`, случайный `package_id` (32 hex-символа), `created_at` в UTC, `source_server`, исходный `game_id`, массив `files` |
|
||
| `game.json` | Объект `Data`: `game`, `scenario`, `teams`, `tickets`, `playlists`, необязательный `screen_plan`, `map` |
|
||
| `files/<sha256>.<extension>` | Медиа с именем по SHA-256 содержимого; одинаковые байты хранятся один раз |
|
||
|
||
Каждая запись `files` манифеста описывает путь, размер распакованных байтов,
|
||
MIME и SHA-256 в нижнем регистре. В перечень входит `game.json`, сам манифест
|
||
не входит. Все остальные ZIP-записи должны быть заявлены и использованы
|
||
ссылками из данных игры. Вложенные архивы и каталоги не используются.
|
||
|
||
В объекте `game` необязательные `wifi_name` и `wifi_password` содержат настройки
|
||
сети для печати карточек команд и чеков. Они сохраняются при экспорте и импорте;
|
||
пакеты без этих полей используют пустые значения. Пароль входит в пакет открытым
|
||
текстом, как и данные входа команд. После изменения настроек для переноса нужен
|
||
новый экспорт: готовый пакет остаётся снимком на момент подготовки.
|
||
|
||
Полный перечень JSON-полей задан типами в `model.go`; неизвестные поля и
|
||
неподдерживаемые версии при чтении отклоняются. Время записывается как RFC3339,
|
||
`token_hash` — как стандартный Base64 от исходных 32 байтов SHA-256.
|
||
Структура истории используется существующая — `storytelling.Story`;
|
||
поля шагов экранов совпадают с `game_screen_plans_repo.Step`.
|
||
|
||
### Переносимые данные
|
||
|
||
- Игра: исходные ID, название, описание, место, запланированное время, статус,
|
||
формат `online`/`offline`, ссылки на сценарий и плейлист. Допускается только
|
||
`draft`, без `started_at`, `ended_at` и игрового прогресса.
|
||
- Сценарий: описание, обложка, полная история с правильными ответами, статус,
|
||
даты, исходный ID автора и назначенный плейлист.
|
||
- Все команды игры, включая неподтверждённые и отменённые: ID, неизменённый
|
||
пароль QR, название, статус, email капитана, число участников, данные
|
||
подтверждения участия, исходный ID создателя, ID доставки регистрации и дата
|
||
создания. Эти данные нужны для сохранения связей и защиты от повторной регистрации.
|
||
- Активные и использованные билеты: ID, исходный хеш токена, статус, даты,
|
||
исходный ID пользователя, использовавшего билет. `used_by` может отсутствовать,
|
||
например после удаления пользователя; статус и `used_at` сохраняются.
|
||
- Оба назначенных плейлиста — игры и сценария, если они различаются; треки с
|
||
исходными ID, именами и позициями. План экранов с выбранным шагом; карта команд
|
||
и единственный стол организатора `organizer_desk/1`.
|
||
|
||
`game.playlist_id` — выбранный для переноса плейлист игры. При подготовке снимка
|
||
экспортёр должен учесть назначение в `game_music_states.playlist_id`: существующий
|
||
интерфейс выбора музыки сохраняет его именно там. При отсутствии такого назначения
|
||
используется `games.playlist_id`, затем default сценария. Текущая позиция,
|
||
состояние проигрывания и сессии экранов в пакет не включаются.
|
||
|
||
ID пользователей — ссылки на исходный сервер, а не существующих пользователей
|
||
площадки. Импортёр обязан обработать их отдельно; кодек не создаёт аккаунты.
|
||
ID игры, команд, сценария, билетов, плейлистов и треков также относятся к исходному
|
||
серверу. Кодек их не перенумеровывает. Импорт должен сохранить значения ID команд
|
||
и пароль, используемые в уже напечатанных ссылках `/team-story/<id>?password=...`,
|
||
а также хеши токенов билетов. Конфликты с локальными ID решаются на этапе 6.
|
||
|
||
Почтовые задания, токены подтверждения участия, аккаунты, пароли пользователей,
|
||
JWT/refresh-токены, действия, полученные улики, ответы команд и результаты не
|
||
входят в формат. Подключённые экраны и их позиции на карте при сборке исключаются;
|
||
на площадке экраны подключаются заново. Состояние проигрывания музыки не переносится.
|
||
|
||
### Медиа
|
||
|
||
Собираются все файловые ссылки: обложка, фон, аудио вступления, видео заключения,
|
||
изображения мест, файлы улик и треки плейлистов. Данные проверяются по сигнатуре
|
||
содержимого; поддерживаются PDF, JPEG, PNG, GIF, WebP, MP3, Ogg, WAV, MP4 и WebM.
|
||
Ссылки внутри копии `Data` заменяются на пути `files/...`; входные данные не меняются.
|
||
|
||
При подготовке нового пакета статичные JPEG/PNG/WebP преобразуются в WebP
|
||
(качество 85), аудио — в MP3 (128 кбит/с), видео — в MP4 (H.264, CRF 23,
|
||
preset fast, yuv420p, звук AAC 128 кбит/с, faststart). Фото и видео уменьшаются
|
||
до 1920 пикселей по длинной стороне с сохранением пропорций; маленькие файлы
|
||
не увеличиваются. Прозрачность и ориентация изображений сохраняются. PDF, GIF,
|
||
APNG и анимированные WebP переносятся без изменений.
|
||
|
||
Оптимизированная копия используется только если она меньше исходной; при
|
||
ошибке, отсутствии кодека/утилиты или превышении двух минут на файл сохраняется
|
||
исходник. Общий срок подготовки пакета остаётся 30 минут. Отмена подготовки
|
||
прерывает конвертер. Преобразование последовательное, до двух потоков
|
||
декодирования/кодирования и фильтров, только из локальных временных файлов;
|
||
сетевые протоколы FFmpeg запрещены. В манифест записываются MIME, размер и SHA-256
|
||
окончательных байтов. Исходники в хранилище и уже готовые ZIP не меняются.
|
||
|
||
Для запуска вне Docker установите `ffmpeg` и `ffprobe` в `PATH`, со сборкой
|
||
кодеков libwebp, libmp3lame, libx264 и AAC. Оба серверных Dockerfile устанавливают
|
||
пакет `ffmpeg`. Сжатие с потерями может ухудшить различимость мелких улик; если
|
||
преобразование не удалось, размер пакета может остаться прежним.
|
||
|
||
Относительное имя и полный URL с настроенным префиксом хранилища открываются
|
||
через переданный источник локальных файлов. Остальные HTTP/HTTPS-ссылки
|
||
скачиваются при сборке. Один URL скачивается один раз; одинаковое содержимое
|
||
разных URL хранится один раз. Файл по недоступной ссылке, пустой файл или ответ
|
||
без поддерживаемой сигнатуры завершают сборку ошибкой.
|
||
|
||
Внешний клиент не использует системный proxy. Адреса проверяются перед
|
||
соединением и после перенаправлений; соединение идёт к проверенному IP без
|
||
повторного DNS-разрешения. Частные, локальные и служебные сети запрещены.
|
||
HTTPS использует штатную проверку сертификатов. До пяти перенаправлений,
|
||
до 120 секунд на файл; отмена контекста прерывает операцию.
|
||
|
||
## Использование и временные файлы
|
||
|
||
```go
|
||
service := game_package_service.NewGamePackageService(fileDomain)
|
||
|
||
openLocal := func(ctx context.Context, name string) (io.ReadCloser, error) {
|
||
file, err := fileStorage.Get(ctx, name)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return io.NopCloser(bytes.NewReader(file.Data)), nil
|
||
}
|
||
|
||
err := service.Pack(ctx, snapshot, openLocal, destination)
|
||
// На ошибке destination нужно отбросить, включая ошибку записи результата.
|
||
|
||
bundle, err := service.Unpack(ctx, sourceReaderAt, archiveSize, tempParent)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer os.RemoveAll(bundle.Dir)
|
||
// bundle.Files: путь внутри ZIP → проверенный временный файл с расширением.
|
||
```
|
||
|
||
`Snapshot.SourceServer` содержит исходный адрес онлайн-сервера.
|
||
`Snapshot.HasProgress` заполняется экспортёром по наличию действий, полученных
|
||
улик и ответов. Кодек не читает БД, поэтому достоверность снимка и этого признака
|
||
обеспечивает экспортёр этапа 5. `Data` не имеет полей для игрового прогресса.
|
||
|
||
`Pack` сначала собирает ZIP в приватном временном каталоге, затем копирует его
|
||
в `destination`. Ошибка получения файлов не выдаёт частичный пакет. Ошибка
|
||
записи или отмена во время выдачи может оставить часть ZIP в destination;
|
||
вызывающий код обязан её удалить. Все внутренние файлы `Pack` удаляются при
|
||
завершении операции. Адаптер существующего `IFileStorage.Get` держит в памяти
|
||
один объект; потоковый источник может возвращать reader без этого буфера.
|
||
|
||
`Unpack` создаёт отдельный каталог внутри `tempParent` (или системного временного
|
||
каталога при пустой строке), с правами `0700`, файлы — `0600`. Файлы создаются со
|
||
случайными именами, независимо от путей ZIP. При любой ошибке собственный каталог
|
||
удаляется. После успеха очистка `bundle.Dir` обязательна для вызывающего кода.
|
||
Временные файлы и пакеты нельзя хранить в Git: они содержат ответы сценария,
|
||
пароли QR и email капитанов.
|
||
|
||
## Лимиты и проверки
|
||
|
||
| Ограничение | Предел |
|
||
| --- | --- |
|
||
| Размер ZIP | 1 ГиБ |
|
||
| Общий распакованный размер, включая JSON | 1 ГиБ |
|
||
| Один медиафайл | 64 МиБ |
|
||
| Каждый JSON и каталог ZIP | 8 МиБ |
|
||
| Количество ZIP-записей, включая JSON | 4096 |
|
||
|
||
Обработка медиа использует ограниченные буферы и временные файлы, не загружает
|
||
весь пакет в память. Перед `zip.NewReader` проверяется каталог, чтобы количество
|
||
записей нельзя было скрыть ложным заголовком. ZIP64 и многотомные ZIP не поддерживаются.
|
||
При чтении отклоняются опасные пути, симлинки, каталоги, дубли ZIP/манифеста,
|
||
незаявленные и неиспользуемые файлы, отсутствие файлов, несовпадение размеров,
|
||
MIME и SHA-256, некорректные связи данных и состояние начатой игры.
|
||
|
||
Ошибки проверяются через `errors.Is`: `ErrInvalidPackage`,
|
||
`ErrUnsupportedVersion`, `ErrLimitExceeded`, `ErrMediaUnavailable`,
|
||
`ErrUnsafeURL`; отмена сохраняет `context.Canceled`/`context.DeadlineExceeded`.
|
||
Кодек не содержит транспортных handlers; экспортёр переводит ошибки в безопасные
|
||
сообщения API.
|
||
|
||
SHA-256 защищает от повреждения, но не подтверждает автора пакета. Подпись и
|
||
шифрование не реализованы. Кодек не блокирует игру и не делает импорт. Импортёр этапа 6 использует его
|
||
проверенный результат; повторные импорты и сохранность QR описаны ниже.
|
||
|
||
Проверка: `go test ./internal/services/game_package_service -count=1` и полный
|
||
`go test ./...`. Тесты покрывают сохранение данных, автономное чтение, загрузку
|
||
медиа, дедупликацию, потоковую обработку, отмену, очистку, лимиты и повреждённые пакеты.
|
||
|
||
## Экспорт со статусом офлайн-режима: этап 5
|
||
|
||
Только для тестовых игр до реализации возврата результатов. Импорт реализован
|
||
на этапе 6; интерфейс передачи, отмена и возврат результатов остаются следующими этапами.
|
||
|
||
### API
|
||
|
||
Все три маршрута требуют `Authorization: Bearer <JWT>` и одну из ролей `admin`,
|
||
`developer`, `organizer`. `author` и `user` не имеют доступа. В `APP_MODE=venue`
|
||
экспорт запрещён, worker не запускается и закрытый bucket не инициализируется.
|
||
|
||
| Метод и маршрут | Результат |
|
||
| --- | --- |
|
||
| `POST /api/games/{ID}/export` | Запуск фоновой подготовки; повторный запрос в `preparing`/`ready` возвращает ту же попытку. Тело: `{}`. |
|
||
| `GET /api/games/{ID}/export` | Последняя попытка: состояние, ID, готовый пакет либо безопасная причина ошибки. |
|
||
| `GET /api/games/{ID}/export/download` | Потоковый ZIP готовой попытки; `Content-Length`, `Content-Disposition`, `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`. |
|
||
|
||
Первые два маршрута возвращают `{"export":{"ID":"...","state":"preparing"}}`.
|
||
В `ready` добавляются `packageID` из фактического манифеста, `size` (int64 в
|
||
protobuf JSON представлен строкой), `sha256`. В `failed` — безопасный `failure`.
|
||
Снимок, инициатор, владелец worker и ключ RustFS наружу не передаются.
|
||
`Game.export` в обычных ответах игры содержит те же сведения для будущего UI.
|
||
|
||
Ошибки доступа: HTTP 401/403; отсутствие попытки — 404; начатая игра, прогресс,
|
||
режим `venue` и ещё не готовый ZIP — HTTP 400
|
||
(`FailedPrecondition`). Неизвестные ошибки дают безопасный HTTP 500.
|
||
Скачивание реализовано отдельным HTTP-handler с штатной JWT-проверкой; ZIP не
|
||
проходит через gRPC `HttpBody` или публичный `/api/files`.
|
||
|
||
### Снимок и статус офлайн-режима
|
||
|
||
Начало экспорта блокирует строку игры в короткой транзакции. Проверяются `draft`,
|
||
отсутствие `started_at`, `ended_at`, действий, улик и ответов команд. Запланированное
|
||
`start_at` переносится. Одним SQL-снимком сохраняются игра, общий сценарий, все
|
||
команды и билеты, карта, план экранов, выбранный плейлист игры и default сценария.
|
||
`game_music_states.playlist_id` имеет приоритет над `games.playlist_id`.
|
||
|
||
Частичный уникальный индекс допускает одну попытку `preparing`/`ready` на игру.
|
||
Экспорт не запрещает изменения игры и связанных данных: команды, регистрация,
|
||
билеты, прогресс, оценки, музыка, экраны и карта продолжают работать штатно.
|
||
Почтовые workers продолжают обработку. Функций и триггеров БД нет.
|
||
|
||
В списке игр и на странице управления `preparing` отображается как подготовка
|
||
офлайн-пакета, `ready` — как «Игра в офлайн-режиме». Это информационный статус,
|
||
а не подтверждение импорта пакета на площадке. Поля `status` и `format` игры
|
||
не меняются. При `failed` отметка офлайн-режима исчезает.
|
||
|
||
ZIP собирается из сохранённого снимка. Изменения после начала экспорта не
|
||
попадают в этот пакет, повторное скачивание отдаёт прежнюю версию. Общие сценарии
|
||
и плейлисты остаются редактируемыми; недоступное или удалённое медиа приводит
|
||
к ошибке сборки. Отдельный интерфейс запуска/скачивания остаётся этапом 7.
|
||
|
||
### Worker и хранение
|
||
|
||
Очередь и снимки находятся в PostgreSQL (`game_exports`), отдельного брокера нет.
|
||
Worker проверяет задания каждые 5 секунд. После захвата попытки аренда длится
|
||
2 минуты, heartbeat обновляет её каждые 30 секунд. Общая подготовка, включая
|
||
ожидание в очереди, ограничена 30 минутами с момента запуска. Потеря владения
|
||
отменяет подготовку; `ready` публикуется только после полной загрузки ZIP и
|
||
повторной проверки ID попытки, владельца, аренды и срока подготовки в БД.
|
||
|
||
ZIP собирается во временном каталоге `0700` с файлами `0600` и потоково
|
||
загружается в отдельный закрытый bucket `<S3_BUCKET>-game-exports` в текущем
|
||
RustFS с текущими учётными данными. Новые переменные окружения и изменения
|
||
production-конфигурации не нужны. Временные файлы удаляются после работы;
|
||
maintenance очищает оставшиеся каталоги терминальных попыток и неполные объекты
|
||
после перезапуска. Данные для очистки сохраняются даже после удаления игры,
|
||
независимо от её текущего наличия.
|
||
|
||
Ошибка сборки или истёкшая аренда переводит попытку в `failed`. Повторный POST создаёт новую попытку и новый снимок. Worker старой
|
||
попытки не может опубликовать результат или изменить новую попытку. Неоднозначный
|
||
ответ БД после публикации не приводит к удалению готового ZIP: удаление допустимо
|
||
только после подтверждённого `failed`. Очистка повторяется до окончания возможной
|
||
работы старого worker.
|
||
|
||
Готовый пакет хранится до реализации возврата результатов. Повторное скачивание
|
||
отдаёт те же байты; закрытие соединения не меняет `ready`.
|
||
|
||
### Проверка
|
||
|
||
`TEST_DATABASE_URL` включает PostgreSQL-проверки в изолированных схемах через
|
||
реальные миграции и публичные методы репозиториев/сервисов. `go test ./...`
|
||
проверяет снимок, конкурентный запуск, возможность изменений после экспорта, истечение
|
||
аренды/timeout, запрет устаревшей публикации, ошибку/повторный запуск, очистку,
|
||
права и режим `venue`.
|
||
|
||
Для отдельного тестового RustFS дополнительно задаются `TEST_S3_ENDPOINT`,
|
||
`TEST_S3_USER`, `TEST_S3_PASSWORD` и запускается
|
||
`go test ./internal/services/game_export_service -run TestExportRustFS -count=1 -v`.
|
||
Тест создаёт отдельный bucket, проверяет кодек, SHA-256, анонимный отказ, потоковую
|
||
выдачу/обрыв и повторное создание клиента, затем удаляет свои объекты и bucket.
|
||
Необязательный `TEST_S3_RESTART_MARKER` позволяет внешнему тестовому harness
|
||
перезапустить изолированный RustFS: после появления marker нужно дождаться health
|
||
и создать `<marker>.continue` в течение трёх минут. Это не production-проверка.
|
||
|
||
|
||
## Локальный импорт: этап 6
|
||
|
||
### API
|
||
|
||
`POST /api/games/import` принимает ZIP непосредственно в теле запроса с
|
||
`Content-Type: application/zip` и `Authorization: Bearer <JWT>`. Multipart и
|
||
Base64 не используются. Разрешены роли `admin`, `developer`, `organizer`;
|
||
операция доступна только в `APP_MODE=venue`.
|
||
|
||
Ответ HTTP 200: `{"gameID":100,"packageID":"…","repeated":false}`.
|
||
Повтор того же пакета возвращает `repeated:true` и прежние идентификаторы,
|
||
не перезаписывая локальные изменения. Повтор после удаления игры также
|
||
возвращает прежний результат и не восстанавливает удалённую игру.
|
||
|
||
Ошибки: 401/403 — авторизация/роль; 400 — неподходящий режим, повреждённый
|
||
пакет, версия или данные; 409 — конфликт ID, изменённые байты под прежним ID
|
||
пакета, другой пакет той же игры или ещё выполняющийся импорт; 413 — лимит
|
||
размера; 499/504 — отмена/timeout; 500 — безопасная внутренняя ошибка.
|
||
Ответы имеют `Cache-Control: no-store`.
|
||
|
||
Маршрут описан в protobuf как `ImportGame(HttpBody) → GameImportRsp`, но
|
||
HTTP-поток обслуживает отдельный handler перед grpc-gateway: ZIP размером до
|
||
1 ГиБ не проходит через gRPC-буфер. Прямой gRPC-вызов использует тот же сервис,
|
||
однако ограничен существующим лимитом сообщений. В обоих конфигурациях прокси
|
||
venue только `/api/games/import` получает лимит 1 ГиБ, отключённую буферизацию
|
||
request body и timeout 30 минут; другие маршруты сохраняют прежние лимиты.
|
||
|
||
### Перенос и конфликты
|
||
|
||
Импорт сохраняет ID игры, команд и билетов, пароли QR, хеши билетов, их
|
||
статусы/даты, подтверждения участия, email капитанов и ID доставки регистрации.
|
||
Занятый локальный ID игры/команды/билета или хеш билета приводят к отказу,
|
||
не затрагивая существующие записи. Конфликты проверяются до загрузки медиа и
|
||
повторно в транзакции публикации.
|
||
|
||
Сценарий, оба плейлиста и треки создаются отдельными копиями с новыми локальными
|
||
ID. Все назначения перепривязываются; сценарий принадлежит импортирующему
|
||
пользователю. `creator_id` команд и `used_by` билетов остаются NULL: совпадение
|
||
числового ID не связывает аккаунты двух серверов. Исходные связи и соответствия
|
||
ID сохраняются в закрытых метаданных `game_imports` для будущего возврата.
|
||
|
||
Формат и статус игры сохраняются. План экранов и карта переносятся без подключённых
|
||
устройств; воспроизведение музыки начинает со штатного состояния покоя.
|
||
Медиа загружаются в локальный RustFS потоково под отдельными именами попытки,
|
||
с заменой всех ссылок. Одинаковое медиа сценария используется повторно; треки
|
||
с одинаковыми байтами получают разные имена, если этого требует существующее
|
||
ограничение уникальности `playlist_tracks.filename`.
|
||
|
||
### Атомарность и восстановление
|
||
|
||
ZIP и распаковка находятся в приватном временном каталоге с правами 0700/0600.
|
||
Вся обработка ограничена 30 минутами. До загрузки файлов в БД фиксируются
|
||
ID попытки, исходный сервер, ID пакета, SHA-256 всего ZIP, оригинальный снимок,
|
||
манифест и ключи будущих объектов. Частичные уникальные индексы разрешают одну
|
||
активную попытку на ID пакета и исходный ID игры, включая завершённые импорты.
|
||
|
||
После загрузки всех файлов одна транзакция создаёт сценарий, плейлисты,
|
||
треки, игру, команды, билеты, план, карту и состояние `ready` со связями ID.
|
||
На время публикации вставки/изменения games/teams/tickets блокируются для
|
||
повторной проверки конфликтов и безопасного продвижения последовательностей.
|
||
Последовательности могут оставить пропуски после rollback; исходные ID
|
||
повторно не выдаются. При ошибке ни одна часть новой игры не видна.
|
||
|
||
Heartbeat каждые 30 секунд продлевает аренду на 2 минуты; истёкшая попытка
|
||
не может публиковать данные. Recovery проверяет БД каждые 5 секунд, переводит
|
||
прерванные попытки в `failed` и повторяет очистку их объектов, в том числе после
|
||
перезапуска и позднего завершения старой загрузки. Неоднозначный результат
|
||
commit не разрешает удаление: очистка допустима только для подтверждённого
|
||
`failed`. Ключи успешного импорта никогда не используются для этой очистки.
|
||
Временные каталоги прерванных запросов удаляются после 32 минут и проверки
|
||
отсутствия активной попытки. Ошибка допускает новую попытку с отдельными ключами.
|
||
|
||
### Проверка
|
||
|
||
`TEST_DATABASE_URL` включает проверки с реальными миграциями в отдельных схемах:
|
||
|
||
```sh
|
||
go test ./internal/repos/game_imports_repo ./internal/services/game_import_service ./internal/app -count=1
|
||
go test -race ./internal/repos/game_imports_repo ./internal/services/game_import_service -count=1
|
||
```
|
||
|
||
Проверяются сохранность данных/QR/билетов, локальные ID копий и исходные связи,
|
||
последовательности, повтор с локальными изменениями, изменённый ZIP под прежним
|
||
ID, конфликты до/после загрузки, конкурентный импорт, rollback всех записей,
|
||
ошибки загрузки/очистки, отмена, аренда/timeout и неопределённый ответ commit.
|
||
HTTP-тесты проверяют роли, ошибки, MIME, лимиты и отсутствие внутренних деталей.
|
||
|
||
Для изолированного RustFS дополнительно задать `TEST_S3_ENDPOINT`, `TEST_S3_USER`,
|
||
`TEST_S3_PASSWORD` и запустить `go test ./internal/services/game_import_service
|
||
-run TestImportRustFSHTTPAndRestart -count=1 -v`. Smoke проверяет настоящий HTTP
|
||
handler, файлы в отдельном bucket и повтор импорта. Необязательный
|
||
`TEST_S3_RESTART_MARKER` позволяет внешнему harness перезапустить только тестовый
|
||
RustFS и создать `<marker>.continue` в течение минуты. Bucket, файлы и тестовая
|
||
схема удаляются после проверки. Проверка без WAN проводится в отдельной Docker-сети
|
||
с `internal: true`; рабочая площадка и production для этого не изменяются.
|
||
|
||
Интерфейс передачи остаётся этапом 7; полный билетный и игровой прогон — этапом 8.
|
||
До готовности возврата результатов используются только тестовые игры.
|
||
|
||
## Пакет результатов, версия 2 (ED-031)
|
||
|
||
Это отдельный контракт: ZIP содержит ровно `manifest.json` и `results.json`,
|
||
без сценария и файлов. Серверное применение реализовано этапом 10; API/UI описаны в этапе 11.
|
||
|
||
`game_results_package_service.NewGameResultsPackageService(repo, mode).Prepare(ctx,
|
||
gameID, output)` доступен только в `venue`, для существующей игры из готового
|
||
импорта со статусом `finish` и заполненными датами проведения. Репозиторий
|
||
читает исходный реестр и текущие игровые данные в read-only транзакции
|
||
`REPEATABLE READ`. Подготовка ничего не меняет и не блокирует редактирование.
|
||
Повторный вызов создаёт новый ID и снимок, включая последующее оценивание.
|
||
|
||
Для работы без БД используются `Pack(ctx, manifest, data, output)` и
|
||
`Unpack(ctx, input io.ReaderAt, size)`. `Pack` задаёт версию, новый 128-битный
|
||
случайный ID, UTC-время, размер и SHA-256 `results.json`. При любой ошибке caller
|
||
отбрасывает выходной файл. `Unpack` возвращает только полностью проверенный
|
||
`Bundle`, читает ограниченные JSON в память, не извлекает файлы и не обращается
|
||
к сети/БД. Отмена контекста сохраняется в цепочке ошибки.
|
||
|
||
### manifest.json
|
||
|
||
| Поле | Значение |
|
||
| --- | --- |
|
||
| `version` | `2` |
|
||
| `package_id`, `created_at` | ID именно пакета результатов, время подготовки |
|
||
| `source_server`, `game_id` | Онлайн-источник и исходный ID игры |
|
||
| `source_package_id`, `source_sha256` | ID и SHA-256 всего исходного ZIP игры из реестра импорта |
|
||
| `size`, `sha256` | Размер и SHA-256 байтов `results.json` |
|
||
|
||
SHA-256 защищает от повреждения; это не подпись и не доказательство подлинности.
|
||
Онлайн-применение должно сверять источник и исходный экспорт на этапе 10.
|
||
|
||
### results.json
|
||
|
||
Все массивы обязательны и являются полным снимком; пустой массив — `[]`,
|
||
пропущенный или `null` массив не допускается.
|
||
|
||
| Поле | Содержимое |
|
||
| --- | --- |
|
||
| `source` | Исходные `teams`/`tickets` из сохранённого `game.json`, `scenario_id`, SHA-256 сериализованного исходного `story` в `scenario_sha256`, `materials` и `question_codes` |
|
||
| `game` | `id`, `status=finish`, неизменённый `format`, `started_at`, `ended_at` |
|
||
| `teams` | Итоговые поля команды из контракта игры плюс `source_id` и необязательный `local_creator_id` |
|
||
| `tickets` | Итоговые поля билета из контракта игры плюс `source_id` и необязательный `local_used_by` |
|
||
| `deleted_team_ids`, `deleted_ticket_ids` | Все исходные ID, отсутствующие в итоговом снимке |
|
||
| `actions` | Локальные `id`, `team_id`, `code`, `via_mcp`, `created_at`; порядок по команде, времени и ID сохраняет повторные и неизвестные коды |
|
||
| `applications` | Локальные `id`, `team_id`, исходные `name`/`image`/`file_type`, `status` (`need`/`done`), `created_at` |
|
||
| `answers` | `team_id`, `question_code`, `answer`, nullable `score`, `created_at`, `updated_at` |
|
||
|
||
`source_id` совпадает с `id` для исходной записи. У новой локальной команды
|
||
это пустая строка, у нового билета — `0`. ID команд и ссылки на них — UUID-строки.
|
||
Все ссылки прогресса используют итоговый локальный `team_id`. Этап 10 уточняет
|
||
выделение ID: новые команды сохраняют venue-ID и пароль для напечатанных QR;
|
||
занятый чужой командой ID отклоняет весь возврат. Новым билетам, ходам и уликам
|
||
выделяются онлайн-ID, соответствия сохраняются в закрытом реестре.
|
||
|
||
`creator_id` и `registration_delivery_id` итоговой команды берутся только из
|
||
исходного снимка; для новой команды они отсутствуют. Текущий локальный создатель
|
||
сохраняется отдельно как `local_creator_id`. `used_by` билета восстанавливается
|
||
из исходного снимка только для ранее использованного билета без локального
|
||
исполнителя; текущий локальный исполнитель хранится в `local_used_by`.
|
||
Эти пространства пользователей нельзя автоматически связывать или переносить
|
||
с ними права. Пароли QR и хеши билетов сохраняются; SMTP-задания, токены писем,
|
||
JWT, refresh-токены и локальные аккаунты в пакет не входят.
|
||
|
||
Материал — тройка `name`, `image`, `file_type`; имя очищается штатным cleaner,
|
||
тип при отсутствии определяется штатным file storage. `image` указывает на
|
||
`files/...` исходного пакета игры, а не на адрес/ключ локального RustFS.
|
||
При сборке проверяется идентичность текущего игрового `story` исходному после
|
||
штатного переименования медиа при импорте, а также ID сценария и формат игры.
|
||
Изменённый сценарий и не связанные с ним материалы отклоняются. Музыка, карта,
|
||
экраны и оформление не возвращаются. Баллы не пересчитываются: сохраняются
|
||
выставленные значения, включая `0` и `null`, также в формате `offline`.
|
||
|
||
### Проверка и ограничения
|
||
|
||
Архив и распакованный объём ограничены 1 ГиБ; каждый из двух JSON — 8 МиБ
|
||
(фактический максимальный объём данных меньше общего предела). Проверяются
|
||
точный состав ZIP, обычные нешифрованные записи, отсутствие дублей, версия,
|
||
размеры, CRC и SHA-256, обязательные поля, исходные/локальные идентификаторы,
|
||
полнота удалений, уникальность хешей билетов, ссылки прогресса, материалы,
|
||
вопросы и порядок ходов. Неизвестные поля и лишние JSON-документы отклоняются.
|
||
|
||
Проверки кодека: `go test ./internal/services/game_results_package_service`.
|
||
Полный PostgreSQL/RustFS-flow: `python3 tests/venue/flow.py --results` из корня
|
||
workspace. Стенд проверяет оба формата, свежую оценку и конкурентные снимки,
|
||
сохранность прежнего ZIP, повторную подготовку после перезапуска и отсутствие
|
||
изменений исходных игр. Пакеты и тестовые ресурсы удаляются после прогона.
|
||
|
||
Подготовка/скачивание и возврат через интерфейс — этап 11. До завершения
|
||
интерфейса и контрольного прогона используются только тестовые игры.
|
||
|
||
|
||
## Применение результатов онлайн (ED-029, этап 10)
|
||
|
||
`game_results_package_service.NewGameResultsApplyService(repo, privateStorage,
|
||
mode, sourceServer).Apply(ctx, expectedGameID, input io.Reader, initiatorID)` доступен
|
||
только при `mode="prod"`. Положительный `expectedGameID` и совпадение с manifest
|
||
проверяются до поиска повтора и изменений. Возвращается закрытая запись реестра;
|
||
transport выдаёт только `gameID`, `packageID`, `appliedAt`.
|
||
|
||
Входные байты один раз сохраняются в приватный временный файл и проверяются
|
||
кодеком этапа 9; SHA-256 всего ZIP связывает повтор с этими же байтами.
|
||
Сервис проверяет сервер-источник, игру, ID и SHA-256 исходного экспорта, читает
|
||
его ZIP из закрытого хранилища, проверяет размер, контрольную сумму и контракт.
|
||
`source` результатов сверяется с данными этого ZIP: команды, билеты, сценарий,
|
||
материалы и вопросы нельзя подменить в загруженном пакете. Формат неизменяем.
|
||
Все временные файлы/каталоги имеют права 0600/0700 и удаляются при успешном
|
||
применении, отказе и отмене.
|
||
|
||
Пакет результатов считается источником истины для возвращаемых данных:
|
||
онлайн-правки состава команд/билетов, прогресса, ответов, баллов, формата,
|
||
статуса и фактических дат перезаписываются без сравнения с прежним состоянием.
|
||
Игровой сценарий восстанавливается из сохранённого экспортного снимка с
|
||
исходными онлайн-ссылками материалов. Если текущий сценарий отличается,
|
||
создаётся отдельная копия для возвращаемой игры; сценарии других игр не меняются.
|
||
Копия сохраняет оформление и playlist текущего сценария; неизменённый сценарий
|
||
повторно не копируется. Название, описание, место, плановая дата, музыка, карта
|
||
и экраны игры сохраняются. Отсутствующая игра не восстанавливается.
|
||
|
||
Полные итоговые снимки заменяют команды/участие и билеты с учётом удалений,
|
||
ходы/MCP, материалы и выдачу, ответы и nullable баллы, статус `finish` и
|
||
фактические даты. Баллы не пересчитываются. Удаление команды очищает её связи
|
||
карты. Исходные команды/билеты сохраняют ID; новые команды сохраняют также
|
||
venue-пароль. Новые билеты получают онлайн-ID и прежний хеш токена. Коллизия
|
||
ID или хеша с чужой записью отклоняет весь пакет. Ходы и улики получают свежие
|
||
онлайн-ID в порядке пакета, включая исправленный снимок. Реестр хранит
|
||
соответствия каждого возврата; соответствия новых билетов сохраняются между
|
||
исправлениями. Команды сохраняют UUID v7 без последовательности.
|
||
Последовательности билетов, ходов и материалов продвигаются монотонным `nextval`, без `setval`
|
||
и глобальных блокировок обычных операций.
|
||
|
||
Материалы переводятся из portable-троек исходного ZIP в исходные онлайн-ссылки
|
||
через сохранённый экспортный снимок. Новых загрузок файлов и скачивания внешних
|
||
URL нет. Существующие онлайн-ссылки создателей и исполнителей и технические
|
||
почтовые поля сохраняются; их изменение не считается изменением возвращаемых
|
||
данных. У новых команд `creator_id=NULL`, у новых локально использованных
|
||
билетов `used_by=NULL`; отсутствующий онлайн-аккаунт не восстанавливается.
|
||
Локальные исполнители остаются только в закрытом реестре. Новые почтовые
|
||
задания и токены не создаются; возвращённые игры исключены из очереди новых
|
||
напоминаний об отзыве.
|
||
|
||
`game_returns` хранит ID/SHA-256 ZIP результатов, исходный экспорт, исходный ID
|
||
игры, время подготовки/применения, соответствия ID (включая сценарий),
|
||
локальных исполнителей и инициатора. Записи остаются после
|
||
удаления игры. Тот же ID с теми же байтами возвращает первоначальную запись без
|
||
изменений, даже после более нового пакета или удаления игры. Тот же ID с другими
|
||
байтами — конфликт. Новый пакет требует `created_at` строго позже последнего
|
||
применённого; время используется без поправок часов. UTC-время подготовки
|
||
сохраняется строкой с фиксированными девятью знаками долей секунды, чтобы
|
||
PostgreSQL не терял наносекунды при сравнении и повторе.
|
||
|
||
Чтение текущих ссылок пользователей, запись данных и реестра выполняются
|
||
одной `SERIALIZABLE`
|
||
транзакцией с блокировками игры, экспорта, сценария и затронутых записей.
|
||
Serialization failure/deadlock повторяются не более трёх раз, затем возвращается
|
||
безопасная повторяемая ошибка. Проверки ZIP/хранилища проходят до транзакции.
|
||
`game.export` исчезает в чтении игры и списка после успешного возврата именно
|
||
соответствующего экспорта; исходный экспорт остаётся `ready`, ZIP сохраняется
|
||
для следующих проверок. Более старый экспорт вместо скрытого не показывается.
|
||
|
||
Проверки: `go test ./...` с отдельным `TEST_DATABASE_URL`, релевантный `go test
|
||
-race`, `python3 tests/venue/flow.py --apply`. Стенд проверяет оба формата,
|
||
настоящие venue-ZIP, prod PostgreSQL/private RustFS, перезапись онлайн-редактирования,
|
||
исправление баллов, старый повтор после нового и перезапуска prod, прежние и
|
||
новые ссылки, сохранение оформления и исчезновение офлайн-отметки.
|
||
|
||
Ограничения: ZIP не имеет криптографического подтверждения подлинности.
|
||
Sequence-значения PostgreSQL не откатываются: большие разрывы venue-ID расходуют
|
||
их даже при отказе. Продвижение ограничено пятью секундами, затем данные
|
||
откатываются и возвращается повторяемая ошибка; INT32-потолок отклоняется.
|
||
Перед поддержкой площадок с такими разрывами требуется расширить типы ID.
|
||
ZIP на 1 ГиБ, 100 клиентов и Wi-Fi этим этапом не проверяются. До завершения
|
||
этапа 11 и контрольного прогона используются только тестовые игры.
|
||
|
||
|
||
## Интерфейс возврата (ED-029, этап 11)
|
||
|
||
Организатор, администратор и разработчик используют два endpoint-а с Bearer JWT:
|
||
|
||
- `GET /api/games/{ID}/results-package/download` — только venue, завершённая
|
||
импортированная игра. Каждый запрос готовит свежий ZIP в приватном временном
|
||
файле, целиком проверяет подготовку и затем выдаёт `application/zip` с
|
||
`Content-Disposition`, `Content-Length`, `Cache-Control: no-store`. Очереди
|
||
и сохранённого пакета нет; файл удаляется при успехе, ошибке и отмене.
|
||
- `POST /api/games/{ID}/results-package/apply` — только prod, сырое тело
|
||
`application/zip` до 1 ГиБ, в том числе без `Content-Length`. Сервис один раз
|
||
сохраняет ограниченный поток с SHA-256 в файл 0600 и проверяет ZIP. Ответ:
|
||
`{ "gameID": 1, "packageID": "…", "appliedAt": "…" }`; закрытый реестр
|
||
не раскрывается. Повтор выдаёт исходное время применения.
|
||
|
||
Неверный пакет/игра — HTTP 400, превышение размера — 413, конфликт — 409,
|
||
временный отказ — 503. Прочие ошибки и авторизация следуют правилам приложения;
|
||
внутренние причины логируются на транспортной границе. Protobuf/OpenAPI описывают
|
||
операции; HTTP использует отдельные бинарные handlers, gRPC ограничен текущим
|
||
размером сообщений. Таймаут бинарных HTTP-операций — 30 минут.
|
||
|
||
В настройках завершённой venue-игры есть «Скачать результаты». В настройках
|
||
prod-игры блок «Возврат результатов с площадки» доступен и после исчезновения
|
||
офлайн-отметки. Форма проверяет один непустой `.zip` до 1 ГиБ и предупреждает
|
||
о замене команд, билетов, результатов и сценария, потере онлайн-правок и
|
||
сохранении оформления/музыки. Бинарный клиент обновляет авторизацию. Во время
|
||
запроса повтор заблокирован; любой принятый ZIP получает сообщение «Результаты
|
||
приняты». После успеха страница пересоздаётся и перечитывает данные; старые
|
||
ответы, формы, модальные окна и черновики редакторов удаляются.
|
||
|
||
Конфигурации nginx и reproxy допускают ZIP 1 ГиБ и долгие операции; работающие
|
||
серверы не обновляются. У reproxy лимит/таймауты общие для его маршрутов.
|
||
Контрольный тест: `python3 tests/venue/flow.py --apply --browser` — оба формата,
|
||
реальные PostgreSQL/RustFS, API и Firefox, свежий ZIP кнопкой, применение через
|
||
страницу prod, исправленный балл, перезапись онлайн-ответа, исходные ссылки и
|
||
билеты, сохранение оформления, снятие офлайн-отметки, повтор после перезапуска.
|
||
ZIP остаётся неподписанным; нагрузка, Wi-Fi и фактический ZIP 1 ГиБ не проверяются.
|
||
|
||
## Переход на UUID v7 (ED-031)
|
||
|
||
Все способы создания команды генерируют UUID v7 в Go. API передаёт ID строкой,
|
||
PostgreSQL хранит `uuid`. Несколько независимых площадок создают команды без
|
||
общего счётчика; перенос игры и возврат результатов сохраняют UUID и ссылки.
|
||
|
||
Перед обновлением завершите игры на площадках и верните результаты, остановите
|
||
запись во всех окружениях и сохраните резервные копии БД. Обновите backend и
|
||
frontend согласованно во всех окружениях и примените миграции. Миграция заменяет
|
||
числовые ID существующих команд на UUID v7, сохраняя пароли, игровые данные,
|
||
связи и карту. Старые числовые ссылки и QR нужно сформировать заново.
|
||
|
||
Пакеты игры и результатов версии 1 больше не принимаются. Миграция очищает
|
||
старые реестры экспорта, импорта и возврата; игровые данные и материалы остаются.
|
||
Старые ZIP-объекты в хранилище остаются, но недоступны через очищенный реестр.
|
||
После обновления создайте новые экспорты версии 2. Откат требует восстановления
|
||
резервной копии БД и предыдущей версии приложения; обратного преобразования ID нет.
|