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 area | public (self-scope: гость по X-Anonymous-Id, пользователь по JWT) |
| Feature group | sessions |
| Статус | draft |
| Документ | docs/06-services/user-activity/api/public/start-session.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | Anonymous (гость; устройство идентифицируется заголовком X-Anonymous-Id, у нового гостя заголовка нет) или User (JWT: claim sub → UserId) |
| Auth policy / Permissions | none (публичный метод); rate limit по X-Anonymous-Id + IP (защита от флуда стартов) |
| Scope (RBAC) | не применяется — актор вне tenant (ADR-0052 п.2) |
| Record-level | self: владелец записи — AnonymousId (и UserId, если JWT); чужие сессии этим методом недоступны |
| Tenant isolation | не применяется — контекст вне tenant (ADR-0052 п.2); tenant-интерсепторы не подключаются |
| Audit | create (CreatedAt/LinkedAt на записи; жизненный цикл воспроизводим по событиям, session.md). Device.IpAddress — квази-PII: в логах маска /24, в ответ и события не попадает |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/user-activity/sessions |
| Success status | 201 (создана новая сессия) / 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).
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
AnonymousId | header X-Anonymous-Id | Guid? | Нет | если задан — валидный Guid (не Guid.Empty); отсутствует у нового гостя → выдаётся сервером (шаг 4) | идентификатор устройства, не секрет |
UserAgent | header User-Agent | string | Да (шлёт браузер) | 1..1024, обрезается по длине; парсинг UA на сервере → DeviceType/Os/Browser | в логах без PII |
IpAddress | connection / X-Forwarded-For (UseForwardedHeaders) | string? | Нет | формат IPv4/IPv6, <= 45 | квази-PII: маска /24 в логах, исключён из ответа и событий |
ActorUserId | identity claims (JWT sub) | Guid? | Нет | JWT пользователя, если авторизован; не принимается из body/query (шаг 1) | — |
Модель ответа StartSessionResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
SessionId | Guid | Да | Session.Id | — | Идентификатор активной сессии (для heartbeat/track-*/link). |
AnonymousId | Guid | Да | Session.AnonymousId | — | Идентификатор устройства. Клиент обязан сохранить (localStorage/cookie) и слать в X-Anonymous-Id. |
UserId | Guid? | Нет | Session.UserId | — | Заполнен, если сессия рождена связанной (JWT на старте); иначе null (гостевая). |
Status | SessionStatus (string) | Да | Session.Status | — | Всегда Active. |
IsNew | bool | Да | computed (шаг 4) | — | true — создана новая сессия; false — восстановлена существующая Active (heartbeat). |
CreatedAt | DateTimeOffset | Да | Session.CreatedAt | — | Начало сессии (сервер-время). |
LastActivityAt | DateTimeOffset | Да | Session.LastActivityAt | — | Момент этой активности (сервер-время). |
HeartbeatIntervalSeconds | int | Да | 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в контексте не публикуется); связывание/закрытие эмитят события в других операциях.
Алгоритм
- Контекст и доступ. Актор —
Anonymous(X-Anonymous-Id, у нового гостя заголовка нет) илиUser(UserIdиз JWTsub), self-scope, вне tenant (ADR-0052 п.2).ActorUserIdберётся только из claimsub(не из body/query). Клиентские timestamp игнорируются (INV-SES-004). Бизнес-идемпотентность — по состоянию (шаг 3). - Проверка входа. Если
AnonymousIdзадан — неGuid.Empty;UserAgentприсутствует,1..1024(обрезка);IpAddress, если задан, — валидный формат; иначеVALIDATION_FAILED(400). Входных ссылок на агрегаты нет — существование не проверяется; операция создаёт/продлевает, поэтому допустимость состояния не проверяется. - Что читаем. Если
AnonymousIdзадан — активную (Status = Active) сессию устройства (единственность — partial-uniqueux_sessions_active_anonymous). Если заголовка нет — чтение не нужно (гарантированно новый гость). - Бизнес-правила и переходы.
- restore (активная сессия есть, INV-SES-001): дубль не создаётся — активность продлевается (
Active → Active); маппинг:SessionId/AnonymousId/UserId/CreatedAtиз записи,LastActivityAt = now(),IsNew = false.DeviceиUserIdне трогаются (устройство фиксируется один раз; связывание — endpointlink, 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.
- restore (активная сессия есть, INV-SES-001): дубль не создаётся — активность продлевается (
- Что меняется. restore — продление
LastActivityAtактивной сессии; если worker закрыл её между шагами 3 и 5 (0 затронутых строк) — переход в ветку create (авто-restart, INV-SES-003). create — вставка новой сессии; гонка двойногоstart-session(нарушениеux_sessions_active_anonymous) → перечитать активную сессию и вернуть её как restore (200,IsNew = false) — идемпотентный успех. - Какие события фиксируются. Интеграционных событий нет:
SessionStartedEvent— доменное (session-startedв контексте не публикуется; outbox толькоsession-linked/session-ended). - Согласованность. Создание либо продление сессии фиксируется в одном согласованном изменении; доменные события — вместе с ним.
- Результат.
{ SessionId, AnonymousId, UserId?, Status = Active, IsNew, CreatedAt, LastActivityAt, HeartbeatIntervalSeconds }→201(create) /200(restore).AnonymousId— контракт с клиентом: сохранить и слать вX-Anonymous-Id.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | none — SessionStartedEvent доменное (без Kafka); связывание/закрытие эмитят session-linked/session-ended в других операциях |
| External calls | none — UA парсится локально; identity не вызывается (UserId берётся из JWT claim) |
| Cache / projections / search | none |
| Notifications | none |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | X-Anonymous-Id не Guid / Guid.Empty; User-Agent пуст или > 1024 | «Некорректные параметры запроса» | no retry |
USER_ACTIVITY_STORAGE_UNAVAILABLE | 503 | PostgreSQL недоступен на шаге 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, WhenPOST /sessions, Then201, сервер выдал новыйAnonymousId,IsNew = true,Status = Active,UserId = null. - Given у устройства уже есть
Active-сессия, When повторныйPOSTс тем жеX-Anonymous-Id, Then200, тот жеSessionId,IsNew = false,LastActivityAtпродлён, дубль не создан (INV-SES-001),Device/UserIdне изменены. - Given предъявлен JWT
subи активной сессии устройства нет, WhenPOST, Then201, сессия рождена связанной (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, WhenPOST, ThenVALIDATION_FAILED(400). - Given PostgreSQL недоступен, When
POST, ThenUSER_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 Grafanapin-user-activity; runbook —docs/06-services/user-activity/runbooks/(будущий путь по конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (2): UC-ACT-001. Трекинг сессии гостя и пользователя, UC-ACT-005. Слияние гостевой и пользовательской активности при входе
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущность/дом полей и инварианты: 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/закрытие).