[{"data":1,"prerenderedAt":229},["ShallowReactive",2],{"post-ru-one-token-two-forms":3},{"id":4,"title":5,"body":6,"category":209,"cover":210,"coverAlt":211,"date":212,"description":213,"extension":214,"locale":215,"meta":216,"navigation":217,"path":218,"published":217,"readingMinutes":219,"seo":220,"slug":221,"stem":222,"tags":223,"__hash__":228},"posts\u002Fru\u002Fposts\u002Fone-token-two-forms.md","Один токен капчи, две ждущие формы: что сломала невидимая проверка на целой платформе",{"type":7,"value":8,"toc":198},"minimark",[9,13,16,19,24,27,30,34,37,40,51,54,60,63,67,70,73,77,84,91,94,100,103,107,110,121,129,132,135,139,150,167,171,180,184],[10,11,12],"p",{},"Покупатель открывает в интернет-магазине форму обратной связи, пишет вопрос и нажимает «Отправить». Сервис капчи решает, что на этот раз нужно задание. Покупатель закрывает его, передумывает и листает ниже, чтобы оставить отзыв о товаре. Там проверка проходит. И страница отправляет форму обратной связи. Отзыв так и висит со спиннером.",[10,14,15],{},"Ничего экзотического тут нет. Так бывает, когда результат проверки объявляют всем, кто его ждёт, а не отдают форме, которая его запросила.",[10,17,18],{},"В inSales, e-commerce-платформе с 60 000+ магазинов, я отвечал за frontend-часть защиты форм от ботов. Сначала перевёл поддерживаемые формы на Google reCAPTCHA Invisible (октябрь–ноябрь 2023 года). Потом добавил второй сервис, Yandex SmartCaptcha, который владелец магазина выбирает одной настройкой: активная работа началась 30 июня 2025 года, основной релиз вышел 26 августа. Клиенты просили альтернативу из-за доступности Google и вопросов к обработке данных, поэтому Google тоже остался. Снаружи всё это похоже на подключение одного скрипта. На деле получился набор проблем асинхронного состояния: формы общей библиотеки витрин, три поколения оформления заказа, два поколения личного кабинета покупателя и вход владельцев магазинов.",[20,21,23],"h2",{"id":22},"невидимая-не-значит-незаметная","Невидимая не значит незаметная",[10,25,26],{},"Невидимый режим убирает чекбокс «Я не робот» из обычного сценария. Покупатель заполняет поля и нажимает кнопку. Форма проверяет поля, запрашивает у сервиса токен и только после этого продолжает отправку. Сервер проверяет токен и решает, принимать ли запрос.",[10,28,29],{},"Но «невидимая» описывает только удачный путь. Сервис всё равно может показать задание, а покупатель может его закрыть, провалить или отойти от компьютера, пока токен не истечёт. Поэтому у каждой формы, кроме «ничего не происходит» и «отправляется», есть третье состояние: ждём проверку. Большая часть ошибок жила именно там.",[20,31,33],{"id":32},"токен-должен-знать-чей-он","Токен должен знать, чей он",[10,35,36],{},"В ранней интеграции было общее событие «проверка завершена», и на него подписывалась каждая форма, которая запускала проверку. Вдобавок шина событий отдавала новому подписчику последние данные. Форма могла получить результат, который появился ещё до того, как она что-то запросила.",[10,38,39],{},"С двумя формами на странице получается ровно сцена из начала. Форма A всё ещё ждёт после закрытого задания, форма B проходит проверку, а общее событие продолжает отправку A.",[41,42,48],"pre",{"className":43,"code":45,"language":46,"meta":47},[44],"language-js","\u002F\u002F Было: кто ждёт, тот и реагирует на любой результат\neventBus.subscribe('captcha:response', ({ token }) => {\n  submitWithToken(pendingForm, token)\n})\n","js","",[49,50,45],"code",{"__ignoreMap":47},[10,52,53],{},"Исправление: перестать рассылать результат всем. Каждая проверка запускается для конкретной формы, и callback получает эту форму вместе с её токеном. По тому же правилу я сделал публичный API для кастомных форм магазинов:",[41,55,58],{"className":56,"code":57,"language":46,"meta":47},[44],"function submitReview(form) {\n  \u002F\u002F Если у формы уже есть токен, вернёт его.\n  \u002F\u002F Иначе запустит проверку и потом вызовет submitReview(form, token).\n  const token = captcha.executeInvisible(form, submitReview)\n  if (!token) return\n\n  sendForm(form, token)\n}\n",[49,59,57],{"__ignoreMap":47},[10,61,62],{},"Изменение небольшое, но код теперь отвечает на другой вопрос. Не «прошёл ли кто-то проверку?», а «прошла ли эта форма свою проверку?». Интеграция Яндекса через два года строилась по тому же правилу.",[20,64,66],{"id":65},"ждать-токен-и-отправлять-запрос-разные-состояния","Ждать токен и отправлять запрос — разные состояния",[10,68,69],{},"Пока запрос выполняется, форма блокирует повторную отправку. Ранняя обработка точно так же блокировала форму и на время ожидания капчи. Если покупатель закрывал задание, форма навсегда оставалась в состоянии отправки: кнопка неактивна, попробовать ещё раз нельзя.",[10,71,72],{},"Я сделал так, чтобы закрытое задание снимало ожидание, перед повторной попыткой капча сбрасывалась, а форма получала свежий токен, а не старый, который мог истечь за время паузы. Проверяли на ошибках заполнения полей, повторном открытии модального окна и повторном заказе в один клик: у каждого из этих сценариев был свой обработчик отправки.",[20,74,76],{"id":75},"два-загрузчика-и-один-глобальный-объект","Два загрузчика и один глобальный объект",[10,78,79,80,83],{},"На некоторых страницах SmartCaptcha умели подключать и системная форма платформы, и виджет витрины. Когда второй загружал скрипт повторно, сервис создавал новый ",[49,81,82],{},"window.smartCaptcha",", который ничего не знал об уже отрисованных экземплярах.",[10,85,86,87,90],{},"Покупатель видел неработающую кнопку отправки. В консоли появлялось ",[49,88,89],{},"Widget with id … does not exist",". И всё зависело от порядка загрузки. Если библиотека витрин успевала первой, системный шаблон видел готовый скрипт и не добавлял свой. При обратном порядке неверное условие в библиотеке пропускало вторую копию.",[10,92,93],{},"Страница не принадлежит ни одной из сторон, поэтому обе должны вести себя как гости:",[41,95,98],{"className":96,"code":97,"language":46,"meta":47},[44],"let sdkReady\n\nfunction loadCaptchaSdk() {\n  \u002F\u002F Используем скрипт, если его уже добавила другая часть страницы\n  const existingScript = document.querySelector(`script[src^=\"${SDK_ORIGIN}\"]`)\n  sdkReady ??= existingScript ? waitForGlobal('smartCaptcha') : injectScript(SDK_URL)\n  return sdkReady\n}\n",[49,99,97],{"__ignoreMap":47},[10,101,102],{},"Я поправил загрузку и в серверных шаблонах, и в библиотеке, а потом ещё раз вернулся к библиотеке и закрыл последнюю последовательность загрузки, которая всё ещё проходила.",[20,104,106],{"id":105},"откат-настройка-которой-не-было","Откат: настройка, которой не было",[10,108,109],{},"8 августа 2025 года первая выкатка выбора сервиса для системных форм магазинов ушла в продакшен под фича-флагом. В части существующих магазинов капча просто не появлялась. На новых тестовых аккаунтах всё работало. Выкатку откатили.",[10,111,112,113,116,117,120],{},"Первое, что приходит в голову, — пустое значение настройки. Оказалось хуже: у некоторых старых магазинов не было самой записи настроек. Условие ждало явного ",[49,114,115],{},"google"," или ",[49,118,119],{},"yandex",", не получало ничего, не выбирало ни один вариант и оставляло форму без капчи.",[41,122,127],{"className":123,"code":125,"language":126,"meta":47},[124],"language-ts","\u002F\u002F Было: каждый потребитель читал настройку сам\nconst provider = store.settings?.captchaProvider\nif (provider === 'yandex') renderYandexCaptcha()\nelse if (provider === 'google') renderGoogleCaptcha()\n\u002F\u002F Магазин без записи настроек проходит мимо обеих веток\n\n\u002F\u002F Стало: у ответа один владелец и значение по умолчанию\nfunction captchaProvider(store: Store): 'google' | 'yandex' {\n  return store.settings?.captchaProvider === 'yandex' ? 'yandex' : 'google'\n}\n","ts",[49,128,125],{"__ignoreMap":47},[10,130,131],{},"Настоящее исправление было на сервере. Я добавил в модель аккаунта один метод, который возвращает тип капчи и подставляет Google, если ничего не сохранено. Существующие магазины и так работали с Google. Потом перевёл потребителей на этот метод, проверил на staging проблемный магазин и свои тестовые формы и 11 августа выпустил исправление.",[10,133,134],{},"Подсказка была в том, что работало. Новые тестовые аккаунты создаются с сегодняшней структурой данных, поэтому ошибку, которая живёт в данных многолетней давности, они показать не могли.",[20,136,138],{"id":137},"не-каждая-ошибка-капчи-была-моей-и-не-каждая-была-про-капчу","Не каждая ошибка капчи была моей. И не каждая была про капчу",[10,140,141,142,145,146,149],{},"Один и тот же симптом «форма не отправляется» имел разные причины: нет токена, потерян экземпляр виджета, сервер вернул ошибку, мешает блокировщик рекламы или сбоит сам сервис. Я добавил логирование событий SmartCaptcha ",[49,143,144],{},"network-error"," и ",[49,147,148],{},"javascript-error"," и собирал воспроизведения с консолью, вкладкой Network, HAR-файлом и видео.",[151,152,153,157,164],"ul",{},[154,155,156],"li",{},"В Safari после прохождения задания следующий клик мог прокрутить страницу наверх. Я исправил это один раз в общей интеграции библиотеки: экземпляр SmartCaptcha уничтожается и рендерится заново. Каждую форму отдельно чинить не пришлось.",[154,158,159,160,163],{},"На некоторых Android-устройствах экранная клавиатура закрывала поле задания. Изменение ",[49,161,162],{},"viewport"," помогало не везде. Проблема была внутри виджета сервиса, поэтому я сделал отдельный тестовый стенд, записал видео, описал устройства и передал всё в поддержку Yandex Cloud.",[154,165,166],{},"Одно обращение «капча зависает при входе» оказалось ошибкой самой формы входа. После таймера сервер отвечал, что попытки закончились, но блок ошибки этого уже не показывал. Я исправил сообщение и состояние кнопки и завёл это как отдельный баг формы входа.",[20,168,170],{"id":169},"чем-закончилось","Чем закончилось",[10,172,173,174,179],{},"К основному релизу оба сервиса работали в четырёх семействах форм библиотеки витрин, в оформлении заказа 1.0–3.0, в личных кабинетах покупателя 1.0 и 2.0 и во входе владельцев магазинов. Одна настройка магазина выбирает сервис для поддерживаемых форм, а когда проверка нужна, по-прежнему решают правила каждой формы. Разработчики магазинов получили документированный API, чтобы подключить любой из сервисов к своим формам. После релиза я продолжал сопровождение: в сентябре 2025 года исправил вход после кнопки «Назад» в браузере, а в 2026 году добавил проверку в подключение собственного домена. Весь проект описан в ",[175,176,178],"a",{"href":177},"\u002Fru\u002Fwork\u002Fform-protection\u002F","кейсе о защите форм",".",[20,181,183],{"id":182},"что-я-бы-проверил-в-любой-команде","Что я бы проверил в любой команде",[151,185,186,189,192,195],{},[154,187,188],{},"Привязывайте каждый асинхронный результат к тому, кто его запросил. Общее событие «готово» работает, пока на странице одна форма, а на реальных страницах их несколько.",[154,190,191],{},"Делайте загрузку стороннего SDK идемпотентной на уровне страницы. Скорее всего, этот скрипт нужен на странице не только вашему коду.",[154,193,194],{},"Проверяйте значения по умолчанию на отсутствующих записях, а не только на пустых. Если фича читает настройки, запустите её на самом старом аккаунте, который найдёте.",[154,196,197],{},"Считайте «ждём проверку» отдельным состоянием со своим сценарием отмены, а не частью «запрос выполняется».",{"title":47,"searchDepth":199,"depth":199,"links":200},2,[201,202,203,204,205,206,207,208],{"id":22,"depth":199,"text":23},{"id":32,"depth":199,"text":33},{"id":65,"depth":199,"text":66},{"id":75,"depth":199,"text":76},{"id":105,"depth":199,"text":106},{"id":137,"depth":199,"text":138},{"id":169,"depth":199,"text":170},{"id":182,"depth":199,"text":183},"Архитектура","\u002Fimages\u002Fblog\u002Fone-token-two-forms.svg","Схема сравнивает общее событие проверки, которое продолжает не ту ждущую форму, и callback, привязанный к форме, запустившей проверку","2026-10-21","Невидимая капча выглядит как один тег script. В формах нескольких поколений она превратилась в ошибки асинхронного состояния и откат в продакшене из-за записи настроек, которой просто не было.","md","ru",{},true,"\u002Fru\u002Fposts\u002Fone-token-two-forms",6,{"title":5,"description":213},"one-token-two-forms","ru\u002Fposts\u002Fone-token-two-forms",[224,225,226,227],"CAPTCHA","Async state","Forms","Rollout","o8wgUA3BVF9ZQrrydyyZp4bqYUFlP6RGHV7CHS3To1I",1793195987031]