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
| Поле | Значение |
|---|---|
| ID | UC-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) |
| Tenant | TenantId = null — контекст вне tenant (ADR-0052 п.2) |
| Record-level | self-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(claimsub). - Схема
user_activityприменена (в т.ч. partial-uniqueux_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.v1 → UserActivitySignedInConsumer |
Основной Поток
Детерминированный (фронтовый) путь — авторитетный линкер; путь-дублёр консюмера — в A1.
- Инвестор, просматривавший платформу гостем, нажимает «Войти» и проходит вход в
identity(SMS / пароль / пин-код). identityвыдаёт JWT (claimsub = UserId); фронт сохраняет токен и продолжает держатьAnonymousId(localStorage).- Фронт немедленно вызывает
POST /api/user-activity/sessions/linkс JWT (sub) и заголовкомX-Anonymous-Id; тело пустое (оба идентификатора — из контекста, клиентские timestamp не принимаются, INV-SES-004). - Система находит
Active-сессию устройства поAnonymousIdпод блокировкой строки (единичная — partial-uniqueux_sessions_active_anonymous); ветвление INV-SES-002 поsession.UserId:UserId == null→ исходLinked. - Связывание (guard
UserId == null):UserId: null → value,LinkedAt = now(chk_sessions_link_pairудовлетворён),StatusостаётсяActive; поднимается событиеSessionLinked. ДочерниеPropertyObjectView/ActionEventне мутируются — backfillUserIdне выполняется (правило чтения). - Сессия сохраняется (
UserId,LinkedAt) и публикуетсяSessionLinkedKafkaEvent(pin.user-activity.session-linked.v1) — атомарно в одном согласованном изменении. - Ответ
200 LinkSessionResult { SessionId, AnonymousId, UserId, LinkedAt, Outcome = Linked }(enumLinkOutcomeстрокой). - Гостевая активность «до входа» входит в единую историю пользователя:
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— резервный snapshotAnonymousId → UserIdкандидатамcrm/favorites.
Альтернативные Потоки
A1. Связывание серверным консюмером входа (путь-дублёр)
identityпубликуетpin.identity.sign-ins.v1;UserActivitySignedInConsumerпо корреляцииAnonymousIdсвязываетActive-сессию идемпотентно (ветка B: связывание сUserId).- Что сработает раньше — фронтовый
linkили консюмер — то и свяжет; второй путь попадает вAlreadyLinked(A2) и является no-op. Гарантирует связывание, даже если фронт не вызвалlink(закрыл вкладку).
Возвращается к шагу 8 основного потока (единая история доступна).
A2. Повторный вход того же пользователя (AlreadyLinked)
session.UserId == actor.UserId→ перехода нет, no-op (мутаций и событий нет).- Ответ
200 { Outcome = AlreadyLinked }(LinkedAtиз записи). Идемпотентность LinkOnce по построению.
A3. Вход другого пользователя на устройстве с уже связанной сессией (Switched, INV-SES-002)
session.UserId != null && != actor.UserId: текущая сессия форс-закрывается —Active → Inactive,EndedAt = now,Duration = LastActivityAt − CreatedAt(INV-SES-005), поднимается событиеSessionEnded.- Открытые просмотры сессии закрываются тем же согласованным изменением (INV-LV-007): каждый
Active-просмотр закрывается (Duration,EndedAt, событиеPropertyObjectViewEnded). - Создаётся новая born-linked
Active-сессия (UserIdустановлен при создании,LinkedAt = CreatedAt = now,Deviceкопируется — то же устройство); поднимается событиеSessionStarted— это не переходnull → value, поэтомуsession-linkedдля новой сессии не публикуется (канонический контракт ../events/session-linked.md). - Публикуются:
SessionEndedKafkaEvent(старая) +PropertyObjectViewEndedKafkaEvent(на каждый закрытый просмотр); всё одним согласованным изменением. Ответ200 { Outcome = Switched, SessionId = id новой сессии }.
A4. Нет Active-сессии устройства
- Сессия закрыта по неактивности (
activity-closer) или не создана server-side → активной сессии нет. - Фронтовый
link→ отказUSER_ACTIVITY_SESSION_NOT_FOUND(404): фронт вызываетstart-session(../api/start-session.md), затем повторяетlink. - Консюмер входа →
skipped-no-active-session(ack, не ошибка): born-linked сессию создаст ближайший authenticatedtrack-*/session-heartbeat(../api/session-heartbeat.md).
A5. Серверный путь без корреляции AnonymousId
identityещё не эмититAnonymousIdнаpin.identity.sign-ins.v1→ консюмер инертен (skipped-no-correlation, ack). Связывание полностью обслуживает фронтовыйlink(A-путь).
A6. Гонка обоих путей / устаревший вход
- Блокировка строки на загрузке сериализует параллельные
link/консюмер; второй попадает вAlreadyLinked(A2) — no-op. - Устаревший
sign-inout-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получают snapshotAnonymousId → UserId.- Merge гостевого избранного инициирован фронтом (
favorites) — вне границuser-activity. - При
Switched(A3): старая сессияInactiveс посчитаннымDuration, её открытые просмотры закрыты, новая born-linkedActiveсоздана;session-linkedдля новой не публиковался.
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|---|---|---|
VALIDATION_FAILED | 400 | X-Anonymous-Id отсутствует / не Guid (USER_ACTIVITY_ANONYMOUS_ID_REQUIRED) | исправить заголовок, повтор |
UNAUTHORIZED | 401 | нет / невалидный JWT (нет claim sub) | повторный вход в identity |
USER_ACTIVITY_SESSION_NOT_FOUND | 404 | нет 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 |
PropertyObjectViewEndedEvent | Switched — на каждый закрытый Active-просмотр прежней сессии (A3, INV-LV-007) | in-proc → property-object-view-ended |
SessionStartedEvent | Switched — создание новой born-linked сессии (A3) | in-proc (не даёт session-linked) |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|---|---|---|
SessionLinkedKafkaEvent.v1 | pin.user-activity.session-linked.v1 | Исход Linked (связывание гостевой сессии) | crm / favorites (кандидаты, ../events/session-linked.md) |
SessionEndedKafkaEvent.v1 | pin.user-activity.session-ended.v1 | Switched — форс-закрытие прежней сессии (A3) | dev-stats (../events/session-ended.md) |
PropertyObjectViewEndedKafkaEvent.v1 | pin.user-activity.property-object-view-ended.v1 | Switched — закрытые просмотры прежней сессии (A3) | PropertyObjectViewStats (self), dev-stats, crm (../events/property-object-view-ended.md) |
Consumed: UserSignedInKafkaEvent.v1 ← pin.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 = SessionId | at-least-once; ровно один раз на сессию по построению (LinkOnce, INV-SES-002) | crm / favorites — кандидаты (snapshot AnonymousId → UserId) |
SessionEndedKafkaEvent · pin.user-activity.session-ended.v1 (v1) | AggregateId = SessionId | at-least-once | dev-stats — только при Switched (A3) |
PropertyObjectViewEndedKafkaEvent · pin.user-activity.property-object-view-ended.v1 (v1) | AggregateId = PropertyObjectViewId | at-least-once | self (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) | конкурентный тест двух путей |
| AC4 | session-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 |
| AC7 | X-Anonymous-Id отсутствует/не Guid → 400; нет JWT sub → 401 | тест валидации/авторизации |
Обратные ссылки
Автоссылки: где используется этот документ.
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- API (детерминированный линкер): ../api/link-session.md; история: ../api/get-my-history.md; merge favorites: ../api/get-session-link.md; жизненный цикл сессии: ../api/start-session.md, ../api/session-heartbeat.md.
- Consumer (путь-дублёр): ../consumers/identity-signed-in.md (ветки B/C/D, идемпотентность).
- Domain entity: ../domain/session.md (INV-SES-002 LinkOnce, INV-SES-005,
chk_sessions_link_pair), ../domain/property-object-view.md (правило чтения, INV-LV-007), ../domain/action-event.md. - События: ../events/session-linked.md, ../events/session-ended.md, ../events/property-object-view-ended.md; входной контракт ../../identity/events/user-signed-in.md.
- Смежные контексты: ../../favorites/README.md (
X-Anonymous-Id, merge избранного), обзор контекста ../README.md. - Решения: ADR-0052 (вне tenant), ADR-0054 (объединение
user-activity, событие связывания на будущее), ADR-0056 (simplicity-first: без backfill, PostgreSQL-only).