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 area | admin |
| Feature group | referral-bond-corrections |
| Статус | draft |
| Документ | docs/06-services/referral/api/admin/rebind-bond.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | Operator(Admin) (администратор платформы) |
| Auth policy / Permissions | referral.registrations.view-all (привилегированный; каталог — permission.md) |
| Scope (RBAC) | платформа (кросс-tenant: администратор правит связи любого реферера/инвестора) |
| Tenant isolation | связь-замена получает TenantId нового реферера (CompanyId; null — инвестор); запрос не ограничен tenant-ом актора |
| Audit | update+create sensitive: actor-stamping (UpdatedByUserId/CreatedByUserId = actor) + события ReferralBondReplacedEvent/ReferralBondCreatedEvent; Reason пишется в access-audit |
Тело/route — привилегированный ввод; всё равно валидируется (формат + проверки существования/состояния) до изменений.
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/admin/referrals/bonds/{bondId}/rebind |
| Success status | 200 (тело AdminRebindBondCommandResult); идемпотентный повтор → тот же 200 с сохранённым телом |
| Idempotency header | Idempotency-Key (обязателен; привилегированная мутация не должна повторяться при retry сети) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
BondId | route | Guid | Да | адресует заменяемую активную связь; непустой; должна существовать и быть Active | — |
NewReferralLinkId | body | Guid | Да | новая ссылка (резолвит нового реферера OwnerUserId); непустой; существует и не удалена | — |
ReferredUserId | body | Guid? | Нет | guard-согласованность: если задан — обязан равняться oldBond.ReferredUserId, иначе REFERRAL_BOND_TARGET_MISMATCH (защита от устаревшего bondId) | — |
Reason | body | string? | Нет | причина коррекции; trim, 0..512; не колонка агрегата — уходит в access-audit | текст оператора: в логах обрезка 64 симв. |
IdempotencyKey | header Idempotency-Key | string | Да | 8..128; ключ повтора привилегированной мутации | — |
ActorUserId | identity claims (sub) | Guid | Да | Required; id администратора для actor-stamping | из body не принимается |
Модель ответа AdminRebindBondCommandResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
NewBondId | Guid | Да | ReferralBondEntity.Id (новая, Active) | — | Идентификатор созданной связи-замены (на неё ссылаются будущие начисления rewards). |
ReplacedBondId | Guid | Да | ReferralBondEntity.Id (старая, Replaced) | — | Идентификатор заменённой связи (Status = Replaced, ReplacedByBondId = NewBondId). |
ФИО/телефон участников наружу не возвращаются (PII живёт в реплике).
Алгоритм
- Контекст и доступ. Оператор — администратор платформы; требуется permission
referral.registrations.view-all(иначе403). Идемпотентность поIdempotency-Key: при уже завершённой операции возвращается сохранённый результат; при незавершённой или ином payload с тем же ключом →IDEMPOTENCY_CONFLICT(409). - Проверка входа.
BondIdиNewReferralLinkIdнепустые,Reason≤ 512,IdempotencyKey8..128; иначеValidationError. - Проверка сущностей и состояния.
- Заменяемая связь существует; иначе
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. - Корректность реферера — решение оператора; реплика владельца синхронно не читается.
- Заменяемая связь существует; иначе
- Переходы и создание. В одном согласованном изменении:
- создаётся журнальная запись
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 uniqueux_referral_bonds_referred_activeсериализует запись — проигравшая ветвь завершается идемпотентным no-op (G5/INV-REF-001). Итог — ровно однаActiveнаReferredUserId(INV-REF-001).
- создаётся журнальная запись
- События. В том же согласованном изменении публикуются (топик
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 + ReferralBondCreatedKafkaEvent → pin.referral.bonds.v1 (ключ ReferredUserId), атомарно с изменением; rewards останавливает начисления по старой и заводит основание по новой |
| Уведомления | опосредованно: notifications — по событию bond-created (уведомление новому рефереру); rewards/messenger — потребители топика |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
ValidationError | 400 | пустой bondId/newReferralLinkId, reason > 512, некорректный Idempotency-Key | «Некорректные данные коррекции» + перечень полей | no retry |
REFERRAL_BOND_NOT_FOUND | 404 | старая связь по bondId не найдена | «Связь не найдена» | no retry |
REFERRAL_LINK_NOT_FOUND | 404 | новая ссылка не найдена/удалена | «Ссылка-замена не найдена» | no retry |
REFERRAL_BOND_INVALID_TRANSITION | 409 | oldBond.Status != Active (Replaced/Revoked терминальны, INV-REF-B4/B6) | «Связь уже закрыта, коррекция невозможна» | no retry |
REFERRAL_BOND_SELF_FORBIDDEN | 409 | новый реферер = приглашённый (INV-REF-B2) | «Нельзя привязать пользователя к самому себе» | no retry |
REFERRAL_BOND_TARGET_MISMATCH | 409 | заданный referredUserId != oldBond.ReferredUserId | «Связь не принадлежит указанному инвестору» | no retry |
REFERRAL_BOND_NO_CHANGE | 409 | новая ссылка/реферер совпадают с текущими | «Перепривязка не меняет реферера» | no retry |
REFERRAL_BOND_CONCURRENCY_CONFLICT | 409 | гонка коррекций (потеря обновления) | «Связь изменена параллельно, повторите» | retry (перечитка) |
IDEMPOTENCY_CONFLICT | 409 | тот же Idempotency-Key, иной payload / операция в процессе | «Конфликт ключа идемпотентности» | no retry |
FORBIDDEN | 403 | нет referral.registrations.view-all | «Недостаточно прав» | no retry |
UNAUTHORIZED | 401 | нет/просрочен токен | «Требуется авторизация» | 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, Then200 {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 запрос, Then409 IDEMPOTENCY_CONFLICT. - Given заменяемая связь уже
Replaced/Revoked, When rebind, Then409 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.ReferredUserId→409 REFERRAL_BOND_TARGET_MISMATCH. - Given параллельная коррекция той же связи, When rebind, Then проигравший получает
409 REFERRAL_BOND_CONCURRENCY_CONFLICT(retry с перечиткой); инвариант «однаActive» не нарушается. - Given актор без
referral.registrations.view-all, When rebind, Then403; без токена —401;reason> 512 / пустойbondId/newReferralLinkId/Idempotency-Keyвне8..128→400 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(будущий).
Обратные ссылки
Автоссылки: где используется этот документ.
- API (3): GET /api/admin/referrals/investors/{investorId}/referrals — Список приглашённых реферером (getInvestorReferrals, admin), POST /api/admin/referrals/bonds/{bondId}/revoke — Отзыв реферальной связи (adminRevokeBond), Referral API — реестр операций и общие контрактные модели
- События (2): Event Contract: pin.referral.bond-closed (ReferralBondClosedKafkaEvent), Event Contract: pin.referral.bond-created (ReferralBondCreatedKafkaEvent)
- registries (1): Реестр API-методов PIN
Связанные документы
- Агрегат (поля, INV-REF-B2/B4/B6, метод
Replace, статусы): referral-bond.md; журнал (RegistrationType,AppliedRule,LeadRequested): referral-registration.md; правило R6 и матрица: attach-rules.md; ссылка: referral-link.md. - События: ../events/bond-created.md, ../events/bond-closed.md.
- Соседние admin-операции: revoke-bond.md, get-investor-referrals.md; реестр — README.md.
- Permissions: permission.md.