From cb5e3150582f92d7873c5b51cc37210b3e5b52aa Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Wed, 2 Sep 2026 23:07:00 +0500 Subject: [PATCH 1/2] XYZ-447: Routing affinity and Customer by email --- proto/customer.thrift | 112 ++++++++++++++++++++++++++++++++ proto/domain.thrift | 49 ++++++++++++++ proto/payment_processing.thrift | 15 +++++ 3 files changed, 176 insertions(+) diff --git a/proto/customer.thrift b/proto/customer.thrift index 70b5314f..8970a079 100644 --- a/proto/customer.thrift +++ b/proto/customer.thrift @@ -74,6 +74,50 @@ struct ProviderTerminalKey { 2: required domain.TerminalRef terminal_ref } +/* Terminal affinity — привязка плательщика к терминалу */ + +/** + * Привязка Customer к терминалу. + * + * У Customer может быть несколько активных привязок; при выборе роута + * приоритетна та, что раньше (меньший bind_seq), среди доступных кандидатов. + * См. domain.RoutingAffinity. + */ +struct TerminalAffinity { + 1: required domain.ProviderRef provider_ref + 2: required domain.TerminalRef terminal_ref + /** Монотонный порядковый номер привязки: меньше = раньше = приоритетнее */ + 3: required i64 bind_seq + /** Время создания привязки; база для TTL since_bound */ + 4: required base.Timestamp bound_at + /** Последний успешный платёж через привязку; база для TTL since_last_use */ + 5: required base.Timestamp last_used_at +} + +/** + * Параметры привязки Customer к терминалу. + * Операция идемпотентна: повторный вызов не меняет bind_seq, только last_used_at. + */ +struct TerminalAffinityParams { + 1: required CustomerID customer_id + 2: required domain.ProviderRef provider_ref + 3: required domain.TerminalRef terminal_ref + /** + * Если задан — истёкшая по нему привязка к тому же терминалу + * снимается перед записью новой, и новая получает bind_seq в хвосте. + */ + 4: optional domain.RoutingAffinityTtl ttl +} + +/** + * Параметры снятия привязки. + */ +struct ReleaseTerminalAffinityParams { + 1: required CustomerID customer_id + 2: required ProviderTerminalKey key + 3: optional string reason +} + /** * BankCard — самостоятельная сущность банковской карты. */ @@ -141,6 +185,12 @@ struct Customer { 6: optional domain.Metadata metadata /** Внешний идентификатор Customer */ 7: optional string external_id + /** + * Email плательщика, нормализованный (нижний регистр, без пробелов по краям). + * Уникален в рамках party_ref. Идентичность плательщика для привязки + * к терминалу; не смешивать с external_id — идентификатором от мерчанта. + */ + 8: optional string email } /** @@ -182,6 +232,8 @@ struct CustomerParams { 3: optional domain.Metadata metadata /** Внешний идентификатор Customer */ 4: optional string external_id + /** Email плательщика; нормализуется сервисом. Уникален в рамках party_ref */ + 5: optional string email } /** @@ -243,6 +295,11 @@ exception CustomerAlreadyExists { 1: required CustomerID id } +/** Email уже занят другим Customer этой party */ +exception CustomerEmailConflict { + 1: required CustomerID id +} + exception BankCardNotFound {} exception InvalidRecurrentParent { @@ -280,6 +337,7 @@ service CustomerManagement { throws ( 1: CustomerAlreadyExists already_exists 2: base.InvalidRequest invalid_request + 3: CustomerEmailConflict email_conflict ) /** @@ -374,6 +432,60 @@ service CustomerManagement { 3: optional ContinuationToken continuation_token ) throws (1: CustomerNotFound not_found) + + /** + * Найти Customer по email в рамках party или создать, если его нет. + * Идемпотентно и безопасно при конкурентных вызовах: арбитр — уникальность + * email в рамках party. Email нормализуется сервисом. + * Никогда не бросает CustomerEmailConflict. + */ + Customer FindOrCreateByEmail( + 1: domain.PartyConfigRef party_ref, + 2: string email + ) + throws (1: base.InvalidRequest invalid_request) + + /** + * Получить Customer по email и party. + */ + CustomerState GetByEmail( + 1: domain.PartyConfigRef party_ref, + 2: string email + ) + throws (1: CustomerNotFound not_found) + + /** + * Получить активные привязки Customer к терминалам. + * Истечение по TTL не учитывается — TTL задаётся кандидатом роутинга + * и применяется вызывающей стороной. + */ + list GetTerminalAffinities(1: CustomerID customer_id) + throws (1: CustomerNotFound not_found) + + /** + * Привязать Customer к терминалу. + * Идемпотентно: повторный вызов не меняет bind_seq, только last_used_at. + */ + TerminalAffinity BindTerminalAffinity(1: TerminalAffinityParams params) + throws ( + 1: CustomerNotFound not_found + 2: base.InvalidRequest invalid_request + ) + + /** + * Снять привязку Customer к терминалу. + */ + void ReleaseTerminalAffinity(1: ReleaseTerminalAffinityParams params) + throws (1: CustomerNotFound not_found) + + /** + * Снять привязки всех Customer к терминалу. + * Административная операция, например при выводе терминала из эксплуатации. + */ + void ReleaseTerminalAffinitiesByTerminal( + 1: ProviderTerminalKey key, + 2: optional string reason + ) } /** diff --git a/proto/domain.thrift b/proto/domain.thrift index ca7de2a1..407d04c1 100644 --- a/proto/domain.thrift +++ b/proto/domain.thrift @@ -540,9 +540,20 @@ struct PaymentRoute { 2: required TerminalRef terminal } +/** + * Скоры роута. Сравниваются как кортеж в порядке объявления полей, поэтому + * позиция поля в объявлении задаёт приоритет критерия: сверху сильнее. + */ struct PaymentRouteScores { 1: optional i32 availability_condition 2: optional i32 conversion_condition + /** + * Ранг привязки плательщика к терминалу (см. RoutingAffinity). + * 0 — привязки нет; больше — привязка раньше. Объявлено выше приоритета + * и веса намеренно: привязка сильнее перенастройки роутинга, но слабее + * критических состояний по FD. + */ + 9: optional i32 terminal_affinity 3: optional i32 terminal_priority_rating 4: optional i32 route_pin 5: optional i32 random_condition @@ -2537,6 +2548,43 @@ struct RoutingPin { 1: required set features } +/** + * Привязка плательщика к терминалу. + * + * Плательщик идентифицируется по email через Customer. Терминал, через который + * прошёл первый успешный платёж, запоминается; в последующих платежах привязанный + * терминал предпочитается приоритету и весу кандидатов, пока доступен. + * Ранг получают только кандидаты с этой настройкой; привязка создаётся, только + * если выбранный кандидат её нёс. + */ +struct RoutingAffinity { + /** Время жизни привязки; не задано — бессрочно. */ + 1: optional RoutingAffinityTtl ttl +} + +/** + * Вид TTL привязки: от какого момента считать срок. + * + * Форму срока выбирает оператор через base.Timer: + * - timeout — привязка истекает, когда с базового момента прошло больше + * timeout секунд; + * - deadline — привязка истекает, если базовый момент раньше deadline + * (абсолютная отсечка, например плановая переразметка с даты). + */ +union RoutingAffinityTtl { + /** + * Жёсткий: базовый момент — создание привязки (bound_at). Истекает + * независимо от активности плательщика — периодическая переразметка + * по актуальным весам. + */ + 1: base.Timer since_bound + /** + * Скользящий: базовый момент — последний успешный платёж через привязку + * (last_used_at). Истекает, только если плательщик через неё не платил. + */ + 2: base.Timer since_last_use +} + struct RoutingCandidate { 1: optional string description 2: required Predicate allowed @@ -2544,6 +2592,7 @@ struct RoutingCandidate { 4: optional i32 priority = CANDIDATE_PRIORITY 5: optional RoutingPin pin 6: optional i32 weight = CANDIDATE_WEIGHT + 7: optional RoutingAffinity affinity } /* Root config */ diff --git a/proto/payment_processing.thrift b/proto/payment_processing.thrift index a03f0bf1..7fd60593 100644 --- a/proto/payment_processing.thrift +++ b/proto/payment_processing.thrift @@ -150,6 +150,7 @@ union InvoicePaymentChangePayload { 18: InvoicePaymentShopLimitApplied invoice_payment_shop_limit_applied 19: InvoicePaymentCascadeTokensLoaded invoice_payment_cascade_tokens_loaded 20: InvoicePaymentExchangeContextChanged invoice_payment_exchange_context_changed + 21: InvoicePaymentTerminalAffinitiesLoaded invoice_payment_terminal_affinities_loaded } /** @@ -488,6 +489,15 @@ struct InvoicePaymentCascadeTokensLoaded { 1: required list tokens } +/** + * Событие о загрузке привязок плательщика к терминалам из хранилища. + * Содержит только живые (не истёкшие по TTL) привязки; пустой список — тоже + * событие: оно фиксирует, что загрузка для платежа выполнена. + */ +struct InvoicePaymentTerminalAffinitiesLoaded { + 1: required list affinities +} + struct InvoicePaymentCaptureStarted { 1: required InvoicePaymentCaptureData data } @@ -900,6 +910,11 @@ typedef map> RouteLimitContext struct RouteDecisionContext { 1: optional bool skip_recurrent + /** + * Выбранный кандидат несёт настройку привязки к терминалу + * (domain.RoutingAffinity): при успехе платежа привязка будет создана. + */ + 2: optional bool terminal_affinity } // Exceptions From 3bdfd4cd0022611dbf3477efdabfedf16d5e2786 Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Fri, 4 Sep 2026 19:54:40 +0500 Subject: [PATCH 2/2] Add deadline to RoutingAffinityTtl and fix up comments --- proto/customer.thrift | 39 +++++++++++++++----------- proto/domain.thrift | 49 +++++++++++++++++---------------- proto/payment_processing.thrift | 8 +++--- 3 files changed, 53 insertions(+), 43 deletions(-) diff --git a/proto/customer.thrift b/proto/customer.thrift index 8970a079..ee66aea1 100644 --- a/proto/customer.thrift +++ b/proto/customer.thrift @@ -79,18 +79,24 @@ struct ProviderTerminalKey { /** * Привязка Customer к терминалу. * - * У Customer может быть несколько активных привязок; при выборе роута - * приоритетна та, что раньше (меньший bind_seq), среди доступных кандидатов. - * См. domain.RoutingAffinity. + * У Customer может быть несколько активных привязок. При выборе роута среди + * доступных кандидатов предпочитается привязка, созданная раньше + * (с меньшим bind_seq). См. domain.RoutingAffinity. */ struct TerminalAffinity { 1: required domain.ProviderRef provider_ref 2: required domain.TerminalRef terminal_ref - /** Монотонный порядковый номер привязки: меньше = раньше = приоритетнее */ + /** + * Порядковый номер привязки, растёт монотонно. Чем меньше, тем раньше + * создана привязка и тем выше её приоритет. + */ 3: required i64 bind_seq - /** Время создания привязки; база для TTL since_bound */ + /** Время создания привязки; точка отсчёта для TTL since_bound */ 4: required base.Timestamp bound_at - /** Последний успешный платёж через привязку; база для TTL since_last_use */ + /** + * Время последнего успешного платежа через привязку; + * точка отсчёта для TTL since_last_use. + */ 5: required base.Timestamp last_used_at } @@ -103,8 +109,8 @@ struct TerminalAffinityParams { 2: required domain.ProviderRef provider_ref 3: required domain.TerminalRef terminal_ref /** - * Если задан — истёкшая по нему привязка к тому же терминалу - * снимается перед записью новой, и новая получает bind_seq в хвосте. + * Если задан и существующая привязка к этому терминалу по нему уже истекла, + * она снимается, а вместо неё создаётся новая с очередным bind_seq. */ 4: optional domain.RoutingAffinityTtl ttl } @@ -186,9 +192,10 @@ struct Customer { /** Внешний идентификатор Customer */ 7: optional string external_id /** - * Email плательщика, нормализованный (нижний регистр, без пробелов по краям). - * Уникален в рамках party_ref. Идентичность плательщика для привязки - * к терминалу; не смешивать с external_id — идентификатором от мерчанта. + * Email плательщика в нормализованном виде (нижний регистр, без пробелов + * по краям). Уникален в рамках party_ref. Служит идентификатором плательщика + * для привязки к терминалу; не путать с external_id — идентификатором, + * который назначает мерчант. */ 8: optional string email } @@ -435,9 +442,9 @@ service CustomerManagement { /** * Найти Customer по email в рамках party или создать, если его нет. - * Идемпотентно и безопасно при конкурентных вызовах: арбитр — уникальность - * email в рамках party. Email нормализуется сервисом. - * Никогда не бросает CustomerEmailConflict. + * Операция идемпотентна и безопасна при конкурентных вызовах: единственность + * Customer гарантирует уникальность email в рамках party. Email нормализуется + * сервисом. CustomerEmailConflict не бросает. */ Customer FindOrCreateByEmail( 1: domain.PartyConfigRef party_ref, @@ -456,8 +463,8 @@ service CustomerManagement { /** * Получить активные привязки Customer к терминалам. - * Истечение по TTL не учитывается — TTL задаётся кандидатом роутинга - * и применяется вызывающей стороной. + * Истечение по TTL здесь не проверяется: TTL задаётся в настройках кандидата + * роутинга, и учитывать его должна вызывающая сторона. */ list GetTerminalAffinities(1: CustomerID customer_id) throws (1: CustomerNotFound not_found) diff --git a/proto/domain.thrift b/proto/domain.thrift index 407d04c1..391e2fdf 100644 --- a/proto/domain.thrift +++ b/proto/domain.thrift @@ -542,16 +542,16 @@ struct PaymentRoute { /** * Скоры роута. Сравниваются как кортеж в порядке объявления полей, поэтому - * позиция поля в объявлении задаёт приоритет критерия: сверху сильнее. + * чем выше поле объявлено, тем значимее критерий. */ struct PaymentRouteScores { 1: optional i32 availability_condition 2: optional i32 conversion_condition /** * Ранг привязки плательщика к терминалу (см. RoutingAffinity). - * 0 — привязки нет; больше — привязка раньше. Объявлено выше приоритета - * и веса намеренно: привязка сильнее перенастройки роутинга, но слабее - * критических состояний по FD. + * 0 — привязки нет; чем больше значение, тем раньше была создана привязка. + * Поле намеренно объявлено выше приоритета и веса: привязка важнее + * настроек роутинга, но уступает критическим состояниям по fault detector. */ 9: optional i32 terminal_affinity 3: optional i32 terminal_priority_rating @@ -2552,37 +2552,40 @@ struct RoutingPin { * Привязка плательщика к терминалу. * * Плательщик идентифицируется по email через Customer. Терминал, через который - * прошёл первый успешный платёж, запоминается; в последующих платежах привязанный - * терминал предпочитается приоритету и весу кандидатов, пока доступен. - * Ранг получают только кандидаты с этой настройкой; привязка создаётся, только - * если выбранный кандидат её нёс. + * прошёл первый успешный платёж, запоминается, и в последующих платежах имеет + * преимущество перед приоритетом и весом кандидатов, пока доступен. + * Настройка действует только на кандидатов, у которых она задана: остальные + * не участвуют в привязке ни при выборе роута, ни при её создании. */ struct RoutingAffinity { - /** Время жизни привязки; не задано — бессрочно. */ + /** Время жизни привязки; если не задано — бессрочно. */ 1: optional RoutingAffinityTtl ttl } /** - * Вид TTL привязки: от какого момента считать срок. - * - * Форму срока выбирает оператор через base.Timer: - * - timeout — привязка истекает, когда с базового момента прошло больше - * timeout секунд; - * - deadline — привязка истекает, если базовый момент раньше deadline - * (абсолютная отсечка, например плановая переразметка с даты). + * Время жизни привязки: каждый вариант задаёт момент истечения. + * Привязка считается истёкшей, как только этот момент наступил. */ union RoutingAffinityTtl { /** - * Жёсткий: базовый момент — создание привязки (bound_at). Истекает - * независимо от активности плательщика — периодическая переразметка - * по актуальным весам. + * Абсолютная отсечка: все привязки кандидата истекают в этот момент, + * независимо от того, когда были созданы и когда использовались. + * Например, для плановой переразметки плательщиков по актуальным весам + * начиная с заданной даты. + */ + 1: base.Timestamp deadline + /** + * Срок с момента создания привязки: истекает через since_bound секунд + * после bound_at, независимо от того, платит ли плательщик. + * Подходит для периодической переразметки по актуальным весам. */ - 1: base.Timer since_bound + 2: base.Timeout since_bound /** - * Скользящий: базовый момент — последний успешный платёж через привязку - * (last_used_at). Истекает, только если плательщик через неё не платил. + * Скользящий срок: истекает, если через привязку не было успешных платежей + * дольше since_last_use секунд (считая от last_used_at). Каждый успешный + * платёж продлевает срок. */ - 2: base.Timer since_last_use + 3: base.Timeout since_last_use } struct RoutingCandidate { diff --git a/proto/payment_processing.thrift b/proto/payment_processing.thrift index 7fd60593..12a3c130 100644 --- a/proto/payment_processing.thrift +++ b/proto/payment_processing.thrift @@ -491,8 +491,8 @@ struct InvoicePaymentCascadeTokensLoaded { /** * Событие о загрузке привязок плательщика к терминалам из хранилища. - * Содержит только живые (не истёкшие по TTL) привязки; пустой список — тоже - * событие: оно фиксирует, что загрузка для платежа выполнена. + * Содержит только действующие (не истёкшие по TTL) привязки. Пустой список — + * тоже событие: оно фиксирует, что загрузка для этого платежа уже выполнена. */ struct InvoicePaymentTerminalAffinitiesLoaded { 1: required list affinities @@ -911,8 +911,8 @@ typedef map> RouteLimitContext struct RouteDecisionContext { 1: optional bool skip_recurrent /** - * Выбранный кандидат несёт настройку привязки к терминалу - * (domain.RoutingAffinity): при успехе платежа привязка будет создана. + * У выбранного кандидата задана настройка привязки к терминалу + * (domain.RoutingAffinity): при успешном платеже привязка будет создана. */ 2: optional bool terminal_affinity }