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

POST /api/v1/favorites/import — Bulk-импорт избранного с устройства

Массово переносит список объектов недвижимости, накопленных клиентом локально на устройстве, в хранилище избранного: клиент отправляет батч propertyObjectId[], сервис создаёт записи Favorite для текущего владельца (гостевая сессия либо аккаунт, если пользователь уже авторизован), пропуская дубли (INV-FAV-002). После успешного ответа клиент очищает локальный набор. Повторный вызов безопасен — все записи окажутся дублями.

Назначение

Клиент может накопить список избранных объектов недвижимости локально на устройстве (например, гость до первого обращения к бэкенду) и один раз перенести его в хранилище, чтобы дальше работали кросс-девайс сценарии и слияние при авторизации (../../domain/merge-strategy.md, раздел «Триггер и точки входа», п.5: импорт выполняется до merge-preview). Заметки при импорте не передаются — переносятся только PropertyObjectId.

Metadata

ПолеЗначение
Сервис/контекстfavorites
API areapublic
Feature groupfavorites-import
Статусdraft
Документdocs/06-services/favorites/api/public/import-favorites.md

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

ПроверкаЗначение
Actor typeAnonymous (гость по X-Anonymous-Id) или User (JWT)
Auth policy / Permissionsnone (self-scope): записи создаются только владельцу из контекста запроса
Scope (RBAC)не применяется
Record-levelвладелец новых записей — актор запроса; чужого владельца задать нельзя (INV-FAV-005, INV-FAV-006 — импорт не переносит чужие записи, только создаёт свои)
Tenant isolationне применяется — сущности вне tenant (ADR-0052 п.2)
Auditcreate (батч): CreatedAt/UpdatedAt; каждый созданный лайк — событие pin.favorites.favorite-added.v1

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/favorites/import
Success status200 (в т.ч. когда всё оказалось дублями — идемпотентный повтор)
Idempotency headerне применяется — идемпотентность по бизнес-ключу «владелец + объект недвижимости» (дубли пропускаются)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
UserIdidentity (JWT claim sub)Guid?Условнозаполнен при авторизованном запросе; приоритет над AnonymousIdвладелец — только сам актор
AnonymousIdheader X-Anonymous-IdGuid?Условновалидный Guid ≠ Guid.Empty; используется при отсутствии JWTнедоверенный вход
PropertyObjectIdsbody propertyObjectIdsIReadOnlyList<Guid>Да1..500 элементов; каждый != Guid.Empty; дубликаты внутри батча схлопываются; существование объектов недвижимости не проверяется (INV-FAV-004)недоверенный вход — мусорные id безвредны (отфильтрует выдача каталога)

Лимит 500 — защита от abuse: реальный размер локального набора — десятки записей (scale envelope ADR-0056). Больше 500 — ValidationError; клиент может разбить на несколько вызовов.

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

ПолеТипОбязательностьИсточникMaskingОписание
RequestedCountintДаcomputed: число уникальных объектов недвижимости в запросеУникальных объектов недвижимости в запросе.
ImportedCountintДаcomputed: число созданных записейФактически создано новых записей.
SkippedDuplicateCountintДаcomputed: RequestedCount - ImportedCountПропущено — объект недвижимости уже был в избранном владельца.
TotalCountintДаcomputed: записей владельца после импортаИтоговый размер избранного (для бейджа фронта).

Алгоритм

  1. Контекст и доступ. Разрешить владельца: UserId из JWT, иначе гостевая сессия AnonymousId из заголовка. Не разрешён ни тот, ни другой — отказ FAVORITES_OWNER_UNRESOLVED.
  2. Проверка входа. PropertyObjectIds не пуст, Count <= 500, каждый элемент != Guid.Empty. Существование объектов недвижимости не проверяется (INV-FAV-004, ADR-0056 — нет sync-вызова в чужой контекст); статуса у Favorite нет.
  3. Что читаем. Вход нормализуется (дубликаты в батче схлопываются). По списку запрошенных объектов недвижимости определяются уже существующие записи владельца — они исключаются из вставки. К созданию остаётся разность «запрошенные минус существующие».
  4. Бизнес-правила и маппинг. Для каждого объекта недвижимости к созданию формируется запись Favorite владельца из контекста (INV-FAV-001; Note = null — заметок при импорте нет). Поля: PropertyObjectId из батча, CreatedAt/UpdatedAt — момент импорта. Каждая запись поднимает доменное событие FavoriteAddedEvent. Счётчики: RequestedCount — уникальных на входе, ImportedCount — созданных, SkippedDuplicateCount = RequestedCount - ImportedCount.
  5. Что меняется. Новые записи добавляются одним батчем. Гонка с параллельным POST /favorites той же пары отбивается partial-unique ux_favorites_user_property_object / ux_favorites_anonymous_property_object: конфликтная строка исключается, набор существующих перечитывается, вставка остатка повторяется один раз, счётчики пересчитываются.
  6. Какие факты/события фиксируются. По одному FavoriteAddedKafkaEvent (ContractVersion = 1, topic pin.favorites.favorite-added.v1, AggregateId = FavoriteId, AggregateType = "Favorite"; payload: FavoriteId, UserId?, AnonymousId?, PropertyObjectId, OccurredAt, без Note) на каждую созданную запись. Пропущенные дубли событий не порождают. Отдельного события «импорт выполнен» нет — потребителю (user-activity) важны атомарные факты add.
  7. Согласованность. Все новые записи и все их события фиксируются в одном согласованном изменении; частичный импорт невозможен (либо весь остаток к созданию, либо откат и retry).
  8. Результат. Модель ImportFavoritesResult: счётчики шага 4 + TotalCount (число записей владельца после импорта). HTTP 200 OK. Контракт клиента: при 200 очистить локальный набор; при любой ошибке — оставить и повторить позже (повтор безопасен).

Диаграмма

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

ТипДетали
Интеграционные событияFavoriteAddedKafkaEvent / pin.favorites.favorite-added.v1 / key = FavoriteId — по одному на созданную запись (до 500 событий за вызов)
Проекции / поискnone (ADR-0056)

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
FAVORITES_OWNER_UNRESOLVED400Нет ни JWT, ни валидного X-Anonymous-Id«Не удалось определить владельца избранного»no retry (сначала получить anonymous_id)
ValidationError400Пустой propertyObjectIds; > 500 элементов; Guid.Empty в спискедетали валидацииno retry (клиент чинит батч)
InternalError500Отказ хранилища / исчерпаны retry гонки uniquegeneric messageretry — импорт идемпотентен, локальный набор не очищен

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

  • Предусловие. Разрешён владелец (JWT или гость по X-Anonymous-Id); батч PropertyObjectIds — 1..500 непустых Guid; выполняется до merge-preview (../../domain/merge-strategy.md, «Триггер и точки входа», п.5).
  • Постусловие. Для каждого запрошенного объекта недвижимости у владельца есть запись Favorite (Note = null); созданные записи опубликованы по одному pin.favorites.favorite-added.v1 на запись; повтор того же батча ничего не создаёт (все — дубли) — импорт идемпотентен по паре «владелец + объект».

Acceptance-критерии

  • Given владелец без записей и батч из K уникальных объектов недвижимости, When POST /import, Then создаётся K записей, ImportedCount = K, SkippedDuplicateCount = 0, публикуется K событий favorite-added, ответ 200.
  • Given часть объектов батча уже в избранном владельца, When POST /import, Then создаются только новые записи, дубли пропущены (SkippedDuplicateCount), события — только на созданные.
  • Given батч содержит внутренние дубликаты, When POST /import, Then дубликаты схлопываются, RequestedCount считается по уникальным объектам недвижимости.
  • Given тот же батч отправлен повторно, When POST /import, Then ImportedCount = 0, всё — дубли, события не публикуются, ответ 200 (клиент вправе очистить локальный набор).
  • Given батч пустой, > 500 элементов или содержит Guid.Empty, When POST /import, Then отказ ValidationError (400).
  • Given запрос без JWT и без валидного X-Anonymous-Id, When POST /import, Then отказ FAVORITES_OWNER_UNRESOLVED (400).
  • Given параллельный POST /favorites той же пары во время импорта, When гонка partial-unique, Then конфликтная строка исключается, вставка остатка повторяется один раз, счётчики пересчитываются, дубль не создаётся.

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

  • Будущее additive-расширение: элементы с заметками (items: [{propertyObjectId, note}]) — новое optional поле body рядом с propertyObjectIds, без breaking change.
  • Logs: info FavoritesImported {owner_type} {RequestedCount} {ImportedCount} — список PropertyObjectId не логируется.
  • Metrics: favorites_import_total{owner_type}, favorites_import_batch_size (histogram), favorites_import_duplicates_total.
  • Traces: сквозной correlation; dashboard/alerts/runbook — ../../README.md.

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

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

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