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

POST /api/user-activity/sessions — Старт/восстановление сессии устройства

Публичная точка входа трекинга: устройство/браузер объявляет о начале активности. Метод создаёт новую Active-сессию или восстанавливает уже открытую (идемпотентно, INV-SES-001), выдаёт гостю AnonymousId (владелец гостевой идентичности для favorites, контракт X-Anonymous-Id) и фиксирует одноразовый снимок DeviceInfo. Дом полей — session.md; карта API — ../README.md. Связывание сессии с пользователем при входе — отдельный endpoint link (INV-SES-002); продление активности — heartbeat (INV-SES-003).

Назначение

Дать фронту первый вызов трекинга: получить стабильный AnonymousId устройства (для последующих heartbeat/track-* и для гостевого избранного) и открыть период активности с зафиксированным устройством. Повторный вызов того же устройства с открытой сессией — не создаёт дубль, а продлевает активность (INV-SES-001).

Metadata

ПолеЗначение
Сервис/контекстuser-activity (модуль sessions)
API areapublic (self-scope: гость по X-Anonymous-Id, пользователь по JWT)
Feature groupsessions
Статусdraft
Документdocs/06-services/user-activity/api/public/start-session.md

Актор и доступ

ПроверкаЗначение
Actor typeAnonymous (гость; устройство идентифицируется заголовком X-Anonymous-Id, у нового гостя заголовка нет) или User (JWT: claim subUserId)
Auth policy / Permissionsnone (публичный метод); rate limit по X-Anonymous-Id + IP (защита от флуда стартов)
Scope (RBAC)не применяется — актор вне tenant (ADR-0052 п.2)
Record-levelself: владелец записи — AnonymousIdUserId, если JWT); чужие сессии этим методом недоступны
Tenant isolationне применяется — контекст вне tenant (ADR-0052 п.2); tenant-интерсепторы не подключаются
Auditcreate (CreatedAt/LinkedAt на записи; жизненный цикл воспроизводим по событиям, session.md). Device.IpAddress — квази-PII: в логах маска /24, в ответ и события не попадает

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/user-activity/sessions
Success status201 (создана новая сессия) / 200 (восстановлена существующая Active — heartbeat, INV-SES-001)
Idempotency headerне применяется — бизнес-идемпотентность по AnonymousId среди Active (partial-unique ux_sessions_active_anonymous, шаги 3/5)
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса

Тело запроса пустое ({}): все данные берутся из заголовков и identity-контекста; клиентские timestamp-ы не принимаются (INV-SES-004).

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
AnonymousIdheader X-Anonymous-IdGuid?Нетесли задан — валидный Guid (не Guid.Empty); отсутствует у нового гостя → выдаётся сервером (шаг 4)идентификатор устройства, не секрет
UserAgentheader User-AgentstringДа (шлёт браузер)1..1024, обрезается по длине; парсинг UA на сервере → DeviceType/Os/Browserв логах без PII
IpAddressconnection / X-Forwarded-For (UseForwardedHeaders)string?Нетформат IPv4/IPv6, <= 45квази-PII: маска /24 в логах, исключён из ответа и событий
ActorUserIdidentity claims (JWT sub)Guid?НетJWT пользователя, если авторизован; не принимается из body/query (шаг 1)

Модель ответа StartSessionResult

ПолеТипОбязательностьИсточникMaskingОписание
SessionIdGuidДаSession.IdИдентификатор активной сессии (для heartbeat/track-*/link).
AnonymousIdGuidДаSession.AnonymousIdИдентификатор устройства. Клиент обязан сохранить (localStorage/cookie) и слать в X-Anonymous-Id.
UserIdGuid?НетSession.UserIdЗаполнен, если сессия рождена связанной (JWT на старте); иначе null (гостевая).
StatusSessionStatus (string)ДаSession.StatusВсегда Active.
IsNewboolДаcomputed (шаг 4)true — создана новая сессия; false — восстановлена существующая Active (heartbeat).
CreatedAtDateTimeOffsetДаSession.CreatedAtНачало сессии (сервер-время).
LastActivityAtDateTimeOffsetДаSession.LastActivityAtМомент этой активности (сервер-время).
HeartbeatIntervalSecondsintДаconfig (по умолчанию 60)Подсказка фронту: период heartbeat; ниже порога закрытия worker-а (10–15 мин).

Device* наружу не возвращается (IpAddress/UserAgent — квази-PII, session.md).

Предусловия и постусловия

Предусловия:

  • Клиент способен сохранить и вернуть AnonymousId (localStorage/cookie); у нового гостя заголовка X-Anonymous-Id ещё нет — сервер выдаёт AnonymousId в ответе.
  • Для рождения уже связанной сессии — предъявлен валидный JWT (claim sub); иначе сессия гостевая (UserId = null).

Постусловия (гарантии после успеха):

  • Существует ровно одна Active-сессия устройства (INV-SES-001): либо создана (IsNew = true, 201), либо восстановлена/продлена (IsNew = false, 200).
  • У устройства есть стабильный AnonymousId, пригодный как ключ владения гостевыми записями favorites.
  • DeviceInfo зафиксирован один раз при создании; при восстановлении Device и UserId не перезаписаны.
  • Интеграционных событий не отправлено (session-started в контексте не публикуется); связывание/закрытие эмитят события в других операциях.

Алгоритм

  1. Контекст и доступ. Актор — Anonymous (X-Anonymous-Id, у нового гостя заголовка нет) или User (UserId из JWT sub), self-scope, вне tenant (ADR-0052 п.2). ActorUserId берётся только из claim sub (не из body/query). Клиентские timestamp игнорируются (INV-SES-004). Бизнес-идемпотентность — по состоянию (шаг 3).
  2. Проверка входа. Если AnonymousId задан — не Guid.Empty; UserAgent присутствует, 1..1024 (обрезка); IpAddress, если задан, — валидный формат; иначе VALIDATION_FAILED (400). Входных ссылок на агрегаты нет — существование не проверяется; операция создаёт/продлевает, поэтому допустимость состояния не проверяется.
  3. Что читаем. Если AnonymousId задан — активную (Status = Active) сессию устройства (единственность — partial-unique ux_sessions_active_anonymous). Если заголовка нет — чтение не нужно (гарантированно новый гость).
  4. Бизнес-правила и переходы.
    • restore (активная сессия есть, INV-SES-001): дубль не создаётся — активность продлевается (Active → Active); маппинг: SessionId/AnonymousId/UserId/CreatedAt из записи, LastActivityAt = now(), IsNew = false. Device и UserId не трогаются (устройство фиксируется один раз; связывание — endpoint link, INV-SES-002);
    • create (активной сессии нет): AnonymousId = заголовок или новый (выдача гостю); DeviceInfo собирается из User-Agent (DeviceType, неопознанное → Unknown, Os?, Browser?, IpAddress?); создаётся Session (Status = Active, CreatedAt = LastActivityAt = now(), INV-SES-004). Если ActorUserId != null — сессия рождается связанной (UserId, LinkedAt = now(); это не транзиция входа, SessionLinkedKafkaEvent не эмитится); IsNew = true. Поднимается доменное SessionStartedEvent.
  5. Что меняется. restore — продление LastActivityAt активной сессии; если worker закрыл её между шагами 3 и 5 (0 затронутых строк) — переход в ветку create (авто-restart, INV-SES-003). create — вставка новой сессии; гонка двойного start-session (нарушение ux_sessions_active_anonymous) → перечитать активную сессию и вернуть её как restore (200, IsNew = false) — идемпотентный успех.
  6. Какие события фиксируются. Интеграционных событий нет: SessionStartedEvent — доменное (session-started в контексте не публикуется; outbox только session-linked/session-ended).
  7. Согласованность. Создание либо продление сессии фиксируется в одном согласованном изменении; доменные события — вместе с ним.
  8. Результат. { SessionId, AnonymousId, UserId?, Status = Active, IsNew, CreatedAt, LastActivityAt, HeartbeatIntervalSeconds }201 (create) / 200 (restore). AnonymousId — контракт с клиентом: сохранить и слать в X-Anonymous-Id.

Диаграмма

Побочные эффекты

ТипДетали
Integration eventsnone — SessionStartedEvent доменное (без Kafka); связывание/закрытие эмитят session-linked/session-ended в других операциях
External callsnone — UA парсится локально; identity не вызывается (UserId берётся из JWT claim)
Cache / projections / searchnone
Notificationsnone

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400X-Anonymous-Id не Guid / Guid.Empty; User-Agent пуст или > 1024«Некорректные параметры запроса»no retry
USER_ACTIVITY_STORAGE_UNAVAILABLE503PostgreSQL недоступен на шаге 5/7«Сервис временно недоступен»retry

Гонка двойного startSession (23505 на ux_sessions_active_anonymous) и закрытие сессии worker-ом между шагами 3–5 не выдаются наружу — обрабатываются внутри как restore/create (INV-SES-001/003), клиент всегда получает 200/201.

Acceptance-критерии

  • Given новый гость без X-Anonymous-Id, When POST /sessions, Then 201, сервер выдал новый AnonymousId, IsNew = true, Status = Active, UserId = null.
  • Given у устройства уже есть Active-сессия, When повторный POST с тем же X-Anonymous-Id, Then 200, тот же SessionId, IsNew = false, LastActivityAt продлён, дубль не создан (INV-SES-001), Device/UserId не изменены.
  • Given предъявлен JWT sub и активной сессии устройства нет, When POST, Then 201, сессия рождена связанной (UserId заполнен, LinkedAt = CreatedAt), при этом SessionLinkedKafkaEvent не эмитится (это создание, а не переход null → value).
  • Given две параллельные попытки старта одного устройства, When обе доходят до вставки, Then одна создаёт сессию, вторая перечитывает активную и возвращает её как restore (200, IsNew = false) — гонка единственности активной сессии наружу не выходит.
  • Given worker закрыл сессию между чтением и продлением, When ветка restore обнаруживает 0 затронутых строк, Then выполняется create (авто-restart, INV-SES-003), клиент получает 201.
  • Given X-Anonymous-Id = Guid.Empty или User-Agent пуст/длиннее 1024, When POST, Then VALIDATION_FAILED (400).
  • Given PostgreSQL недоступен, When POST, Then USER_ACTIVITY_STORAGE_UNAVAILABLE (503), клиент повторяет.

Совместимость и наблюдаемость

  • AnonymousId (X-Anonymous-Id) — совместный контракт с favorites: переименование/смена формата — breaking (координируется с favorites). Новые optional-поля ответа — additive. Неопознанный DeviceType при парсинге UA → Unknown (не ошибка).
  • Logs: session started / session restored (SessionId, AnonymousId, IsNew, DeviceType; IP — маска /24; UserAgent — обрезан, без PII). Metrics: user_activity_sessions_started_total{result=new|restored}, latency p95 < 200 мс (heartbeat-ветка — одно условное продление без загрузки агрегата). Traces — сквозной trace через traceparent; dashboard Grafana pin-user-activity; runbook — docs/06-services/user-activity/runbooks/ (будущий путь по конвенции).

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

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

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

  • Сущность/дом полей и инварианты: session.md (INV-SES-001/002/003/004), VO DeviceInfo.
  • Смежные операции сессии (будущие пути): heartbeat.md (INV-SES-003), link.md (INV-SES-002), консюмер ../consumers/identity-signed-in.md (связывание при входе).
  • Контекст: ../README.md; контракт X-Anonymous-Id и merge — ../../favorites/README.md.
  • Решения: ADR-0052 (вне tenant), ADR-0054 (модуль sessions), ADR-0056 п.3 (heartbeat/закрытие).