ADR 001 Tour Model - PlutoGr/TravelAgency GitHub Wiki
Статус: принято, реализовано в #36, #37 и #38 (PR #58, main 591be7d). Эпик #43. Автор: Вирт (тимлид и архитектор).
-
Statusпринимает значенияDraft,PublishedиUnpublished. ЗначениеPendingReviewзарезервировано под премодерацию и на этапе 1 не используется. -
Sourceпринимает значенияManagerиAutomation, на этапе 1 всегдаManager.OwnerIdуказывает на менеджера и может быть пустым только у туров из автоматизации. - Удалить можно только
Draft, на остальные статусыDELETEотвечает 409. Премодерации нет. - Опубликованный тур всегда проходит проверку публикации. Правка, которая её ломает, получает 422, и тур сам с публикации не снимается.
- В
Tourдобавляются поляShortDescription(до 300 символов),Description,DepartureCity,MealPlan(RO, BB, HB, FB, AI или UAI),AccommodationTextиPublishedAt. - Конкурентные правки ловим через столбец
Tours.Version(bigint, concurrency token в EF). Каждое изменение тура, включая шаги мастера, которые меняют только дочерние таблицы, увеличивает его на 1 (Touch). Версия уходит клиенту какETag, проверяется поIf-Match(W/и кавычки допускаются), при расхождении приходит 409. Если версия сменилась между чтением и сохранением, EF ловит это условиемWHERE Version = <старая>вUPDATE, и ответ тоже 409. ЕслиIf-Matchнет, приходит 428 (на всехPUTшагов,publish,unpublishиDELETE).POSTчерновикаIf-Matchне требует. -
TourPriceпереименовывается вTourOffer(Предложение из Product-Vision: тур на конкретные даты с ценой; опции добавятся на этапе 2). ИмяProposalзанято сущностью Booking. Переименование делается черезRenameTableиRenameColumn, безDropTable. - Новые дочерние таблицы:
-
TourDay: программа по дням; -
TourInclusion: пункты «включено» и «не включено»; -
TourImage:MediaFileId, порядок,IsCover, alt. Обложка одна, это обеспечивает уникальный индекс.
-
- Задел на этап 2 (составные туры): таблица
TourComponentsи необязательныйComponentIdу дней и включений. В ответе страницы тура полеcomponents: [].
Коды лежат в Catalog.Domain (TourPublishRequirementCodes), это единственный источник: title, shortDescription, description, program.dayCount (дней ровно DurationDays), inclusions.included (не меньше одного), mealPlan, accommodation, offers.future (хотя бы одно предложение с будущей датой), images.cover, images.minCount (не меньше 3).
Ответ 422 в формате ProblemDetails: type: "https://travelagency/errors/tour-not-publishable", коды в расширении missing. На фронте один файл «код → текст» и тест vitest на полноту.
Публичное (только Published):
-
GET /api/v1/catalog/tours: фильтры, сортировка, цена «от», ближайшая дата, до 5 превью; -
GET /api/v1/catalog/tours/{id}: дляDraftиUnpublishedотвечает 404; -
GET /api/v1/catalog/tours/cards?ids=(до 50 id): дляPublishedполная карточка, дляUnpublishedминимальная (id,title, обложка,available: false), черновики и несуществующие id не попадают в ответ.
Управление, /api/v1/catalog/manage/tours (роли менеджер и админ):
- список
?status&source,GET {id}с чек-листом готовности,POST(черновик); -
PUT {id}/basics|description|program|conditions|prices|images(шаги мастера 3 и 4 сохраняются однимconditions); -
POST {id}/publish,POST {id}/unpublish,DELETE {id}. Менеджер видит только свои туры и на чужой получает 403. Админ видит все туры и может снять с публикации любой. Ошибки 403, 409, 422 и 428 приходят в формате ProblemDetails черезCatalogExceptionMapper.
Gateway:
-
catalog-manage-read-routeпускает к/catalog/manage/**только менеджеров и админов и проверяется раньше публичного GET-маршрута, гость получает 401; -
media-files-public-routeанонимный, только GET; -
media/manage/filesзакрыт ролями менеджера и админа. СтарыеPOST,PUT,PATCHиDELETEна/catalog/toursудаляются в одном PR с задачей 5.
- На
PUT imagesс id файла, которого нет у владельца тура, ответ 422. Ширина обложки наpublishберётся из Media (width), кодimages.coverMinWidth, ширина 0 не проходит. - Загрузка
POST /api/v1/media/upload?purpose=tour-image: jpeg, png и webp, до 10 МБ. Превью w200, w800 и w1600. - У файла есть флаг
IsPublic, по умолчаниюfalse.media.proto:MarkMediaFilesPublic(ids)работает только в одну сторону. Catalog вызывает его приpublishи приPUT imagesу опубликованного тура, сначала вызов и только при успехе сохранение статуса. Если Media недоступен, приходит 503, статус не меняется, повтор идемпотентен. - Публичная отдача
GET /api/v1/media/files/{id}/{w200|w800|w1600}: потоком из хранилища,Cache-Control: public, max-age=31536000, immutable. Для непубличного файла 404. После снятия тура с публикации фото остаётся публичным, поэтому избранное не ломается. -
GET /api/v1/media/manage/files/{id}/{size}доступен владельцу файла и админу,Cache-Control: private, no-store. - Ссылки
http://minio:9000больше не используются. Bucket закрыт, замена MinIO (#11) затрагивает толькоIStorageService. -
media.protoGetMediaFiles(ids)работает по сервисному токенуGRPC_INTERNAL_SERVICE_TOKEN, наружу через Gateway не открывается. REST Media слушает на 8080, gRPC на 8081 (HTTP/2, #50). - nginx не меняется:
location /api/сclient_max_body_size 12m. На 413 фронт показывает понятный текст.
-
GetTourSnapshotотдаётfound = trueтолько дляPublished. Его используют создание заявки и избранное. - Новый
GetTourSnapshotForExistingBookingотдаёт снимок дляPublishedиUnpublished, а дляDraftотвечаетfound = false. Его вызывает толькоCreateProposalCommandHandler. - В
TourSnapshotResponseдобавляетсяstring status = 9. Поля цены в снимке не переименовываются. - На недоступный тур Booking отвечает 422
tour-unavailableв формате ProblemDetails.
- Тур с
IsActive=trueполучает статусPublished, сfalseстатусUnpublished. Демо-туры получают владельца [email protected] и источникManager.ImageUrlудаляется позже, отдельной миграцией. - Перед мержем задачи 1 DevOps прогоняет
efbundle(linux-x64, self-contained, строка подключения через--connection) на дампе во временном контейнере и сверяет агрегаты поtour-offer-migration-check.sqlдо и после. - Сидер запускается по
Seeding:DemoCatalogилиASPNETCORE_SEED_DATAв Catalog и Media, поэтому на сервере ничего менять не нужно. Он идемпотентно дозаполняет 5 демо-туров, публикует их через доменныйPublish()и кладёт демо-фото с фиксированными id. Фото со свободной лицензией, WebP до 400 КБ, источники записаны вREADME. - В compose добавляются токен для media-service и
GrpcClients__MediaServiceUrlдля catalog-service, это делается в PR задачи 3. - Ограничение сидеров (#70). Identity находит демо-аккаунты по email и сохраняет уже существующий id. Catalog и Media ставят владельцем константу
DemoSeedIds.ManagerId. На чистой базе это один и тот же id, а на базе, созданной до фиксированных id, ссылки расходятся: тур и фото принадлежат пользователю, которого нет. Код не меняем, потому что прод не сидируется. На старой dev-базе это исправляется разовой правкойOwnerIdвToursиMediaFiles, так сделали 2026-10-08. - Учебный кейс. У сервисов нет общей базы, они ссылаются на пользователя только по id. Поэтому все сидеры должны брать id из одного источника. Тесты на чистой базе такое расхождение не ловят, его видно только на базе с историей.
- Лимиты фото: до 20 на тур, до 10 МБ на файл, обложка шириной от 1280 px.
- Проживание на этапе 1 хранится текстом, отель как сущность на этапе 2.
- Публикация без премодерации.
Сначала версию собирались брать из системного столбца Postgres xmin. Но xmin меняется только когда переписывается сама строка Tours. Если шаг мастера меняет одни фото или дни программы, строка тура остаётся прежней, xmin не растёт, и устаревший If-Match прошёл бы проверку. Явный Version увеличивает доменный метод Touch при любом изменении агрегата, поэтому ETag описывает тур целиком. Миграция AddTourVersion добавила столбец со значением 0, новые черновики начинаются с 1.