Skip to content

ТЗ — Клиентский кабинет B2C (cms/cabinet-b2c)

Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: несколько Статус: ТЗ к разработке

Назначение и возможности

Зона 3 кабинета (Inertia + React SSR, см. /cms-v2/client-cabinets): личный кабинет физического лица поверх API-слоя ядра. Базовая площадка, поверх которой другие модули расширяют секции через реестр (заказы, бонусы, документы и т.д. — отдельные модули).

  • Профиль: контакты, аватар, смена email/телефона с подтверждением, удаление аккаунта (152-ФЗ);
  • заказы: список, детализация, статусы в реальном времени (интеграция cms/commerce-orders);
  • адреса: адресная книга, адрес по умолчанию, привязка к ПВЗ;
  • избранное: вишлист, списки, сравнение;
  • бонусы: баланс, история начислений/списаний (если подключён модуль лояльности);
  • подписки на уведомления: каналы и типы событий, управление согласиями;
  • секции кабинета расширяются модулями через CabinetSectionContract (реестр), без правки ядра кабинета;
  • управление сессиями: список активных устройств, «выйти со всех устройств».

Зависимости и выключение

requires: ядро (auth, API-слой) · suggests: cms/commerce-orders (секция заказов), cms/two-factor (доп. шаг подтверждения на смену email/удаление аккаунта), cms/dadata (suggest-provider — автозаполнение и нормализация адресов), cms/consents (детальный журнал согласий и политика хранения; без модуля работает упрощённая анонимизация на базовых примитивах согласий ядра) · provides: cabinet-shell

Поведение при выключении: раздел кабинета недоступен целиком (редирект на публичный сайт), заказы/профиль остаются доступны через админку/Filament для сотрудников; данные пользователей сохраняются и восстанавливаются при повторном включении. Деградация по каждому suggests-модулю — см. «Крайние случаи».

Модель данных

ТаблицаКлючевые поляПримечание
cms_cabinet_addressesid, user_id, label, address_json, is_defaultадресная книга
cms_cabinet_favoritesid, user_id, favoritable_type, favoritable_idизбранное, полиморфное
cms_cabinet_sessionsid, user_id, device_hash, ip, last_used_atактивные сессии для управления устройствами
cms_cabinet_verification_codesid, user_id, type, target_value, code_hash, expires_at, attempts, confirmed_atподтверждение смены email/телефона (type: email/phone)
cms_cabinet_deletion_requestsid, user_id, requested_at, scheduled_purge_at, status, cancelled_atgrace-период удаления аккаунта

user_id — FK constrained()->index() на всех таблицах; address_jsonjson()->nullable() + cast array. Индекс (favoritable_type, favoritable_id) в cms_cabinet_favorites под полиморфную выборку; уникальный составной индекс (user_id, favoritable_type, favoritable_id) — не даёт задвоить избранное. В cms_cabinet_verification_codes — уникальный частичный индекс (user_id, type) там, где confirmed_at IS NULL (одна активная заявка на тип), индекс по expires_at под джобу очистки. status в cms_cabinet_deletion_requests — PHP Enum (pending/cancelled/completed).

ПДн-паспорт. Персональные данные: address_json (адрес доставки/проживания), ip/device_hash в cms_cabinet_sessions (техданные устройства пользователя), target_value (email/телефон) в cms_cabinet_verification_codes. Срок хранения: адреса и избранное — пока аккаунт активен + 30 дней после завершённого удаления (окно на споры по заказам), далее анонимизация; cms_cabinet_sessions — 90 дней от last_used_at (джоба PurgeExpiredCabinetSessions); cms_cabinet_verification_codes — до expires_at

  • 7 дней (окно антифрод-аудита), затем purge. Модуль реализует хуки ядра «выгрузить всё по субъекту» (профиль, адреса, избранное, история сессий) и «забыть по запросу» (обезличивание адресов, ip → null, device_hash пересоленный) согласно 152-ФЗ и политике cms/consents.

Входные и выходные данные

Входы:

ИсточникПоляЧем валидируется
Форма профиля (PUT /api/v1/cabinet/me)name, phone_display, avatar_media_idFormRequest whitelist; avatar_media_id — существующий объект медиатеки текущего пользователя
Запрос смены email (POST /api/v1/cabinet/me/email)new_emailFormRequest: формат, уникальность, rate-limit на пользователя
Подтверждение email (POST /api/v1/cabinet/me/email/confirm)codeFormRequest: TTL email_change_confirmation_ttl_minutes, лимит попыток из attempts
Запрос смены телефона (POST /api/v1/cabinet/me/phone)new_phoneFormRequest: формат RU-номера, rate-limit
Подтверждение телефона (POST /api/v1/cabinet/me/phone/confirm)codeFormRequest: код из SMS, TTL, лимит попыток
Адрес (POST/PUT /api/v1/cabinet/addresses)label, address_json{city,street,house,apartment,zip}, is_defaultFormRequest whitelist полей address_json; нормализация через suggest-provider (cms/dadata), если подключён
Избранное (POST /api/v1/cabinet/favorites)favoritable_type, favoritable_idFormRequest: favoritable_type — whitelist известных типов, favoritable_id — существующая сущность
Настройки уведомлений (PUT /api/v1/cabinet/notifications)channel, event_type, enabledFormRequest whitelist каналов/типов из реестра NotificationDispatch
Запрос удаления аккаунта (POST /api/v1/cabinet/me/delete)password, reason?FormRequest: подтверждение пароля текущего пользователя
Отмена удаления (POST /api/v1/cabinet/me/delete/cancel)только владелец, в пределах grace-периода
Отзыв сессии (DELETE /api/v1/cabinet/sessions/{id})id (path)scoped-запрос WHERE user_id = $currentUser->id

Всё, что не перечислено выше, модуль обязан отвергать (whitelist-принцип §11 стандарта): неизвестные поля профиля, произвольный favoritable_type, чужой media_id.

Выходы:

ПотребительДанныеФормат
React-кабинет (Inertia, зона 3)профиль, адреса, избранное, сессии, настройки уведомленийREST-конверт {data, meta}
cms/commerce-orders (при включении)секция «Заказы» в кабинетерендер через CabinetSectionContract
cms/consentsфакт запроса/отмены/завершения удаления аккаунтасобытия CabinetAccountDeletion*
NotificationDispatch (ядро)письмо/SMS подтверждения email/телефона, уведомление на старый email о сменевызов контракта, log-fallback при выключенном notification-channel
Filament (cabinet-b2c.manage)карточка активности пользователя (последний вход, заказы)admin-view, не публичный API
cms/audit (если включён)факты смены email/телефона, отзыва сессиисобытие → аудит-лог

Настройки (группа cabinet-b2c)

КлючТипДефолтaffectsPageCacheОписание
cabinet-b2c.registration_enabledbooltrueнетРазрешить самостоятельную регистрацию
cabinet-b2c.require_phone_verificationbooltrueнетОбязательное подтверждение телефона
cabinet-b2c.sections_enabledarray["profile","orders","addresses","favorites"]нетАктивные секции кабинета
cabinet-b2c.max_favorites_per_userint500нетЛимит позиций избранного на пользователя
cabinet-b2c.max_addresses_per_userint20нетЛимит адресов в адресной книге
cabinet-b2c.pending_registration_ttl_hoursint24нетTTL незавершённой регистрации (email/телефон не подтверждён)
cabinet-b2c.email_change_confirmation_ttl_minutesint60нетСрок жизни кода подтверждения новой почты
cabinet-b2c.code_resend_cooldown_secondsint60нетМинимальный интервал между повторной отправкой кода
cabinet-b2c.deletion_grace_period_daysint14нетОкно на отмену запроса удаления аккаунта
cabinet-b2c.self_deletion_enabledbooltrueнетKill-switch: разрешить самостоятельное удаление аккаунта без выключения модуля

Достижение лимита (max_favorites_per_user, max_addresses_per_user) отдаёт понятную ошибку 422 и метрику, не 500 и не тихое обрезание списка.

API

МетодПутьДоступНазначение
GET/api/v1/cabinet/meauthДанные текущего профиля
PUT/api/v1/cabinet/meauthОбновление профиля
POST/api/v1/cabinet/me/emailauth, rate-limitЗапрос смены email (письмо-подтверждение на новый + уведомление на старый)
POST/api/v1/cabinet/me/email/confirmauthПодтверждение нового email кодом
POST/api/v1/cabinet/me/phoneauth, rate-limitЗапрос смены телефона
POST/api/v1/cabinet/me/phone/confirmauthПодтверждение телефона кодом
GET/api/v1/cabinet/addressesauthАдресная книга
POST/api/v1/cabinet/addressesauthДобавление адреса
PUT/api/v1/cabinet/addresses/{id}auth (владелец)Изменение адреса
DELETE/api/v1/cabinet/addresses/{id}auth (владелец)Удаление адреса
GET/api/v1/cabinet/favoritesauthСписок избранного
POST/api/v1/cabinet/favoritesauthДобавить в избранное
DELETE/api/v1/cabinet/favorites/{id}auth (владелец)Убрать из избранного
GET/PUT/api/v1/cabinet/notificationsauthНастройки подписок на уведомления
GET/api/v1/cabinet/sessionsauthСписок активных сессий
DELETE/api/v1/cabinet/sessions/{id}auth (владелец)Завершить сессию устройства
POST/api/v1/cabinet/me/deleteauth, подтверждение пароляЗапрос удаления аккаунта
POST/api/v1/cabinet/me/delete/cancelauth (владелец)Отмена удаления в пределах grace-периода

Коллекции (addresses, favorites, sessions) — keyset-пагинация (не OFFSET).

Компоненты

Filament: карточка пользователя со сводкой активности кабинета (последний вход, заказы). Реестр CabinetSectionContract для расширения секций модулями. Команды: cms:cabinet-b2c:doctor --json.

Демо-контент: сидер демо-пользователя кабинета с заполненными адресами, избранным и историей сессий — галерея /_gallery и playground показывают все секции без ручного ввода. Фронтенд-бюджет: React-остров кабинета грузится отдельным чанком (не в общий бандл темы), тяжёлые виджеты секций (карта ПВЗ) — lazy-load по видимости, формы доступны с клавиатуры, поля — с label.

События и обмен

СобытиеКогдаPayload
CabinetProfileUpdatedизменён профильuser_id, changed_fields[]
CabinetEmailChangeRequestedзапрошена смена emailuser_id, old_email, new_email
CabinetEmailChangedновый email подтверждён и применёнuser_id, old_email, new_email
CabinetPhoneChangeRequestedзапрошена смена телефонаuser_id, new_phone
CabinetPhoneChangedтелефон подтверждён и применёнuser_id
CabinetSessionRevokedзавершена сессия устройстваuser_id, session_id
CabinetAccountDeletionRequestedзапрошено удаление аккаунтаuser_id, scheduled_purge_at
CabinetAccountDeletionCancelledпользователь отменил удаление в grace-периодеuser_id
CabinetAccountDeletedgrace-период истёк, обезличивание завершеноuser_id

Слушает: — (модуль сам не подписывается на чужие события; интеграции — через provides и сервис-вызовы, см. таблицу ниже). Provides-контракт cabinet-shell — реестр CabinetSectionContract потребляется модулями-расширениями (заказы, бонусы, документы).

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
cms/commerce-ordersprovides (cabinet-shell, реестр CabinetSectionContract)cms/commerce-orders → cabinet-b2cсекция «Заказы» регистрируется в кабинете, рендерится сервисом заказов
cms/consentsсобытиеcabinet-b2c → cms/consentsCabinetAccountDeletionRequested/…Cancelled инициируют/останавливают обезличивание заказов и отзывов
cms/two-factorсервис-вызов (мягкая зависимость, suggests)cabinet-b2c → cms/two-factorдоп. шаг подтверждения на смену email/удаление аккаунта; при выключении шаг пропускается
cms/dadataprovides-контракт suggest-providercabinet-b2c → cms/dadataнормализация и автоподсказка адреса при вводе; при выключении — обычный текстовый ввод
NotificationDispatch (ядро)сервисный контрактcabinet-b2c → ядрописьма/SMS подтверждения, уведомление о смене email на старый адрес; log-fallback без notification-channel
RequestContext (ядро)сервисный контрактядро → cabinet-b2cтекущий пользователь/site_id для скоупа всех запросов
cms/audit (если включён)событиеcabinet-b2c → cms/auditфакты смены email/телефона, отзыва сессии попадают в журнал безопасности
cms/attack-monitor (если включён)событиеcabinet-b2c → cms/attack-monitorсерии неудачных подтверждений кода — сигнал возможного брутфорса
React-кабинет (зона 3)внешний канал REST /api/v1cabinet-b2c → фронтендheadless-рендер всех секций

Фоновая работа

Очередь cabinet-b2c (низкий приоритет), джобы — идемпотентны, расписание через ScheduleRegistrar:

  • PurgeExpiredCabinetSessions — ежедневно, чистит cms_cabinet_sessions старше 90 дней от last_used_at;
  • PurgeExpiredPendingRegistrations — ежедневно, помечает expired незавершённые регистрации старше pending_registration_ttl_hours (обрабатывается ядром auth, модуль участвует стороной секции профиля);
  • PurgeExpiredVerificationCodes — ежедневно, чистит cms_cabinet_verification_codes старше expires_at + 7 дней;
  • FinalizeAccountDeletion — ежедневно, для заявок с истёкшим scheduled_purge_at и статусом pending запускает обезличивание (сервис-вызов cms/consents, при отсутствии модуля — базовый механизм ядра), ключ идемпотентности — user_id (повторный прогон не дублирует эффект), издаёт CabinetAccountDeleted.

Операции профиля/адресов/избранного/сессий синхронны в рамках HTTP-запроса — фоновая работа только у ретеншна и финализации удаления.

Эксплуатация (ранбук). Метрики: число отправленных/подтверждённых кодов, доля неудачных подтверждений (детект брутфорса), число отозванных сессий, длительность FinalizeAccountDeletion. Алерты: доля неудачных подтверждений кода выше порога, отставание очереди cabinet-b2c от расписания.

СимптомЧто проверитьКоманда
Код подтверждения email/телефона не приходитдоступность NotificationDispatch / log-fallback вместо реальной отправкиcms:cabinet-b2c:doctor --json
Сессии не отзываются на клиентерассинхрон cms_cabinet_sessions и токенов Sanctumcms:cabinet-b2c:doctor --json
Подсказки адреса не работаютдоступность cms/dadata и suggest-providercms:dadata:doctor --json
Аккаунты не удаляются по истечении grace-периодаотставание очереди cabinet-b2c, ошибки FinalizeAccountDeletioncms:cabinet-b2c:doctor --json, логи очереди

Бэкап/рестор: в бэкап попадают все таблицы модуля и связанные медиа-аватары. После рестора cms_cabinet_sessions не восстанавливается принудительно (устаревшие записи просто expire по TTL — не критично для консистентности); cms_cabinet_verification_codes с истёкшим expires_at можно не восстанавливать — пользователь запросит код заново.

Производительность и кеш

Ожидаемые объёмы: сотни тысяч — единицы миллионов пользователей на инсталляцию, в среднем 2–5 адресов и до нескольких сотен позиций избранного на пользователя. Горячий путь — открытие кабинета: bootstrap-запрос GET /api/v1/cabinet/me на каждой странице React-острова, бюджет ≤3 запроса (профиль + счётчик избранного + активные секции реестра, без N+1 по адресам/сессиям — они грузятся отдельно по требованию вкладки). Настройки читаются из кеша группы — 0 запросов на горячем пути (контрактный тест).

Критичные индексы — см. «Модель данных»: user_id на всех таблицах (изоляция по владельцу — она же и главный вектор безопасности, и главный индекс производительности), составной уникальный (user_id, favoritable_type, favoritable_id) — защищает и от дублей, и от полного скана при проверке «уже в избранном».

Теги cabinet-b2c:profile:<user_id>, cabinet-b2c:favorites:<user_id> — приватный кеш уровня пользователя (короткий TTL), инвалидируется событиями CabinetProfileUpdated и изменением избранного. Персональные данные кабинета не попадают в общий page-cache — сегментация по правилам ядра (§10 стандарта), не самодельная: страница кабинета в принципе не участвует в page-cache сайта, только в приватном кеше по user_id.

Безопасность

IDOR — главный вектор для кабинета. Каждый эндпоинт обязан использовать scoped-запрос через владельца (WHERE user_id = $currentUser->id), а не голый Model::find($id): адреса, избранное, сессии, заявки на смену email/телефона — доступ к чужой записи по перебору id должен возвращать 404, а не 403 (не подтверждать существование чужого ресурса). Контрактный тест перебирает id соседнего пользователя на каждом эндпоинте.

Смена email/телефона — двойное подтверждение для email: письмо на старый адрес — уведомление (не блокирует, информирует о попытке смены), письмо на новый адрес — подтверждение с кодом; смена применяется только после подтверждения нового адреса. Телефон — однократное подтверждение SMS-кодом. Ни одна смена не применяется мгновенно без верификации.

Удаление аккаунта — не каскадное: запрос переводит аккаунт в grace-период (deletion_grace_period_days), по истечении — обезличивание (не hard delete) по политике cms/consents: заказы и отзывы сохраняются обезличенными (ссылка на пользователя разрывается, факт покупки/отзыва — нет), финансовые записи не трогаются (append-only, специфика §4 стандарта). Kill-switch self_deletion_enabled позволяет временно отключить самостоятельное удаление (например, при расследовании инцидента) без выключения модуля.

Управление сессиями реально инвалидирует токен/сессию устройства при отзыве. Rate-limit на запрос/подтверждение кода (по пользователю и по IP) — защита от перебора кода и спама письмами/SMS.

Матрица ролей:

ДействиеВладелец аккаунтаСотрудник (cabinet-b2c.manage)studio
Просмотр/редактирование своего профиля, адресов, избранного
Просмотр профиля любого пользователя (обращение в поддержку)
Отзыв чужой активной сессии (при компрометации аккаунта)
Запрос/отмена удаления своего аккаунта
Отключение kill-switch self_deletion_enabled
Принудительное восстановление обезличенного аккаунта (инцидент)

Права: cabinet-b2c.view.own, cabinet-b2c.manage (для сотрудников через админку).

UX-требования

Для пользователя кабинета: форма профиля/адреса при ошибке валидации (422) сохраняет уже введённые значения (клиентское состояние не сбрасывается); индикатор загрузки/skeleton вместо пустого экрана на bootstrap-запросе; список сессий явно подсвечивает «это устройство»; форма смены email/телефона показывает, на какой адрес уйдёт письмо/SMS, до отправки; кабинет адаптивен (мобильная раскладка секций, не только десктоп — большинство визитов в личный кабинет с мобильных устройств); формы доступны с клавиатуры.

Для админа/сотрудника студии: пустая адресная книга/избранное в Filament-карточке — с подсказкой, а не голым пустым списком; отзыв сессии пользователя сотрудником — подтверждение действия («вы точно хотите завершить сессию клиента?»); ошибки на человеческом языке («пользователь уже подтвердил email» вместо кода исключения); принудительное восстановление аккаунта — отдельное необратимо-звучащее действие с явным подтверждением и записью в аудит.

Крайние случаи и типовые баги

  • Двойной сабмит формы смены email → повторный POST с тем же new_email не создаёт вторую заявку, обновляет существующую (уникальный частичный индекс (user_id, type) по неподтверждённым).
  • Код подтверждения истёк → код невалиден, ошибка «код устарел», доступна повторная отправка не чаще code_resend_cooldown_seconds.
  • Подтверждён новый email, старый не открыл письмо → это ожидаемо: письмо на старый адрес — уведомление, не условие применения смены; смена уже произошла по факту подтверждения нового адреса.
  • cms/two-factor выключен посреди активного challenge (пользователь начал смену email с 2FA-шагом, модуль отключили) → деградация: шаг 2FA пропускается, операция продолжается по обычной проверке (пароль/код), не зависает и не падает 500.
  • cms/dadata недоступен/таймаут при вводе адреса → поле остаётся обычным текстовым вводом без подсказок, форма адреса не блокируется (согласовано с docs/dadata.md).
  • Удаление аккаунта с активными неисполненными заказами → запрос уходит в grace-период, заказы не удаляются каскадно; финализация обезличивания откладывается, пока у пользователя есть заказы в незавершённом статусе (сервис-вызов к cms/commerce-orders перед FinalizeAccountDeletion, если модуль подключён).
  • Незавершённая регистрация (email/телефон не подтверждён) → аккаунт существует в состоянии pending до pending_registration_ttl_hours, далее — expired; email из истёкшей заявки можно использовать повторно для новой регистрации; повторная отправка кода — под тем же code_resend_cooldown_seconds.
  • Пустые и огромные данные избранного/адресной книги → пустое состояние с подсказкой «добавить адрес» / «сохранять товары в избранное», не пустая таблица без объяснения; на другом полюсе — тысячи позиций избранного не деградируют список благодаря keyset-пагинации, а max_favorites_per_user даёт понятную ошибку при достижении лимита вместо тихого обрезания.
  • Ловушка измерения city — адрес привязан к ПВЗ конкретного city_id; на сайте без cms/multicity поле nullable, адресная книга работает без города (контрактный тест гоняется в обоих режимах, §4 стандарта).
  • ⚠️ Противоречие: require_phone_verification = true, но в инсталляции не подключён ни один notification-channel для SMS → регистрация с обязательным подтверждением телефона блокируется без явного объяснения пользователю и админу. Разрешение: cms:cabinet-b2c:doctor при enable/health-чеке проверяет наличие резолвящегося notification-channel для SMS при включённом require_phone_verification и алертит явной ошибкой в админке, а не тихим 500 на форме регистрации.
  • Гонка «выйти со всех устройств» и параллельного запроса с другого устройства → отзыв сессии атомарен по session_id; повторный/параллельный запрос на уже отозванную сессию получает 404, а не 500.
  • Модуль cabinet-b2c выключен, пока пользователь внутри кабинета → следующий запрос к /api/v1/cabinet/* отдаёт редирект/понятную ошибку, а не 500; открытая вкладка показывает fallback-экран «раздел временно недоступен» вместо белого экрана.

Донорский код

Что взятьПуть
Подходы к личному кабинетунесколько проектов студии (профиль, адреса, избранное)

Миграция legacy: команда cms:cabinet-b2c:import-legacy --source=<профиль> переносит профиль/адреса/избранное со старой платформы клиента, маппинг по external_id на пользователя ядра; идемпотентна (повторный прогон обновляет, не дублирует), --dry-run с отчётом расхождений. Обязательна для клиентских переездов с donor-проектов студии, где кабинет уже существовал.

Тесты и приёмка

  • [ ] Контрактный тест: пользователь видит и редактирует только свои данные (IDOR-тест перебора id соседа на каждом эндпоинте: адреса, избранное, сессии, заявки смены email/телефона — 404, не 403);
  • [ ] Смена email требует подтверждения нового адреса и применяется только после него; письмо на старый адрес — уведомление, не блокирует смену;
  • [ ] Смена телефона требует подтверждения кодом, не применяется мгновенно;
  • [ ] Двойной сабмит запроса смены email не создаёт вторую заявку (идемпотентность);
  • [ ] Удаление аккаунта уходит в grace-период, не удаляет данные каскадно; отмена в пределах периода восстанавливает аккаунт без следов удаления;
  • [ ] FinalizeAccountDeletion идемпотентна (повторный прогон не дублирует эффект) и соответствует политике cms/consents (обезличивание, а не hard delete при активных заказах);
  • [ ] Незавершённая регистрация истекает по TTL, email освобождается для повторной попытки; повторная отправка кода ограничена cooldown;
  • [ ] Реестр секций позволяет модулю добавить пункт меню кабинета без правки кода ядра кабинета;
  • [ ] Деградация при выключении редиректит на публичный сайт без 500; деградация при выключении cms/two-factor/cms/dadata не блокирует операции профиля/адреса;
  • [ ] Управление сессиями реально инвалидирует токен/сессию устройства при отзыве; гонка параллельного отзыва отдаёт 404, не 500;
  • [ ] Лимиты max_favorites_per_user/max_addresses_per_user отдают 422 с понятной ошибкой при достижении;
  • [ ] Хуки «выгрузить всё по субъекту» и «забыть по запросу» покрыты тестом (ПДн-паспорт);
  • [ ] Контрактный набор cms-testing зелёный, testbench-изоляция пакета, feature-тест на каждый роут;
  • [ ] Тестовая БД только cabinet-b2c_test; migrate:fresh/refresh/reset, db:wipe запрещены.

Внутренняя база знаний студии