Consumer: Identity Sign-Ins (UserActivitySignedInConsumer)
Назначение
При успешном входе (confirmSignIn / verifySignInPassword / пин-код) identity публикует
pin.identity.user-signed-in. Consumer применяет к активной сессии устройства правило INV-SES-002 «LinkOnce»
(session.md):
- гостевая
Active-сессия (UserId == null) связывается с вошедшим пользователем (UserId: null → value,LinkedAt = now()) — гостевая активность «до входа» приписывается учётке (просмотры/действия читаются в истории пользователя, property-object-view.md «Правило чтения»); - вход другого пользователя на устройстве с уже связанной
Active-сессией — текущая сессия форс-закрывается (Active → Inactive,Durationсчитается,session-ended), а новая рождается уже связанной (UserIdустановлен при создании); - повтор того же пользователя — no-op (идемпотентно).
| Metadata | Значение |
|---|---|
| Owning team | PIN Engagement |
| Статус документа | draft |
| Область действия | контекст user-activity; таблицы user_activity.sessions, user_activity.property_object_views |
| Change context | greenfield (ADR-0054 объединение; ADR-0056 simplicity-first, PostgreSQL-only) |
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | user-activity (консюмеры и worker-ы) |
| Consumer | UserActivitySignedInConsumer |
| Обрабатываемое событие | pin.identity.user-signed-in |
| Topic | pin.identity.sign-ins.v1 (6 партиций, key = UserId) |
| ContractVersion | 1; при несовпадении версии — warn-and-proceed |
| Consumer group | pin-user-activity-sign-ins |
| Producer | identity (confirm-операции входа; refresh-flow не публикует) |
| Эффект (событие → что меняется) | связывание / форс-закрытие Active-сессии устройства (user_activity.sessions); закрытие открытых просмотров форс-закрываемой сессии (user_activity.property_object_views) |
Контекст события
- Из метаданных события доступны:
MessageId(ключ дедупа),CreatedAt(= время входа),Version,AggregateId = UserId,AggregateType = "User". - Actor — system-actor консюмера (не пользовательский контекст). Контекст вне tenant (ADR-0052 п.2):
изоляция record-level по
AnonymousId/UserId. - Корреляция сквозного трейса восстанавливается из метаданных события и пробрасывается в исходящие события
pin.user-activity.session-*/property-object-view-ended. - Идемпотентность: consumer-level — ключ
user-activity-sign-in:{MessageId}; business-level — INV-SES-002 (см. «Идемпотентность и replay»).
Assumptions и зависимости (корреляция устройства)
Событие входа (identity v1, PII-минимизировано) несёт UserId и identity-SessionId
(RefreshSession.Id), но не device-AnonymousId этого контекста. Разрешение сессии устройства:
- Deterministic-путь (фронт):
POST /sessions/link(заголовокX-Anonymous-Id+ JWT claimsub) — авторитетный линкер, фронт держит иAnonymousId(localStorage), и JWT; session.md перечисляет его источникомUserId, session-linked.md — producer-омsession-linked. - Event-путь (этот consumer): идемпотентный комплемент, гарантирующий связывание/форс-закрытие без
участия фронта. Требует device-
AnonymousId; берёт его как additive-корреляцию наpin.identity.sign-ins.v1(клиентскийX-Anonymous-Id, пробрасываемыйidentity), консистентно с формулировкой user-signed-in.md «Связывание активной anonymous_id-сессии». Еслиidentityещё не эмитит поле — consumer инертен (warn + ack), связывание полностью обслуживаетPOST /sessions/link.
Входные данные / payload события
Контракт — event: pin.identity.user-signed-in; ниже — только
поля, которые consumer реально читает, и их маппинг.
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
AnonymousId | Guid | Да (корреляция, см. Assumptions) | Ключ поиска активной сессии устройства (AnonymousId + Status = Active) |
UserId | Guid | Да | Цель связывания → Session.UserId; сравнение с Session.UserId (ветвление B/C/D) |
OccurredAt (= время события) | DateTimeOffset | Да | Монотонный best-effort guard против out-of-order (ветка D); server-clock now() ставит сам consumer (INV-SES-004) |
SignInMethod | string (enum Sms/Password/PinCode) | Да | Только лог/метрика; на семантику связывания не влияет; unknown → warn-and-proceed |
SessionId (identity RefreshSession.Id) | Guid | Да | Не ключ нашей Session; логируется как correlation-хэндл витрины сессий |
IsFirstSignIn, DevicePlatform, DeviceDisplayName | bool/string?/string? | — | Не используются (Device фиксируется при startSession, не обновляется) |
Алгоритм
- Приём входа и контекста. Восстановить контекст события (
MessageId,CreatedAt,AggregateId = UserId); actor = system; сверить версию сContractVersion = 1— при несовпадении warn-and-proceed; consumer- идемпотентность по ключуuser-activity-sign-in:{MessageId}— дубль → логskipped-duplicate, ack. - Проверка входа.
UserIdнепустой;AnonymousIdнепустой — при отсутствии/пустоте (корреляция не эмитится) логskipped-no-correlation, ack (consumer инертен, см. Assumptions).SignInMethodвне enum → warn, продолжение. Существование пользователяidentity.users(UserId)синхронно не проверяется (кросс-контекст, логическая ссылка session.md; валидность гарантирует producer). - Резолв активной сессии устройства. Найти
Active-сессию поAnonymousIdпод блокировкой строки (блокирующе, не с пропуском: consumer должен получить авторитетное состояние и сериализоваться против worker-аactivity-closerи heartbeat, INV-SES-006). Результат единичный (INV-SES-001SingleActivePerDevice), может отсутствовать. - Бизнес-правила и переходы (INV-SES-002):
- A — нет активной сессии (
session == null): связывать/закрывать нечего (сессия закрыта по неактивности или не создана server-side). No-op, логskipped-no-active-session; born-linked сессию создаст ближайший authenticated track/heartbeat/link(у consumer-а нетDevice.UserAgentдля создания). - B — гостевая сессия (
session.UserId == null): связать сессию сUserId→UserId = UserId,LinkedAt = now();StatusостаётсяActive. Поднимается событиеSessionLinked. - C — уже связана с тем же (
session.UserId == UserId): no-op (LinkOnce, повтор входа), логskipped-already-linked. - D — связана с другим (
session.UserId != null && != UserId), guardCreatedAt > session.LinkedAt(иначе — устаревший вход out-of-order →skipped-stale):- форс-закрытие текущей:
Active → Inactive,EndedAt = now(),Duration = LastActivityAt − CreatedAt(INV-SES-005); открытыеPropertyObjectViewсессии закрываются тем же согласованным изменением (INV-LV-007, см. шаг 5). Поднимается событиеSessionEnded. - создание новой born-linked:
Status = Active,UserIdустановлен при создании (LinkedAt = CreatedAt = now()),Deviceпереиспользуется (то же физическое устройство). Это не переходnull → value, поэтомуsession-linkedдля новой сессии не публикуется (session-linked.md).
- форс-закрытие текущей:
- A — нет активной сессии (
- Что меняется (порядок с учётом FK/INV-SES-001):
- B: сессия обновляется (связана).
- D: открытые
Active-просмотры форс-закрываемой сессии закрываются (каждый —Duration,EndedAt, событиеPropertyObjectViewEnded); затем старая сессия сохраняется какInactive, новая — какActiveв одном согласованном изменении (сохраняется partial-uniqueux_sessions_active_anonymous, INV-SES-001). - Во всех ветках — отметка idempotency-ключа.
- Какие события фиксируются:
- B:
SessionLinkedKafkaEvent→pin.user-activity.session-linked.v1(AggregateId = SessionId,ContractVersion = 1; payload — session-linked.md). - D:
SessionEndedKafkaEvent→pin.user-activity.session-ended.v1(закрытая сессия,UserId= прежний; payload — session-ended.md) + по одномуPropertyObjectViewEndedKafkaEvent→pin.user-activity.property-object-view-ended.v1на каждый закрытыйPropertyObjectView(property-object-view-ended.md, чтобыPropertyObjectViewStatsучла длительность). Новая born-linked сессия событий не даёт. - A/C: событий нет.
- B:
- Согласованность. Изменения
sessions+property_object_views+ отметка идемпотентности + исходящие события фиксируются в одном согласованном изменении; Kafka-offset коммитится только после успешной фиксации. - Результат. Успех → ack. Инфраструктурная ошибка → retry по политике консюмера (см. «Retry и DLQ»). Метрики/логи — раздел «Наблюдаемость».
Диаграмма
Идемпотентность и replay
- Consumer-level: дедуп по
MessageId(ключuser-activity-sign-in:{MessageId}) — повторная at-least-once доставка after-commit-crash не мутирует состояние. - Business-level (INV-SES-002): связывание идемпотентно по построению — повторная обработка того же входа
попадает в ветку C (сессия уже связана с
UserId) → no-op. Форс-закрытие (D) при повторе не повторяется: после первой обработки активная сессия связана сUserId→ снова ветка C. - Event — факт (append-only, не snapshot): version-guard не нужен (правило user-signed-in.md), достаточно
дедупа по
MessageId. Против out-of-order разных входов на одном устройстве — best-effort guard ветки DCreatedAt > session.LinkedAt(толерантен к межсерверному clock-skew). - Replay: безопасен (дедуп по
MessageId+ идемпотентность INV-SES-002). Полный re-read топика с offset 0 не нужен (identity— owner входов); точечный replay из DLQ — после ручного разбора.
Retry и DLQ
DLQ-топик группы: pin.user-activity.sign-ins-dlq.v1 (событие + заголовки x-dlq-reason,
x-dlq-error-code, x-source-topic, x-original-offset).
| Сбой | Retry | DLQ | Ручное действие |
|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | Бесконечный exponential backoff 1s→60s с паузой партиции (событие не теряется) | Нет | Runbook: восстановить зависимость |
Нет корреляции AnonymousId (identity не эмитит поле) | Нет — контролируемый skipped-no-correlation, ack | Нет | Не ошибка: связывание обслуживает POST /sessions/link; при включении поля — replay не требуется |
| Устаревший вход out-of-order (guard ветки D) | Нет — контролируемый skipped-stale, ack | Нет | Нет (штатная защита от out-of-order) |
Contract mismatch (несовпадение версии) / unknown SignInMethod | Нет — warn-and-proceed | Нет | Обновить маппинг при breaking-bump (.v2) |
| Непредвиденная ошибка обработки | 3 попытки немедленно (перечитать сессию) | После исчерпания | Расследование гонки/бага |
Acceptance Criteria
| # | Given / When / Then |
|---|---|
| AC1 (ветка A) | Given user-signed-in с AnonymousId, для которого нет Active-сессии When consumer обрабатывает Then no-op, skipped-no-active-session, ack; born-linked сессию создаст ближайший authenticated track/heartbeat/link (у consumer-а нет Device для создания) |
| AC2 (ветка B) | Given гостевая Active-сессия устройства (UserId = null) When приходит вход UserId = U Then сессия связывается (UserId = U, LinkedAt = now(), Status остаётся Active), публикуется session-linked атомарно со сменой состояния |
| AC3 (ветка C) | Given сессия уже связана с тем же UserId = U When приходит повторный вход U (или replay того же события) Then no-op, skipped-already-linked, событий нет (LinkOnce идемпотентен) |
| AC4 (ветка D) | Given сессия связана с UserId = U1, CreatedAt входа > session.LinkedAt When входит другой UserId = U2 Then текущая сессия форс-закрывается (Active → Inactive, Duration считается), её открытые просмотры закрыты, публикуются session-ended + по одному property-object-view-ended; создаётся новая born-linked сессия (UserId = U2), для неё session-linked не публикуется |
| AC5 (out-of-order) | Given сессия связана с U1, приходит устаревший вход (CreatedAt <= session.LinkedAt) When обработан Then skipped-stale, ack, состояние не меняется (guard ветки D) |
| AC6 (нет корреляции) | Given identity ещё не эмитит AnonymousId When событие обработано Then consumer инертен: skipped-no-correlation, ack; связывание полностью обслуживает POST /sessions/link |
| AC7 (дубль) | Given повторная at-least-once доставка с тем же MessageId When обработано Then skipped-duplicate, ack, без мутаций |
Наблюдаемость и безопасность
- Logs (structured, по событию):
consumed/skipped-duplicate/skipped-no-correlation/skipped-no-active-session/linked/skipped-already-linked/user-switched/skipped-stale/failedс полямиMessageId,AnonymousId,UserId,SessionId. PII нет;Device.*(UA/IP) в событии отсутствуют и не логируются. - Metrics:
user_activity_signin_consume_duration_ms,user_activity_sessions_linked_total,user_activity_sessions_user_switched_total,user_activity_signin_stale_total,user_activity_signin_no_correlation_total,user_activity_signin_duplicates_total,user_activity_signin_retries_total,user_activity_signin_dlq_total(ожидается 0). - Traces: consume-span → span обработки → spans обращений к хранилищу; correlation из метаданных события
пробрасывается в исходящие
session-linked/session-ended/property-object-view-ended. - Alerts: lag > 1 мин (SLA user-signed-in.md); DLQ > 0; всплеск
no_correlation(регресс контрактаidentity) илиstale(нарушение порядка). - Безопасность: system-actor (не внешний); контекст вне tenant (ADR-0052 п.2) — изоляция record-level по
AnonymousId/UserId, tenant-фильтр не применяется; доступ к топикуpin.identity.sign-ins.v1— m2m ACL Kafka. Событие не содержит secrets/токенов/кодов/телефона (user-signed-in.md).
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: Session
- API (1): POST /api/user-activity/sessions — Старт/восстановление сессии устройства
- Use Cases (2): UC-ACT-001. Трекинг сессии гостя и пользователя, UC-ACT-005. Слияние гостевой и пользовательской активности при входе
- События (3): Event Contract: pin.identity.user-signed-in (UserSignedInKafkaEvent), Event Contract: pin.user-activity.session-ended (SessionEndedKafkaEvent), Event Contract: pin.user-activity.session-linked (SessionLinkedKafkaEvent)
- Консюмеры (1): Consumer: Favorites Events (UserActivityFavoriteAddedConsumer / ...RemovedConsumer)
Связанные документы
- Дом изменяемого агрегата и инварианты: session.md (INV-SES-001/002/004/005/006), property-object-view.md (INV-LV-007 — закрытие просмотров при форс-закрытии сессии).
- Входной контракт: ../../identity/events/user-signed-in.md; смежная сущность источника: ../../identity/domain/user-session.md.
- Производимые события: session-linked.md, session-ended.md, property-object-view-ended.md.
- Смежный producer линковки:
POST /sessions/link(../api/); worker закрытия: activity-closer; обзор контекста: ../README.md. - Решения: ADR-0052 (вне tenant), ADR-0054 (объединение, событие связывания), ADR-0056 (simplicity-first, PostgreSQL-only, polling-delta закрытие).