Синхронизация 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 — промежуточное зеркало последнего известного состояния параметров из фида. Разделяет чтение фида и запись в карточки товаров.
Схема
| Поле | Тип | Описание |
|---|---|---|
id | BIGINT PK | Автоинкремент |
product_id | BIGINT | ID товара (без FK — нет зависимости от products) |
name | VARCHAR(255) | Название параметра (после применения alias rules) |
unit | VARCHAR(100) | Единица измерения (может быть NULL) |
value | TEXT | Значение параметра |
first_seen_at | TIMESTAMP | Дата первого появления этого параметра |
Нет 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 запускается дифференциальное применение: обновляются только товары с изменившимся набором атрибутов.
- Загрузить все строки из
xml_feed_staging+ всеAUTO-атрибуты изproduct_attributes(batch query). - Найти «осиротевшие» ID — товары у которых были
AUTO-атрибуты, но нет в staging. Удалить их атрибуты. - Для каждого товара из staging: сравнить мультисет атрибутов через
Map<key, Long>(groupingBy + counting). Если совпадает → пропустить. - Изменившиеся товары:
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
);