Первоначально опубликовано на https://poddarayush.com 19 июля 2023 г.

Этот пост написан в форме руководства. Но это вдохновлено моим опытом создания аналогичного платежного кошелька для ScriptDoor.

Вас просят разработать внутренний кошелек для вашего приложения, где пользователи могут переводить друг другу «денежные токены» (также называемые токенами). Они могут приобрести токены, совершив платеж по кредитной карте, а затем передать эти токены друг другу.

Например, Гарри покупает 5 токенов на вашей платформе (приложении), а затем передает 3 токена Тони. Затем Тони передает 2 жетона Пеппер.

В оставшейся части сообщения мы будем предполагать, что у пользователей есть токены, которые они хотят передать. Мы не будем рассматривать покупку токенов.

Разработка простой системы передачи токенов

Простая система передачи токенов включает в себя отслеживание баланса токенов каждого пользователя. Передача токенов просто обновляет балансы токенов участвующих сторон. Вы можете отслеживать историю транзакций, чтобы показать ее пользователю, но это не обязательно для функционирования вашего внутреннего кошелька.

Следующая анимация демонстрирует, как балансы обновляются при каждом переводе:

Проблема №1 — Срок действия некоторых токенов истекает; некоторые не будут

Возникает бизнес-требование о том, что срок действия некоторых токенов, полученных пользователем, истекает через месяц, если передача удовлетворяет некоторым условиям¹, т. е. получатель должен использовать эти токены в течение месяца с момента получения.

Например, если Тони передаст 3 жетона Пеппер Поттс 2 июня, то Пеппер должна использовать эти жетоны до 2 июля.

Решение — сохранить дату истечения срока действия

Очевидно, что просто хранить баланс недостаточно. Теперь нам нужно несколько записей, каждая из которых представляет баланс токена и соответствующий срок его действия.

После того, как несколько человек отправят несколько токенов Пеппер, ее записи будут выглядеть так:

Следующая анимация точно демонстрирует, как будут обновляться балансы Pepper, если кто-то сейчас отправит 5 токенов 7 июня.

Проблема №2 — Pepper хочет передать токены

Дополнительное требование, связанное с этой проблемой, заключается в том, что при передаче токенов в первую очередь должны отправляться токены, срок действия которых ближе всего к дате истечения срока действия.

Предположим, что Пеппер хочет отправить Тони 11 жетонов. Затем необходимо передать следующие токены (см. таблицу выше):

  1. 3 токена со сроком действия 2 июля
  2. 10 токенов со сроком действия 3 июля

Но сумма этих двух равна 13. Это проблема!

Решение — сохранить сдачу

Что бы вы сделали, если бы столкнулись с таким сценарием при оплате в продуктовом магазине. Представьте, что у вас есть две банкноты (настоящие бумажные деньги) стоимостью 3 и 10 долларов соответственно.

Вы отдаете эти две купюры кассиру, и кассир возвращает сдачу, т. е. 2 доллара.

Точно так же мы могли бы смоделировать нашу систему, чтобы следовать этой системе транзакций, с дополнительным преимуществом, заключающимся в возможности разбить бумажную купюру на более мелкие номиналы любого номинала. 10-долларовую купюру можно разбить (разорвать, разделить) на 2-долларовую купюру и 8-долларовую купюру. Это приведет к тому, что Пеппер будет обладать этими символическими «счетами»:

  1. 3 токена со сроком действия 2 июля
  2. 8 токенов со сроком действия 3 июля
  3. 2 токена со сроком действия 3 июля

Затем банкнота с 3 жетонами и купюра с 8 жетонами могут быть переданы Тони, обновив его соответствующие балансы жетонов.

Проблема №3 — Отслеживание жизненного цикла каждого токена

Для простоты предположим, что ни один из токенов никогда не истекает.

Рассмотрим такой сценарий: Пеппер получает по 2 жетона от Тони, Джарвиса, Хэппи, Роуди и Джокера. Это означает, что после этих транзакций у нее остается 10 токенов. Теперь она хочет передать 4 жетона Питеру. Балансы будут обновлены для Пеппера и Питера. Но у нас нет способа выяснить, были ли токены, полученные Питером, изначально переданы Тони и Джарвисом ИЛИ Хэппи и Джокером ИЛИ какой-то другой комбинацией.

По сути, проблема заключается в следующем: как администратор я хочу знать, кто был владельцем каждого токена, который вы (как пользователь) в настоящее время имеете в своем кошельке. Другими словами, я хочу отслеживать жизненный цикл каждого токена, когда он переходит из рук в руки.

Решение. Баланс вашего кошелька — это сумма всех бумажных денег, которые он хранит.

В одном из абзацев выше мы говорили об оплате бумажными деньгами. Что, если бы был способ добавить имя владельца к денежной купюре, когда она переходила из рук в руки? Итак, когда Пеппер получает в банке 10-долларовую купюру, ее имя написано на купюре. Затем, когда Пеппер передает тот же счет Питеру, имя Питера добавляется к счету.

Дальше в этом обсуждении этот список будет называться owner_history.

Что, если Пеппер хочет перевести Питеру 7 долларов США, а не 10 долларов США? Мы разделим валютный счет на две части: 3 доллара и 7 долларов США, обеспечив клонирование списка owner_history.

Итак, можем ли мы рассматривать бумажник пользователя как набор «токен-счетов», а не просто единовременную сумму баланса? Основываясь на обсуждении до сих пор, это преимущества, которые дает использование «токен-счетов» приносить:

  • Они позволяют отслеживать жизненный цикл каждого токена, выпущенного на платформе.
  • Мы можем прикрепить свойства к каждому «токен-биллу».

Примеры некоторых свойств:

  • Вы можете установить даты истечения срока действия «счета-токена».
  • Если вам когда-либо потребуется классифицировать токены, вы можете прикрепить эту информацию к любому соответствующему «счету за токены».
  • Вы можете определить определенные предварительные условия транзакции на основе свойств, определенных в «счете за токены».

По сути, представление кошелька пользователя в виде набора «токен-банкнот» обеспечивает огромную гибкость. Это позволяет вам удовлетворить любые возможные будущие запросы функций.

В чем компромисс такой гибкости? Запросы, которые мы выполняем при передаче токенов от одного пользователя другому, будут более сложными. Вместо простого обновления соответствующих балансов наши запросы будут выполнять следующие задачи:

  • Получите все соответствующие «токен-счета», которые необходимо передать пользователю-получателю.
  • Разделите последний «токен-банкнот» на случай, если у нас нет «точной сдачи».
  • Добавьте идентификатор пользователя-получателя в список owner_history для каждой передаваемой «токеновой купюры».
  • Обновите текущего владельца каждого переданного «счета-токена».

При представлении кошелька пользователя в виде набора «токен-счетов» типичный поток платежей будет выглядеть следующим образом:

  • Джоуи покупает 5 токенов на платформе.
  • Ему выдается «жетонная купюра» номиналом 5. Мы будем называть этот «токен-счет» bill-1.
  • Его идентификатор добавляется в список owner_history этого «счета за жетоны».
  • Он покупает 5 токенов дважды (в разное время) с платформы.
  • Ему выдаются две «символические купюры» номиналом 5
  • Его идентификатор также добавлен в список owner_history этих «аккредитивов» (bill-2 и bill-3).
  • Джоуи хочет передать Крамеру 7 жетонов.
  • bill-2 делится на две купюры - bill-2-1 стоимостью 2 жетона и bill-2-2 стоимостью 3 жетона. Списокowner_history скопирован из списка bill-2 для обоих новых разделений.
  • bill-1 (5 жетонов) и bill-2-1 (2 жетона) передаются Крамеру.
  • Идентификатор Крамера добавлен в список owner_history из bill-1 и bill-2-1.

Следующая анимация показывает немного более сложную версию этого, где Пеппер получает по два жетона от Роуди и Джокера. Затем она передает 2 жетона Петру.

Глубокое техническое погружение

Необходимо решить два технических вопроса:

  1. Как мы находим «токен-счета», которые будут использоваться для транзакции?
  2. Как выполнить разделение, если у нас нет точной сдачи?

Нахождение «жетонов»

Поскольку реляционные базы данных (такие как MySQL, PostgreSQL) используются наиболее широко, следующее обсуждение будет основано на них.

Самый интуитивно понятный вариант — загрузить в приложение все «счета-токены», принадлежащие пользователю-отправителю. Затем мы можем перебрать их и сохранить соответствующие «токен-счета» в отдельном списке. Преимущество этого подхода заключается в том, что ваш запрос на получение «токен-счетов» из базы данных будет простым. Однако он не масштабируется. Если у пользователя есть десятки тысяч «счетов за токены», то мы будем загружать огромное количество ненужных данных даже для транзакций, включающих только один «счет за токены».

Второй вариант — написать SQL-запрос таким образом, чтобы в память приложения загружались только соответствующие токен-счета. Запрос будет использовать функции окна для агрегирования всех соответствующих строк.

Вот как будет выглядеть запрос (при условии транзакции 10 токенов):

  • Начните перебирать каждую строку «токен-счет» в базе данных. Для простоты предположим, что «аккредитивные купюры» заказаны id.
  • Для каждой строки вычислите текущую кумулятивную сумму. Вот упрощенный фрагмент запроса (value – это номинальная стоимость каждой "банкноты").
SELECT sum(value)
OVER (ORDER BY id
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
    AS cumulative_sum
FROM token_bills
  • Кроме того, вычислить текущую кумулятивную сумму до строки, предшествующей текущей строке.
SELECT sum(value)
OVER (ORDER BY id
        ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING)
    AS pre_cumulative_sum
FROM token_bills

Наконец, выберите все строки, где либо:

  • cumulative_sum меньше 10 токенов (указывает строки, которые будут использоваться для транзакции)
  • ИЛИ cumulative_sum больше или равно 10 токенам И pre_cumulative_sum меньше 10 токенов (последняя строка в списке соответствующих «счетов за жетоны»)

Это скелет окончательного запроса:

SELECT *
FROM (SELECT *, 
        SUM(value) OVER (ORDER BY id
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cumulative_sum,
        SUM(value) OVER (ORDER BY id
            ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) AS pre_cumulative_sum
    FROM token_bills
    WHERE user_id=1) AS tb
WHERE cumulative_sum < 10
    OR (cumulative_sum >= 10 AND (pre_cumulative_sum < 10 OR pre_cumulative_sum IS NULL))

Выполнение разделения

Если полученные «счета за жетоны» не соответствуют точному изменению, нам нужно разделить последний «счет за жетоны» на две части. Одна из частей останется у пользователя, а другая будет использоваться для транзакции.

Для той части, которая останется у пользователя, можно клонировать исходную «токен-фактуру» и вставить в базу данных. value нового «счета за токены» будет равно количеству токенов, которое должно остаться у пользователя.

Для той части, которая будет использоваться для транзакции, мы можем просто обновить value исходного «счета за токены».

Например, если «токен-счет» value=5 разделить на часть value=2 (для использования в транзакции) и часть value=3 (для хранения у пользователя), то запросы будут выглядеть следующим образом:

INSERT INTO token_bills (user_id, value) VALUES (:sender_id, 3);
UPDATE token_bills SET value=3 WHERE id=:original_token_bill_id;

Заключение

Мы начали с простого решения, заключающегося в простом хранении балансов, но вскоре обнаружили, что оно негибкое и не может удовлетворить любые возможные запросы функций.

Моделирование нашего кошелька как набора «токен-банкнот» помогло нам разбить сложную проблему на более мелкие, управляемые проблемы. Кроме того, это подготовило нашу систему к будущему, гарантируя, что мы сможем легко удовлетворить любые потенциальные запросы функций, которые могут возникнуть.

Кредиты

Спасибо @samwhoo за помощь с анимацией. Посмотрите его визуальные блоги на samwho.dev.

Сноски

  1. Условия не являются существенными для идеи поста. Они могут быть такими же сумасшедшими, как переносы происходят в первую неделю месяца.