Разбор 10

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

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

WooCommerce WordPress PHP платёжный шлюз сессии idempotency
Клиент
интернет-магазин
Стек
WooCommerce · PHP · MySQL · балансировщик
Срок
~5 часов
Итог
заказы не теряются

Суть проблемы

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

Как было До

Корзина хранилась в файловых PHP-сессиях на одном из двух серверов. При попадании на другой узел — пустая корзина. Колбэк шлюза шёл на http://, WooCommerce его отбрасывал. Дубли создавались двойным сабмитом формы без idempotency.

заказ #1042 ── оплата ок ── запись в БД: НЕТ
заказ #1043 ── дубль ── оплата ×2
Как стало После

Сессии WooCommerce переведены в БД (shared между узлами). Колбэк принимает https://, подпись проверяется, дубли отсекаются idempotency-key, скидка пересчитывается на бэке.

заказ #1042 ── оплата ok ── запись в БД: ЕСТЬ
заказ #1043 ── дубль отброшен (idempotency)

Как диагностировал

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

  1. Воспроизвёл пустую корзину на чекауте Открыл магазин с двух разных IP, смотрю куки: wc_session_* есть на странице корзины, но при переходе на /checkout сессия «другая». На сайте за балансировщиком два узла — файловые сессии не шарятся, каждый сервер видел «свою» корзину. Подтвердил: ls /var/lib/php/sessions на двух узлах — разные файлы.
  2. Проверил обработчик сессий WooCommerce В wp-config.php не был задан кастомный session handler, WC хранил сессии в файлах. За балансировщиком без sticky-session это и давало пустую корзину. Дополнительно кэширующий плагин агрессивно кэшировал страницу /checkout — фрагменты корзины (wc_cart_fragments) отдавались из кэша чужой сессии.
  3. Разобрался с «пропавшим» оплаченным заказом Включил логирование колбэка шлюза: wp-content/uploads/wc-logs/gateway-*.log. Колбэк приходил, но WooCommerce возвращал шлюзу 404 на ?wc-api=callback. Причина — после миграции слетели пермалинки, плюс siteurl был http://, а шлюз слал webhook на HTTPS-домен: сервер делал редирект 301, POST-тело терялось, заказ не создавался.
  4. Нашёл причину дублей заказов В логе оплаты увидел два запроса POST /checkout с разницей 180 мс — двойной клик по «Оплатить». Форма не блокировала повторный сабмит, idempotency-ключа не было. Хук woocommerce_checkout_order_processed отрабатывал дважды → два заказа, шлюз для второго делал второе списание (capture по новой авторизации).
  5. Проверил расчёт скидки по купону Сравнил сумму заказа в письме и в БД. На фронте скидку считал JS и подставлял итог в скрытый инпут order_total; бэк доверял этому значению и не пересчитывал. При отключённом JS или подмене инпута — в заказ уходила полная цена. Купон в БД применялся, но order_total брался из запроса.
  6. Сверил конфигурацию и зависимости Проверил siteurl/home в wp_options, домен куки, WP_DEBUG_LOG, правила кэширования для /cart, /checkout, /my-account. Убедился, что ни одна из четырёх проблем не «лечится» правкой фронта — все корневые причины на бэке и инфре.

Что исправил

Чинил по слоям: инфра/сессии → приём колбэка → защита от дублей → пересчёт цены на бэке. Ниже — пары «было → стало» по каждому слою.

1. Сессии корзины — в базу, общий для обоих узлов

WooCommerce умеет хранить сессии в таблице wp_woocommerce_sessions вместо файлов. На двух серверах за балансировщиком это снимает проблему «пустой корзины» без sticky-session.

wp-config.phpбыло
// дефолт: сессии в файлах, за балансером теряются
define('WP_DEBUG', false);
// session handler не задан → PHP files, по узлу
// cookie domain не зафиксирован
wp-config.phpстало
// WC хранит сессии в wp_woocommerce_sessions (общая БД для узлов)
define('WP_DEBUG', false);
define('WP_DEBUG_LOG', true);

// фиксируем домен куки, чтобы сессия не плодилась по поддоменам
define('COOKIE_DOMAIN', '.example.com');

// НЕ кэшировать динамические страницы корзины/оформления
define('DONOTCACHEPAGE', true); // ставится условно на /cart|/checkout

В кэширующем плагине добавил исключения для /cart, /checkout, /my-account и снял кэш с AJAX-эндпоинта ?wc-ajax=*. Фрагменты корзины теперь всегда живые.

2. Приём колбэка платёжного шлюза

Зафиксировал HTTPS в siteurl и пересохранил пермалинки, чтобы эндпоинт ?wc-api=callback снова отвечал 200. Добавил логирование и проверку подписи колбэка, чтобы не полагаться на «молчаливый» успех.

wp-config.php + functions.phpбыло
// siteurl = http://example.com → шлюз на https получает 301, POST-тело теряется
// пермалинки слетели → ?wc-api=callback отдаёт 404
// обработчик колбэка ничего не логирует
public function handle_callback() {
    if (isset($_POST['status']) && $_POST['status'] === 'paid') {
        WC()->session()->set('order_paid', true); // заказ реально не создаётся
    }
}
wp-config.php + functions.phpстало
// siteurl = https://example.com (фиксируем), пермалинки пересохранены
public function handle_callback() {
    // 1. проверяем подпись шлюза, иначе отбрасываем
    if (!$this->verify_signature($_POST)) {
        status_header(403); return;
    }

    // 2. idempotency: один payment_id → один заказ
    $payment_id = sanitize_text_field($_POST['payment_id']);
    if (get_post_meta_by_payment($payment_id)) {
        status_header(200); return; // уже обработано
    }

    // 3. находим черновик заказа и подтверждаем оплату
    $order = wc_get_order($_POST['order_id']);
    if ($order) {
        $order->payment_complete($payment_id);
        update_post_meta($order->get_id(), '_gateway_payment_id', $payment_id);
        wc_get_logger()->info("callback ok #{$order->get_id()}", ['source' => 'gateway']);
        status_header(200);
    } else {
        wc_get_logger()->error("callback: order not found", ['source' => 'gateway']);
        status_header(404);
    }
}

3. Защита от дублей — idempotency и блокировка повторного сабмита

Главное — не создавать заказ повторно для одной и той же попытки оплаты. На фронте блокирую кнопку после первого клика, на бэке сверяю idempotency-ключ.

checkout.jsбыло
// двойной клик = два POST /checkout = два заказа
$('.checkout-button').on('click', function() {
    $('form.checkout').submit();
});
checkout.jsстало
$('.checkout-button').on('click', function() {
    var $btn = $(this);
    if ($btn.is(':disabled')) return; // повторный клик игнорируем
    $btn.prop('disabled', true).addClass('loading');
    $('form.checkout').submit();
});
functions.php — хук создания заказастало
// серверная защита: один idempotency_key → один заказ
add_action('woocommerce_checkout_create_order', function($order) {
    $key = sanitize_text_field($_POST['idempotency_key'] ?? '');
    if ($key && get_transient('ck_' . $key)) {
        wc_add_notice('Заказ уже оформляется, подождите…', 'error');
        throw new Exception('duplicate_checkout');
    }
    if ($key) set_transient('ck_' . $key, $order->get_id(), 300);
});

4. Скидка по купону — пересчёт на бэке

Фронт больше не источник правды для суммы. Бэк сам применяет купон и пересчитывает итог; значение из инпута игнорируется.

functions.php — валидация заказабыло
// бэк доверяет totals из формы
$order->set_total(floatval($_POST['order_total']));
// купон применялся на фронте, в БД заказа скидки нет
functions.php — валидация заказастало
// применяем купон на сервере и пересчитываем totals
if (!empty($_POST['coupon_code'])) {
    $code = sanitize_text_field($_POST['coupon_code']);
    if (WC()->cart()->apply_coupon($code) !== true) {
        throw new Exception('coupon_invalid');
    }
}
WC()->cart()->calculate_totals();
// итог берём из пересчитанной корзины, а не из формы
$order->set_total(WC()->cart()->get_cart_contents_total() + WC()->cart()->get_cart_contents_tax());
Главное правило оформления заказа: фронт показывает цифры для удобства, но единственный источник правды для суммы, скидки и состава — сервер. Любое значение из формы подлежит пересчёту и валидации на бэке.

Результат

После перевода сессий в БД, починки колбэка, idempotency и серверного пересчёта цены — оформление заказа стало детерминированным на обоих узлах за балансировщиком.

~12/мес → 0
0
потерянных оплаченных заказов
были → нет
0
дубликатов заказов и списаний
часто → 0
0
случаев «пустой корзины на чекауте»
конверсия в покупку
+18%
после восстановления чекаута

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

Бонус: добавил мониторинг — если за сутки не создано ни одного заказа при наличии успешных колбэков шлюза, алерт уходит в чат. Так «тихая» потеря заказов больше не останется незамеченной неделями.
Есть похожая проблема?

Сначала найдём причину. Потом решим, что действительно нужно исправить.

Опишите симптом и что из-за него перестало работать. Я посмотрю, где искать причину и насколько задача похожа на точечный фикс.

Описать проблему ↗