Модуль customer¶
Статус¶
Создан каркас Spring Modulith-модуля, публичная граница customer::api. Реализованы вход и
регистрация покупателя по телефону и одноразовому коду (CustomerAuthChallenges), серверные
refresh-сессии с несколькими устройствами (CustomerSessions), access JWT (CustomerAccessTokens),
обновление профиля — имя и дата рождения (CustomerProfiles), сохранённые адреса (CustomerAddresses), блокировка
номера до регистрации (customer.blocked_phone) и одноразовый перенос покупателей/адресов/блокировок
из legacy MySQL (customer::migration, см. ниже). HTTP-граница storefront подключает
/api/v1/auth/code, /login, /refresh, /logout, защищённые bearer-токеном /api/v1/users/me
(GET/PATCH) и /api/v1/users/me/addresses (GET/POST/PATCH/DELETE) — см. модуль
storefront. Доставка кода по SMS, Telegram и MAX подключена реально, через
notifications::api (см. модуль notifications); для Telegram и MAX нужна
разовая привязка телефона к чату бота (customer.telegram_contact/customer.max_contact,
CustomerTelegramContacts/CustomerMaxContacts, вебхуки StorefrontTelegramWebhookController/
StorefrontMaxWebhookController в storefront) — без неё канал возвращает
CustomerChannelUnavailableException (422 CHANNEL_UNAVAILABLE).
Список сессий с отзывом одного устройства, logout-all, блокировка уже существующего покупателя через административный сценарий,
вход через Telegram WebApp и перенос legacy-токенов не реализованы.
Назначение¶
Модуль customer отвечает за идентификацию и сессии покупателей сети.
Модуль владеет:
- учётной записью покупателя
CustomerEntity(телефон, имя, дата рождения, статус, версия сессий); - challenge одноразового кода (запрос, проверка, троттлинг повторной отправки, попытки);
- refresh-сессиями по устройствам и их ротацией;
- выпуском и проверкой access JWT покупателя;
- редактируемыми полями профиля покупателя — имя и дата рождения (
CustomerProfiles.update); - сохранёнными адресами покупателя (
CustomerAddresses); - блокировкой номера телефона независимо от того, есть ли уже аккаунт (
customer.blocked_phone); - привязкой телефона к чату Telegram- и MAX-бота (
customer.telegram_contact/customer.max_contact,CustomerTelegramContacts/CustomerMaxContacts) — ключ телефон, как уblocked_phone: контакт может появиться до регистрации аккаунта, а сам факт шаринга контакта в боте не является входом; - одноразовым переносом покупателей/адресов/блокировок из legacy MySQL (
customer::migration).
Модуль не отвечает за:
- HTTP-интерфейс — им владеет
storefront(включая вебхуки Telegram- и MAX-бота); - сами провайдеры доставки (smsc.ru, Telegram Bot API, MAX Bot API) — ими владеет
notifications;customerрезолвит канал в конкретного получателя (телефон для SMS,chat_idизtelegram_contact/max_contactдля Telegram/MAX) и вызываетnotifications::api; - учётные записи и доступ сотрудников ГК/ФЧ — ими владеет
iam; - справочник городов, зоны доставки, привязку адреса к ресторану — этим будет владеть
restaurant/catalog, когда появится геомодель (см. «Сохранённые адреса» ниже); - заказы, программу лояльности, согласие на рекламу и настройки уведомлений, блокировку уже существующего покупателя через отдельный административный API (пока есть только блокировка по номеру).
Сотрудники и покупатели — независимые контуры. Совпадение телефона не объединяет CustomerAccount
и iam.UserAccount.
Границы и зависимости¶
Единственная разрешённая зависимость — notifications::api (используется для доставки кода по
SMS/Telegram/MAX, см. ProviderCustomerCodeSender); домен входа по телефону и коду не требует
данных iam, franchise или iiko.
Структура пакетов¶
ru.panampizza.monolith.customer
├── api
│ ├── CustomerApi
│ ├── CustomerPrincipal
│ ├── CustomerIdentity
│ ├── AuthChannel
│ ├── AuthChallengeIssued
│ ├── CustomerLoginResult
│ ├── CustomerAuthChallenges
│ ├── CustomerDevice
│ ├── CustomerSession
│ ├── CustomerSessions
│ ├── CustomerAccessToken
│ ├── VerifiedCustomerAccess
│ ├── CustomerAccessTokens
│ ├── CustomerProfiles
│ ├── CustomerAddresses
│ ├── CustomerTelegramContacts
│ ├── CustomerMaxContacts
│ ├── CustomerChannelUnavailableException
│ └── CustomerCodeDeliveryFailedException
├── migration
│ ├── CustomerLegacyImport
│ └── CustomerLegacyImportConfiguration (публичная точка @Import для cli-модуля)
├── domain сущности, инварианты, репозитории, доменные операции
│ ├── CustomerEntity, CustomerRepository
│ ├── CustomerAccountService (findOrCreateByPhone — общая для сценариев операция)
│ ├── PhoneNumbers (normalizePhone / requireValidPhone)
│ ├── AuthChallengeEntity, AuthChallengeRepository, BlockedPhoneEntity, BlockedPhoneRepository
│ ├── CustomerSessionEntity, CustomerSessionCredentialEntity и их репозитории
│ ├── CustomerAddressEntity, CustomerAddressRepository
│ └── TelegramContact*/MaxContact* — entity, репозитории, *ContactLookup
├── application сценарии — реализации интерфейсов api
│ ├── CustomerIdentities (toIdentity — маппинг покупателя в CustomerIdentity)
│ ├── auth/CustomerAuthChallengeService
│ ├── session/CustomerSessionService, CustomerAccessTokenService
│ ├── profile/CustomerProfileService
│ ├── address/CustomerAddressService
│ └── contact/CustomerTelegramContactService, CustomerMaxContactService
└── infrastructure криптография, доставка кода, настройки
├── CustomerCodeHasher, ChallengeCodeMatcher (+ тестовый режим)
├── CustomerCodeSender (ProviderCustomerCodeSender → notifications::api, NoOp)
├── CustomerAccessTokenCodec (формат, подпись и проверка access JWT; CustomerAccessClaims)
├── CustomerRefreshTokenCodec
└── CustomerAuthChallengeProperties, CustomerJwtProperties
Правила слоёв проверяет CustomerArchitectureTests:
domainне зависит отapplication,infrastructureиmigration(от типовapi— может);infrastructureне зависит отapplicationиmigration;- сценарии в
application/<функция>не зависят друг от друга: общая логика опускается вdomain, общий маппинг лежит в корнеapplication; migrationзависит только отdomain.
Access JWT разделён по слоям: CustomerAccessTokenCodec отвечает за формат токена (подпись,
заголовок, iss/aud/typ, временные claims), а CustomerAccessTokenService — за то, действует
ли доступ: срок с учётом сессии, отзыв и срок сессии, статус покупателя и совпадение ver.
Модель данных (customer.*)¶
customer.customer
├── id: UUID
├── phone: varchar(20), unique, формат +7XXXXXXXXXX
├── name: varchar(200), по умолчанию ''
├── birth_date: date, опциональна, не раньше 1900-01-01; задаётся один раз
├── status: ACTIVE | BLOCKED
├── session_version: integer, по умолчанию 1
├── created_at
└── version
customer.auth_challenge
├── id: UUID
├── phone: varchar(20)
├── purpose: varchar(20), сейчас только 'LOGIN'
├── channel: SMS | TELEGRAM | MAX
├── code_hash: varchar(64), HMAC-SHA256 с серверным pepper — код не хранится открытым текстом
├── attempts / max_attempts
├── expires_at, consumed_at, requested_ip, created_at
└── unique index (phone, purpose) where consumed_at is null
customer.customer_session
├── id: UUID
├── customer_id: UUID, FK → customer.customer
├── device_id: UUID, device_type, device_name, app_version
├── auth_method: OTP | TELEGRAM
├── expires_at (скользящий срок, продлевается при ротации), created_at, last_used_at, revoked_at
├── version
├── index (customer_id) where revoked_at is null
└── unique index (customer_id, device_id) where revoked_at is null
customer.customer_session_credential
├── id: UUID
├── session_id: UUID, FK → customer.customer_session
├── generation, token_hash (SHA-256), created_at, rotated_at, successor_id
└── version
customer.customer_address
├── id: UUID
├── customer_id: UUID, FK → customer.customer
├── city, street, house: varchar, обязательны — только для отображения, без FK на справочник
├── housing, floor, apartment, entrance, doorphone: varchar, опциональны
├── comment: text, опционален
├── latitude, longitude: numeric(9,6), обязательны
├── created_at
└── version
customer.blocked_phone
├── phone: varchar(20), primary key, формат +7XXXXXXXXXX
├── reason: text, обязателен
├── blocked_at
└── version
customer.telegram_contact
├── phone: varchar(20), primary key, формат +7XXXXXXXXXX
├── chat_id: bigint
├── username: varchar(64), опционален
├── linked_at
└── version
customer.max_contact
├── phone: varchar(20), primary key, формат +7XXXXXXXXXX
├── chat_id: bigint
├── username: varchar(64), опционален
├── linked_at
└── version
Сценарии, схема и claim'ы согласованы в технической постановке входа покупателей; этот документ фиксирует, что из неё реализовано.
Вход и регистрация по коду¶
Публичный контракт CustomerAuthChallenges:
request нормализует телефон до +7XXXXXXXXXX, отклоняет заблокированный телефон и проверяет
единственный незакрытый challenge на телефон: повторный запрос в течение resendAfter (2 минуты)
отклоняется с оставшимся временем ожидания, а после его истечения прежняя строка гасится
(consumed_at), а не остаётся второй активной записью — иначе она нарушила бы уникальный индекс
(phone, purpose) where consumed_at is null. Код — шесть цифр, генерируется SecureRandom,
хранится только в виде HMAC-SHA256 с серверным pepper (CustomerCodeHasher). Открытый код передаётся
только в CustomerCodeSender.send, ничего не логирует по умолчанию и никуда не сохраняется.
verify находит активный challenge под блокировкой строки (SELECT ... FOR UPDATE), отклоняет
истёкший или исчерпанный по попыткам код одной и той же ошибкой CustomerAuthenticationFailedException
— ответ не показывает, что именно не так и существует ли телефон. Неверный код увеличивает счётчик
попыток; после maxAttempts (5) challenge сгорает досрочно. Верный код гасит challenge и вызывает
CustomerAccountService.findOrCreateByPhone: вход и регистрация — один сценарий, newAccount
в результате отличает только что созданного покупателя.
Доставка кода¶
ProviderCustomerCodeSender (единственная реализация CustomerCodeSender) резолвит канал в
получателя и делегирует отправку notifications::api:
| Канал | Получатель | Провайдер | Если недоступен |
|---|---|---|---|
SMS |
телефон (сам по себе адрес) | notifications.api.SmsSender |
— |
TELEGRAM |
chat_id из customer.telegram_contact по телефону |
notifications.api.TelegramSender |
CustomerChannelUnavailableException — контакт не привязан |
MAX |
chat_id из customer.max_contact по телефону |
notifications.api.MaxSender |
CustomerChannelUnavailableException — контакт не привязан |
Любая ошибка провайдера (сетевая, код ошибки от smsc.ru, ok: false от Telegram, HTTP-статус с
телом от MAX) оборачивается в CustomerCodeDeliveryFailedException. Оба исключения выбрасываются
из send() внутри @Transactional request(): challenge не сохраняется, клиент получает ошибку и
может повторить запрос (см. модуль notifications).
Тестовый режим¶
Флаг окружения panam.test-mode.enabled (env PANAM_TEST_MODE_ENABLED, по умолчанию false) —
не специфичен для customer, тем же именем в будущем сможет пользоваться и другой модуль (например,
подавление выгрузки заказов в iiko), но переключается независимо в каждом месте через
@ConditionalOnProperty на бинах, а не через общий инжектируемый бин — так verify() и request()
остаются без ветвления по флагу внутри метода. При true:
ProviderCustomerCodeSenderзаменяется наNoOpCustomerCodeSender: код не уходит ни в SMS, ни в Telegram, ни в MAX, только пишется в лог — реальный SMS-баланс и Telegram/MAX API не расходуются на test-сервере;- вместо бина
HashChallengeCodeMatcher(реальная проверка кода по хешу) поднимаетсяTestModeChallengeCodeMatcher: он сам создаётHashChallengeCodeMatcherповерхCustomerCodeHasher(чтобы в контексте был ровно одинChallengeCodeMatcher) и дополнительно принимает фиксированный код1111—CustomerAuthChallengeService.verifyвызывает единыйChallengeCodeMatcher.matches(...)и не знает о тестовом режиме вообще. Сам код по-прежнему генерируется и хешируется как обычно,1111— запасной вариант поверх него. Остальные проверки (TTL,maxAttempts, блокировка телефона) не меняются.
Ставится только в .env test-сервера (deploy/monolith/.env.example); на prod всегда false.
Блокировка по номеру¶
requireNotBlocked проверяет два независимых источника блокировки — оба дают одну и ту же
CustomerPhoneBlockedException, не раскрывая, какой именно сработал:
customer.blocked_phone— номер в чёрном списке, привязки к аккаунту не требует: блокируетrequest/verifyдаже для телефона, у которого ещё нетcustomer.customer(перенесено из legacyblack_lists, см. «Перенос legacy-данных» ниже).customer.customer.status = BLOCKED— блокировка уже существующего покупателя через будущий административный сценарий, которого пока нет.
Разблокировка (DELETE из blocked_phone или смена status) сейчас возможна только вручную —
административного endpoint нет ни для одного из двух механизмов.
Refresh-сессии¶
Публичный контракт CustomerSessions — create/rotate/revoke/revokeAll, по аналогии с
AdminSessions/FranchiseSessions модуля iam, но с явным устройством и скользящим сроком вместо
фиксированного 30-дневного окна.
create(customerId, device, method) привязывает сессию к device.id — постоянному UUID установки
клиента, который генерирует и хранит сам клиент (не секрет и не доказательство подлинности
устройства). Повторный вход с тем же device.id отзывает прежнюю сессию этого устройства перед
вставкой новой — без этого нарушился бы уникальный индекс (customer_id, device_id) where revoked_at
is null, а по продукту повторный вход не должен плодить дубли одного устройства в списке сессий.
rotate(refreshToken) — та же идемпотентная ротация с 30-секундным окном повтора и правилом reuse,
что в iam (повтор старого token в течение окна возвращает уже выпущенного преемника; повтор позже
считается reuse и отзывает сессию), и дополнительно продлевает expires_at ещё на 365 дней при
каждой успешной ротации — регулярно используемое устройство не имеет предельной календарной даты
входа, как того требует продукт.
revoke/revokeAll отзывают одну или все сессии покупателя. Публичного GET /sessions и отзыва
одного устройства по sessionId из HTTP пока нет — это следующий этап вместе со списком устройств.
Refresh-токен — не JWT, а непрозрачный id.подпись (CustomerRefreshTokenCodec, HMAC-SHA256),
секрет переиспользуется из CustomerJwtProperties — тот же приём, что уже применён для сотрудников
в iam.RefreshTokenCodec. Это осознанное отличие от технической
постановки, которая описывает refresh как отдельный
JWT с typ=refresh: непрозрачный формат физически не совпадает с access-JWT, поэтому токен нельзя
спутать местами (передать access туда, где ожидается refresh) даже при проверке одной подписи —
ровно та проблема legacy-сервиса, которую и требовалось закрыть.
Access JWT¶
Публичный контракт CustomerAccessTokens выпускает token для существующей refresh-сессии и
проверяет полученный. JWT подписывается HS256, JOSE typ=at+jwt, действует 15 минут, но не
дольше самой refresh-сессии.
Обязательные claims:
sub ID покупателя
sid ID refresh-сессии
jti уникальный ID access token
iss настраиваемый issuer, по умолчанию panam
aud panam-storefront
typ access
ver customer.session_version на момент выпуска
iat, nbf, exp
Проверка требует точного совпадения алгоритма, типа, issuer, audience и временных claims, затем
IAM-аналогично iam — перезагружает сессию и покупателя по sid, проверяет отзыв/срок сессии,
статус ACTIVE покупателя и совпадение ver с текущим session_version. Отзыв refresh-сессии
немедленно делает связанный access token недействительным; будущий сценарий «выйти на всех
устройствах» инкрементирует session_version (CustomerEntity.invalidateIssuedSessions) — это уже
проверяется тестами, хотя сам публичный сценарий отзыва всех сессий с инкрементом версии пока не
собран в HTTP.
permissions/membership в claims нет: у покупателя нет ролей.
Настройки — префикс panam.customer.jwt:
PANAM_CUSTOMER_JWT_ISSUER по умолчанию panam
PANAM_CUSTOMER_JWT_AUDIENCE по умолчанию panam-storefront
PANAM_CUSTOMER_JWT_ACCESS_TTL по умолчанию 15m
PANAM_CUSTOMER_JWT_SECRET
PANAM_CUSTOMER_JWT_SECRET_FILE
Должен быть указан ровно один источник секрета, не короче 32 UTF-8 байт; проверяется при старте
контекста (CustomerAccessTokenCodec), как и в iam.
Отдельный pepper для хеширования кода — префикс panam.customer.auth-challenge:
PANAM_CUSTOMER_AUTH_CHALLENGE_PEPPER_SECRET
PANAM_CUSTOMER_AUTH_CHALLENGE_PEPPER_SECRET_FILE
PANAM_CUSTOMER_AUTH_CHALLENGE_CODE_TTL по умолчанию 10m
PANAM_CUSTOMER_AUTH_CHALLENGE_RESEND_AFTER по умолчанию 2m
PANAM_CUSTOMER_AUTH_CHALLENGE_MAX_ATTEMPTS по умолчанию 5
Профиль¶
Публичный контракт CustomerProfiles.update(customerId, CustomerProfileUpdate(name?, birthDate?)) —
частичное обновление профиля, перенос legacy ProfileController::update (POST /client/update).
null в поле означает «не менять». Все поля проверяются до изменения сущности, поэтому
невалидное поле не оставляет частично применённого обновления. Любое нарушение бросает
CustomerProfileRejectedException, неизвестный customerId — тоже (маловероятная гонка между
проверкой access token и обновлением профиля в рамках одного запроса).
nameобрезается по пробелам и должен содержать от 2 до 32 символов (те же границы, что использовала регистрация в legacy Go-сервисе). Подсказки вариантов имени при вводе даёт неcustomer, а модуль dadata; сохраняемое имя с ними не сверяется.birthDate— от 1900-01-01 до сегодняшнего дня (UTC). Как и в legacy, задаётся один раз (CustomerEntity.assignBirthDate): попытка сменить уже заданную дату отклоняется. Повтор той же даты принимается, чтобы клиент мог отправлять профиль целиком. В legacy другая дата молча игнорировалась, здесь клиент получает явную ошибку. Исправить ошибочную дату можно будет только через будущий административный сценарий.
Из legacy update осознанно не перенесены:
allow_ads— согласие на рекламные рассылки. Нужны ли отдельные согласия и кто обрабатывает отказ, пока открытый вопрос (верхнеуровневые решения), а старый фронтенд это поле не отправлял: менялось оно только из админки.dish_customizations— не профиль, а непрозрачный JSON-снимок состояния старого фронтенда (активные адрес, город и пиццерия, тип доставки и оплаты, приборы, сдача и т.п.). Синхронизацию такого состояния между устройствами, если она понадобится, нужно проектировать отдельно, а не хранить чужой JSON в профиле. Чтение профиля отдельного публичного метода не имеет:storefrontстроит ответ прямо изCustomerIdentity, которую уже возвращаетCustomerAccessTokens.verify— лишний поход в БД не нужен.
Сохранённые адреса¶
Публичный контракт CustomerAddresses — list/add/updateComment/remove. Адрес
неизменяем: изменённый адрес — это по сути другой адрес, поэтому клиент удаляет старый и сохраняет
новый, а не правит существующий. Меняться может только comment (updateComment; null или
пустая строка удаляют комментарий); остальные колонки сущности помечены updatable = false.
updateComment/remove ищут
адрес парой (id, customerId): адрес чужого покупателя или несуществующий id дают одну и ту же
CustomerAddressNotFoundException, не различая эти случаи. city/street/house/housing/
floor/apartment/entrance/doorphone/comment — свободный текст (обрезается по пробелам,
пустая опциональная строка становится null); latitude/longitude обязательны и проверяются на
допустимый диапазон (CustomerAddressRejectedException иначе). Координаты сервер не вычисляет —
их присылает клиент (обычно с виджета карты), как и в legacy.
Здесь намеренно нет сущности «Город» и нет привязки адреса к ресторану/iiko при сохранении —
это осознанное отличие от старого Laravel-бэкенда (site/backend, не Go-сервис user, где
адресов не было вовсе). В legacy client_addresses в 2020 году ссылался на city_id/street_id
через FK, но в 2021-м эту связь явно убрали миграцией refactoring_client_addresses в пользу
свободного текста — сама команда отказалась от нормализации города на адресе. Отдельная проблема
legacy: CreateAddressHandler резолвил street_crm_id/iiko_street_name под конкретную
пиццерию, переданную при создании адреса, — при закрытии ресторана и открытии нового под другим
ФЧ этот резолв протухал молча.
Целевая модель: city/street остаются свободным текстом только для отображения в адресной
книге; сопоставление адреса с рестораном/зоной доставки, когда появится order, будет
геометрическим — точка (latitude/longitude) внутри полигона зоны доставки, а не сравнение
названий городов/улиц. Поэтому координаты обязательны уже сейчас, а не добавляются позже.
Restaurant-специфичные поля (аналог street_crm_id/iiko_street_name) на сохранённый адрес
никогда не пишутся — такой резолв будет выполняться заново на этапе заказа под конкретный ресторан,
а не кэшироваться на профиле покупателя. Справочник городов (аналог dictionary_cities — с
таймзоной, полигоном зоны доставки, баннерами) — если понадобится, это будущая задача модуля
restaurant/catalog, а не customer: в legacy он был привязан к пиццерии, а не к адресу клиента.
Что осознанно не перенесено из legacy-сервиса user¶
Полный список — в разделе 12.2 технической постановки. В этом модуле уже закрыто:
- access JWT действует 15 минут вместо
expв 2100 году; - refresh — отдельный непрозрачный токен с серверной ротацией, а не вторая JWT с тем же форматом, что и access;
- код подтверждения хранится только как HMAC-хеш, а не открытым текстом;
- нет query-параметра
?token=и нет неявного фолбэка «любой невалидный JWT — это legacy-токен»; - одна учётная запись
customer.customerвместо дублирующих таблиц блокировки в двух системах.
Ещё не перенесено и не реализовано: список устройств с отзывом одного,
logout-all, вход через Telegram WebApp, impersonation, миграция
legacy-токенов (/auth/legacy-exchange) и связанное с ней временное хранилище обмена. Перенос
телефона/имени/даты рождения/адресов/блокировок — реализован частично, см. «Перенос legacy-данных» ниже; бонусы
и накопления (§6.5 06-decisions.md) не переносятся вовсе — источник ещё не определён.
Перенос legacy-данных¶
Публичный контракт CustomerLegacyImport (named interface customer::migration) — не для обычных
сценариев приложения, только для одноразового CLI-инструмента (./gradlew importLegacyCustomers,
модуль cli, см. LegacyImportCommand). Источник — старый Laravel-бэкенд (site/backend, таблицы
clients/client_addresses/black_lists в MySQL), а не Go-сервис user, в котором адресов не
было вовсе.
Перенос идемпотентен, безопасно перезапускать:
upsertCustomer— dedup по телефону (уже unique). При создании id покупателя — это legacy UUID напрямую, не новый случайный, для сквозной прослеживаемости при отладке. Пустой/невалидный телефон — пропуск, имя существующего покупателя не перезаписывается, если уже задано.birth_dateпереносится по тем же правилам, что и в профиле: только в пустое поле и только в диапазоне от 1900-01-01 до сегодняшнего дня; дата вне диапазона (мусор в legacy) пропускается, а не прерывает импорт.upsertAddress— dedup по совпадению текстовых полей адреса у одного покупателя (тот же приём, что использовал сам legacy вClient::findAddress()). Адреса без координат (в legacy они появились только в 2022 году) пропускаются — координаты в нашей схеме обязательны.upsertBlockedPhone— dedup по телефону (primary key). Пустая причина в legacy (commentnullable) заменяется на текст-заглушку, а не оставляется пустой —reasonобязателен.
Явно не переносится: crm_id, tg_id/tg_username, max_chat_id/max_name,
dish_customizations, allow_ads/allow_notification — источник (MySQL) будет доступен ещё
долго. dish_customizations и allow_ads/allow_notification привязаны к сценариям, которые ещё
не спроектированы (см. «Профиль» выше); переносить их сейчас означало бы расширять схему customer.customer под
функциональность, которой нет. tg_id/tg_username и max_chat_id/max_name не переносятся по
другой причине — целевая связь телефона с чатом уже реализована (customer.telegram_contact,
customer.max_contact), но заново, через привязку в боте (см. «Доставка кода» выше), а не
переносом legacy-идентификатора: непроверенный перенос выдавал бы код по чужому chat_id, если
legacy-запись устарела.
dryRun=true (по умолчанию) не пишет в БД, но выполняет все проверки — статистика в логе честная
(created/updated/skipped по каждой из трёх таблиц), а не просто «сколько строк прочитано».
Проверка модульности¶
Модуль входит в общий ModularityTests (ApplicationModules.verify()), который проверяет
отсутствие запрещённых зависимостей и циклов.
Следующий этап¶
Доставка кода по всем трём каналам (SMS, Telegram, MAX) реализована. Остаются: настройки
уведомлений и согласие на рекламу (после решения открытого вопроса о согласиях), список сессий с отзывом одного устройства и
logout-all, блокировка уже существующего покупателя через административный сценарий, вход через
Telegram WebApp, перенос legacy-токенов.