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

POST /api/admin/referrals/bonds/{bondId}/rebind — коррекция связи (R6, Replace)

Назначение

Ручная перепривязка инвестора к другому рефереру/ссылке администратором платформы: старая активная связь переводится в Replaced, атомарно создаётся новая связь-замена (Active, AppliedRule = R6) и журнальная запись Rebind. Оператор исправляет ошибочную/спорную связь: инвестор был привязан не к тому рефереру (опечатка в slug, спор о первичности касания, фрод). Правило R6 матрицы привязки (attach-rules.md): автоматический rebind запрещён (решение № 3 README.md) — связь есть основание денежных выплат, смена реферера без ручного контроля есть фрод-риск. Агрегат — ReferralBondEntity (+ журнал ReferralRegistrationEntity).

Metadata

ПолеЗначение
Сервис/контекстreferral
API areaadmin
Feature groupreferral-bond-corrections
Статусdraft
Документdocs/06-services/referral/api/admin/rebind-bond.md

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

ПроверкаЗначение
Actor typeOperator(Admin) (администратор платформы)
Auth policy / Permissionsreferral.registrations.view-all (привилегированный; каталог — permission.md)
Scope (RBAC)платформа (кросс-tenant: администратор правит связи любого реферера/инвестора)
Tenant isolationсвязь-замена получает TenantId нового реферера (CompanyId; null — инвестор); запрос не ограничен tenant-ом актора
Auditupdate+create sensitive: actor-stamping (UpdatedByUserId/CreatedByUserId = actor) + события ReferralBondReplacedEvent/ReferralBondCreatedEvent; Reason пишется в access-audit

Тело/route — привилегированный ввод; всё равно валидируется (формат + проверки существования/состояния) до изменений.

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/admin/referrals/bonds/{bondId}/rebind
Success status200 (тело AdminRebindBondCommandResult); идемпотентный повтор → тот же 200 с сохранённым телом
Idempotency headerIdempotency-Key (обязателен; привилегированная мутация не должна повторяться при retry сети)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
BondIdrouteGuidДаадресует заменяемую активную связь; непустой; должна существовать и быть Active
NewReferralLinkIdbodyGuidДановая ссылка (резолвит нового реферера OwnerUserId); непустой; существует и не удалена
ReferredUserIdbodyGuid?Нетguard-согласованность: если задан — обязан равняться oldBond.ReferredUserId, иначе REFERRAL_BOND_TARGET_MISMATCH (защита от устаревшего bondId)
Reasonbodystring?Нетпричина коррекции; trim, 0..512; не колонка агрегата — уходит в access-auditтекст оператора: в логах обрезка 64 симв.
IdempotencyKeyheader Idempotency-KeystringДа8..128; ключ повтора привилегированной мутации
ActorUserIdidentity claims (sub)GuidДаRequired; id администратора для actor-stampingиз body не принимается

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

ПолеТипОбязательностьИсточникMaskingОписание
NewBondIdGuidДаReferralBondEntity.Id (новая, Active)Идентификатор созданной связи-замены (на неё ссылаются будущие начисления rewards).
ReplacedBondIdGuidДаReferralBondEntity.Id (старая, Replaced)Идентификатор заменённой связи (Status = Replaced, ReplacedByBondId = NewBondId).

ФИО/телефон участников наружу не возвращаются (PII живёт в реплике).

Алгоритм

  1. Контекст и доступ. Оператор — администратор платформы; требуется permission referral.registrations.view-all (иначе 403). Идемпотентность по Idempotency-Key: при уже завершённой операции возвращается сохранённый результат; при незавершённой или ином payload с тем же ключом → IDEMPOTENCY_CONFLICT (409).
  2. Проверка входа. BondId и NewReferralLinkId непустые, Reason ≤ 512, IdempotencyKey 8..128; иначе ValidationError.
  3. Проверка сущностей и состояния.
    • Заменяемая связь существует; иначе REFERRAL_BOND_NOT_FOUND. Её статус должен быть Active (переход из Replaced/Revoked запрещён, терминальность INV-REF-B4/B6) — иначе REFERRAL_BOND_INVALID_TRANSITION.
    • Новая ссылка существует и не удалена; иначе REFERRAL_LINK_NOT_FOUND (источник нового реферера, ссылки, tenant и канала замены).
    • Если задан ReferredUserId — обязан равняться oldBond.ReferredUserId, иначе REFERRAL_BOND_TARGET_MISMATCH.
    • Новый реферер (newLink.OwnerUserId) ≠ приглашённый (oldBond.ReferredUserId) — иначе REFERRAL_BOND_SELF_FORBIDDEN (INV-REF-B2 NoSelfBond).
    • Перепривязка реально меняет реферера/ссылку (newLink.Id != oldBond.ReferralLinkId ИЛИ newLink.OwnerUserId != oldBond.ReferrerUserId) — иначе REFERRAL_BOND_NO_CHANGE.
    • Корректность реферера — решение оператора; реплика владельца синхронно не читается.
  4. Переходы и создание. В одном согласованном изменении:
    • создаётся журнальная запись Rebind (ReferralLinkId/LinkUrl/ReferrerUserId — из новой ссылки, ReferredUserId — от старой связи, AppliedRule = R6, LeadRequested = false — admin-коррекция лид не запрашивает);
    • создаётся новая связь-замена: ReferredUserId от старой, ReferrerUserId/ReferralLinkId/ TenantId от новой ссылки, Status = Active, BoundAt = now(), AppliedRule = R6, CreatedByUserId = ActorUserId (guard INV-REF-B2);
    • старая связь переводится Active → Replaced, ReplacedByBondId = NewBondId, UpdatedByUserId = ActorUserId (INV-REF-B4 — только атомарно с созданием замены; INV-REF-B6 — только из Active). Порядок «перевод старой в Replaced → создание новой Active» освобождает «единственную активную» для ReferredUserId. Конкурентная коррекция той же связи → REFERRAL_BOND_CONCURRENCY_CONFLICT (retry). Конкурентный автоматический триггер привязки того же инвестора (consumer T1/T2) не создаёт вторую активную связь: partial unique ux_referral_bonds_referred_active сериализует запись — проигравшая ветвь завершается идемпотентным no-op (G5/INV-REF-001). Итог — ровно одна Active на ReferredUserId (INV-REF-001).
  5. События. В том же согласованном изменении публикуются (топик pin.referral.bonds.v1, ключ ReferredUserId — строгий порядок closed→created per-инвестор):
    • ReferralBondClosedKafkaEvent (старая; Status = Replaced, ReplacedByBondId = NewBondId, RevokeReason = null — контракт ../events/bond-closed.md);
    • ReferralBondCreatedKafkaEvent (новая; ReferrerUserId, ReferrerCompanyId, ReferralLinkId, Channel, BoundAt, AppliedRule = R6 — контракт ../events/bond-created.md). Лид-события нет (R6 → «Лид: Нет»). Возвращаются NewBondId и ReplacedBondId.

Диаграмма

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

ТипДетали
Интеграционные событияReferralBondClosedKafkaEvent + ReferralBondCreatedKafkaEventpin.referral.bonds.v1 (ключ ReferredUserId), атомарно с изменением; rewards останавливает начисления по старой и заводит основание по новой
Уведомленияопосредованно: notifications — по событию bond-created (уведомление новому рефереру); rewards/messenger — потребители топика

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
ValidationError400пустой bondId/newReferralLinkId, reason > 512, некорректный Idempotency-Key«Некорректные данные коррекции» + перечень полейno retry
REFERRAL_BOND_NOT_FOUND404старая связь по bondId не найдена«Связь не найдена»no retry
REFERRAL_LINK_NOT_FOUND404новая ссылка не найдена/удалена«Ссылка-замена не найдена»no retry
REFERRAL_BOND_INVALID_TRANSITION409oldBond.Status != Active (Replaced/Revoked терминальны, INV-REF-B4/B6)«Связь уже закрыта, коррекция невозможна»no retry
REFERRAL_BOND_SELF_FORBIDDEN409новый реферер = приглашённый (INV-REF-B2)«Нельзя привязать пользователя к самому себе»no retry
REFERRAL_BOND_TARGET_MISMATCH409заданный referredUserId != oldBond.ReferredUserId«Связь не принадлежит указанному инвестору»no retry
REFERRAL_BOND_NO_CHANGE409новая ссылка/реферер совпадают с текущими«Перепривязка не меняет реферера»no retry
REFERRAL_BOND_CONCURRENCY_CONFLICT409гонка коррекций (потеря обновления)«Связь изменена параллельно, повторите»retry (перечитка)
IDEMPOTENCY_CONFLICT409тот же Idempotency-Key, иной payload / операция в процессе«Конфликт ключа идемпотентности»no retry
FORBIDDEN403нет referral.registrations.view-all«Недостаточно прав»no retry
UNAUTHORIZED401нет/просрочен токен«Требуется авторизация»retry после повторной авторизации

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

Предусловия: заменяемая связь существует и Status = Active; новая ссылка существует и не удалена; её владелец (новый реферер) ≠ приглашённый; новая ссылка/реферер отличаются от текущих. Оператор подтвердил корректность нового реферера (реплика владельца синхронно не читается — решение оператора).

Постусловия (при успехе):

  • ровно одна связь Active на ReferredUserId (INV-REF-001): старая → Replaced (ReplacedByBondId = NewBondId), новая создана Active (AppliedRule = R6, TenantId нового реферера, Version новой связи = 1);
  • в журнале появилась запись Rebind (LeadRequested = false); журнал Initial старой связи не меняется (append-only);
  • в pin.referral.bonds.v1 (ключ ReferredUserId) в строгом порядке ушли ReferralBondClosedKafkaEvent (старая) → ReferralBondCreatedKafkaEvent (новая); rewards остановит начисления по ReplacedBondId и заведёт основание по NewBondId (eventual consistency, окно — лаг доставки);
  • промежуточное состояние: смена связи атомарна и синхронна — сразу после коммита приглашённый видит нового реферера в getMyReferrer, а старая связь уже Replaced; остановка/заведение начислений в rewards и уведомление новому рефереру (notifications) подтягиваются асинхронно в окне доставки;
  • Reason записан в access-audit; результат сохранён под Idempotency-Key.

Acceptance Criteria

  • Given активная связь и валидная новая ссылка с другим реферером, When админ вызывает rebind с свежим Idempotency-Key, Then 200 {newBondId, replacedBondId}; старая связь Replaced, новая Active (AppliedRule = R6), запись журнала Rebind, два события в pin.referral.bonds.v1 по ключу ReferredUserId (closed→created).
  • Given та же операция повторяется с тем же Idempotency-Key и тем же payload, When retry сети, Then тот же 200 с сохранённым телом; повторных переходов/событий нет.
  • Given тот же Idempotency-Key, но иной payload или операция ещё в процессе, When запрос, Then 409 IDEMPOTENCY_CONFLICT.
  • Given заменяемая связь уже Replaced/Revoked, When rebind, Then 409 REFERRAL_BOND_INVALID_TRANSITION (терминальность INV-REF-B4/B6); связь не найдена → 404 REFERRAL_BOND_NOT_FOUND; новая ссылка не найдена/удалена → 404 REFERRAL_LINK_NOT_FOUND.
  • Given новый реферер = приглашённый, When rebind, Then 409 REFERRAL_BOND_SELF_FORBIDDEN (INV-REF-B2); новая ссылка/реферер совпадают с текущими → 409 REFERRAL_BOND_NO_CHANGE; заданный ReferredUserId ≠ oldBond.ReferredUserId409 REFERRAL_BOND_TARGET_MISMATCH.
  • Given параллельная коррекция той же связи, When rebind, Then проигравший получает 409 REFERRAL_BOND_CONCURRENCY_CONFLICT (retry с перечиткой); инвариант «одна Active» не нарушается.
  • Given актор без referral.registrations.view-all, When rebind, Then 403; без токена — 401; reason > 512 / пустой bondId/newReferralLinkId / Idempotency-Key вне 8..128400 ValidationError.

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

  • Enum ReferralRegistrationType.Rebind и ReferralAttachRule.R6 — часть контракта; новые additive-поля результата — без breaking change, удаление/переименование/смена типа — breaking.
  • Payload обоих событий — по bond-created.md / bond-closed.md (общий топик, порядок per-ReferredUserId по ключу).
  • Logs: admin bond rebind (ActorUserId, ReplacedBondId, NewBondId, NewReferralLinkId, без PII; reason — обрезка); metrics: referral_admin_rebind_total{result}, счётчик срабатываний R6; traces — сквозной trace через traceparent; dashboard — referral-overview (README.md, Runtime); runbook — runbooks/referral-bond-corrections.md (будущий).

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

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

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