POST /api/v1/admin/owner-mappings/{mappingId}/resolve — Ручное разрешение конфликта связи (resolveOwnerMappingConflict)
Назначение
Admin-операция ручного разрешения конфликта сопоставления Собственник ↔ Инвестор Службой заботы:
сотрудник вручную указывает нужного инвестора для связи, застрявшей в Status = Conflict (дубль
телефона, невалидный номер, коллизия id, найденный пользователь — не инвестор), и переводит её
Conflict → Linked. Разрешение снимает блокировку синхронизации: зависшее в DLQ событие владения при
повторном replay проходит INV-8 (mapping.Status = Linked) — объекты собственника привязываются к
инвестору (../../consumers/talent-owners-sync.md, Retry/DLQ). Точка входа
конфликтов — очередь list-owner-mapping-conflicts.md.
Дом полей связи — ../../domain/owner-mapping.md.
| Поле | Значение |
|---|
| Сервис/контекст | investor-owner |
| API area | admin (Служба заботы) |
| Feature group | owner-mappings |
| Статус | draft |
| Документ | docs/06-services/investor-owner/api/admin/resolve-owner-mapping-conflict.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | Operator(Admin) (сотрудник Службы заботы, Bearer JWT) |
| Auth policy / Permissions | authenticated; permission investor-owner.mappings.manage (../../../identity/domain/permission.md) |
| Scope (RBAC) | платформенный (не self, не tenant) |
| Record-level | не применяется (админ видит любую связь); ключ — mappingId |
| Tenant isolation | не применяется — данные инвестора вне tenant (ADR-0052) |
| Audit | update (привилегированная): ResolvedByUserId/UpdatedByUserId из JWT, ResolutionNote (обязательна) + доменное событие OwnerMappingLinked |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/admin/owner-mappings/{mappingId}/resolve |
| Success status | 200 (связь установлена) |
| Idempotency header | не применяется (переход Conflict → Linked; повтор на уже Linked → 409) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ResolvedByUserId | identity claims (sub) | Guid | Да | валидный JWT — 401 до операции; permission — 403 | actor-stamping (ResolvedByUserId/UpdatedByUserId) |
MappingId | route | Guid | Да | != Guid.Empty; существующая связь | — |
InvestorUserId | body | Guid | Да | != Guid.Empty; вручную выбранный сотрудником инвестор; уникальность INV-5 | ссылка на identity.users(id) без FK |
ResolutionNote | body | string | Да | непустая после trim, 1..512 — обязательна для аудита ручного разрешения | пишется в аудит; без raw PII третьих лиц |
Модель ответа OwnerMappingModel
Проекция агрегата OwnerMappingEntity (дом полей — ../../domain/owner-mapping.md);
здесь — только дельта. Общая с очередью list-owner-mapping-conflicts.md.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
id | Guid | Да | OwnerMapping.Id (= OwnerId) | — | Идентификатор связи. |
investorUserId | Guid | Да | OwnerMapping.InvestorUserId | — | Привязанный инвестор (после разрешения обязателен, INV-2). |
status | string (OwnerMatchStatus) | Да | OwnerMapping.Status | — | После операции — всегда Linked. |
linkKind | string (OwnerLinkKind) | Да | OwnerMapping.LinkKind | — | ExistingInvestorMatched (ручное сопоставление существующего инвестора). |
phoneNumberMasked | string | Да | OwnerMapping.PhoneNumber | +7***1234 | Телефон собственника (PII — маска). |
ownerFullNameMasked | string | Да | OwnerMapping.OwnerFullName | И*** П*** | ФИО собственника (PII — маска). |
linkedAt | string (ISO 8601) | Да | OwnerMapping.LinkedAt | — | Момент разрешения. |
resolvedByUserId | Guid | Да | OwnerMapping.ResolvedByUserId | — | Сотрудник заботы, разрешивший конфликт. |
version | long | Да | OwnerMapping.Version | — | Новая версия связи. |
Алгоритм
- Контекст и доступ. Операцию выполняет оператор Службы заботы (
ResolvedByUserId — из claim sub);
требуется permission investor-owner.mappings.manage (иначе 403); scope платформенный.
- Проверка входа и предусловий.
MappingId != Guid.Empty, InvestorUserId != Guid.Empty,
ResolutionNote непустая после trim (1..512) — иначе ValidationError (400). Найти связь по
MappingId; нет → INVESTOR_OWNER_MAPPING_NOT_FOUND (404). Статус связи должен быть Conflict —
единственный разрешённый; иначе INVESTOR_OWNER_MAPPING_NOT_IN_CONFLICT (409) (повтор на Linked /
связь в PendingLink). Best-effort проверка выбранного инвестора в identity
(../../../identity/api/get-user.md): пользователь существует и
Type = Investor; иначе INVESTOR_OWNER_MATCHED_USER_NOT_INVESTOR (422). Сбой/timeout identity не
блокирует (guard INV-5 в БД остаётся жёстким).
- Разрешение и переход. Переход
Conflict → Linked: InvestorUserId = аргумент,
LinkKind = ExistingInvestorMatched (INV-7 — иммутабельно после установки), LinkedAt = now(),
Status = Linked, ResolvedByUserId/UpdatedByUserId = actor, ConflictReason снимается. Проверяется
INV-2 LinkedCompleteness (InvestorUserId ∧ LinkKind ∧ LinkedAt != null); ResolutionNote — в аудит.
INV-5 UniqueInvestor: если инвестор уже связан с другим собственником (unique
ux_owner_mappings_investor_user_id) → INVESTOR_OWNER_INVESTOR_ALREADY_MAPPED (409).
- События. Публикуется интеграционное
InvestorOwnerLinkedKafkaEvent → topic
pin.investor-owner.owner-linked.v1 (ContractVersion = 1, key/AggregateId = Id; payload
{OwnerId (= Id), InvestorUserId, LinkKind, LinkedAt, Version}, телефон не передаётся) — тот же
контракт, что consumer синхронизации публикует при автолинковке (единый дом —
owner-mapping.md); доменное OwnerMappingLinked. Обновление связи и
публикация события фиксируются в одном согласованном изменении.
- Результат.
200 с моделью OwnerMappingModel (PII-маскирование). Далее сотрудник инициирует
replay зависшего в DLQ события владения — оно проходит INV-8.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | InvestorOwnerLinkedKafkaEvent → pin.investor-owner.owner-linked.v1 (key = Id, ContractVersion = 1); потребители — notifications, profile |
| Внешние вызовы | identity get-user (проверка Type = Investor, timeout 500 мс, без retry, best-effort) |
| Проекции / поиск | none |
| Уведомления | опосредованно: notifications/profile — консюмеры owner-linked (приветствие / создание профиля) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
| — (auth) | 401 | Нет/просрочен/невалиден JWT | без тела схемы | re-login |
FORBIDDEN | 403 | Нет investor-owner.mappings.manage | «Недостаточно прав» | no retry |
ValidationError | 400 | MappingId/InvestorUserId пусты; ResolutionNote пустая | Список ошибок полей | no retry |
INVESTOR_OWNER_MAPPING_NOT_FOUND | 404 | Связь по MappingId не найдена | «Связь не найдена» | no retry |
INVESTOR_OWNER_MAPPING_NOT_IN_CONFLICT | 409 | Status != Conflict (уже Linked / PendingLink) | «Связь не в состоянии конфликта» | no retry |
INVESTOR_OWNER_INVESTOR_ALREADY_MAPPED | 409 | INV-5: инвестор уже связан с другим собственником (ux_owner_mappings_investor_user_id) | «Инвестор уже сопоставлен» | no retry (выбрать другого) |
INVESTOR_OWNER_MATCHED_USER_NOT_INVESTOR | 422 | identity: пользователь не существует или Type != Investor | «Выбранный пользователь не инвестор» | no retry |
| — (concurrency) | 409 | Гонка с синхронизацией/другим админом | «Повторите попытку» | retry (перечитать связь) |
Совместимость и наблюдаемость
OwnerMappingModel — проекция агрегата; новые optional поля additive; удаление/переименование/смена типа —
breaking (migration + rollout admin-UI). Enum OwnerMatchStatus/OwnerLinkKind сериализуются строкой;
контракт pin.investor-owner.owner-linked.v1 неизменен относительно автолинковки синхронизации.
- Logs:
mapping-conflict-resolved (MappingId, InvestorUserId, ResolvedByUserId, LinkKind;
телефон/ФИО — маской; ResolutionNote — в audit-лог); metrics:
investor_owner_conflicts_resolved_total, latency histogram; traces — сквозной trace + вложенный span
identity; dashboard investor-owner-overview; runbook — «разбор конфликтов связи» (../../README.md).
Acceptance Criteria
- Given связь в
Status = Conflict и валидный инвестор (Type = Investor, не связан с другим
собственником), When POST /{mappingId}/resolve c InvestorUserId и ResolutionNote, Then 200,
переход Conflict → Linked, LinkKind = ExistingInvestorMatched, LinkedAt = now(), снятие
ConflictReason; публикуется InvestorOwnerLinkedKafkaEvent в том же согласованном изменении (телефон в
payload не передаётся).
- Given разрешённая связь и зависшее в DLQ событие владения этого собственника, When оператор
инициирует replay, Then событие проходит INV-8 (
mapping.Status = Linked) и объекты привязываются к
инвестору.
- Given связь не в
Conflict (уже Linked или PendingLink), When запрос, Then
INVESTOR_OWNER_MAPPING_NOT_IN_CONFLICT (409).
- Given выбранный инвестор уже связан с другим собственником, When разрешение, Then
INVESTOR_OWNER_INVESTOR_ALREADY_MAPPED (409, INV-5) — выбрать другого.
- Given выбранный пользователь не существует или
Type != Investor, When разрешение, Then
INVESTOR_OWNER_MATCHED_USER_NOT_INVESTOR (422); при этом сбой/таймаут identity не блокирует (guard INV-5
в БД остаётся жёстким).
- Given
MappingId не найден, Then INVESTOR_OWNER_MAPPING_NOT_FOUND (404); пустые
MappingId/InvestorUserId/ResolutionNote — ValidationError (400); нет investor-owner.mappings.manage
— 403; невалидный JWT — 401.
- Given гонка с синхронизацией или другим админом по той же связи, When разрешение, Then
проигравший получает
409 (concurrency) и повторяет после перечитки.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы