Перейти к основному содержимому

Синхронизация XML-фида (атрибуты товаров)

DM Shop загружает атрибуты товаров из внешнего YML XML-фида (формат Яндекс.Маркет). Синхронизация запускается по расписанию раз в 30 секунд и состоит из трёх этапов.


Конвейер синхронизации

Шаг 1 — Скачать XML (GET xml-feed.url)
└─ Распаковать gzip если нужно
└─ Срезать UTF-8 BOM (EF BB BF) если присутствует

Шаг 2 — SHA-256 хеш нового файла
├─ Совпадает с сохранённым → STOP (пропустить)
└─ Новый → продолжить

Шаг 3 — Парсинг XML → список OfferData
└─ Поддерживает типы: <name> и <model> (vendor.model)

Шаг 4 (TX A) — XmlFeedStagingWriter.populateStaging()
├─ Загрузить все правила атрибутов
├─ Для каждого оффера: найти товар по имени
├─ Применить alias rules к параметрам
└─ UPSERT в xml_feed_staging (сохранить first_seen_at)

Шаг 5 (TX B) — XmlFeedProcessor.applyDiff()
├─ Bulk load staging + текущих AUTO-атрибутов
├─ Удалить осиротевшие (были AUTO, нет в staging)
└─ Для изменившихся товаров: DELETE + INSERT атрибутов

Шаг 6 — Сохранить новый хеш в app_config

TX A и TX B — отдельные Spring-бины и отдельные транзакции. Это обход ограничения Spring AOP на self-invocation: если processOffers() и applyDiff() находились бы в одном бине, внутренний вызов шёл бы мимо прокси и @Transactional игнорировался бы.

примечание

Синк работает только с атрибутами из XML-фида (источник AUTO). Атрибуты, добавленные вручную через карточку товара, не затрагиваются.


Проверка хеша

После скачивания файла вычисляется SHA-256 хеш сырых байт (до парсинга). Хеш сравнивается со значением из таблицы app_config (ключ xml_feed_hash).

СитуацияРезультат
Хеши совпадаютСинк завершается без обращений к БД. Лог: XML feed unchanged (hash match), skipping sync
Хеши отличаются или запись отсутствуетСинк продолжается. После успешного завершения хеш обновляется

Принудительный сброс хеша через POST /api/v1/admin/xml-feed/sync?resetCache=true удаляет запись из app_config, гарантируя полный перезапуск при следующем вызове.


Staging-таблица

xml_feed_staging — промежуточное зеркало последнего известного состояния параметров из фида. Разделяет чтение фида и запись в карточки товаров.

Схема

ПолеТипОписание
idBIGINT PKАвтоинкремент
product_idBIGINTID товара (без FK — нет зависимости от products)
nameVARCHAR(255)Название параметра (после применения alias rules)
unitVARCHAR(100)Единица измерения (может быть NULL)
valueTEXTЗначение параметра
first_seen_atTIMESTAMPДата первого появления этого параметра

Нет FK на таблицу products — намеренно: staging может содержать данные для товаров, которые ещё не созданы или временно удалены.

Стратегия UPSERT (per-product)

При каждом синке для каждого товара:

  • Неизменившиеся строки остаются нетронутыми → first_seen_at сохраняется с самого первого появления
  • Новые параметры вставляются с first_seen_at = now()
  • Исчезнувшие параметры удаляются точечно (не TRUNCATE)

Правила атрибутов

Правила позволяют переименовать или схлопнуть несколько источников в один атрибут перед записью в staging. Это решает проблему разных названий одного параметра в разных категориях фида.

Структура правила

ПолеОписание
targetNameИтоговое название атрибута в карточке товара
sourceNamesСписок источников в порядке приоритета (индекс 0 = наивысший)

Хранится в таблице attribute_alias_rules. Поле source_names — JSON-массив в TEXT-колонке, десериализуется через StringListConverter (Jackson).

Алгоритм применения

для каждого правила:
найти параметры из фида, чьё name входит в sourceNames
если ничего нет → пропустить правило
взять первый source по индексу, у которого есть хотя бы один параметр
взять ВСЕ значения этого source → переименовать в targetName
удалить из списка всё что совпало с ЛЮБЫМ sourceNames этого rule
добавить переименованные параметры

Примеры

Простое переименование — один источник, параметр просто получает новое имя:

Правило: targetName="Цвет", sourceNames=["Цвет товара - Ноутбуки"]

Из фида: Результат:
Цвет товара - Ноутбуки: Серый → Цвет: Серый
Объём SSD: 512 ГБ → Объём SSD: 512 ГБ

Схлопывание с приоритетом — два источника называют одно и то же. Берётся первый доступный:

Правило: targetName="Процессор"
sourceNames=["Процессор - Ноутбуки", "Линейка процессора - Ноутбуки"]

Из фида: Результат:
Процессор - Ноутбуки: Apple M2 → Процессор: Apple M2
Линейка процессора - Ноутбуки: Apple M2 (второй источник удалён)
Частота: 3.5 ГГц → Частота: 3.5 ГГц

Оба источника присутствуют, но взят sourceNames[0]. sourceNames[1] удалён как совпавший.


Источник 0 отсутствует — берётся следующий доступный:

Правило: targetName="Процессор"
sourceNames=["Процессор - Ноутбуки", "Линейка процессора - Ноутбуки"]

Из фида: Результат:
Линейка процессора - Ноутбуки: Intel i7 → Процессор: Intel i7
Объём RAM: 16 ГБ → Объём RAM: 16 ГБ

(«Процессор - Ноутбуки» не найден → берётся sourceNames[1])

Multi-value — если источник повторяется, все значения сохраняются:

Правило: targetName="Разъёмы"
sourceNames=["Интерфейсы подключения - Ноутбуки"]

Из фида: Результат:
Интерфейсы подключения - Ноутбуки: USB-C → Разъёмы: USB-C
Интерфейсы подключения - Ноутбуки: HDMI → Разъёмы: HDMI
Интерфейсы подключения - Ноутбуки: 3.5mm → Разъёмы: 3.5mm

Применение diff

После записи в staging запускается дифференциальное применение: обновляются только товары с изменившимся набором атрибутов.

  1. Загрузить все строки из xml_feed_staging + все AUTO-атрибуты из product_attributes (batch query).
  2. Найти «осиротевшие» ID — товары у которых были AUTO-атрибуты, но нет в staging. Удалить их атрибуты.
  3. Для каждого товара из staging: сравнить мультисет атрибутов через Map<key, Long> (groupingBy + counting). Если совпадает → пропустить.
  4. Изменившиеся товары: deleteByProductIdAndSource(AUTO) + saveAll(новые из staging).

Ключ сравнения: name + "|" + (unit ?? "") + "|" + value — регистр и пробелы важны, значения сравниваются как есть.


Обработка формата YML XML

Фид поставляется в формате Яндекс.Маркет YML. Поддерживаются два типа офферов:

<!-- Простой оффер: название в <name> -->
<offer id="123">
<name>MacBook Pro 14"</name>
<param name="Процессор - Ноутбуки">Apple M3 Pro</param>
</offer>

<!-- vendor.model оффер: название в <model> -->
<offer id="456" type="vendor.model">
<vendor>Apple</vendor>
<model>MacBook Air 13"</model>
<param name="Объём RAM">16 ГБ</param>
</offer>

При скачивании файл может быть:

  • Gzip-сжатым (magic bytes 1F 8B) — автоматически распаковывается
  • С UTF-8 BOM (bytes EF BB BF) — срезается перед парсингом, иначе SAX-парсер падает с Content is not allowed in prolog

Конфигурация

# application.yaml
xml-feed:
url: https://example.com/feed.xml

API

Все эндпоинты требуют роль ADMIN.

Правила атрибутов

МетодПутьОписание
GET/api/v1/admin/attribute-rulesСписок всех правил
POST/api/v1/admin/attribute-rulesСоздать правило
PUT/api/v1/admin/attribute-rules/{id}Обновить правило
DELETE/api/v1/admin/attribute-rules/{id}Удалить правило

Тело запроса (POST / PUT):

{
"targetName": "Процессор",
"sourceNames": [
"Процессор - Ноутбуки",
"Линейка процессора - Ноутбуки"
]
}

Ответ:

{
"id": 1,
"targetName": "Процессор",
"sourceNames": ["Процессор - Ноутбуки", "Линейка процессора - Ноутбуки"],
"createdAt": "2026-05-07T12:00:00"
}

XML-фид

МетодПутьОписание
POST/api/v1/admin/xml-feed/sync?resetCache=falseЗапустить синк вручную. resetCache=true — сбросить хеш перед запуском
GET/api/v1/admin/xml-feed/param-namesУникальные имена параметров из staging с количеством вхождений

Ответ param-names:

[
{ "name": "Объём SSD", "count": 1842 },
{ "name": "Объём RAM", "count": 1761 },
{ "name": "Цвет товара - Ноутбуки", "count": 1204 }
]

Схема БД

-- Промежуточная таблица (зеркало фида)
CREATE TABLE xml_feed_staging (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
unit VARCHAR(100),
value TEXT NOT NULL,
first_seen_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_xml_feed_staging_product_id ON xml_feed_staging(product_id);

-- Правила переименования/схлопывания
CREATE TABLE attribute_alias_rules (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
target_name VARCHAR(255) NOT NULL,
source_names TEXT NOT NULL, -- JSON array: ["src1","src2"]
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);