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

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.

Metadata

ПолеЗначение
Сервис/контекстinvestor-owner
API areaadmin (Служба заботы)
Feature groupowner-mappings
Статусdraft
Документdocs/06-services/investor-owner/api/admin/resolve-owner-mapping-conflict.md

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

ПроверкаЗначение
Actor typeOperator(Admin) (сотрудник Службы заботы, Bearer JWT)
Auth policy / Permissionsauthenticated; permission investor-owner.mappings.manage (../../../identity/domain/permission.md)
Scope (RBAC)платформенный (не self, не tenant)
Record-levelне применяется (админ видит любую связь); ключ — mappingId
Tenant isolationне применяется — данные инвестора вне tenant (ADR-0052)
Auditupdate (привилегированная): ResolvedByUserId/UpdatedByUserId из JWT, ResolutionNote (обязательна) + доменное событие OwnerMappingLinked

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/admin/owner-mappings/{mappingId}/resolve
Success status200 (связь установлена)
Idempotency headerне применяется (переход Conflict → Linked; повтор на уже Linked409)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
ResolvedByUserIdidentity claims (sub)GuidДавалидный JWT — 401 до операции; permission — 403actor-stamping (ResolvedByUserId/UpdatedByUserId)
MappingIdrouteGuidДа!= Guid.Empty; существующая связь
InvestorUserIdbodyGuidДа!= Guid.Empty; вручную выбранный сотрудником инвестор; уникальность INV-5ссылка на identity.users(id) без FK
ResolutionNotebodystringДанепустая после trim, 1..512 — обязательна для аудита ручного разрешенияпишется в аудит; без raw PII третьих лиц

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

Проекция агрегата OwnerMappingEntity (дом полей — ../../domain/owner-mapping.md); здесь — только дельта. Общая с очередью list-owner-mapping-conflicts.md.

ПолеТипОбязательностьИсточникMaskingОписание
idGuidДаOwnerMapping.Id (= OwnerId)Идентификатор связи.
investorUserIdGuidДаOwnerMapping.InvestorUserIdПривязанный инвестор (после разрешения обязателен, INV-2).
statusstring (OwnerMatchStatus)ДаOwnerMapping.StatusПосле операции — всегда Linked.
linkKindstring (OwnerLinkKind)ДаOwnerMapping.LinkKindExistingInvestorMatched (ручное сопоставление существующего инвестора).
phoneNumberMaskedstringДаOwnerMapping.PhoneNumber+7***1234Телефон собственника (PII — маска).
ownerFullNameMaskedstringДаOwnerMapping.OwnerFullNameИ*** П***ФИО собственника (PII — маска).
linkedAtstring (ISO 8601)ДаOwnerMapping.LinkedAtМомент разрешения.
resolvedByUserIdGuidДаOwnerMapping.ResolvedByUserIdСотрудник заботы, разрешивший конфликт.
versionlongДаOwnerMapping.VersionНовая версия связи.

Алгоритм

  1. Контекст и доступ. Операцию выполняет оператор Службы заботы (ResolvedByUserId — из claim sub); требуется permission investor-owner.mappings.manage (иначе 403); scope платформенный.
  2. Проверка входа и предусловий. 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 в БД остаётся жёстким).
  3. Разрешение и переход. Переход 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).
  4. События. Публикуется интеграционное 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. Обновление связи и публикация события фиксируются в одном согласованном изменении.
  5. Результат. 200 с моделью OwnerMappingModel (PII-маскирование). Далее сотрудник инициирует replay зависшего в DLQ события владения — оно проходит INV-8.

Диаграмма

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

ТипДетали
Интеграционные событияInvestorOwnerLinkedKafkaEventpin.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
FORBIDDEN403Нет investor-owner.mappings.manage«Недостаточно прав»no retry
ValidationError400MappingId/InvestorUserId пусты; ResolutionNote пустаяСписок ошибок полейno retry
INVESTOR_OWNER_MAPPING_NOT_FOUND404Связь по MappingId не найдена«Связь не найдена»no retry
INVESTOR_OWNER_MAPPING_NOT_IN_CONFLICT409Status != Conflict (уже Linked / PendingLink)«Связь не в состоянии конфликта»no retry
INVESTOR_OWNER_INVESTOR_ALREADY_MAPPED409INV-5: инвестор уже связан с другим собственником (ux_owner_mappings_investor_user_id)«Инвестор уже сопоставлен»no retry (выбрать другого)
INVESTOR_OWNER_MATCHED_USER_NOT_INVESTOR422identity: пользователь не существует или 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/ResolutionNoteValidationError (400); нет investor-owner.mappings.manage403; невалидный JWT — 401.
  • Given гонка с синхронизацией или другим админом по той же связи, When разрешение, Then проигравший получает 409 (concurrency) и повторяет после перечитки.

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

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

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