Перейти к содержанию

Модуль storefront

Статус

Создан Spring Modulith-модуль. Реализован вход и регистрация покупателя по коду: POST /api/v1/auth/code, /login, /refresh, /logout, а также защищённые bearer-токеном GET/PATCH /api/v1/users/me, GET /api/v1/users/me/name-suggestions и GET/POST/PATCH/DELETE /api/v1/users/me/addresses. POST /api/v1/auth/telegram/webhook и POST /api/v1/auth/max/webhook принимают вебхуки Telegram- и MAX-бота (привязка контакта, см. ниже). Каталожные endpoint (/api/v1/menu и т.д.) не реализованы.

Назначение и границы

Модуль storefront — HTTP/application-граница витрины сайта и мобильного приложения. Он принимает запросы /api/v1/**, преобразует HTTP-модель в вызовы публичных контрактов доменных модулей и не владеет доменными таблицами.

На текущем этапе разрешены три зависимости:

storefront ──▶ customer::api
storefront ──▶ dadata::api
storefront ──▶ notifications::api

dadata::api нужна только для подсказок имени (NameSuggestions, см. «Профиль» ниже).

notifications::api нужна только вебхукам Telegram-/MAX-бота — они шлют ответные сообщения (TelegramSender/MaxSender) и проверяют секрет (TelegramWebhookAuthentication/ MaxWebhookAuthentication) напрямую, в обход customer (привязка контакта, наоборот, идёт только через customer::api, см. ниже). Прямой доступ к внутренним классам и таблицам customer/notifications запрещён. Зависимость на catalog::api будет добавлена вместе со сценариями каталога.

Модуль расположен в Gradle-модуле web, как admin и franchiseoffice — он владеет Spring MVC и HTTP security chain, а доменные модули остаются в библиотеке app и не зависят от MVC.

OpenAPI этого HTTP-контура публикуется отдельно на /v3/api-docs/storefront (группа storefront в Swagger UI) и содержит только маршруты /api/v1/**; вебхуки Telegram-/MAX-бота скрыты через @Hidden. Bearer-схема покупателя объявлена как customerBearer. Локальный снимок для генерации клиента создаётся командой task openapi:export-storefront в web/build/openapi/storefront.json.

Вход по коду

StorefrontLoginService — тонкий адаптер customer::api к HTTP, по образцу AdminLoginService:

  • POST /api/v1/auth/code {phone, channel} → CustomerAuthChallenges.request;
  • POST /api/v1/auth/login {phone, code, device} → CustomerAuthChallenges.verify, затем CustomerSessions.create и CustomerAccessTokens.issue; вход и регистрация — один сценарий, ответ не отличает их отдельным кодом ошибки;
  • POST /api/v1/auth/refresh (refresh — из cookie) → CustomerSessions.rotate + CustomerAccessTokens.issue;
  • POST /api/v1/auth/logout → CustomerSessions.revoke, идемпотентен без cookie.

Отображаемое имя устройства формирует backend (buildDeviceName) по заголовку User-Agent — минимальная эвристика по браузеру и ОС, а не полноценный UA-парсер; для неизвестного значения или мобильных типов используется имя по умолчанию. Клиент не может переименовать устройство.

Refresh-токен передаётся только в cookie panam_customer_refresh: HttpOnly, Secure (настраивается через panam.storefront.auth.refresh-cookie-secure), SameSite=Lax, Path=/api/v1/auth. SameSite=Lax, а не Strict, как у admin/franchiseoffice: витрина — публичная точка входа, на которую возможны переходы с внешних ссылок и из Telegram.

Ошибки — RFC 7807 ProblemDetail: CustomerAuthenticationFailedException → 401, CustomerPhoneBlockedException → 403, CustomerChannelUnavailableException → 422 (контакт не привязан в выбранном канале), CustomerCodeDeliveryFailedException → 503 (провайдер не доставил), CustomerAuthChallengeRateLimitedException → 429 с Retry-After, неверный refresh → 401 с очищающей cookie. Троттлинг по IP на /code//login//refresh использует общий LoginRateLimiter (audience storefront) — тот же компонент, что уже применяют admin и franchiseoffice. Машинный код ошибки (INVALID_CODE, ACCOUNT_BLOCKED и т.д.) из технической постановки в тело ответа пока не добавлен — по этому слайсу title/detail/status соответствуют формату, уже принятому в admin/franchiseoffice.

Профиль

StorefrontBearerAuthenticationFilter — bearer-фильтр по образцу AdminBearerAuthenticationFilter: разбирает заголовок Authorization: Bearer <token>, проверяет его через CustomerAccessTokens.verify и кладёт в SecurityContext StorefrontSecurityPrincipal (CustomerIdentity + sessionId). Отсутствие заголовка пропускает запрос дальше неаутентифицированным; невалидный или просроченный token уходит в StorefrontAuthenticationEntryPoint → 401 application/problem+json. Фильтр регистрируется только на цепочку storefront (/api/v1/auth/**, /api/v1/users/**), поэтому /api/v1/auth/{code,login,refresh,logout} по-прежнему permitAll, а /api/v1/users/** требует аутентификацию.

GET /api/v1/users/me возвращает профиль (id, phone, name, birthDate) прямо из принципала — без похода в БД, идентичность уже перепроверена внутри verify. Тот же объект user возвращают /login и /refresh.

PATCH /api/v1/users/me {name?, birthDate?} — частичное обновление, замена legacy POST /client/update: отсутствующее или null поле не меняется, пустой объект просто возвращает профиль. birthDate передаётся в ISO-формате yyyy-MM-dd (legacy принимал d.m.Y). Запрос вызывает CustomerProfiles.update; невалидное имя, дата рождения вне диапазона, попытка сменить уже заданную дату или неизвестный customerId отдают 400 через CustomerProfileRejectedException.

GET /api/v1/users/me/name-suggestions?query=Ив → {"suggestions": ["Иван", ...]} — StorefrontNameSuggestionController, тонкий адаптер dadata::api.NameSuggestions (см. модуль dadata). Это замена legacy POST /names, но с двумя отличиями:

  • endpoint требует bearer-токен покупателя (legacy был публичным), чтобы квоту DaData нельзя было расходовать анонимно. Имя заполняется уже после входа, а старый фронтенд и так отправлял токен;
  • сбой или отключённый ключ DaData дают пустой список, а не 500.

query обрезается по пробелам. Пустой запрос даёт пустой список, запрос длиннее 100 символов (та же граница, что у legacy NameRequest) — 400 через StorefrontNameSuggestionQueryRejectedException. Аутентифицированный запрос из HTTP-теста не собрать (см. StorefrontBearerAuthenticationFilterTests), поэтому контроллер проверяется юнит-тестом StorefrontNameSuggestionControllerTests, а через HTTP — только отказ без токена.

Побочный эффект: как только на цепочке storefront появился authenticationEntryPoint, Spring Security стал использовать его не только для /users/**, но и для любого anyRequest().denyAll() в рамках этой же цепочки при анонимном запросе (ExceptionTranslationFilter отправляет AccessDeniedException анонимного principal в entry point, а не в стандартный 403-обработчик). Поэтому, например, запрос на несуществующий путь внутри /api/v1/auth/** теперь тоже отвечает 401 "Authentication required", а не generic 403 — это учтено в тестах.

Адреса

StorefrontAddressController — тонкий адаптер CustomerAddresses под /api/v1/users/me/addresses, защищён тем же bearer-фильтром, что и /users/me:

  • GET → CustomerAddresses.list;
  • POST (201) → CustomerAddresses.add;
  • PATCH /{addressId} {comment} → CustomerAddresses.updateComment — меняет только комментарий (null или пустая строка удаляют его). PUT намеренно нет: адрес неизменяем, другой адрес — это DELETE старого и POST нового;
  • DELETE /{addressId} (204) → CustomerAddresses.remove.

CustomerAddressRejectedException → 400, CustomerAddressNotFoundException → 404 (в том числе для чужого адреса — контроллер не различает «не найден» и «принадлежит другому покупателю»).

Боты: привязка контакта

StorefrontTelegramWebhookController (POST /api/v1/auth/telegram/webhook) и StorefrontMaxWebhookController (POST /api/v1/auth/max/webhook) — endpoint'ы без bearer-аутентификации и без LoginRateLimiter (боты сами не ретраят агрессивно, а секрет уже отсекает посторонних), permitAll в цепочке storefront. Разбор без Spring Security: заголовок (X-Telegram-Bot-Api-Secret-Token / X-Max-Bot-Api-Secret) проверяется вручную через TelegramWebhookAuthentication.verify/MaxWebhookAuthentication.verify; неверный или отсутствующий секрет — 403 без тела.

Оба контроллера обрабатывают одну и ту же пару случаев апдейта, каждый в формате своего Bot API:

  • сообщение с контактом (у Telegram — поле contact.phone_number; у MAX — VCF-блок attachment.payload.vcf_info, парсится строка TEL:...) → CustomerTelegramContacts/CustomerMaxContacts.link(phone, chatId, username), затем подтверждение через send;
  • старт диалога без контакта (Telegram: text == "/start"; MAX: update_type == "bot_started") → requestContact — сообщение с кнопкой «Поделиться номером телефона».

Всё остальное (редактирование сообщений, другие команды и т.д.) игнорируется, отвечает 200 без действия — боты не должны получать ошибку и ретраить.

Получение кода боты не генерируют: код по-прежнему выпускает только POST /api/v1/auth/code, боты — исключительно привязка контакта и канал доставки (см. модуль customer, раздел «Доставка кода», и модуль notifications).

Проверка модульности

Модуль входит в WebModularityTests.

Следующий этап

Список сессий и отзыв одного устройства, logout-all, вход через Telegram WebApp, машинные коды ошибок из технической постановки.