# Design ## Контекст Билдер URL записи метабазы сейчас живёт в транспорте `internal/httpapi` (`providerURL` — канонический URL по provider/id/type; `matchURL` — выбор ссылки матча: URL выбранного кандидата, если его provider+id совпадают с эффективными, иначе канонический). Telegram-транспорт (`internal/tgbot`) не может переиспользовать эти функции: транспорт не должен зависеть от другого транспорта, да и незачем дублировать логику. Оба транспорта уже зависят от ядра `internal/worker` и его `ReviewData`. ## Решение ### Билдер URL — в ядро worker Переносим в `internal/worker`: - `func ProviderURL(provider, id, mediaType string) string` — экспортируемая чистая функция (канонический URL; пустой id или неизвестный провайдер → пусто). - `func (rd *ReviewData) MatchURL() string` — метод: приоритет URL выбранного кандидата (при совпадении provider+id с эффективными), иначе `ProviderURL(rd.Provider, rd.ProviderID, string(rd.Plan.Type))`. Тип медиа для URL всегда `rd.Plan.Type` (так и звали оба вызова в httpapi), поэтому метод берёт его сам — вызывающему не нужно передавать. httpapi делегирует: `view.MatchURL = rd.MatchURL()`; `sourceMatchURL` зовёт `worker.ProviderURL(...)`. Обратный разбор `parseProviderURL` (URL → provider/id) и парсинг ручного ввода остаются в httpapi — это транспортный ввод, не общий билдер. Тесты `providerURL`/`matchURL` переезжают в `internal/worker`. ### Что показываем в боте `baseLine` меняем: принимает `*worker.ReviewData` (а не сырой `*store.Recognition`), использует **эффективные** `rd.Provider`/`rd.ProviderID` (как веб — с учётом ручных правок) и `rd.MatchURL()`: - есть URL → `provider id ↗` (provider, id экранированы через `esc`; URL — через `escHref`, см. «Безопасность»); - нет URL → `provider id` текстом (как раньше); - матча нет (`""`/`none`) → возвращает пусто (вызывающий решает, показывать ли индикатор). Строку матча показываем в двух местах: - **карточка ревью** (`reviewCard`) — точка подтверждения, где привязку ещё можно поправить; строка «База: …» уже была, добавляем в неё ссылку и переводим на эффективный провайдер. Когда матча нет — сохраняем текущее поведение: «База: нет матча» (в ревью полезно видеть, что база не выбрана). - **уведомление о готовности** (`renderDone`) — добавляем строку «База: …**только при наличии матча**, чтобы ошибочную привязку было видно и в финальном пинге (файлы уже разложены, но расхождение заметно сразу). Без матча строку опускаем — в готовности «нет матча» лишний шум. Асимметрия «нет матча» между поверхностями осознанная: индикатор в ревью помогает (можно добавить базу), в финальном пинге — нет. `baseLine` поэтому отдаёт пусто на «нет матча», а текст «нет матча» подставляет `reviewCard` (единственная поверхность, где он нужен). Прочие уведомления (падение/рассинхрон) матч не показывают: там нет подтверждённого результата раскладки, релевантна причина сбоя, а не запись базы. ### Безопасность `provider`, `id`, `URL` — недоверенные (метабаза/LLM/ручной ввод). `provider` и id экранируются через `esc` (текстовый контекст). Для **URL контекст другой — значение атрибута `href`**, а `esc` (`tgbotapi.EscapeText(ModeHTML)`) заменяет только `<`, `>`, `&` и **не трогает `"`**. Между тем `ProviderURL` подставляет id в URL сырым (`"…/title/" + id`), а id недоверенный: id с `"` разорвал бы атрибут `href` и Telegram отклонил бы сообщение (parse error) → уведомление о матче тихо не доставилось бы. Поэтому URL экранируем хелпером `escHref`, который поверх `esc` дополнительно заменяет `"` на `"` (порядок безопасен: `esc` уже перевёл `&` в `&`, так что `&` в `"` не удвоится). Ссылка рисуется только при непустом URL из `MatchURL()`. Миграции не нужны — данные матча уже в БД (`recognition`, `metadata_candidate`).