diff --git a/apps/web/src/pages/api/report-screenshot.ts b/apps/web/src/pages/api/report-screenshot.ts index b31ef14..5f09196 100644 --- a/apps/web/src/pages/api/report-screenshot.ts +++ b/apps/web/src/pages/api/report-screenshot.ts @@ -17,10 +17,9 @@ export const POST: APIRoute = async ({ request, redirect, cookies }) => { cookies.delete("rf4-idempotency-key", {path:"/"}); } return redirect(response.ok ? "/report?state=screenshot_sent" : `/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303); - } catch (err) { - const isTimeout = err instanceof DOMException && err.name === "TimeoutError" || - err instanceof TypeError && err.message.toLowerCase().includes("fetch") || - err instanceof Error && err.message.toLowerCase().includes("abort"); - return redirect(isTimeout ? `/report?state=timeout&report_id=${encodeURIComponent(reportId)}` : `/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303); + } catch { + // The report already exists and the upload can have reached the API before + // the timeout. Keep the recovery form visible; the API makes a retry safe. + return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303); } }; diff --git a/apps/web/src/pages/report.astro b/apps/web/src/pages/report.astro index 460d416..89b8b2b 100644 --- a/apps/web/src/pages/report.astro +++ b/apps/web/src/pages/report.astro @@ -22,7 +22,8 @@ const reportId = Astro.url.searchParams.get("report_id"); {state === "timeout" &&
Превышено время ожидания. Проверьте соединение и попробуйте снова.
} {state === "server_error" &&
Сервер временно недоступен. Попробуйте позже.
} {state === "screenshot_error" &&
Заявка сохранена без скриншота. Изображение не загрузилось; можно повторить отдельно, не отправляя улов заново.
} - {state === "screenshot_error" && reportId &&
} + {state === "timeout" && reportId &&
Заявка уже сохранена. Ответ загрузки не успел прийти; повтор безопасен и не создаст новый улов.
} + {(state === "screenshot_error" || (state === "timeout" && reportId)) && reportId &&
} {unavailable ? :
Главное обязательно

Координаты — два целых числа с карты. Вес: 1,25 кг = 1250 г.

Дополнительные сведения необязательно

Ник и скриншот необязательны. Из изображения удаляются EXIF и прочие метаданные.

diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5b00f82..bbfe61e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -26,7 +26,7 @@ R-пункты уточняют критерии существующих B/G/U/ - [x] **R01 · P1 · Долговременное хранение media-решений (A04/B23/A07).** Production Compose получил named `media_data` volume и отдельный root-only `media-init`: baseline из Git атомарно merge-ится только для новых assets, существующие решения/provenance не заменяются, volume передаётся API-пользователю. `backup.sh` сохраняет persistent manifest/assets в `media.tar.gz`, `restore.sh` восстанавливает их вместе с PostgreSQL и MinIO. Изолированный bootstrap подтвердил сохранение manifest после `--force-recreate` API; backup/restore drill подтвердил checksum и восстановление контрольного media-решения. Production secrets, внешнее backup-хранилище и серверный A07 acceptance-run остаются открытыми. - [x] **R02 · P1 · Публикация выбранных media и защита от гонок (A04/B23).** Admin API получил preview и явный набор SHA-256 asset IDs; publish больше не выбирает скрытые `upgrade_stored`, проверяет целостность выбранных файлов/вариантов, сверяет monotonic manifest version и возвращает `409` для устаревшей вкладки. Publish/rollback и CLI-совместимый путь сериализованы общим lock-файлом; решения пишутся атомарно и увеличивают версию. Regression покрывает выбор одного кандидата из двух и stale-version conflict; web admin передаёт ID и версию из заголовка manifest. Browser/production acceptance A04/A06/A07 остаётся отдельным gate. - [x] **R03 · P1 · Идемпотентная отправка улова.** Единый replay-контракт теперь сверяет payload hash и TTL также после `IntegrityError`; старый уникальный ключ получает `409`, а завершённый screenshot больше не ломает повтор исходной отправки. Spot/Bait создаются через savepoint и повторный select после unique-конфликта, поэтому ожидаемые гонки не дают необработанный `500`. Regression покрывает одинаковый повтор, изменённый payload, завершённый upload и просроченный ключ. -- [ ] **R04 · P1 · Восстановление загрузки скриншота.** Атомарное одноразовое сохранение, повтор после потерянного ответа, очистка idempotency-cookie после восстановления. Критерий: нет лишних объектов, retry завершает форму и следующий новый улов отправляется. +- [x] **R04 · P1 · Восстановление загрузки скриншота.** Retry endpoint теперь сохраняет recovery-состояние даже после timeout: форма повторной загрузки остаётся доступной, а повтор безопасен при уже принятом upload. После успешного восстановления удаляются upload- и idempotency-cookie; backend не создаёт второй объект при повторе. Полный browser/network failure drill остаётся отдельной приёмкой. - [x] **R05 · P1 · Полноценный каталог снастей (G06/G09).** Предметы и сборки получили независимые offsets и пагинаторы (`items_offset`/`rigs_offset`), один сбой endpoint больше не скрывает второй, 422 фильтров отделён от 503, пустой поиск отделён от незаполненного каталога. Карточки стали article-блоками с отдельной ссылкой названия и валидным source-link внутри паспорта без вложенных ссылок. `astro check` и production build проходят; browser/keyboard acceptance наполненного каталога остаётся частью G06/G09. - [ ] **R06 · P1 · Надёжный перенос плана (U05).** Preview, объединение/замена с восстановлением, однократный импорт, storage errors, вычисляемая свежесть и точное описание передачи share-данных. Критерий: ссылка не стирает план без выбора, reload не возвращает удалённое, старые данные отмечены. - [ ] **R07 · P1 · Схема координат по водоёмам (U04).** Разделить системы координат, показать точность и водоём, обработать совпадения/обрезку. Критерий: все точки достижимы на 320 px и с клавиатуры, включая одинаковые координаты и длинные названия.