Суть проблемы
Магазин на WooCommerce после переезда на два сервера за балансировщиком начал вести себя нестабильно на самом критичном участке — оформлении заказа. Жалобы сыпались с трёх сторон сразу: покупатели, платёжный шлюз и собственная админка не сходились между собой.
-
!
Корзина пустеет на чекаутеПоложил товар в корзину, перешёл к оформлению — «в вашей корзине нет товаров». На странице корзины товары есть, на чекауте пропадают.
-
!
Оплаченный заказ не попадает в админкуПокупатель оплатил картой, деньги списались, но в WooCommerce заказа нет — статус «в обработке» не создаётся, товар не списывается со склада.
-
!
Дубликаты заказовЧасть оплат порождает два заказа с одним содержимым, иногда — двойное списание с карты. Покупатель получает два письма подтверждения.
-
!
Скидка по купону не сохраняетсяПромокод применяется на фронте и показывает новую цену, но в созданном заказе — полная стоимость, без скидки.
Корзина хранилась в файловых PHP-сессиях на одном из двух серверов. При попадании на другой узел — пустая корзина. Колбэк шлюза шёл на http://, WooCommerce его отбрасывал. Дубли создавались двойным сабмитом формы без idempotency.
Сессии WooCommerce переведены в БД (shared между узлами). Колбэк принимает https://, подпись проверяется, дубли отсекаются idempotency-key, скидка пересчитывается на бэке.
Как диагностировал
Сложность в том, что три симптома выглядели независимыми, но корневые причины пересекались: переезд за балансировщик и «кривой» кэширующий плагин ломали сразу и сессии, и колбэки, и сабмит формы. Разбирал каждый по своей цепочке.
-
Воспроизвёл пустую корзину на чекауте
Открыл магазин с двух разных IP, смотрю куки:
wc_session_*есть на странице корзины, но при переходе на/checkoutсессия «другая». На сайте за балансировщиком два узла — файловые сессии не шарятся, каждый сервер видел «свою» корзину. Подтвердил:ls /var/lib/php/sessionsна двух узлах — разные файлы. -
Проверил обработчик сессий WooCommerce
В
wp-config.phpне был задан кастомный session handler, WC хранил сессии в файлах. За балансировщиком без sticky-session это и давало пустую корзину. Дополнительно кэширующий плагин агрессивно кэшировал страницу/checkout— фрагменты корзины (wc_cart_fragments) отдавались из кэша чужой сессии. -
Разобрался с «пропавшим» оплаченным заказом
Включил логирование колбэка шлюза:
wp-content/uploads/wc-logs/gateway-*.log. Колбэк приходил, но WooCommerce возвращал шлюзу404на?wc-api=callback. Причина — после миграции слетели пермалинки, плюсsiteurlбылhttp://, а шлюз слал webhook на HTTPS-домен: сервер делал редирект301, POST-тело терялось, заказ не создавался. -
Нашёл причину дублей заказов
В логе оплаты увидел два запроса
POST /checkoutс разницей 180 мс — двойной клик по «Оплатить». Форма не блокировала повторный сабмит, idempotency-ключа не было. Хукwoocommerce_checkout_order_processedотрабатывал дважды → два заказа, шлюз для второго делал второе списание (capture по новой авторизации). -
Проверил расчёт скидки по купону
Сравнил сумму заказа в письме и в БД. На фронте скидку считал JS и подставлял итог в скрытый инпут
order_total; бэк доверял этому значению и не пересчитывал. При отключённом JS или подмене инпута — в заказ уходила полная цена. Купон в БД применялся, ноorder_totalбрался из запроса. -
Сверил конфигурацию и зависимости
Проверил
siteurl/homeвwp_options, домен куки,WP_DEBUG_LOG, правила кэширования для/cart,/checkout,/my-account. Убедился, что ни одна из четырёх проблем не «лечится» правкой фронта — все корневые причины на бэке и инфре.
Что исправил
Чинил по слоям: инфра/сессии → приём колбэка → защита от дублей → пересчёт цены на бэке. Ниже — пары «было → стало» по каждому слою.
1. Сессии корзины — в базу, общий для обоих узлов
WooCommerce умеет хранить сессии в таблице wp_woocommerce_sessions вместо файлов. На двух серверах за балансировщиком это снимает проблему «пустой корзины» без sticky-session.
// дефолт: сессии в файлах, за балансером теряются
define('WP_DEBUG', false);
// session handler не задан → PHP files, по узлу
// cookie domain не зафиксирован
// 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. Добавил логирование и проверку подписи колбэка, чтобы не полагаться на «молчаливый» успех.
// 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); // заказ реально не создаётся
}
}
// 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-ключ.
// двойной клик = два POST /checkout = два заказа
$('.checkout-button').on('click', function() {
$('form.checkout').submit();
});
$('.checkout-button').on('click', function() {
var $btn = $(this);
if ($btn.is(':disabled')) return; // повторный клик игнорируем
$btn.prop('disabled', true).addClass('loading');
$('form.checkout').submit();
});
// серверная защита: один 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. Скидка по купону — пересчёт на бэке
Фронт больше не источник правды для суммы. Бэк сам применяет купон и пересчитывает итог; значение из инпута игнорируется.
// бэк доверяет totals из формы
$order->set_total(floatval($_POST['order_total']));
// купон применялся на фронте, в БД заказа скидки нет
// применяем купон на сервере и пересчитываем 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 и серверного пересчёта цены — оформление заказа стало детерминированным на обоих узлах за балансировщиком.
Оплаченные заказы теперь гарантированно появляются в админке со статусом «В обработке», товар списывается со склада, купоны корректно снижают итог, а повторный клик по «Оплатить» не порождает дублей. Все колбэки шлюза логируются — молчаливых отказов больше нет.