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 area | public (self-scope: гость по X-Anonymous-Id, пользователь по JWT sub) |
| Feature group | actions |
| Статус | draft |
| Документ | docs/06-services/user-activity/api/public/track-action.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | Anonymous (заголовок X-Anonymous-Id) или User (JWT sub, auth SessionId) |
| Auth policy / Permissions | none (публичный self-scope); rate limit по актору/IP |
| Scope (RBAC) | не применяется (актор вне tenant, ADR-0052 п.2) |
| Record-level | владелец: запись пишется в сессию актора (AnonymousId/UserId); чужие сессии недоступны |
| Tenant isolation | не применяется — контекст вне tenant (ADR-0052 п.2); tenant-интерсепторы не подключаются |
| Audit | create (сама запись — журнал действий); SearchQuery — Internal (в логах ≤32 символов); PII нет |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/user-activity/actions |
| Success status | 201 (действие записано) |
| Idempotency header | не применяется (append-only; каждый ApplyFilter/Search — самостоятельное событие; дедуп SourceMessageId — только для consumer-происхождения, INV-ACT-004, здесь null) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
| actor identity | JWT sub / header X-Anonymous-Id | Guid | Да | ≥1 из: JWT инвестора или X-Anonymous-Id; иначе 401 | self-scope; не принимается из body |
ActionType | body | ActionEventType | Да | enum строкой; допустимы только ApplyFilter / Search; прочие подтипы → USER_ACTIVITY_ACTION_TYPE_UNSUPPORTED | недоверенный вход |
Filter | body | AppliedFilterModel? | Да при ApplyFilter (иначе игнор) | дом полей — action-event.md#appliedfiltermodel; ContractVersion = 1; ≥1 непустое поле (INV-ACT-006); Price/Area — NullableRange<decimal> (Min ≤ Max, INV-ACT-007) | ref-модели фильтра — логические ссылки, синхронно не проверяются |
SearchQuery | body | string? | Да при Search (иначе игнор) | 1..512 после trim (INV-ACT-007) | Internal: в логах ≤32 символов |
ResultsCount | body | int? | Нет | >= 0 (размер выдачи каталога/поиска) | — |
Filter — именованная typed-модель AppliedFilterModel (дом — action-event.md);
поля здесь не дублируются («один дом на концепт»). Ровно одно из (Filter, SearchQuery) обязательно —
по значению ActionType.
Модель ответа TrackActionResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
ActionEventId | Guid | Да | ActionEvent.Id | — | Идентификатор записанного действия. |
ActionType | string | Да | ActionEvent.ActionType (enum строкой) | — | Тип действия (эхо запроса). |
SessionId | Guid | Да | Session.Id (ensure-сессия, шаг 3) | — | Сессия, в которой записано действие. |
OccurredAt | DateTimeOffset | Да | ActionEvent.OccurredAt (now() сервера) | — | Момент записи (серверное время). |
Предусловия и постусловия
Предусловия:
- Определён актор (
X-Anonymous-Idили JWTsub); иначе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).
Алгоритм
- Контекст и доступ. Актор —
User(JWTsub) илиAnonymous(X-Anonymous-Id), self-scope, вне tenant (ADR-0052 п.2). Ни одного идентификатора актора →USER_ACTIVITY_UNAUTHENTICATED(401). Реестр append-only, Idempotency lookup не применяется. - Проверка входа.
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:SearchQuery—1..512после trim (INV-ACT-007);ResultsCount >= 0, если задан;- иные нарушения формата →
VALIDATION_FAILED(400); - ref-модели
Filter(City/Developer/Project) — логические ссылки справочников, синхронно не проверяются (как INV-LV-006); клиентский timestamp не принимается (INV-ACT-005).
- Обеспечение сессии (INV-ACT-003). Активная (
Status = Active) сессия актора поAnonymousId(или поUserIdдля авторизованного без заголовка). Найдена → используются еёId/AnonymousId/UserId; не найдена → создаётся новаяActive-сессия (действие «оживляет» устройство, INV-SES-003;Deviceиз заголовков запроса). - Бизнес-правила и переходы. Создаётся подтип реестра (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). - Что меняется. Новая сессия (если создана; порядок FK — сессия-родитель раньше события) или heartbeat
существующей (0 затронутых строк → сессия успела закрыться → новая, INV-SES-003); затем вставка записи
ActionEvent. - Какие события фиксируются. Интеграционное
ActionTrackedKafkaEvent→pin.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). - Согласованность. Обеспечение/heartbeat сессии, вставка
ActionEventи публикацияaction-trackedфиксируются в одном согласованном изменении. - Результат.
{ ActionEventId, ActionType, SessionId, OccurredAt }→201. Идентификаторы наружу — только собственной записи актора.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | ActionTrackedKafkaEvent → pin.user-activity.action-tracked.v1 (key ActionEventId), outbox |
| External calls | none (id-поля Filter — логические ссылки, синхронно не резолвятся) |
| Cache / projections / search | none (GIN по filter_snapshot не создаётся, ADR-0056) |
| Notifications | опосредованно: crm — консюмер action-tracked (лента карточки лида) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
USER_ACTIVITY_UNAUTHENTICATED | 401 | нет ни JWT, ни X-Anonymous-Id | «Требуется идентификация устройства» | no retry |
VALIDATION_FAILED | 400 | формат/диапазоны Filter/SearchQuery/ResultsCount | «Проверьте корректность полей» | no retry |
USER_ACTIVITY_ACTION_TYPE_UNSUPPORTED | 400 | ActionType вне {ApplyFilter, Search} (view/favorites/selection — иной источник) | «Тип действия не поддерживается» | no retry |
USER_ACTIVITY_FILTER_EMPTY | 400 | ApplyFilter без единого заполненного поля фильтра (INV-ACT-006) | «Фильтр пуст» | no retry |
USER_ACTIVITY_ACTION_INVALID | 500 | нарушение INV-ACT-002 (недостижимо через API — страховка) | «Внутренняя ошибка» | no retry |
Acceptance-критерии
- Given валидный
ApplyFilterс ≥1 непустым полем, WhenPOST /actions, Then201, записанApplyFilterActionEvent, опубликованaction-trackedсFilterSnapshot(ContractVersion = 1). - Given валидный
Search(SearchQueryдлиной1..512), WhenPOST, Then201, записанSearchActionEvent, опубликованaction-trackedсSearchQuery(Internal). - Given
ActionTypeвне{ApplyFilter, Search}(напр.ViewPropertyObject/AddToFavorites), WhenPOST, ThenUSER_ACTIVITY_ACTION_TYPE_UNSUPPORTED(400) — эти подтипы имеют иной источник. - Given
ApplyFilterбез единого заполненного поля, WhenPOST, ThenUSER_ACTIVITY_FILTER_EMPTY(400) (INV-ACT-006). - Given
Price.Min > Price.Max(илиArea.Min > Area.Max), WhenPOST, ThenVALIDATION_FAILED(400) (INV-ACT-007). - Given активной сессии актора нет, When
POST, Then сессия создаётся (ensure), действие записано (INV-ACT-003/INV-SES-003). - Given нет ни JWT, ни
X-Anonymous-Id, WhenPOST, ThenUSER_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; dashboardpin-user-activity; runbook — по конвенцииdocs/06-services/user-activity/runbooks/.
Обратные ссылки
Автоссылки: где используется этот документ.
- API (2): Catalog Public API — реестр методов и общие правила, GET /api/v1/public/projects — Лента published-проектов (поиск + фильтры + сортировки)
- Use Cases (1): UC-ACT-003. Инвестор просматривает свою историю просмотров и действий
- Консюмеры (1): Consumer: User-Activity Actions (crm-user-activity-actions)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: 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).