Полная постановка задания находится в TASK.md.
docker compose up --buildAPI: http://localhost:3000
docker compose exec api npm run migrate
docker compose exec api npm test
docker compose down -v- ссылка на репозиторий или ветку с изменениями;
- обновленный
README.mdс разделомАнализ и рефакторинг(ответы на 7 вопросов изTASK.md); - инструкция запуска тестов и подтверждение, что обязательные сценарии проходят.
- Учёт баланса: Считалась сумма всех начислений (
accrual) без учётаexpires_atи без вычета списаний (spend), то есть доступный баланс был неверным. - Гонки: Не было транзакции и блокировки строк — при параллельных запросах баланс мог уйти в минус или произойти двойное списание.
- Идемпотентность: Не было идентификатора запроса (
requestId/Idempotency-Key), повтор запроса создавал второе списание. - Просроченные начисления: В баланс не закладывалось правило «просроченное начисление не участвует в балансе».
- Отрицательный баланс: В одной транзакции делается
SELECT ... FOR UPDATEпо строке пользователя вusers, затем пересчитывается доступный баланс (только не просроченные начисления минус списания) и создаётся списание. Параллельные запросы по одному пользователю выполняются последовательно на уровне блокировки строки, поэтому баланс не уходит в минус. - Двойное списание по одному и тому же запросу: Перед списанием проверяется наличие уже существующего списания с тем же
(user_id, request_id). Если есть — возвращается успех сduplicated: trueбез нового списания. Уникальный индекс по(user_id, request_id)в БД страхует от дубликатов при гонке.
- БД: Частичный уникальный индекс
(user_id, request_id) WHERE request_id IS NOT NULL— не допускает двух списаний с одной парой (user, requestId). - Код: Расчёт доступного баланса только по начислениям с
expires_at IS NULL OR expires_at > NOW()минус сумма списаний; в одной транзакции блокировка пользователя → проверка идемпотентности → проверка баланса → вставка списания; обязательныйrequestId(из тела или заголовкаIdempotency-Key); при том жеrequestIdи другомamount— ответ 409.
- Идентификатор берётся из
body.requestIdили заголовкаIdempotency-Key(заголовок приоритетнее). Область идемпотентности — пара(user_id, requestId). - Перед списанием ищется существующее списание с такой парой. Если найдено и
amountсовпадает — возвращается200 { success: true, duplicated: true }. Еслиamountдругой —409. Если не найдено — выполняется списание и возвращается200 { success: true, duplicated: false }. - Уникальный индекс по
(user_id, request_id)в БД страхует от дубликатов при гонке.
- При расчёте доступного баланса учитываются только начисления с
expires_at IS NULL OR expires_at > NOW(). Та же логика используется внутри транзакции списания при проверке достаточности баланса.
- Повторные попытки: При постановке задачи заданы
attempts: 3иbackoff: { type: 'exponential', delay: 1000 }(не менее 1000 мс). - Предсказуемый jobId: Задача ставится с
jobId: 'expire-accruals'. - Защита от дублей: Для каждого просроченного начисления создаётся списание с
request_id = "expire:<accrual_id>". Перед созданием проверяется наличие такой записи; при наличии списание не создаётся. Повторный запуск джобы не даёт повторного списания по одному начислению.
- Добавлена только новая миграция 003; в
downне восстанавливается старый глобальный уникальный индекс поrequest_id. - Защита от дублей очереди проверена на уровне бизнес-логики (идемпотентное создание списания); в тестах не поднимается полный воркер BullMQ.