Модуль 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-модель в вызовы публичных контрактов доменных модулей и не
владеет доменными таблицами.
На текущем этапе разрешены три зависимости:
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, машинные коды
ошибок из технической постановки.