Каждый промокод должен быть уникальным в рамках нашего сервиса, поэтому необходимо подобрать такую длину промокода, при которой можно будет достичь баланса между бизнес-требованиями и требованиями программной реализации:
- Длина не должна быть слишком большой (требование продакт-менеджера),
- Длина должна быть такой, при которой не будет возникать много коллизий при генерации новых промокодов (чем меньше вероятность, тем лучше),
- Пользователь не должен иметь возможность легко выполнить подбор действующих промокодов.
На основе этих требований можно ввести предварительные ограничения: 4 <= length <= 10
.
Определим количество возможных вариаций для исходных алфавитов, используемых при генерации промокода, и выбранной длины:
F(alphabet, length) = length(alphabet)^length
Пример вычислений:
- Алфавит
[a-zA-Z0-9]
: длина алфавита - 62 символа. - Алфавит
[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
), поэтому необходимо будет учесть это при проектировании классов/интерфейсов.
Желаемый размер скидки ожидаем в %, накладываем ограничение 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."
}
}
Получение размера скидки на основе указанного промокода. Если промокод недействителен, то отдаём ошибку.
Я бы предложил разбить данный запрос на два:
- Получение размера скидки по промокоду (без "обналичивания" промокода),
- Запрос на использование промокода.
Это может быть полезно, поскольку пользователь может отказаться от завершения покупки после ввода промокода. Либо для нашего сервиса будет реализован административный раздел на фронтенде, где будет визуализация промокодов системы и интерфейс для работы с ними.
Далее представлено описание для единого запроса: получение размера скидки и обновление самого промокода.
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
, которые образуют слой хранения данных. Для этих классов также определим интерфейсы, чтобы в тестах можно было замокировать работу с БД.