Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

Тестовое задание системного аналитика

Решение задания для интернет-магазина «Петрушка Зеленая».

Содержание

Задание 1. Анализ требований

1.1. Противоречия и недочеты исходного ТЗ

Фрагмент ТЗ Проблема Предлагаемое решение
1 П. 2: количество изменяется не менее чем до 1. П. 9: при уменьшении до 0 товар удаляется Неясно, является ли 0 допустимым количеством или отдельной командой удаления Количество позиции хранится только в диапазоне 1..10. Ввод/действие «уменьшить до 0» трактуется как удаление позиции; отдельная кнопка удаления также остается
2 П. 1: от 1 до 10 единиц одного товара Не определено, относится лимит к одной операции добавления или к суммарному количеству SKU в корзине Лимит 1..10 относится к итоговому количеству одного SKU после операции. Операция, превышающая лимит, отклоняется целиком
3 П. 3 и п. 4 Есть два лимита, но не описано, что происходит при нарушении каждого из них и учитываются ли уже добавленные позиции Явно считать уникальные SKU и суммарное количество по корзине; при нарушении любого лимита не менять корзину и вернуть код причины
4 П. 1, п. 3 и п. 4 вместе Пункты логически совместимы: это перекрывающиеся ограничения, а не противоречие, и выполняться они должны одновременно. Недочет в другом - не описаны порядок проверки лимитов, приоритет между ними и результат операции при одновременном нарушении нескольких ограничений. Пользователь, нарушивший сразу два лимита, увидит недетерминированное сообщение об ошибке Сохранить все три лимита и зафиксировать их совместное действие. Проверять лимиты в фиксированном порядке: количество уникальных SKU, количество единиц одного SKU, суммарное количество единиц. Возвращать код первого нарушенного лимита
5 П. 5: «Товары могут быть разные» Формулировка не задает понятие «разный товар»: SKU, product_id, вариант размера/вкуса или карточка товара Лимит уникальных товаров считать по sku_id; разные варианты одного продукта считаются разными SKU
6 П. 6: «Лимит корзины превышен» Одно сообщение не объясняет, какой лимит нарушен; не задано поведение частичного добавления Возвращать единый пользовательский текст и машинный reason: MAX_UNIQUE_ITEMS, MAX_TOTAL_QUANTITY или MAX_SKU_QUANTITY; частичное добавление запрещено
7 П. 7 и п. 13 Прямое противоречие: цена фиксируется при добавлении, но затем автоматически меняется из каталога Для данной версии выбрать фиксацию цены в момент добавления. Изменение каталожной цены не меняет сохраненную цену в корзине; исключения должны быть отдельным бизнес-решением
8 П. 8 Недостаточно полей для экрана: не указаны название, SKU, изображение, валюта, итог корзины, скидка и доступность Зафиксировать состав ответа и правила расчета: product, quantity, unit_price, line_total, currency, cart_total
9 П. 10 и п. 11 «Может быть» и «должна быть» задают разные обязательности; неясно, идет ли речь о показе блока или о рассылке Сделать рекламу опциональным контентом маркетинговой кампании. Показывать ее только при наличии активной кампании, согласии пользователя и соблюдении расписания
10 П. 11 Не определены «утро», «вечер», часовой пояс, частота показов, место размещения и правила для часовых поясов Зафиксировать расписание, например 09:00 и 18:00 по часовому поясу пользователя, не более одного рекламного блока за слот; значения вынести в настройки кампании
11 Нумерация: 1–11, затем 13 Отсутствует п. 12; невозможно понять, потерян ли функциональный сценарий В восстановленной версии использовать последовательную нумерацию и отдельно отметить отсутствие исходного п. 12
12 П. 6 Не задано, что происходит при добавлении товара, которого уже нет в продаже или который недоступен в нужном количестве Перед добавлением проверять активность SKU и доступность; возвращать отдельные ошибки PRODUCT_UNAVAILABLE и STOCK_LIMIT_EXCEEDED
13 Все пункты Не определены пользователь, срок жизни корзины, синхронизация между устройствами и поведение гостевой корзины Зафиксировать идентификатор корзины, правила авторизации, хранение и объединение гостевой корзины после входа
14 Все пункты Не описаны конкурирующие изменения: две вкладки, два устройства или параллельные запросы могут превысить лимиты Проверять лимиты атомарно на стороне сервера и использовать версию корзины/optimistic locking
15 П. 7 и п. 13 Не описано, как цена связана с валютой, скидками, налогами и округлением Хранить денежные значения в минимальных единицах валюты или decimal, передавать валюту и правило округления; не использовать binary floating point
16 П. 8 Не описано, что видит пользователь при пустой корзине, ошибке загрузки или частично удаленном товаре Добавить состояния empty, loading, error, а также явное сообщение при недоступности товара

1.2. Непротиворечивая версия фрагмента ТЗ

Исходный фрагмент нельзя сделать логически завершенным, не приняв решений, которых в нем нет. Ниже приведен один из возможных вариантов; все добавленные бизнес-правила требуют подтверждения заказчика и вынесены в вопросы раздела 1.3.

Бизнес-правила, которых не было в исходном ТЗ: алгоритм слияния гостевой корзины и отбрасывание непоместившихся позиций (п. 1), порядок расчета скидок, промокода и налога (п. 9), расписание показа рекламы в 09:00 и 18:00 (п. 12), требование аудита (п. 15). Это предложения аналитика, а не решения бизнеса: например, вместо отбрасывания излишка при слиянии заказчик может предпочесть экран разрешения конфликта.

Функционал корзины

  1. Корзина принадлежит пользователю или анонимной сессии. При авторизации гостевая корзина сливается с корзиной пользователя по следующему алгоритму:
    1. Позиции с одинаковым sku_id объединяются: итоговое количество равно сумме количеств, но не более 10 единиц; излишек отбрасывается.
    2. Позиции обрабатываются в порядке убывания времени последнего изменения, чтобы при переполнении сохранялись те, с которыми пользователь работал недавно.
    3. Если после объединения нарушен лимит уникальных SKU или суммарного количества, позиции, не поместившиеся в лимит, в корзину не переносятся.
    4. Цена объединенной позиции берется из более позднего снимка цены, а момент фиксации обновляется на его значение.
    5. Если хотя бы одна позиция была отброшена или урезана, сервер возвращает список затронутых SKU, а клиент показывает пользователю сообщение о том, что часть товаров не перенесена.
    6. Слияние идемпотентно: повторный вход с тем же идентификатором гостевой сессии не изменяет корзину повторно. После успешного слияния гостевая корзина удаляется.
  2. В корзине может находиться от 0 до 5 различных SKU. Различными считаются разные значения sku_id; варианты одного продукта с разными характеристиками считаются разными SKU.
  3. Для одной позиции количество составляет от 1 до 10 единиц. Суммарное количество единиц всех позиций составляет от 0 до 20. Ограничения перекрываются и действуют одновременно: 10 единиц одного SKU допустимы, только пока остальные позиции занимают не более 10 единиц. Ни одно из ограничений не отменяет другое.
  4. При добавлении SKU клиент передает желаемое количество. Количество - целое число. До проверки лимитов сервер валидирует его формат и отклоняет запрос с кодом INVALID_QUANTITY, не изменяя корзину, если значение дробное, отрицательное, отсутствует, равно null, передано строкой или имеет иной некорректный тип. Значение 0 не является ошибкой формата: оно трактуется как команда удаления позиции согласно п. 7. После успешной валидации сервер рассчитывает итоговое количество с учетом уже добавленного SKU и проверяет все лимиты атомарно.
  5. Если операция нарушает хотя бы один лимит, она отклоняется полностью: состояние корзины не меняется. Пользователь получает сообщение «Лимит корзины превышен» и код причины. Лимиты проверяются в фиксированном порядке, и возвращается код первого нарушенного, чтобы при нарушении сразу нескольких лимитов ответ был детерминированным: количество уникальных SKU (MAX_UNIQUE_ITEMS), затем количество единиц одного SKU (MAX_SKU_QUANTITY), затем суммарное количество единиц (MAX_TOTAL_QUANTITY).
  6. Если SKU недоступен, удален из каталога или недостаточно остатка, операция отклоняется с отдельным кодом ошибки. Сервер не добавляет доступное частичное количество без явного выбора пользователя.
  7. Количество позиции можно изменить на значение от 1 до 10. Попытка установить 0 считается командой удаления позиции. Позицию также можно удалить отдельной кнопкой.
  8. При добавлении позиции в корзину сохраняется цена за единицу, валюта и момент фиксации цены. Последующие изменения цены в каталоге не изменяют цену уже сохраненной позиции.
  9. Расчет стоимости выполняется в следующем порядке, а все денежные значения хранятся в минимальных единицах валюты (копейках) и возвращаются вместе с кодом валюты:
    1. line_total = unit_price * quantity по зафиксированной цене позиции.
    2. К позиции применяются скидки на товар; результат округляется до копейки по правилу «половина вверх».
    3. cart_subtotal равен сумме округленных line_total.
    4. К cart_subtotal применяются сначала корзинные скидки, затем промокод - промокод считается от уже уцененной суммы, а не от исходной. Промокод пересчитывается при каждом изменении состава корзины и снимается, если корзина перестала удовлетворять его условиям, о чем пользователь уведомляется.
    5. Налог рассчитывается по ставке SKU от стоимости позиции после скидок и включается в отображаемую цену; отдельной строкой показывается только справочная сумма налога.
    6. cart_total равен cart_subtotal за вычетом корзинных скидок и скидки по промокоду. Промокод учитывается отдельной суммой и не входит в состав корзинных скидок. Доставка в стоимость корзины не входит и рассчитывается на шаге оформления заказа.
    7. Скидки, промокод и налог зависят от актуального состояния каталога и не фиксируются в момент добавления - в отличие от unit_price. Итог корзины пересчитывается на сервере при каждом чтении.
  10. На странице корзины отображаются: название товара, изображение, SKU/характеристики, цена за единицу, количество, стоимость позиции, доступность, итог корзины и доступные действия.
  11. При пустой корзине отображается специальное состояние с предложением перейти в каталог. При ошибке загрузки отображается состояние ошибки с возможностью повторить запрос.
  12. Рекламный блок в корзине является необязательным. Он возвращается только при наличии активной кампании, подходящей пользователю и экрану. Кампания задает содержимое, период действия, часовой пояс и расписание. По умолчанию рекламный блок может показываться в будни в 09:00 и 18:00 по часовому поясу пользователя, не более одного раза за слот.
  13. Рекламный блок не меняет состав, цены и лимиты корзины. Переход по рекламе выполняется только по URL, прошедшему серверную валидацию.
  14. Сервер является источником истины для корзины, ценовых снимков и лимитов. Все операции изменения выполняются атомарно; при конфликте версии клиент перечитывает корзину.
  15. Сервер ведет аудит добавления, изменения количества, удаления, отказов по лимитам и переходов по рекламным блокам без сохранения лишних персональных данных.

1.3. Уточняющие вопросы заказчику

Корзина и пользователь

  1. Корзина доступна гостю или только авторизованному пользователю?
  2. Как долго хранится гостевая и авторизованная корзина?
  3. Что происходит при входе пользователя, у которого уже есть корзина на другом устройстве?
  4. Нужно ли объединять одинаковые SKU или оставлять отдельные позиции?
  5. Что делать с позициями, которые не поместились в лимиты при слиянии корзин: отбрасывать их, показывать экран разрешения конфликта или сохранять как отложенные?
  6. Считается ли один product_id с разными вариантами одной или несколькими разновидностями товара?

Лимиты и операции

  1. Лимит 10 относится к итоговому количеству SKU или к одной операции добавления?
  2. При превышении лимита нужно отклонять всю операцию или добавлять максимально доступное количество?
  3. Разрешено ли добавление нескольких одинаковых SKU из разных карточек/источников?
  4. Что делать при недостаточном остатке: запретить изменение, уменьшить количество или показать предупреждение?
  5. Нужно ли резервировать остаток товара в момент добавления в корзину?
  6. Какие значения количества считать допустимыми: только целые числа? Как система должна реагировать на дробные, отрицательные и нечисловые значения?

Цена и деньги

  1. Действительно ли цена должна быть зафиксирована до оформления заказа, и есть ли срок фиксации?
  2. Учитываются ли скидки, промокоды, бонусы, доставка, налоги и минимальная сумма заказа?
  3. В какой валюте хранятся и отображаются цены? Как выполняется округление?
  4. Что происходит, если SKU удален из каталога после добавления в корзину?

Реклама и аналитика

  1. Речь идет о рекламном блоке на экране корзины или о PUSH-рассылке?
  2. Каковы точные часы «утра» и «вечера» и какой часовой пояс использовать?
  3. Нужны ли персонализация, частотный лимит, A/B-тесты и возможность отключить рекламу?
  4. Какие URL разрешены для внешнего перехода и нужно ли вести пользователя во внешний браузер?
  5. Какие события нужны аналитике: показ, клик, переход, покупка после клика?

Нефункциональные требования

  1. Какие SLA, максимальное время ответа и ожидаемая нагрузка?
  2. Нужны ли web/mobile-контракты с версионированием и обратной совместимостью?
  3. Как обрабатываются параллельные изменения корзины с двух устройств?
  4. Какие требования к логированию, аудиту, персональным данным и удалению данных?

1.4. Критерии приемки

Сценарий Ожидаемый результат
В пустую корзину добавляют 1 единицу SKU В корзине одна позиция с количеством 1 и зафиксированной ценой на момент добавления
Пользователь устанавливает количество позиции в 0 Позиция удаляется; в корзине не хранится позиция с количеством 0
В корзине уже 5 разных SKU, добавляется шестой Операция отклонена, состояние корзины не изменено, код причины MAX_UNIQUE_ITEMS
Пользователь пытается установить 11 единиц одного SKU Операция отклонена с причиной MAX_SKU_QUANTITY
В корзине 20 единиц, добавляется еще одна Операция отклонена с причиной MAX_TOTAL_QUANTITY
В корзине 3 SKU по 5 единиц (всего 15), пользователь добавляет 10 единиц четвертого SKU Операция отклонена с причиной MAX_TOTAL_QUANTITY: лимит в 10 единиц на SKU не нарушен, но суммарный лимит в 20 единиц связывающий
Операция нарушает сразу и лимит уникальных SKU, и суммарный лимит Возвращается код первого нарушенного по порядку проверки - MAX_UNIQUE_ITEMS; ответ детерминирован
Клиент передает quantity = 2.5, -1, "3" или null Запрос отклонен с кодом INVALID_QUANTITY до проверки лимитов; корзина не изменена
Клиент передает quantity = 0 Не ошибка формата: позиция удаляется из корзины
Цена SKU изменилась в каталоге после добавления Цена этой позиции в корзине не меняется
SKU удален из каталога или закончился на складе Новое добавление/увеличение количества отклонено с кодом доступности; уже добавленная позиция явно помечена на экране
Два устройства одновременно меняют корзину Сервер атомарно проверяет лимиты; конфликтующая операция не приводит к превышению лимита
Гость с 7 единицами SKU входит под аккаунтом, где у того же SKU 5 единиц Позиции объединяются до 10 единиц, излишек в 2 единицы отбрасывается, пользователь получает уведомление о неперенесенных товарах
Слияние корзин переполняет лимит в 5 уникальных SKU Переносятся позиции с наиболее поздним временем изменения; остальные не переносятся и перечисляются в ответе
Повторная авторизация с тем же идентификатором гостевой сессии Корзина не изменяется: слияние идемпотентно
Промокод перестал удовлетворять условиям после удаления позиции Промокод снимается, cart_total пересчитывается, пользователь уведомлен

Задание 2. Проектирование API

Что показывает макет

Экран «Выберите магазин» состоит из заголовка и вертикального списка плашек. Каждая плашка содержит четыре элемента:

  • название магазина - основной видимый текст (METRO, Ашан, ВкусВилл, ВИКТОРИЯ);
  • логотип на цветной подложке, у каждого партнера своей;
  • строку доставки в одном из двух вариантов: «Ближайшая доставка / сегодня 21:00-23:00» либо «Быстрая доставка / от 20 до 60 минут», причем второй вариант выделен акцентным цветом;
  • переход на внешний ресурс по нажатию.

Строка доставки - это половина содержимого плашки, поэтому она входит в контракт наравне с названием и логотипом.

Запрос

GET /api/v1/mobile/partner-stores?city_id=moscow HTTP/1.1
Host: api.petrushka-zelenaya.example
Accept: application/json
Accept-Language: ru-RU
X-Timezone: Europe/Moscow

В задании местоположение не упомянуто - это допущение. Но сроки доставки на макете зависят от зоны, поэтому запрос должен содержать хотя бы один из параметров: address_id, если у пользователя есть сохраненный адрес, иначе city_id. При передаче обоих приоритет у address_id как у более точного. Если не передан ни один и местоположение не удалось определить по токену авторизации, сервер отвечает 400 LOCATION_REQUIRED. В OpenAPI оба параметра помечены необязательными, потому что спецификация 3.0 не выражает условие «хотя бы один»; правило проверяется на сервере и описано в контракте.

Все временные величины ответа - границы слотов, поле slot.date и относительные слова «сегодня»/«завтра» - вычисляются в одном часовом поясе: часовом поясе зоны доставки. Именно в нем происходит доставка, поэтому «сегодня 21:00-23:00» всегда означает вечер того дня, когда курьер приедет по адресу.

X-Timezone передает часовой пояс устройства и служит ровно одной цели: сервер сравнивает его с поясом зоны доставки. Если они совпадают, в value используются относительные слова. Если нет - пользователь в поездке, и «сегодня» на его часах и «сегодня» в зоне доставки могут быть разными днями, поэтому сервер возвращает явную дату («9 июля, 21:00-23:00»). Клиент в обоих случаях выводит value как есть и никакой конвертации не делает. Если заголовок не передан, сервер считает, что пояса совпадают.

Соответствие полей макету

Элемент экрана Поле ответа
Заголовок экрана data.screen_title
Название магазина name
Логотип и его подложка logo.url, logo.background_color
Первая строка доставки delivery.label
Вторая строка доставки (жирная) delivery.value
Выделение строки акцентным цветом delivery.style = ACCENT
Слот доставки для сортировки и аналитики delivery.slot
Интервал экспресс-доставки delivery.express
Нажатие на плашку external_url и open_mode
Порядок плашек порядок элементов массива items

Текст строки доставки приходит с сервера готовым к отображению (label, value), а машинные значения (slot, express) передаются рядом. Так клиент не воспроизводит логику форматирования дат, но аналитика, сортировка и локальная проверка актуальности остаются возможны. Цвет акцента задается темой клиента: сервер передает только style, но не hex.

Два варианта строки доставки разведены в схеме через oneOf, а kind служит дискриминатором для кодогенераторов. Смешать варианты нельзя: внутри каждого из них стоит not: required на чужое поле, поэтому объект со slot и express одновременно отклонит валидатор, а не код клиента. Одного oneOf для этого недостаточно - additionalProperties: false не наследуется через allOf, и лишнее поле прошло бы проверку.

Пример ответа

{
  "data": {
    "screen_title": "Выберите магазин",
    "items": [
      {
        "id": "partner-metro",
        "name": "METRO",
        "logo": {
          "url": "https://cdn.petrushka-zelenaya.example/partners/metro/logo.png",
          "background_color": "#0A2C6E"
        },
        "delivery": {
          "kind": "NEAREST_SLOT",
          "label": "Ближайшая доставка",
          "value": "сегодня 21:00-23:00",
          "style": "DEFAULT",
          "slot": {
            "date": "2026-07-09",
            "starts_at": "2026-07-09T18:00:00Z",
            "ends_at": "2026-07-09T20:00:00Z"
          }
        },
        "external_url": "https://partner.example/metro",
        "open_mode": "external_browser"
      },
      {
        "id": "partner-auchan",
        "name": "Ашан",
        "logo": {
          "url": "https://cdn.petrushka-zelenaya.example/partners/auchan/logo.png",
          "background_color": "#FFFFFF"
        },
        "delivery": {
          "kind": "NEAREST_SLOT",
          "label": "Ближайшая доставка",
          "value": "сегодня 18:00-20:00",
          "style": "DEFAULT",
          "slot": {
            "date": "2026-07-09",
            "starts_at": "2026-07-09T15:00:00Z",
            "ends_at": "2026-07-09T17:00:00Z"
          }
        },
        "external_url": "https://partner.example/auchan",
        "open_mode": "external_browser"
      },
      {
        "id": "partner-vkusvill",
        "name": "ВкусВилл",
        "logo": {
          "url": "https://cdn.petrushka-zelenaya.example/partners/vkusvill/logo.png",
          "background_color": "#FFFFFF"
        },
        "delivery": {
          "kind": "EXPRESS",
          "label": "Быстрая доставка",
          "value": "от 20 до 60 минут",
          "style": "ACCENT",
          "express": {
            "min_minutes": 20,
            "max_minutes": 60
          }
        },
        "external_url": "https://partner.example/vkusvill",
        "open_mode": "external_browser"
      },
      {
        "id": "partner-victoria",
        "name": "ВИКТОРИЯ",
        "logo": {
          "url": "https://cdn.petrushka-zelenaya.example/partners/victoria/logo.png",
          "background_color": "#C4E538"
        },
        "delivery": {
          "kind": "NEAREST_SLOT",
          "label": "Ближайшая доставка",
          "value": "сегодня 17:00-19:00",
          "style": "DEFAULT",
          "slot": {
            "date": "2026-07-09",
            "starts_at": "2026-07-09T14:00:00Z",
            "ends_at": "2026-07-09T16:00:00Z"
          }
        },
        "external_url": "https://partner.example/victoria",
        "open_mode": "external_browser"
      }
    ]
  },
  "meta": {
    "request_id": "req-01J7EXAMPLE9K2",
    "generated_at": "2026-07-09T09:00:00Z"
  }
}

Ответ сформирован в 09:00 UTC, то есть в 12:00 по Москве: все слоты в примере еще не наступили. Сервер не возвращает истекший слот - «ближайшим» всегда становится следующий доступный, при необходимости на завтра.

Полный контракт - в api/partner-stores.openapi.yaml, ответ со всеми четырьмя магазинами макета - в api/partner-stores-response.json.

Обсуждаемые решения

  • Если для зоны срок доставки неизвестен, поле delivery не передается вовсе, и плашка показывается без строки доставки. Отсутствие поля предпочтительнее null: клиенту не нужно различать «нет данных» и «пустая строка».
  • Пагинация не добавлена: список партнеров в одном городе исчисляется десятками и умещается в один ответ. При росте каталога добавляется курсорная пагинация без ломающих изменений.
  • Авторизация не обязательна для просмотра списка, но при наличии токена сервер может подставить address_id пользователя сам. В контракте это выражено схемой bearerAuth и объявлением security: [{}, {bearerAuth: []}]: пустой объект разрешает анонимный вызов, второй вариант - вызов с токеном.
  • open_mode объявлен перечислением с единственным значением external_browser. Булев флаг пришлось бы заменять при появлении второго способа открытия (например, in_app_webview), а расширение перечисления обратной совместимости не ломает, если клиент игнорирует плашки с неизвестным значением.
  • Внешняя ссылка формируется и allowlist-проверяется на сервере; клиент открывает только HTTPS-ссылку в системном браузере.
  • Названия и логотипы партнеров в примере взяты из макета и служат демонстрацией; домены .example не являются рабочими.

Задание 3. Архитектура PUSH-уведомлений

Верхнеуровневая схема и пояснения находятся в architecture/push-notifications.md.

Основной поток:

  1. Мобильное приложение получает push-токен у APNs/FCM и регистрирует его в Token Service.
  2. Бизнес-микросервисы публикуют доменные события через outbox в Kafka: отмена заказа, неактивность корзины, изменение статуса доставки.
  3. Marketing/Campaign Service запускает рекламные кампании, а Scheduler создает события для отложенных сценариев. Сегмент кампании разворачивается в отдельные команды отправки батчами: Audience Service отдает user_id страницами, Fan-out Workers публикуют их в Kafka с ключом партиционирования user_id.
  4. Notification Orchestrator читает события, проверяет настройки пользователя, частотные лимиты, дедупликацию и шаблон сообщения, после чего запрашивает у Device Token Service список активных устройств пользователя.
  5. Provider Adapter отправляет уведомление на каждый активный токен в APNs или FCM и нормализует ответ провайдера. Исходящий поток удерживается в пределах квот провайдера отдельным лимитером; при их исчерпании сообщение уходит в Retry Queue, а не теряется.
  6. Временные ошибки уходят в Retry Queue и возвращаются в адаптер воркером с backoff; постоянные ошибки и исчерпанные попытки попадают в DLQ, а невалидные токены деактивируются в Device Token Service.

Обязательные свойства решения:

  • идемпотентность на двух уровнях: решение о рассылке по (event_id, notification_type, user_id), отправка по (event_id, notification_type, device_id);
  • отдельные Retry Queue с воркером и backoff и Dead Letter Queue для постоянных ошибок;
  • отсутствие повторной отправки после успешного ответа провайдера;
  • разворачивание рекламных кампаний батчами с партиционированием по user_id, квотами провайдера и приоритетом транзакционного трафика над рекламным;
  • учет часового пояса, opt-in/opt-out и quiet hours;
  • шифрование токенов, минимизация PII и аудит действий;
  • метрики принятия провайдером, открытия, ошибок и задержки.

Принятые допущения

  1. Состав полей ответа выведен из макета: заголовок экрана, название магазина, логотип с подложкой, строка доставки в двух вариантах и переход по внешней ссылке. Названия и цвета подложек взяты из макета как демонстрационные и не являются реальными данными каталога партнеров.
  2. Строка доставки формируется на сервере как готовый текст, потому что правила «сегодня/завтра», округление интервалов и локализация относятся к бизнес-логике, а не к отображению. Машинные поля slot и express передаются рядом для аналитики и сортировки.
  3. Единственным часовым поясом для расчета слотов, поля slot.date и слов «сегодня»/«завтра» выбран часовой пояс зоны доставки, а не устройства: доставка происходит в зоне, поэтому «сегодня» должно означать день приезда курьера. X-Timezone используется только для того, чтобы обнаружить расхождение и в этом случае вернуть явную дату вместо относительного слова.
  4. В API используются демонстрационные домены .example; в рабочей системе URL должны приходить из конфигурации/каталога партнеров и проходить allowlist-проверку.
  5. Пагинация в контракт не включена: список партнеров в пределах одного города умещается в один ответ. Авторизация не обязательна - экран доступен до входа, а токен лишь позволяет серверу подставить адрес пользователя. Оба решения меняются без ломающих изменений контракта.
  6. Для шины событий выбран Kafka: она подходит для надежной публикации доменных событий, повторного чтения и независимой подписки нескольких потребителей. Точный выбор брокера должен быть подтвержден эксплуатационными требованиями.
  7. Статус ACCEPTED_BY_PROVIDER означает, что APNs/FCM приняли запрос, но не доказывает показ уведомления на устройстве. Открытие фиксируется отдельным событием мобильного приложения.
  8. Размер аудитории рекламной кампании в задании не задан. Схема рассчитана на миллионы получателей: сегмент разворачивается батчами, а не одним запросом. Для небольшой аудитории те же компоненты работают без изменений.
  9. Цены в корзине фиксируются согласно п. 7 исходного ТЗ; исходный п. 13 помечен как противоречащий и исключен из исправленной версии.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors