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

UserSearchDocument

Поисковая проекция пользователей компании UserSearchDocument: обслуживает поиск по ФИО, телефону и email (параметр SearchTerm экрана «Команда», ../api/company-user/list-company-users.md). Поиск реализован на Elasticsearch (а не PostgreSQL-FTS) ради typo-tolerance, транслитерации и морфологии ФИО (ADR-0059). Проекция перестраиваемая — её всегда можно восстановить из источника истины.

Назначение

Source of truth — агрегат UserEntity (identity.users, ФИО + нормализованный телефон/email). Проекция хранит только индексируемые/фильтруемые поля для fuzzy-поиска (typo-tolerance, транслитерация кириллица↔латиница, морфология ФИО — ADR-0059 п.1). Прямого доступа к Elasticsearch из бизнес-логики нет — поиск идёт через инфраструктурный слой поиска (ADR-0059). Проекция наполняется тем же согласованным изменением, что мутирует учётку (сигнал user-upserted), плюс rebuild-джоб.

Границы: только пользователи компаний

Проекция обслуживает только пользователей компаний (CompanyId != null: агенты, менеджеры застройщика, партнёры, сотрудники ПИН). Инвесторы (CompanyId = null) в индекс не попадают — их через «Команду» не ищут; поиск дублей инвестора по ФИО/телефону для фиксаций CRM — отдельная реплика и проекция контекста crm (см. user-upserted, consumer crm, не identity-индекс). Глобальный точечный lookup по телефону/email (уникальные ux_users_phone_number/ux_users_email) — прямой btree-lookup в PostgreSQL, Elasticsearch не задействован (ADR-0056).

Index

ПолеЗначение
Index aliasidentity-users
Versioned indexidentity-users-v1 (переключение alias, ADR-0029)
Owner serviceidentity
Source of truthUserEntity (../domain/user.md) в PostgreSQL (identity.users)
Rebuild jobUserSearchRebuildJob

Document Id И Routing

  • Document id: UserId (Guid = identity.users.id; детерминирован — upsert идемпотентен).
  • Routing key: CompanyId — поиск всегда сужен к одной компании (scope-фильтр видимости, ниже); маршрутизация по tenant локализует запрос. Кластер малый (ADR-0056), но routing по CompanyId бесплатен и совпадает с гейтом видимости.
  • Delete/tombstone: строка живёт, пока пользователь состоит в компании. Блокировка/архивация (Status = Blocked|Archived) → документ остаётся с IsActive = false/Status, но security-trimming-ом вырезается из discovery-выдачи; физическое удаление реплики/выход из компании (CompanyId → null) → delete документа по UserId.

Поля

ПолеТипIndexedStoredAnalyzerSourceSecurity
UserIdkeywordдадаUserEntity.Idinclude
CompanyIdkeywordдадаUserEntity.CompanyIdscope-trimming (tenant)
FirstNametextданетru_name (russian morphology + lowercase + edge-ngram)FullName.FirstNamePII
MiddleNametextданетru_nameFullName.MiddleNamePII
LastNametextданетru_nameFullName.LastNamePII
NameTranslittextданетtranslit (ICU кириллица↔латиница)FirstName ⊕ MiddleName ⊕ LastNamePII
FullNameSearchtextданетru_nameFirstName ⊕ MiddleName ⊕ LastName (copy_to)основной матч SearchTerm
PhoneDigitstextданетdigits_edge_ngramPhoneNumber (только цифры)префикс/подстрока телефона
EmailPrefixtextданетemail_edge_ngram (до @)Email (локальная часть)подстрока email
TypekeywordдадаUserEntity.Typeфильтр (роль-тип)
StatuskeywordдадаUserEntity.Statusфильтр
IsActivebooleanдадаStatus ∈ {Invited, Active}security-trimming discovery
SchemaVersionintegerдадаверсия схемы документаinclude
UpdatedAtdateдадаUserEntity.UpdatedAtfreshness watermark
IndexedAtdateдадамомент индексацииfreshness
Внешний ввод — не raw-DSL

Телефон/email индексируются только фрагментами для матча (PhoneDigits, EmailPrefix), но не хранятся (stored=нет): индекс отдаёт лишь UserId + match-score; полные ФИО/телефон/email выдаёт источник (identity.users) уже с маскированием по правам (см. Security). Секреты (PasswordHash/PinCodeHash/CodeHash/RefreshTokenHash) в проекцию не кладутся. Внешний ввод (SearchTerm) идёт только как поисковый терм (multi-match по FullNameSearch/NameTranslit/ PhoneDigits/EmailPrefix с fuzziness) — не в raw-DSL.

Source Events

EventVersionTransform
pin.identity.users.v1 (UserUpsertedKafkaEvent)1При CompanyId != null — upsert документа (map ФИО/телефон/email/Type/Status/IsActive); при CompanyId == null (инвестор) или Status = Archived с выходом из компании — delete по UserId. Индексация в том же согласованном изменении, что мутация учётки (Version-guard INV-9): stale/дубль → skip.

Проекция наполняется инкрементально в обработке мутаций учётки (create/invite/update/block/archive — после фиксации изменения учётки) + периодический/ручной UserSearchRebuildJob из identity.users для bootstrap и восстановления консистентности.

Без отдельного consumer-а

Отдельного consumer-а не требуется: identity — владелец UserEntity, индексирует в своём же процессе (ADR-0056, один сервис/одна БД).

Согласованность и жизненный цикл документа

Смена компании: delete + reindex, не update на месте

Routing key = CompanyId. При переходе пользователя из компании A в компанию B документ нельзя обновить «на месте» — старый шард по routing A остался бы виден в выдаче A; требуется **delete по (UserId, routing = A)

  • index под routing = B**. Тот же принцип при потере компании (CompanyId → null, become-investor): delete документа, пользователь исчезает из «Команды».
  • Появление в индексе (investor → agent, ADR-0061). Получение CompanyId (вступление в агентство) переводит пользователя в область индекса — upsert по сигналу user-upserted; выход/возврат к инвестору — delete.
  • Rebuild ↔ инкремент. Backfill UserSearchRebuildJob и инкрементальная индексация могут пересечься; конфликт разрешается watermark-ом (Version/IndexedAt): применяется бОльшая версия, устаревшая запись backfill-а не затирает свежий инкремент. Переключение alias атомарно, старый индекс держится до конца rollback-окна.
  • Идемпотентность. Document id = UserId детерминирован — повторный upsert одного события не плодит дубликатов (last-writer по Version).

Freshness

  • SLA: категория search (секунды–минуты; eventual относительно PostgreSQL).
  • Watermark: IndexedAt + UserEntity.UpdatedAt/Version.
  • Stale behavior: показать результаты с freshness-маркером; статус/адресуемость (IsActive, роли, снятие маски телефона) проверяются по source-of-truth (identity.users), не по проекции.

Security

  • Scope-фильтр видимости (до выполнения запроса, ADR-0059 п.3) — обязателен для каждого запроса и сужает выдачу к CompanyId актора: искать пользователей может только сотрудник компании с permission identity.users.view в scope Organization(CompanyId)/Platform (правило area — ../api/company-user/README.md); платформенный/admin-актор — по своему scope. Инвестор (без компании) поиск пользователей не выполняет. IsActive = false (Blocked/Archived) вырезается из discovery-поверхностей.
  • Полнотекстовый индекс не хранит secrets, tokens, платёжные/KYC-данные, IP/UA.
PII

FirstName/MiddleName/LastName/PhoneDigits/EmailPrefix — индексируются для матча, но не возвращаются проекцией; телефон/email в ответе list-company-users маскируются (+7***1234/d***@mail) и раскрываются только при identity.users.manage (маскирование выполняет потребитель проекции по правам актора, не индекс).

Rebuild

  1. Создать версионный индекс identity-users-vN с analyzers (ru_name, translit, digits_edge_ngram, email_edge_ngram).
  2. Backfill UserSearchRebuildJob из PostgreSQL: обход всех пользователей компаний (CompanyId != null), map в UserSearchDocument.
  3. Валидация counts/checksums против источника (identity.users где company_id is not null).
  4. Атомарно переключить alias identity-users на новую версию.
  5. Держать предыдущий индекс до конца rollback-окна.

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

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

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