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 alias | identity-users |
| Versioned index | identity-users-v1 (переключение alias, ADR-0029) |
| Owner service | identity |
| Source of truth | UserEntity (../domain/user.md) в PostgreSQL (identity.users) |
| Rebuild job | UserSearchRebuildJob |
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.
Поля
| Поле | Тип | Indexed | Stored | Analyzer | Source | Security |
|---|---|---|---|---|---|---|
UserId | keyword | да | да | — | UserEntity.Id | include |
CompanyId | keyword | да | да | — | UserEntity.CompanyId | scope-trimming (tenant) |
FirstName | text | да | нет | ru_name (russian morphology + lowercase + edge-ngram) | FullName.FirstName | PII |
MiddleName | text | да | нет | ru_name | FullName.MiddleName | PII |
LastName | text | да | нет | ru_name | FullName.LastName | PII |
NameTranslit | text | да | нет | translit (ICU кириллица↔латиница) | FirstName ⊕ MiddleName ⊕ LastName | PII |
FullNameSearch | text | да | нет | ru_name | FirstName ⊕ MiddleName ⊕ LastName (copy_to) | основной матч SearchTerm |
PhoneDigits | text | да | нет | digits_edge_ngram | PhoneNumber (только цифры) | префикс/подстрока телефона |
EmailPrefix | text | да | нет | email_edge_ngram (до @) | Email (локальная часть) | подстрока email |
Type | keyword | да | да | — | UserEntity.Type | фильтр (роль-тип) |
Status | keyword | да | да | — | UserEntity.Status | фильтр |
IsActive | boolean | да | да | — | Status ∈ {Invited, Active} | security-trimming discovery |
SchemaVersion | integer | да | да | — | версия схемы документа | include |
UpdatedAt | date | да | да | — | UserEntity.UpdatedAt | freshness watermark |
IndexedAt | date | да | да | — | момент индексации | freshness |
Телефон/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
| Event | Version | Transform |
|---|---|---|
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-а не требуется: identity — владелец UserEntity, индексирует в своём же процессе
(ADR-0056, один сервис/одна БД).
Согласованность и жизненный цикл документа
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актора: искать пользователей может только сотрудник компании с permissionidentity.users.viewв scopeOrganization(CompanyId)/Platform(правило area — ../api/company-user/README.md); платформенный/admin-актор — по своему scope. Инвестор (без компании) поиск пользователей не выполняет.IsActive = false(Blocked/Archived) вырезается из discovery-поверхностей. - Полнотекстовый индекс не хранит secrets, tokens, платёжные/KYC-данные, IP/UA.
FirstName/MiddleName/LastName/PhoneDigits/EmailPrefix — индексируются для матча, но не
возвращаются проекцией; телефон/email в ответе list-company-users маскируются (+7***1234/d***@mail) и
раскрываются только при identity.users.manage (маскирование выполняет потребитель проекции по правам
актора, не индекс).
Rebuild
- Создать версионный индекс
identity-users-vNс analyzers (ru_name,translit,digits_edge_ngram,email_edge_ngram). - Backfill
UserSearchRebuildJobиз PostgreSQL: обход всех пользователей компаний (CompanyId != null), map вUserSearchDocument. - Валидация counts/checksums против источника (
identity.usersгдеcompany_id is not null). - Атомарно переключить alias
identity-usersна новую версию. - Держать предыдущий индекс до конца rollback-окна.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: UserEntity
- API (3): DELETE /api/admin/v1/users/{userId} — Удаление пользователя: архив vs полное удаление (admin), GET /api/v1/companies/my/users — Список пользователей компании, PUT /api/investors/me — Обновление профиля инвестора (updateInvestor)
- index.md (1): Identity Service (Pin.Identity)
Связанные документы
- Source of truth: ../domain/user.md (агрегат
UserEntity,FullName, INV-1..11); входное событие — ../events/user-upserted.md. - Потребитель поиска (API): ../api/company-user/list-company-users.md
(параметр
SearchTerm); правила области — ../api/company-user/README.md. - Решения: ADR-0059 (Elasticsearch-проекции), ADR-0008 (search projection), ADR-0029 (alias strategy),
ADR-0014 (scope-filters), ADR-0052 (tenancy
TenantId = CompanyId), ADR-0056 (simplicity-first).