Skip to content

Latest commit

 

History

History
378 lines (255 loc) · 15.9 KB

design.md

File metadata and controls

378 lines (255 loc) · 15.9 KB

Проектирование сервиса выдачи промокодов

Длина промокода

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

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

На основе этих требований можно ввести предварительные ограничения: 4 <= length <= 10.

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

F(alphabet, length) = length(alphabet)^length

Пример вычислений:

  1. Алфавит [a-zA-Z0-9]: длина алфавита - 62 символа.
  2. Алфавит [0-9]: длина алфавита - 10 символов.
4 7 10
10 10000 10000000 10000000000
62 14776336 3521614606208 839299365868340224

Выбор оптимальной длины будет зависеть от количества хранимых промокодов для конкретного алфавита. Например, если сервис уже хранит 50'000 промокодов, то вероятность коллизии для нового промокода составит:

4 7 10
10 500% 0.5% 0,0005%
62 0,34% 0,00000142% ~0%

На основе полученных данных я бы предложил выбрать длину промокода 5-7 символов для вышеуказанных алфавитов.

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

Алгоритм генерации промокода

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

Пример на псеводокоде:

string generate_promocode(const unsigned int promocode_length) {
  const alphabet = [a-zA-Z0-9];
  const alphabet_length = length(alphabet);
  let promocode = [];

  for (int i = 0; i < promocode_length; ++i) {
    promocode[i] = alphabet[random_int(0, alphabet_length - 1)];
  }

  return implode(promocode);
}

В дальнейшем может понадобиться создать генератор промокодов из заданного набора слов (например, SUMMER20), поэтому необходимо будет учесть это при проектировании классов/интерфейсов.

Дизайн REST API

Желаемый размер скидки ожидаем в %, накладываем ограничение 0 < discount < 100 - требование уточнить у продакт-менеджера.

Для алфавитов, из которых генерируются промокоды, введем наименования, которые будут использовать пользователи нашего АПИ:

  • alphanumeric - [a-zA-Z0-9],
  • numeric - [0-9].

Создание многоразового промокода с лимитом по времени

Создание промокода на основе указанного размера скидки и с ограничением по времени действия.

URL: /api/promocode/reusable/generate?limit=date&alphabet=:alphabet

Метод: POST

Query параметры

  • limit - используемое ограничение для промокода,
  • alphabet - используемый алфавит. Значение по умолчанию: alphanumeric.

Тело запроса

Необходимо передать желаемый размер скидки, в %.

Необходимо передать время действия промокода: считаем, что дата и время приходят в формате ISO-8601. Накладываем ограничение, что время и дата должны быть больше текущей (также уточнить требования).

{
    "discount": "int",
    "expired_at": "datetime"
}

Успешный ответ

Условие: Размер скидки имеет допустимое значение, указаны время и дата в формате ISO-8601 и промокод успешно создан

Код ответа: 201 CREATED

Пример содержимого ответа

{
    "type": "reusable",
    "expiration": "date",
    "promocode": 123456,
    "discount": 50,
    "meta": {
        "created_at": "datetime",
        "expired_at": "datetime"
    }
}

Ответ с ошибкой

Условие: Не указано обязательное поле, ошибка валидации, неизвестный тип или ограничение промокода.

Код ответа: 400 BAD REQUEST

Пример содержимого ответа

{
    "discount": {
    	"token": "field.required",
        "message": "This field is required."
    }
}

Создание многоразового промокода с лимитом по количеству использований

Создание промокода на основе указанного размера скидки и с ограничением по количеству использований.

URL: /api/promocode/reusable/generate?limit=usage&alphabet=:alphabet

Метод: POST

Query параметры

  • limit - используемое ограничение для промокода,
  • alphabet - используемый алфавит. Значение по умолчанию: alphanumeric.

Тело запроса

Необходимо передать желаемый размер скидки, в %.

Необходимо передать максимальное количество использований данного промокода: 0 < using_count < N (уточнить требование).

{
    "discount": "int",
    "max_usage_count": "int"
}

Успешный ответ

Условие: Размер скидки имеет допустимое значение, указано максимальное количество использований и промокод успешно создан

Код ответа: 201 CREATED

Пример содержимого ответа

{
    "type": "reusable",
    "expiration": "usage",
    "promocode": 123456,
    "discount": 50,
    "meta": {
        "usage_count": 0,
        "max_usage_count": 5,
        "created_at": "datetime",
        "updated_at": "datetime"   
    }
}

Ответ с ошибкой

Условие: Не указано обязательное поле или ошибка валидации

Код ответа: 400 BAD REQUEST

Пример содержимого ответа

{
    "discount": {
    	"token": "field.required",
        "message": "This field is required."
    }
}

Создание одноразового промокода

Создание одноразового промокода на основе указанного размера скидки.

URL: /api/promocode/disposable/generate?alphabet=:alphabet

Метод: POST

Query параметры

  • alphabet - используемый алфавит. Значение по умолчанию: alphanumeric.

Тело запроса

Необходимо передать желаемый размер скидки, в %.

{
    "discount": "int"
}

Успешный ответ

Условие: Размер скидки имеет допустимое значение и промокод успешно создан

Код ответа: 201 CREATED

Пример содержимого ответа

{
    "type": "disposable",
    "promocode": 123456,
    "discount": 50,
    "meta": {
        "created_at": "datetime"
    }
}

Ответ с ошибкой

Условие : Не указано обязательное поле или ошибка валидации

Код ответа : 400 BAD REQUEST

Пример содержимого ответа

{
    "discount": {
    	"token": "field.required",
        "message": "This field is required."
    }
}

Получить размер скидки по промокоду

Получение размера скидки на основе указанного промокода. Если промокод недействителен, то отдаём ошибку.

Я бы предложил разбить данный запрос на два:

  1. Получение размера скидки по промокоду (без "обналичивания" промокода),
  2. Запрос на использование промокода.

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

Далее представлено описание для единого запроса: получение размера скидки и обновление самого промокода.

URL: /api/promocode/:promocode/use

Метод: PUT

Успешный ответ

Условие : Промокод действителен и успешно использован

Код ответа : 200 OK

Пример содержимого ответа

Может варьироваться в зависимости от типа промокода.

{
    "type": "reusable",
    "expiration": "date",
    "promocode": 123456,
    "discount": 50,
    "meta": {
        "created_at": "datetime",
        "expired_at": "datetime"
    }
}

Ответ с ошибкой

Условие: Промокод не найден

Код ответа: 404 NOT FOUND

ИЛИ

Условие: Промокод недействителен

Код ответа: 400 BAD REQUEST

Пример содержимого ответа

{
    "promocode": {
    	"token": "promocode.expired",
        "message": "The promocode is expired."
    }
}

Схема БД

Тип промокода будем хранить в поле expiration для возможности извлечения нужной стратегии окончания действия промокода:

  • by_date,
  • by_usage.
CREATE TABLE "promocode" (
  "id" SERIAL PRIMARY KEY,
  "value" varchar(20) NOT NULL,
  "discount" int NOT NULL,
  "expiration" varchar(20) NOT NULL, -- Expiration strategy table mapping
  "created_at" datetime NOT NULL DEFAULT (now()),
  "updated_at" datetime
);

CREATE TABLE "expiration_by_date" (
  "id" int UNIQUE,
  "expired_at" datetime NOT NULL
);

CREATE TABLE "expiration_by_usage" (
  "id" int UNIQUE,
  "usage_count" int NOT NULL DEFAULT 0,
  "max_usage_count" int NOT NULL
);

ALTER TABLE "promocode_exp_by_date" ADD FOREIGN KEY ("id") REFERENCES "promocode" ("id");

ALTER TABLE "promocode_exp_by_usage" ADD FOREIGN KEY ("id") REFERENCES "promocode" ("id");

CREATE UNIQUE INDEX unique_value_uppercase_idx ON "promocode" (UPPER(value));

Описание реализации (классы/интерфейсы)

Для описания предметной области используем следующие сущности:

  • AbstractPromocode: базовый класс для представления абстрактного промокода, все реализации промокодов должны наследоваться от этого класса.
  • ReusablePromocode: представление многоразового промокода (должен иметь метод для задания нужной стратегии окончания действия, в дальнейшем можно расширить для использования нескольких стратегий, например, и по количестве использований, и по дате).
  • DisposablePromocode: представление одоноразового промокода (по сути частный случай многоразового промокода).

Стратегии окончания действия промокода:

  • ExpirationStrategyInterface: интерфейс стратегии с методами getName, isExpired.
  • ExpirationByDateStrategy,
  • ExpirationByUsageStrategy.

Для генераторов промокодов создадим интерфейс AlpabetGeneratorInterface, у которого будет один метод generate(array $alphabet, int $length): string. Создадим базовую реализацию генератора DefaultGenerator, реализующий этот интерфейс.

Также понадобится один контроллер для реализации REST API. Для удобства создания объектов-промокодов можно предусмотреть класс PromocodeBuilder, который будет инкапсулировать работу со стратегиями окончания действия и генератором значений промокодов.

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