Перейти к основному содержимому

UC-ACT-005. Слияние гостевой и пользовательской активности при входе

Назначение

Описывает сценарий, в котором инвестор, ранее просматривавший платформу как гость, входит в учётную запись — и его гостевая активность «до входа» (сессия устройства, просмотры объектов недвижимости, действия) сливается в единую историю пользователя «до и после входа». Механизм слияния — связывание гостевой Active-сессии устройства с UserId (INV-SES-002 LinkOnce, ../domain/session.md): дочерние PropertyObjectView/ActionEvent не переписываются (без backfill UserId) — их принадлежность пользователю обеспечивается логически, через связанную сессию («Правило чтения», simplicity-first, ADR-0056). Связывание — сигнал favorites на merge гостевого избранного (фронтовый триггер, вне границ user-activity). Связан с контекстом user-activity.

Metadata

ПолеЗначение
IDUC-ACT-005
Контекстuser-activity (см. docs/06-services/user-activity/)
Типосновной
Статусdraft

Актор

ПолеЗначение
РольИнвестор (User, JWT claim sub); серверный путь-дублёр выполняет System (консюмер входа, см. A1)
Permissions / scopeаутентифицированный пользователь; спец-permission нет (self-scope). Записи актора — по AnonymousId (заголовок X-Anonymous-Id) и UserId (JWT sub)
TenantTenantId = null — контекст вне tenant (ADR-0052 п.2)
Record-levelself-scope: связывается только Active-сессия с AnonymousId из заголовка актора; UserId берётся из JWT, из тела запроса не принимается

Предусловие

  • У устройства есть гостевая Active-сессия: AnonymousId заполнен, UserId == null, Status == Active (создана start-session, продлевается heartbeat, INV-SES-001/003).
  • В этой сессии могли накопиться гостевые записи PropertyObjectView/ActionEvent со снимком UserId == null (снимок на момент действия, дом — ../domain/property-object-view.md, ../domain/action-event.md).
  • Фронт хранит AnonymousId (localStorage/cookie) и после входа получает JWT от identity (claim sub).
  • Схема user_activity применена (в т.ч. partial-unique ux_sessions_active_anonymous, chk_sessions_link_pair).

Триггер

ПолеЗначение
ИсточникAPI call (детерминированный путь) + Kafka event (путь-дублёр)
Триггерящий элементУспешный вход в identity → фронт вызывает POST /api/user-activity/sessions/link (../api/link-session.md); параллельно identity публикует pin.identity.sign-ins.v1UserActivitySignedInConsumer

Основной Поток

Детерминированный (фронтовый) путь — авторитетный линкер; путь-дублёр консюмера — в A1.

  1. Инвестор, просматривавший платформу гостем, нажимает «Войти» и проходит вход в identity (SMS / пароль / пин-код).
  2. identity выдаёт JWT (claim sub = UserId); фронт сохраняет токен и продолжает держать AnonymousId (localStorage).
  3. Фронт немедленно вызывает POST /api/user-activity/sessions/link с JWT (sub) и заголовком X-Anonymous-Id; тело пустое (оба идентификатора — из контекста, клиентские timestamp не принимаются, INV-SES-004).
  4. Система находит Active-сессию устройства по AnonymousId под блокировкой строки (единичная — partial-unique ux_sessions_active_anonymous); ветвление INV-SES-002 по session.UserId: UserId == null → исход Linked.
  5. Связывание (guard UserId == null): UserId: null → value, LinkedAt = now (chk_sessions_link_pair удовлетворён), Status остаётся Active; поднимается событие SessionLinked. Дочерние PropertyObjectView/ActionEvent не мутируются — backfill UserId не выполняется (правило чтения).
  6. Сессия сохраняется (UserId, LinkedAt) и публикуется SessionLinkedKafkaEvent (pin.user-activity.session-linked.v1) — атомарно в одном согласованном изменении.
  7. Ответ 200 LinkSessionResult { SessionId, AnonymousId, UserId, LinkedAt, Outcome = Linked } (enum LinkOutcome строкой).
  8. Гостевая активность «до входа» входит в единую историю пользователя: get-my-history (../api/get-my-history.md) под JWT и internal-выдачи карточки лида crm поднимают до-входные записи через связанные сессии («Правило чтения», property-object-view.md), без backfill. Параллельно фронт инициирует merge гостевого избранного (favorites через ../api/get-session-link.md) — вне границ user-activity; событие session-linked — резервный snapshot AnonymousId → UserId кандидатам crm/favorites.

Альтернативные Потоки

A1. Связывание серверным консюмером входа (путь-дублёр)

  1. identity публикует pin.identity.sign-ins.v1; UserActivitySignedInConsumer по корреляции AnonymousId связывает Active-сессию идемпотентно (ветка B: связывание с UserId).
  2. Что сработает раньше — фронтовый link или консюмер — то и свяжет; второй путь попадает в AlreadyLinked (A2) и является no-op. Гарантирует связывание, даже если фронт не вызвал link (закрыл вкладку).

Возвращается к шагу 8 основного потока (единая история доступна).

A2. Повторный вход того же пользователя (AlreadyLinked)

  1. session.UserId == actor.UserId → перехода нет, no-op (мутаций и событий нет).
  2. Ответ 200 { Outcome = AlreadyLinked } (LinkedAt из записи). Идемпотентность LinkOnce по построению.

A3. Вход другого пользователя на устройстве с уже связанной сессией (Switched, INV-SES-002)

  1. session.UserId != null && != actor.UserId: текущая сессия форс-закрывается — Active → Inactive, EndedAt = now, Duration = LastActivityAt − CreatedAt (INV-SES-005), поднимается событие SessionEnded.
  2. Открытые просмотры сессии закрываются тем же согласованным изменением (INV-LV-007): каждый Active-просмотр закрывается (Duration, EndedAt, событие PropertyObjectViewEnded).
  3. Создаётся новая born-linked Active-сессия (UserId установлен при создании, LinkedAt = CreatedAt = now, Device копируется — то же устройство); поднимается событие SessionStarted — это не переход null → value, поэтому session-linked для новой сессии не публикуется (канонический контракт ../events/session-linked.md).
  4. Публикуются: SessionEndedKafkaEvent (старая) + PropertyObjectViewEndedKafkaEvent (на каждый закрытый просмотр); всё одним согласованным изменением. Ответ 200 { Outcome = Switched, SessionId = id новой сессии }.

A4. Нет Active-сессии устройства

  1. Сессия закрыта по неактивности (activity-closer) или не создана server-side → активной сессии нет.
  2. Фронтовый link → отказ USER_ACTIVITY_SESSION_NOT_FOUND (404): фронт вызывает start-session (../api/start-session.md), затем повторяет link.
  3. Консюмер входа → skipped-no-active-session (ack, не ошибка): born-linked сессию создаст ближайший authenticated track-*/session-heartbeat (../api/session-heartbeat.md).

A5. Серверный путь без корреляции AnonymousId

  1. identity ещё не эмитит AnonymousId на pin.identity.sign-ins.v1 → консюмер инертен (skipped-no-correlation, ack). Связывание полностью обслуживает фронтовый link (A-путь).

A6. Гонка обоих путей / устаревший вход

  1. Блокировка строки на загрузке сериализует параллельные link/консюмер; второй попадает в AlreadyLinked (A2) — no-op.
  2. Устаревший sign-in out-of-order на связанной сессии гасится guard-ом консюмера (CreatedAt > session.LinkedAt, иначе skipped-stale). Отдельного кода конфликта нет.

Постусловие

  • Гостевая Active-сессия устройства связана: UserId = actor.UserId, LinkedAt = now, Status остаётся Active (INV-SES-002 LinkOnce — связывание однократно).
  • Дочерние PropertyObjectView/ActionEvent не изменены (UserId — снимок на момент действия); принадлежность пользователю обеспечивается логически через связанную сессию (правило чтения), без backfill (ADR-0056).
  • Единая история «до и после входа» доступна в get-my-history (под JWT) и internal-выдачах карточки лида crm.
  • SessionLinkedKafkaEvent опубликован ровно один раз на сессию (LinkOnce) атомарно с изменением сессии; кандидаты crm/favorites получают snapshot AnonymousId → UserId.
  • Merge гостевого избранного инициирован фронтом (favorites) — вне границ user-activity.
  • При Switched (A3): старая сессия Inactive с посчитанным Duration, её открытые просмотры закрыты, новая born-linked Active создана; session-linked для новой не публиковался.

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
VALIDATION_FAILED400X-Anonymous-Id отсутствует / не Guid (USER_ACTIVITY_ANONYMOUS_ID_REQUIRED)исправить заголовок, повтор
UNAUTHORIZED401нет / невалидный JWT (нет claim sub)повторный вход в identity
USER_ACTIVITY_SESSION_NOT_FOUND404нет Active-сессии для AnonymousId (A4)вызвать start-session, затем повторить link (или дождаться консюмера sign-ins)

Серверный путь-дублёр ошибок HTTP не даёт: skipped-no-active-session / skipped-no-correlation / skipped-stale — контролируемый ack (не ошибка), см. ../consumers/identity-signed-in.md.

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

СобытиеКогда поднимаетсяSubscribers
SessionLinkedEventИсход Linked (Session.UserId: null → value)in-proc (публикация session-linked, аудит)
SessionEndedEventИсход Switched — форс-закрытие прежней сессии (A3)in-proc → session-ended
PropertyObjectViewEndedEventSwitched — на каждый закрытый Active-просмотр прежней сессии (A3, INV-LV-007)in-proc → property-object-view-ended
SessionStartedEventSwitched — создание новой born-linked сессии (A3)in-proc (не даёт session-linked)

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
SessionLinkedKafkaEvent.v1pin.user-activity.session-linked.v1Исход Linked (связывание гостевой сессии)crm / favorites (кандидаты, ../events/session-linked.md)
SessionEndedKafkaEvent.v1pin.user-activity.session-ended.v1Switched — форс-закрытие прежней сессии (A3)dev-stats (../events/session-ended.md)
PropertyObjectViewEndedKafkaEvent.v1pin.user-activity.property-object-view-ended.v1Switched — закрытые просмотры прежней сессии (A3)PropertyObjectViewStats (self), dev-stats, crm (../events/property-object-view-ended.md)

Consumed: UserSignedInKafkaEvent.v1pin.identity.sign-ins.v1 (путь A1; контракт — ../../identity/events/user-signed-in.md). Все интеграционные события публикуются атомарно со сменой состояния.

Кросс-сервисные контракты

Ожидает от identity (путь-дублёр A1): событие pin.identity.sign-ins.v1 (user-signed-in.md) с корреляцией AnonymousId, UserId (= sub) и CreatedAt (для stale-guard A6: устаревший sign-in на уже связанной сессии гасится по CreatedAt > session.LinkedAt). Без AnonymousId в payload консюмер инертен (skipped-no-correlation, A5) — связывание целиком обслуживает фронтовый link.

Предоставляет:

Событие / TopicКлюч партицииДоставка / семантикаПотребители
SessionLinkedKafkaEvent · pin.user-activity.session-linked.v1 (v1)AggregateId = SessionIdat-least-once; ровно один раз на сессию по построению (LinkOnce, INV-SES-002)crm / favorites — кандидаты (snapshot AnonymousId → UserId)
SessionEndedKafkaEvent · pin.user-activity.session-ended.v1 (v1)AggregateId = SessionIdat-least-oncedev-stats — только при Switched (A3)
PropertyObjectViewEndedKafkaEvent · pin.user-activity.property-object-view-ended.v1 (v1)AggregateId = PropertyObjectViewIdat-least-onceself (PropertyObjectViewStats), dev-stats, crm — только при Switched (A3)

Порядок и что видят потребители. Детерминированный link и консюмер входа гонятся — первый выигрывает, второй попадает в AlreadyLinked no-op (A2/A6), строковая блокировка сериализует их. session-linkedнеобязательный snapshot: merge гостевого избранного триггерит фронт (favorites), событие лишь резервный сигнал; потребитель обязан быть идемпотентным (LinkOnce ⇒ повторная доставка — no-op по SessionId). Окно eventual-consistency на историю отсутствует — до-входные записи принадлежат пользователю логически (правило чтения, без backfill) и видны сразу после link; на избранное — до завершения фронтового merge.

Acceptance Criteria

#КритерийПроверка
AC1Гостевая Active-сессия после link получает UserId = sub, LinkedAt, Status остаётся Active; повтор link тем же пользователем — AlreadyLinked no-opинтеграционный тест связывания
AC2Дочерние PropertyObjectView/ActionEvent не мутируются (UserId == null сохранён), но get-my-history под JWT возвращает и до-входные гостевые записи через правило чтения (по связанным сессиям)тест истории до/после входа
AC3Что сработает раньше — фронтовый link или консюмер sign-ins — то и связывает; второй путь идемпотентен (AlreadyLinked)конкурентный тест двух путей
AC4session-linked публикуется ровно один раз на сессию атомарно с изменением сессиитест публикации (LinkOnce)
AC5Вход другого пользователя (Switched): прежняя сессия форс-закрыта (Duration посчитан, просмотры закрыты, session-ended/property-object-view-ended), новая born-linked Active; session-linked для новой не публикуетсятест переключения пользователя
AC6Нет Active-сессии → link 404 USER_ACTIVITY_SESSION_NOT_FOUND; консюмер skipped-no-active-session (ack). Нет корреляции AnonymousId → консюмер инертен, связывание обслуживает linkтест A4/A5
AC7X-Anonymous-Id отсутствует/не Guid → 400; нет JWT sub401тест валидации/авторизации

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные Документы