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

POST /api/user-activity/actions — Зафиксировать действие пользователя (track-action)

Публичная self-scope точка входа ленты действий контекста user-activity (модуль actions): фиксирует одно значимое клиент-инициированное действие с типизированным payload по ActionType — «применён фильтр каталога» (ApplyFilter) и «выполнен текстовый поиск» (Search). Создаёт запись append-only TPH-иерархии ActionEvent и публикует событие action-tracked для ленты карточки лида CRM.

Границы метода. Публичный endpoint принимает только подтипы, источник которых — сам клиент витрины (ApplyFilter, Search). Остальные подтипы ActionEvent создаются не через этот метод и здесь отклоняются: ViewPropertyObject пишется внутри контекста той же транзакцией, что PropertyObjectView (endpoint track-property-object-view, событие PropertyObjectViewStartedEvent); AddToFavorites/RemoveFromFavorites/ AddToSelection — производные записи консюмеров favorites/selections (INV-ACT-003, дом action-event.md). Это сохраняет «один дом» и серверную достоверность источника.

Назначение

Принять с витрины типизированное действие (применение фильтра каталога или текстовый поиск), привязать его к активной сессии устройства/пользователя (обеспечение по INV-ACT-003) и записать в ленту действий как источник поведенческой истории (/profile/events, карточка лида CRM, вход рекомендаций news).

Частота и дебаунс (важно для нагрузки). На витрине каталога кнопки «Найти» нет — фильтры применяются на лету. Фронт разделяет два потока: (1) выдача каталога перезапрашивается с debounce 200 мс после изменения контролов фильтра (чтобы список реагировал живо); (2) трекинг фильтра — этот track-action с ApplyFilter — шлётся вторым, отдельным запросом с бо́льшим trailing-дебаунсом 5000–7000 мс. Так в action_events сохраняется только устоявшийся фильтр — тот, на котором пользователь остановился, а не каждое промежуточное изменение слайдера/чекбокса. Это не флудит бэкенд и БД (одно осмысленное ApplyFilterActionEvent вместо десятков тиков) и удерживает семантику «одно сохранённое ApplyFilter = один осознанный набор параметров». Search трекается по факту выполненного поиска.

Metadata

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

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

ПроверкаЗначение
Actor typeAnonymous (заголовок X-Anonymous-Id) или User (JWT sub, auth SessionId)
Auth policy / Permissionsnone (публичный self-scope); rate limit по актору/IP
Scope (RBAC)не применяется (актор вне tenant, ADR-0052 п.2)
Record-levelвладелец: запись пишется в сессию актора (AnonymousId/UserId); чужие сессии недоступны
Tenant isolationне применяется — контекст вне tenant (ADR-0052 п.2); tenant-интерсепторы не подключаются
Auditcreate (сама запись — журнал действий); SearchQuery — Internal (в логах ≤32 символов); PII нет

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/user-activity/actions
Success status201 (действие записано)
Idempotency headerне применяется (append-only; каждый ApplyFilter/Search — самостоятельное событие; дедуп SourceMessageId — только для consumer-происхождения, INV-ACT-004, здесь null)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
actor identityJWT sub / header X-Anonymous-IdGuidДа≥1 из: JWT инвестора или X-Anonymous-Id; иначе 401self-scope; не принимается из body
ActionTypebodyActionEventTypeДаenum строкой; допустимы только ApplyFilter / Search; прочие подтипы → USER_ACTIVITY_ACTION_TYPE_UNSUPPORTEDнедоверенный вход
FilterbodyAppliedFilterModel?Да при ApplyFilter (иначе игнор)дом полей — action-event.md#appliedfiltermodel; ContractVersion = 1; ≥1 непустое поле (INV-ACT-006); Price/AreaNullableRange<decimal> (Min ≤ Max, INV-ACT-007)ref-модели фильтра — логические ссылки, синхронно не проверяются
SearchQuerybodystring?Да при Search (иначе игнор)1..512 после trim (INV-ACT-007)Internal: в логах ≤32 символов
ResultsCountbodyint?Нет>= 0 (размер выдачи каталога/поиска)

Filter — именованная typed-модель AppliedFilterModel (дом — action-event.md); поля здесь не дублируются («один дом на концепт»). Ровно одно из (Filter, SearchQuery) обязательно — по значению ActionType.

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

ПолеТипОбязательностьИсточникMaskingОписание
ActionEventIdGuidДаActionEvent.IdИдентификатор записанного действия.
ActionTypestringДаActionEvent.ActionType (enum строкой)Тип действия (эхо запроса).
SessionIdGuidДаSession.Id (ensure-сессия, шаг 3)Сессия, в которой записано действие.
OccurredAtDateTimeOffsetДаActionEvent.OccurredAt (now() сервера)Момент записи (серверное время).

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

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

  • Определён актор (X-Anonymous-Id или JWT sub); иначе USER_ACTIVITY_UNAUTHENTICATED (401).
  • ActionType ∈ {ApplyFilter, Search}, и ровно один payload соответствует типу (Filter для ApplyFilter, SearchQuery для Search).
  • Наличие активной сессии не требуется — обеспечивается методом (INV-ACT-003).

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

  • В append-only ленту добавлена ровно одна запись (ApplyFilterActionEvent/SearchActionEvent) с серверным OccurredAt и SourceMessageId = null (client-origin).
  • Обеспечена/продлена Active-сессия актора (LastActivityAt = now).
  • В одном согласованном изменении опубликован action-tracked (потребитель — crm, вход рекомендаций news).

Алгоритм

  1. Контекст и доступ. Актор — User (JWT sub) или Anonymous (X-Anonymous-Id), self-scope, вне tenant (ADR-0052 п.2). Ни одного идентификатора актора → USER_ACTIVITY_UNAUTHENTICATED (401). Реестр append-only, Idempotency lookup не применяется.
  2. Проверка входа.
    • ActionType ∈ {ApplyFilter, Search} — иначе USER_ACTIVITY_ACTION_TYPE_UNSUPPORTED (400);
    • ApplyFilter: Filter задан и содержит ≥1 непустое поле (INV-ACT-006 → USER_ACTIVITY_FILTER_EMPTY); диапазоны Price.Min ≤ Price.Max, Area.Min ≤ Area.Max (границы >= 0), Rooms ∈ 0..10; unknown-значения PropertyObjectStatuses отбрасываются (INV-ACT-007);
    • Search: SearchQuery1..512 после trim (INV-ACT-007); ResultsCount >= 0, если задан;
    • иные нарушения формата → VALIDATION_FAILED (400);
    • ref-модели Filter (City/Developer/Project) — логические ссылки справочников, синхронно не проверяются (как INV-LV-006); клиентский timestamp не принимается (INV-ACT-005).
  3. Обеспечение сессии (INV-ACT-003). Активная (Status = Active) сессия актора по AnonymousId (или по UserId для авторизованного без заголовка). Найдена → используются её Id/AnonymousId/UserId; не найдена → создаётся новая Active-сессия (действие «оживляет» устройство, INV-SES-003; Device из заголовков запроса).
  4. Бизнес-правила и переходы. Создаётся подтип реестра (INV-ACT-002): ApplyFilterActionEvent (Filter, ResultsCount?) или SearchActionEvent (SearchQuery.Trim(), ResultsCount?). Базовые поля: SessionId, AnonymousId, UserId (снимок на момент действия), OccurredAt = now() (сервер, INV-ACT-005), SourceMessageId = null (track-API). Сессия продлевается: LastActivityAt = now() (heartbeat, INV-SES-003/INV-SES-004).
  5. Что меняется. Новая сессия (если создана; порядок FK — сессия-родитель раньше события) или heartbeat существующей (0 затронутых строк → сессия успела закрыться → новая, INV-SES-003); затем вставка записи ActionEvent.
  6. Какие события фиксируются. Интеграционное ActionTrackedKafkaEventpin.user-activity.action-tracked.v1 (ContractVersion = 1, AggregateType = "ActionEvent") через outbox. Payload: ActionEventId, ActionType (string), SessionId, AnonymousId, UserId?, OccurredAt + подтипные optional зеркально TPH: FilterSnapshot? (AppliedFilterModel), SearchQuery?, ResultsCount? (PropertyObjectId/ PropertyObjectViewId/FavoriteId/SelectionId = null для этих подтипов). Потребитель — crm (лента лида); PII нет, SearchQuery — Internal. Доменное ActionRecordedEvent — внутреннее. Реконсиляция имени — README-решение №4: домен именует событие ActionRecordedKafkaEvent/action-recorded; каноничное consumer-facing имя — action-tracked (README).
  7. Согласованность. Обеспечение/heartbeat сессии, вставка ActionEvent и публикация action-tracked фиксируются в одном согласованном изменении.
  8. Результат. { ActionEventId, ActionType, SessionId, OccurredAt }201. Идентификаторы наружу — только собственной записи актора.

Диаграмма

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

ТипДетали
Integration eventsActionTrackedKafkaEventpin.user-activity.action-tracked.v1 (key ActionEventId), outbox
External callsnone (id-поля Filter — логические ссылки, синхронно не резолвятся)
Cache / projections / searchnone (GIN по filter_snapshot не создаётся, ADR-0056)
Notificationsопосредованно: crm — консюмер action-tracked (лента карточки лида)

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
USER_ACTIVITY_UNAUTHENTICATED401нет ни JWT, ни X-Anonymous-Id«Требуется идентификация устройства»no retry
VALIDATION_FAILED400формат/диапазоны Filter/SearchQuery/ResultsCount«Проверьте корректность полей»no retry
USER_ACTIVITY_ACTION_TYPE_UNSUPPORTED400ActionType вне {ApplyFilter, Search} (view/favorites/selection — иной источник)«Тип действия не поддерживается»no retry
USER_ACTIVITY_FILTER_EMPTY400ApplyFilter без единого заполненного поля фильтра (INV-ACT-006)«Фильтр пуст»no retry
USER_ACTIVITY_ACTION_INVALID500нарушение INV-ACT-002 (недостижимо через API — страховка)«Внутренняя ошибка»no retry

Acceptance-критерии

  • Given валидный ApplyFilter с ≥1 непустым полем, When POST /actions, Then 201, записан ApplyFilterActionEvent, опубликован action-tracked с FilterSnapshot (ContractVersion = 1).
  • Given валидный Search (SearchQuery длиной 1..512), When POST, Then 201, записан SearchActionEvent, опубликован action-tracked с SearchQuery (Internal).
  • Given ActionType вне {ApplyFilter, Search} (напр. ViewPropertyObject/AddToFavorites), When POST, Then USER_ACTIVITY_ACTION_TYPE_UNSUPPORTED (400) — эти подтипы имеют иной источник.
  • Given ApplyFilter без единого заполненного поля, When POST, Then USER_ACTIVITY_FILTER_EMPTY (400) (INV-ACT-006).
  • Given Price.Min > Price.Max (или Area.Min > Area.Max), When POST, Then VALIDATION_FAILED (400) (INV-ACT-007).
  • Given активной сессии актора нет, When POST, Then сессия создаётся (ensure), действие записано (INV-ACT-003/INV-SES-003).
  • Given нет ни JWT, ни X-Anonymous-Id, When POST, Then USER_ACTIVITY_UNAUTHENTICATED (401).
  • Given клиент прислал timestamp, When POST, Then он игнорируется, OccurredAt — серверный (INV-ACT-005).

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

  • Новые optional-поля AppliedFilterModel/новые ActionType — additive (без breaking); переименование/смена типа полей или контракта события — breaking (ContractVersion = 2 + чтение обеих версий, migration + rollout). Unknown enum-значения фильтров отбрасываются валидатором (INV-ACT-007).
  • Logs: action-tracked recorded (ActionEventId, ActionType, SessionId; SearchQuery — ≤32 символов; без PII); metrics: user_activity_actions_total{action_type,result}, latency p95 (< 200 мс, SLO README); traces — сквозной trace через traceparent; dashboard pin-user-activity; runbook — по конвенции docs/06-services/user-activity/runbooks/.

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

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

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

  • Сущности: action-event.md (TPH ApplyFilter/Search, дом AppliedFilterModel), session.md (ensure/heartbeat, INV-SES-003).
  • Событие: ../events/action-tracked.md (будущий контракт); consumer — ../../crm/README.md.
  • Обзор и карта API: ../README.md (решение №4 — имя события action-tracked).
  • Решения: ADR-0052 (вне tenant), ADR-0054 (модуль actions), ADR-0056 (simplicity-first, PostgreSQL-only).