Разбираем переезд интернет-магазина с OpenCart 2 на Next.js и PostgreSQL. Магазин оптовый, электронные компоненты, сайту двадцать лет, в каталоге 4 751 товар. Если у вас OpenCart и вы думаете уезжать, то будет полезно почитать, что может всплыть в самый неожиданный момент. Магазин запущен в боевом режиме, поэтому всё ниже про то, что уже перенесено и работает. Про трафик после переключения домена говорить рано, замера ещё нет. Обмен с учётной системой - отдельная большая тема, и здесь его нет. Читать далее
Уровень сложностиСредний
Время на прочтение6 мин
Охват и читатели5.5K
Туториал
Разбираем переезд интернет-магазина с OpenCart 2 на Next.js и PostgreSQL. Магазин оптовый, электронные компоненты, сайту двадцать лет, в каталоге 4 751 товар. Если у вас OpenCart и вы думаете уезжать, то будет полезно почитать, что может всплыть в самый неожиданный момент.
Магазин запущен в боевом режиме, поэтому всё ниже про то, что уже перенесено и работает. Про трафик после переключения домена говорить рано, замера ещё нет. Обмен с учётной системой - отдельная большая тема, и здесь его нет.
Что за магазин и почему уезжалиOpenCart 2, двадцать лет жизни. Главных болей две: слабый параметрический поиск и админка, в которой неудобно работать с каталогом. Для радиодеталей первое критично: покупатель ищет не по названию, а по параметрам вроде ёмкости, напряжения и корпуса.
Объём такой: 4 751 товар, 115 разных атрибутов, в среднем пять на товар и до семнадцати у самых подробных. Картинки есть у 58% позиций, PDF-даташиты у 49%. Вместе с остальным медиа это около 10 ГБ на хостинге заказчика.
Сам по себе объём небольшой, PostgreSQL его не заметит. Сложность в другом: всё, что двадцать лет поддерживалось руками, живёт в базе OpenCart в своём формате, и забирать это придётся из дампа: API до этих данных не доходит.
Дамп вместо APIУ OpenCart нет удобного экспорта того, что нам нужно: страниц производителей с логотипами и описаниями, новостей, старых адресов. Поэтому источник - обычный mysqldump.
Формат у него предсказуемый: каждая строка INSERT лежит на своей строке файла, поля разделены «,\t». Для разовой миграции этого достаточно, полноценный SQL-парсер не нужен, хватает прохода по строкам от INSERT INTO до строки, которая кончается точкой с запятой.
Первая неприятность вылезла на текстах.
Двойное экранированиеЧасть текстов в базе экранирована дважды. В описании производителя лежит не « », а « ». Один проход декодера оставляет в тексте мусор, который на витрине выглядит как символы HTML посреди слова.
Решение - декодировать два раза. Но порядок замен внутри прохода важен (код из проекта, сокращён):
// «&» раскрывается ПЕРВЫМ: иначе « » превратится в « »
// уже после того, как замена « » отработала, и пробел останется в тексте.
const decodeOnce = s => s
.replace(/&/g, '&')
.replace(/</g, '<').replace(/>/g, '>')
.replace(/ /g, ' ').replace(/"/g, '"')
.replace(/«/g, '«').replace(/»/g, '»')
.replace(/'/g, "'")
const unescapeHtml = s => decodeOnce(decodeOnce(s))
Если поставить & в конец цепочки, один проход даст « », и второй его уже не увидит как двойное экранирование. В зависимости от порядка остальных замен неразрывный пробел либо останется сущностью, либо превратится в пробел только местами. На паре сотен описаний такое не ловится глазами.
Производителей 488. Всё, что про них поддерживалось вручную (логотип, ссылка на сайт, описание, адрес страницы), лежит в OpenCart. А товары на новом сайте отбираются по полю производителя из учётной системы. Написания там расходятся: «Pol-Sun» на сайте и «POLSUN» в учёте, «ON Bright» и «ON-BRIGHT».
Связывать по нормализованному названию соблазнительно и хрупко: сегодня сопоставилось, завтра в учёте завели новое написание, и производитель на витрине молча остался без товаров. Поэтому у каждого производителя хранится отдельное поле с исходными написаниями из учёта, и отбор товаров идёт по нему, а не по названию, которое видит покупатель.
Правило шире этого проекта: название для людей и ключ для связи - разные поля, даже когда сегодня они совпадают.
Адреса: 7 721 правило и догоняющий патчСтарые адреса товаров у OpenCart строятся из ЧПУ категорий и товара. Новые у нас идут по коду товара из учётной системы. Отсюда карта 301-редиректов на 7 721 правило, например:
/kondensatoryi/chip-kondensatoryi-0402-1/0402-x7r-22uf-10-25v → /product/001037675
В адресе категории стоит -1: OpenCart дописывает числовой суффикс, когда ЧПУ повторяется, и за двадцать лет таких суффиксов накапливается много. При сопоставлении старых адресов с товарами суффикс надо отрезать, иначе дубль не найдёт свою пару.
Вторая деталь - новые адреса. Часть кодов в учёте начинается с кириллического префикса, и в адресе товара он живёт в процентной кодировке: /product/%D0%AD%D0%9A-00000856. Это рабочий вариант, но его стоит проверить заранее на всей цепочке: генератор карты сайта, кэш, аналитика, логи. Везде, где адрес сравнивается строкой, кодированная и раскодированная формы окажутся разными ключами.
Теперь про то, что в первую карту не вошло. Первые 7 721 правило закрыли товары и категории. Страницы производителей вида /refond остались без сопоставления по простой причине: вести их было некуда, раздела производителей на новом сайте ещё не существовало. Когда раздел построили, вместе с ним выехали ещё 543 правила на /manufacturers/refond.
Отсюда вывод, который стоит сделать до начала работ. У OpenCart ЧПУ всех сущностей лежат в одной отдельной таблице, и её надо выгрузить целиком и разобрать по типам: товары, категории, производители, статьи, информационные страницы. Для каждого типа заранее решается, куда он переедет на новом сайте. Тип без адресата означает недостающий раздел, и узнать о нём лучше из таблицы, чем из поиска.
Медиа: 5 149 файлов и кириллица в путяхКартинки и даташиты лежат в /image/catalog/. В список на перенос попало 5 149 файлов, раскладка по папкам с датой: ГГГГММДД/файл.
Качать такой объём имеет смысл только с докачкой: уже скачанное пропускается, упавшее перезапускается без повтора всего списка. Вторая деталь - пути. В именах файлов встречаются пробелы и кириллица, и кодировать нужно каждый сегмент пути отдельно, сохраняя слэши между ними:
from urllib.parse import quote
def build_url(base, rel):
# каждый сегмент отдельно: пробелы -> %20, кириллица -> %XX,
# а слэши между сегментами остаются слэшами
safe = "/".join(quote(seg) for seg in rel.lstrip("/").split("/"))
return base.rstrip("/") + "/" + safe
Если закодировать путь целиком, слэши превратятся в %2F, и сервер отдаст 404 на каждый файл во вложенной папке.
На старом сайте 330 новостей за двадцать лет. Перенесли 87, отбирал заказчик по таблице с заголовками и датами, которую мы собрали из дампа.
По умолчанию переезд хочется сделать полным. Но новость о событии десятилетней давности на новом сайте работает против него: покупатель видит её рядом с актуальным каталогом и делает выводы о магазине. Переезд - редкий момент, когда устаревшее можно не тащить, и заказчик этим воспользовался.
Грабля дня переключения: 301 съедает тело запросаЭта грабля случилась в сам день переключения.
После переключения сайт отправлял заказы во внешнюю систему заказчика вебхуком. Заказы там появлялись, но пустые: ни покупателя, ни телефона, сумма ноль. Причина оказалась в адресе вебхука. В настройках был записан адрес, который после переезда начал отвечать 301, типовой случай, когда записан http://, а сервер переводит на https://.
При 301 клиент повторяет запрос по новому адресу уже методом GET, и тело теряется. А строка запроса сохраняется. Поэтому действие распознавалось, заказ заводился, но данных в нём не было.
Мы не стали доверять гипотезе и воспроизвели: подняли адрес, отвечающий переадресацией, отправили на него POST с телом 121 байт, до конечного адреса дошёл GET с телом 0 байт.
Проверяется одной командой с сервера: если первая строка ответа на запрос к адресу вебхука HTTP/1.1 301 или 302, причина ваша, а правильный адрес будет в заголовке Location. Лечится записью конечного адреса. Если переадресация нужна по сути, используют 307 или 308, они сохраняют метод и тело.
Насколько этот магазин типичен, мы проверили на данных: в обходе зоны .ru вручную разобрали 191 магазин на OpenCart.
Живых торгующих магазинов среди размеченных - 131 из 152, около 86%. Для сравнения, в похожей ручной проверке у WooCommerce годных было 33%: под WordPress стоит слишком много блогов и медиа. OpenCart ставят ради каталога, и почти всегда это действительно магазин.
Самая частая тема - стандартная default, у 33 магазинов. Следом unishop2 у 22. Остальное рассыпается по десяткам коммерческих тем с одной-пятью установками. Для переезда это значит одно: вёрстку и кастомизации придётся разбирать под каждый магазин, общего шаблона нет.
И учёт. Признак обмена с 1С снаружи нашёлся у 2 магазинов из 179, оба по косвенным следам: GUID-имена картинок в папке catalog/1c/ и скрипт темы, который чистит описания «если 1С выгрузила их». Учёт у таких магазинов почти наверняка есть, просто снаружи его не видно. Откуда магазин берёт цены и остатки, можно узнать только у владельца, и спрашивать об этом стоит до оценки переезда.
Брать данные из дампа базы: всё, что поддерживалось руками, лежит в таблицах, до которых экспорт из админки не доходит.
Декодировать тексты дважды и раскрывать & первым. Проверить на выборке описаний до переноса всего каталога.
Строить карту адресов по таблице ЧПУ целиком, со всеми типами сущностей, и отрезать числовые суффиксы дублей.
Связывать товары с учётом по отдельному полю исходных написаний, название для покупателя для этого не годится.
Качать медиа с докачкой и кодировать путь по сегментам.
В день переключения проверить адреса вебхуков и интеграций на 301. Если переадресация нужна, использовать 307 или 308.
Устаревший контент отобрать. Целиком переносить только то, что до сих пор работает.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Один контракт данных, разные адаптеры: как перестать писать миграцию магазина заново | 0 | 7.92 | 03-09-2026 |
| 2 | ora2pg переносит около 80% Oracle‑схемы. А что происходит с оставшимися 20%? | 1 | 11.01 | 22-08-2026 |
| 3 | JOIN как в ORM: связи по foreign key в PostgreSQL | 0 | 7.3 | 02-08-2026 |
| 4 | Бесшовный переезд с Perl на Nuxt: история одного 20-летнего монолита | 0 | 13.74 | 13-08-2026 |
| 5 | Я дважды неправильно объяснил один баг в ora2pg | 0 | 11.17 | 24-09-2026 |
| 6 | Диапазонный тип данных в PostgreSQL: ускоряем запросы | 5 | 7 | 26-06-2026 |
| 7 | Как я организовал проект на Next.js | 0 | 6.5 | 21-07-2026 |
| 8 | Приложение течёт, а утечки нет: как оптимизатор картинок Next.js съел 4-гигабайтный VPS | 0 | 6.97 | 13-08-2026 |
| 9 | Мажорное обновление Greengage с помощью pg_upgrade и ggupgrade | 5 | 7 | 26-06-2026 |
| 10 | PostgreSQL 19: Часть 3 или Коммитфест 2025-11 | 0 | 19.17 | 03-02-2026 |