# Пакет игры, версия 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/.` | Медиа с именем по 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/?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 ` и одну из ролей `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 `-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 и создать `.continue` в течение трёх минут. Это не production-проверка. ## Локальный импорт: этап 6 ### API `POST /api/games/import` принимает ZIP непосредственно в теле запроса с `Content-Type: application/zip` и `Authorization: Bearer `. 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 и создать `.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 нет.