generated from VLADIMIR/template
249 lines
22 KiB
Markdown
249 lines
22 KiB
Markdown
# Пакет игры, версия 1 — ED-029, этап 4
|
||
|
||
Пакет — самостоятельный ZIP для одной игры до старта. Сборка получает готовый
|
||
снимок данных и файлы; чтение не обращается к сети, БД или хранилищу приложения.
|
||
Реализация: `internal/services/game_package_service`.
|
||
|
||
## Контракт
|
||
|
||
| Запись | Содержание |
|
||
| --- | --- |
|
||
| `manifest.json` | `version=1`, случайный `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-записи должны быть заявлены и использованы
|
||
ссылками из данных игры. Вложенные архивы и каталоги не используются.
|
||
|
||
Полный перечень 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/...`; входные данные не меняются.
|
||
|
||
Относительное имя и полный 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 защищает от повреждения, но не подтверждает автора пакета. Подпись и
|
||
шифрование не реализованы. Кодек не блокирует игру и не делает импорт; повторные
|
||
импорты, сохранность QR на площадке и полный перенос проверяются на этапе 6.
|
||
|
||
Проверка: `go test ./internal/services/game_package_service -count=1` и полный
|
||
`go test ./...`. Тесты покрывают сохранение данных, автономное чтение, загрузку
|
||
медиа, дедупликацию, потоковую обработку, отмену, очистку, лимиты и повреждённые пакеты.
|
||
|
||
## Экспорт со статусом офлайн-режима: этап 5
|
||
|
||
Только для тестовых игр до реализации возврата результатов. Импорт, интерфейс
|
||
передачи, отмена и возврат результатов реализуются следующими этапами.
|
||
|
||
### 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-проверка.
|