Овцеводческая CRM, Узбекистан и .NET 10: семь инженерных историй с граблями

от автора

Мы делаем QoyHunter — учётную систему для овцеводческих хозяйств Узбекистана, с витриной животных, доставкой и блогом на соседних поддоменах. С точки зрения бизнес-логики ничего экзотического: CRM как CRM. Экзотика начинается там, где эта CRM встречается с реальностью рынка.

А реальность такая: основной канал пользователей — Telegram, а не браузер; основной язык — не всегда русский (узбекский обязателен, английский — по явному выбору); часовой пояс один (Ташкент, UTC+5, без перехода на летнее время — тоже давало баги, но об этом ниже); а разработка идёт на Windows-ноутбуках без сервера БД под рукой. Из этих фактов вытекает добрая половина решений ниже. Стек: бэкенд на ASP.NET Core (.NET 10), фронт на React + Vite, админка на react-admin, всё за nginx в docker compose.

1. Скрыть продукт целиком, а не просто спрятать кнопку в интерфейсе

Сам продукт ещё не запущен публично: на проде сейчас живут только публичные поверхности — лендинг «скоро открытие» с формой сбора лидов, витрина, доставка и блог. При этом CRM целиком (учёт, аналитика, финансы) уже написана и задеплоена на тот же самый бэкенд, потому что раскатывать отдельную сборку «без продукта» и потом накатывать вторую «с продуктом» — двойная работа и двойной риск разъехаться.

Первая идея была наивной: спрятать соответствующие пункты меню на фронте. Она не выдерживает пяти минут критики — весь API уже стоит на проде, и достаточно открыть DevTools или просто угадать URL, чтобы увидеть, что «скоро» на самом деле уже готово. Прятать нужно бэкенд, а не фронт.

Решили через default-deny middleware: пока включён флаг StubMode, публично доступен только явный allowlist префиксов /api/*, всё остальное возвращает 404 — как будто маршрута не существует вовсе. Ключевое свойство: allowlist явный и короткий, а не блеклист. Если завтра кто-то добавит новый контроллер AnimalHealthController, он окажется скрытым автоматически, просто по факту существования флага, — не нужно помнить «а, точно, надо ещё и этот эндпоинт спрятать».

// backend/src/QoyHunter.Api/Middleware/StubModeGateMiddleware.csprivate static readonly string[] AllowedApiPrefixes =[    "/api/public",       // публичная конфигурация фронта (StubMode, botUsername, языки)    "/api/legal",        // публичные юр-документы    "/api/client-logs",  // приём клиентских ошибок лендинга-заглушки    "/api/feedback",     // публичная форма обратной связи Этапа 0    "/api/delivery",     // второй публичный сервис: заявки на доставку животных    "/api/market",       // третий публичный сервис: маркет — витрина, породы, заказы    "/api/blog",         // блог: публичное чтение, комментарии/реакции    // …плюс пара служебных префиксов, вырезанных из листинга];public async Task InvokeAsync(HttpContext context){    if (!features.CurrentValue.StubMode) { await next(context); return; } // гейт неактивен    // /api-путь сверяем с allowlist через StartsWithSegments,    // совпадение → next()…    // Скрытый эндпоинт основного продукта: 404 без тела — как будто маршрута не существует.    context.Response.StatusCode = StatusCodes.Status404NotFound;}

Отдельно пришлось разобраться со Swagger: он рефлексией собирает описание вообще всех контроллеров и по умолчанию отдаётся вне /api, то есть штатно проходит мимо этого гейта. В Program.cs включение Swagger дополнительно завязано на !StubMode — иначе спецификация API молча слила бы список всех «скрытых» ручек любому, кто наберёт /swagger.

Позже появился второй, более мягкий флаг PreLaunch: API остаётся открытым, просто бот и Mini App разворачивают пользователя на лендинг, а продукт живёт по прямой ссылке для своих. Два разных флага под две разные задачи. Решение не самое элегантное, из-за чего нужен и комментарий в FeatureOptions.cs , который объясняет разницу, чтобы не перепутать через полгода. 🙂

«Скрыть фичу до релиза» оказалось вопросом авторизации на бэкенде, а не видимости на фронте. Причём вопросом архитектурным: default-deny с явным allowlist не даёт забыть спрятать что-то новое, blacklist рано или поздно даёт дыру.

Схема default-deny гейта StubMode: явный allowlist префиксов /api, всё остальное — 404, Swagger гасится тем же флагом

Схема default-deny гейта StubMode: явный allowlist префиксов /api, всё остальное — 404, Swagger гасится тем же флагом

2. Настоящий Postgres на деве, который гарантированно умирает вместе с процессом

Локальная разработка ведётся на Windows-ноутбуках без Docker Desktop под рукой (или просто без желания держать его постоянно запущенным ради одной локальной БД). Хотелось настоящий Postgres — тот же движок, что в проде, — но без системного сервиса, который висит в фоне и о котором через месяц забываешь, потребляя память и держа порт занятым.

Взяли MysticMind.PostgresEmbed — библиотеку, которая распаковывает бинарники Postgres и управляет процессом postmaster. Но у обёртки есть подвох: по умолчанию она создаёт новый instanceId (GUID) на каждый запуск — а значит, новый каталог кластера на ~135 МБ, инициализированный заново, без данных с прошлого раза. Через неделю разработки на диске лежит десяток мёртвых каталогов, а данные не переживают перезапуск API. Фикс — зафиксировать instanceId константой:

// backend/src/QoyHunter.LocalDb/EmbeddedPostgres.cs/// <summary>/// Стабильный идентификатор инстанса — гарантирует единственный каталог кластера между запусками./// Значение фиксировано намеренно (не случайное), иначе каталоги-инстансы копятся и данные не персистятся./// </summary>private static readonly Guid StableInstanceId = new("7f3c1a90-0000-4000-8000-00c0ffee0001");

Вторая часть задачи интереснее: гарантировать, что после смерти нашего API процесс postgres.exe не останется висеть в фоне. IDisposable.Dispose() тут не спасает: DI-контейнер не диспозит инстансы, зарегистрированные через AddSingleton(instance) (он их не создавал — незачем и убирать), поэтому останов вызывается явно на IHostApplicationLifetime.ApplicationStopping. Но это штатный путь — а если API просто kill -9, упадёт, или его прибьют кнопкой Stop в IDE? Managed-код отработать не успеет.

Для этого случая — Windows Job Object с флагом JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: ОС сама убивает назначенный процесс в момент закрытия хендла джоба, а хендл закрывается вместе с нашим процессом — что бы с ним ни случилось.

// backend/src/QoyHunter.LocalDb/WindowsProcessJob.csprivate const uint JobObjectLimitKillOnJobClose = 0x2000;var info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION{    BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION    {        LimitFlags = JobObjectLimitKillOnJobClose,    },};// ...var process = OpenProcess(ProcessTerminate | ProcessSetQuota, false, pid);AssignProcessToJobObject(job, process);

И третий слой, на всякий случай: при старте API ищем осиротевший postmaster.pid от прошлого аварийного запуска и убиваем его — но только если исполняемый файл процесса лежит именно в нашей распакованной директории инстанса (иначе легко зацепить чужой системный процесс с переиспользованным PID) (Но вообще этот код не нужен — после всех шишек что мне пришлось набить — нужно было позакрывать старые процессы, но если сразу делать «правильно» — то этот фрагент не нужен):

if (modulePath is not null &&    modulePath.StartsWith(instanceDir, StringComparison.OrdinalIgnoreCase)){    proc.Kill(entireProcessTree: true);    proc.WaitForExit(5000);}

Три слоя ради одной гарантии выглядят избыточно, пока не вспомнишь, сколько раз процесс умирает не так, как задумано: краш, OOM, форс-килл из IDE. Ну и отдельно: весь этот проект — только под Debug, в релизную сборку он не попадает вообще, через условный ProjectReference:

<!-- backend/src/QoyHunter.Api/QoyHunter.Api.csproj --><ItemGroup Condition="'$(Configuration)' == 'Debug'">  <ProjectReference Include="..\QoyHunter.LocalDb\QoyHunter.LocalDb.csproj" /></ItemGroup>

Так dev-only зависимости (сама библиотека embed и её транзитивный SharpCompress для распаковки архивов) физически не попадают в прод-образ.

3. Telegram как настоящий OAuth2/OIDC-провайдер для веб-входа

Внутри Telegram Mini App вход бесшовный: приложение получает подписанный initData от telegram-web-app.js, бэкенд проверяет HMAC по токену бота — и пользователь просто оказывается внутри без единой формы. Но у нас есть и обычный веб — блог и часть посадочных страниц, — где человек открывает ссылку в обычном браузере, вне Telegram, и initData взять неоткуда.

Оказалось, у Telegram для этого случая есть готовый OAuth2/OIDC-провайдер — oauth.telegram.org, с client_id, равным id бота, authorization_endpoint, token_endpoint и JWKS для проверки подписи id_token. То есть с точки зрения протокола Telegram здесь ведёт себя ровно как Google или GitHub. Реализовали стандартный Authorization Code + PKCE (S256): раз client_secret обязан жить только на сервере, а PKCE защищает конкретно от перехвата кода на клиенте.

// backend/src/QoyHunter.Api/Auth/TelegramOidcLoginService.cspublic TelegramOidcStart Start(string? returnTo = null){    var oidc = Oidc;    var state = TelegramOidcPkce.NewState();    var verifier = TelegramOidcPkce.NewCodeVerifier();    var challenge = TelegramOidcPkce.CodeChallenge(verifier);    var envelope = new StateEnvelope(state, verifier, DateTimeOffset.UtcNow.Add(StateTtl).ToUnixTimeSeconds(), normalizedReturn);    var cookieValue = _protector.Protect(JsonSerializer.Serialize(envelope));    var query = new Dictionary<string, string?>    {        ["client_id"] = oidc.ClientId,        ["redirect_uri"] = oidc.RedirectUri,        ["response_type"] = "code",        ["scope"] = "openid profile",        ["state"] = state,        ["code_challenge"] = challenge,        ["code_challenge_method"] = "S256",    };    var url = QueryHelpers.AddQueryString(oidc.AuthorizationEndpoint, query);    return new TelegramOidcStart(url, cookieValue);}

State и code_verifier не идут в отдельную таблицу БД (не хотелось заводить сущность ради десятиминутного TTL) — они зашифрованы через ASP.NET Core Data Protection и лежат в HttpOnly-cookie, scoped на путь /api/auth/telegram. На колбэке state из query сверяется с state из cookie через CryptographicOperations.FixedTimeEquals (обычное сравнение строк тут уязвимо к тайминг-атаке), после чего код меняется на id_token, токен проверяется по JWKS, а аккаунт ищется по sub (Telegram id) — не по username, потому что username в Telegram можно сменить или вообще не заводить.

Ещё один нюанс: тем же входом пользуются и приложение, и блог, а после логина каждый должен вернуться в себя, а не куда попало — состояние returnTo кладётся в тот же зашифрованный конверт и на колбэке решает, куда редиректить:

public string BuildSuccessRedirect(string bridgeCode, string? returnTo = null) =>    $"{(returnTo == "blog" ? BlogBaseUrl() : AppBaseUrl())}/#b={Uri.EscapeDataString(bridgeCode)}";

Сама сессия у нас основана на паре refresh/access токенов, а не на cookie провайдера, поэтому после успешного OIDC-обмена сервер выдаёт одноразовый bridge-код во фрагменте URL (#b=...), который SPA сразу меняет на нашу обычную пару токенов — тот же механизм, что уже использовался для перехода из Telegram Mini App на сайт.

ИИ к слову не знает то что у Telegram есть OpenID вариант логина (Проверено с Codex 5.4 и Opus 4.6, сейчас возможно ситуация другая), поэтому не городите свой протокол «войти через Telegram на сайте» поверх виджета логина и просвятите своего агента вручную. Для веба у Telegram уже есть нормальный OIDC: PKCE, JWKS, стабильный sub — всё как у любого другого провайдера.

4. Один SPA-бандл на четыре с лишним домена

У продукта несколько сайтов: лендинг на корневом домене, витрина животных на bazar., сервис доставки на dostavka., блог на blog. — плюс закрытые служебные поддомены. Все они — по сути один и тот же React-бандл, который на рантайме смотрит на window.location.hostname и решает, какой раздел рисовать:

// frontend/src/lib/hosting.tsexport function isAppHost(hostname: string): boolean {  return firstLabel(hostname) === 'app'}export function isDeliveryHost(hostname: string): boolean {  return firstLabel(hostname) === 'dostavka'}export function isBazarHost(hostname: string): boolean {  return firstLabel(hostname) === 'bazar'}

Один бандл — меньше дублирования сборки и деплоя. Да и по собственному опыту агенты лучше работают с монолитами, и они удобнее для разработки в одиночку, всякие (уже «немодные») микросервисы советую использовать только если вам прям нужны микросервисы. Но у него есть врождённая проблема с SEO: единственный index.html означает единственные <title>, <meta description>, canonical и JSON-LD на всех хостов сразу. В индексации это выглядело так: у сервиса доставки в выдаче красовался заголовок лендинга (или вообще английский текст — краулер ведь не носит куку выбора языка), у карточек маркета — generic-описание без привязки к домену.

Решение — постпроцесс сборки: отдельный скрипт после vite build читает готовый dist/index.html и штампует из него index-dostavka.html, index-bazar.html, index-blog.html с собственными title/description/canonical/OG/JSON-LD для каждого хоста:

// frontend/scripts/build-seo-pages.mjsconst HOSTS = [  {    file: 'index-dostavka.html',    url: 'https://dostavka.qoyhunter.com/',    title: 'Доставка животных по Узбекистану — QoyHunter',    description: 'Перевозка баранов, овец и сельхозживотных по всему Узбекистану...',    jsonLd: { '@context': 'https://schema.org', '@type': 'Service', /* ... */ },  },  // index-bazar.html, index-blog.html — аналогично]function setMeta(html, { url, title, description, jsonLd, lang }) {  return html    .replace(/<title>[^<]*<\/title>/, `<title>${title}</title>`)    .replace(/(<meta\s+name="description"\s+content=")[^"]*(")/s, `$1${description}$2`)    .replace(/(<link rel="canonical" href=")[^"]*(")/, `$1${url}$2`)    // ... og:*, twitter:*, hreflang, JSON-LD}

А дальше nginx на каждом server-блоке отдаёт свой файл вместо общего index.html:

# deploy/nginx/https.conf — блок dostavka.qoyhunter.comindex index-dostavka.html;location / { try_files $uri $uri/ /index-dostavka.html; }location = /index-dostavka.html { add_header Cache-Control "no-cache"; }

Файл с бандлом на диске один — просто у краулера, зашедшего на разные хосты, разный вход в SPA. Здесь же всплыл смежный баг: язык интерфейса изначально хранился в localStorage, а localStorage не расшаривается между поддоменами браузером — переход с лендинга на витрину или в доставку каждый раз сбрасывал выбранный узбекский обратно на язык браузера. Почему? localStorage привязан к origin (схема+хост+порт), а у bazar.qoyhunter.com и dostavka.qoyhunter.com origin разный, хотя домен вроде бы «один и тот же». Чинится куки на корневом домене:

// frontend/src/i18n/index.tsconst cookieDomain = typeof window === 'undefined' ? undefined : rootHost(window.location.hostname)detection: {  order: ['querystring', 'cookie', 'localStorage', 'navigator'],  lookupCookie: 'qoy_lang',  cookieDomain,  cookieMinutes: 60 * 24 * 365,}

rootHost срезает известные поддомены сервисов (www., bazar., dostavka., blog. и другие), и cookie с Domain=qoyhunter.com видна на всех них сразу — в отличие от localStorage, который живёт per-origin.

Мультидоменная архитектура на одном бандле экономит на дублировании сборки. Расплата: всё, что браузер традиционно считает «per-origin» (localStorage, часть cookie без явного Domain), приходится руками поднимать на уровень корневого домена.

Один Vite-бандл на все хосты: постпроцесс штампует index-*.html, nginx выбирает файл по server_name

Один Vite-бандл на все хосты: постпроцесс штампует index-*.html, nginx выбирает файл по server_name

5. SSR блога бэкендом поверх статики фронта

У лендинга, витрины и доставки контент известен на момент сборки — поэтому SEO-страницы можно пререндерить постпроцессом, как в предыдущей истории. У блога контент появляется уже после того, как фронт собран и задеплоен: редактор публикует новый пост через админку, и было бы странно пересобирать весь SPA-бандл ради каждой статьи.

Решение — рендерить страницы блога не постпроцессом сборки, а бэкендом на лету, при этом переиспользуя тот же HTML-шаблон, что и у SPA (со всеми хешированными путями к JS/CSS бандлам после Vite build). Шаблон читается из файла на диске:

// backend/src/QoyHunter.Api/Blog/BlogRenderService.csprivate string? Template(){    if (_templateCache is not null) return _templateCache;    var path = config["Blog:TemplatePath"];    if (string.IsNullOrEmpty(path) || !File.Exists(path))    {        logger.LogWarning("Blog: шаблон не найден по пути {Path} — серверный рендер отключён", path);        return null;    }    _templateCache = File.ReadAllText(path);    return _templateCache;}

А путь до этого файла в проде — не файл, скопированный в образ API при сборке (у бэкенда в Docker-контексте нет каталога с фронтовыми исходниками), а смонтированный docker-том, тот же самый ./web, который раздаёт nginx:

# deploy/docker-compose.yml, сервис apiBlog__TemplatePath: "/app/webroot/index-blog.html"volumes:  - ./web:/app/webroot:ro

То есть контейнер бэкенда читает как read-only тот же артефакт фронтовой сборки, что и веб-сервер, и на его основе собирает готовый HTML: заголовок, описание, canonical, hreflang и JSON-LD Article — в <head>, а сам пост (Markdown → HTML через Markdig, с DisableHtml(), чтобы автор поста не мог вставить <script>) — прямо в <div id="root">:

html = html.Replace("<div id=\"root\"></div>", $"<div id=\"root\">{rootContent}</div>");

Дальше nginx проксирует «страничные» пути блога (/, /p/{slug}, языковые префиксы) на этот эндпоинт бэкенда, а при любой ошибке — 404, 500 или API временно недоступен — откатывается на обычный SPA-фоллбек через error_page ... = @spa:

location ~ ^/p/(?<slug>[a-z0-9-]+)$ {    proxy_pass http://api:5080/api/blog/render/post/$slug?lang=ru;    proxy_intercept_errors on;    error_page 404 500 502 503 = @spa;}

Краулер в результате получает честный серверный HTML со своим <title> и JSON-LD Article на каждый пост (что критично для индексации и сниппетов), а живой пользователь после первой отрисовки получает тот же JS-бандл, который дальше подхватывает комментарии и реакции как обычное SPA. Гибрид «SSR только там, где решает SEO, SPA — во всём остальном» вместо полноценного Next.js-подобного фреймворка на React ради одного раздела сайта.

6. Тихие баги нормализации: часовые пояса и региональные локали

Две истории из одной категории — обе о том, что бэкенд и браузер расходятся в базовых допущениях, если их не свести явно.

Часовой пояс. Сервер хранит и отдаёт всё в UTC — индустриальный стандарт, ничего необычного. Но там, где важна граница календарных суток (по какому дню группировать взвешивания, на какой день ставить напоминание «сегодня»), UTC — плохой выбор: полночь по UTC не совпадает с полночью в Ташкенте, и запись, сделанная в 23:30 по местному времени, «уезжает» на следующий день при наивном приведении. Поэтому я завел единый статический класс-помощник, который резолвит пояс один раз при старте:

// backend/src/QoyHunter.Api/Configuration/AppClock.cspublic const string DefaultTimeZoneId = "Asia/Tashkent";public static readonly TimeSpan DefaultOffset = TimeSpan.FromHours(5);public static TimeZoneInfo Resolve(string? timeZoneId){    var id = string.IsNullOrWhiteSpace(timeZoneId) ? DefaultTimeZoneId : timeZoneId.Trim();    try { return TimeZoneInfo.FindSystemTimeZoneById(id); }    catch (TimeZoneNotFoundException) { }    catch (InvalidTimeZoneException) { }    // Фолбэк на фиксированное +5: Ташкент не переходит на летнее время,    // поэтому фолбэк совпадает с реальным поясом по смещению на любой ОС.    return TimeZoneInfo.CreateCustomTimeZone("Asia/Tashkent (+05)", DefaultOffset, "Asia/Tashkent", "Asia/Tashkent");}public static DateOnly Today() => LocalDate(DateTimeOffset.UtcNow);

Фолбэк на «жёсткое +5» здесь осознанный: узбекский часовой пояс не знает перехода на летнее время, поэтому даже если на сервере урезанная tz-база (бывает в минимальных Linux-образах) и Asia/Tashkent не резолвится по имени, фиксированное смещение остаётся корректным всегда, а не только «пока не наступит переход на летнее время». Это единственное место в бэкенде, где день считается «сегодня» — для напоминаний, дефолтных дат операций, выгрузок CSV и бота. А вот в UI то же самое время намеренно показывается в часовом поясе браузера пользователя, а не Ташкента — потому что часть пользователей открывает CRM из соседних стран с другим смещением, и это уже пояс самого человека, а не фермы.

Региональная локаль. Отдельный от часовых поясов, но похожий по природе баг: у части пользователей браузер определяет язык интерфейса не как ru, а как ru-RU (или uz-Latn) — с региональным субтегом. Мы передавали этот код напрямую в Intl.NumberFormat/toLocaleDateString, и для части экзотических комбинаций (или урезанных ICU-данных окружения) форматирование съезжало в root-фоллбек: даты вида «2026 M07 02» вместо «02 июл. 2026», числа без разделителей разрядов. Фикс — нормализовать язык до базового подтега перед тем, как отдавать его в Intl: (Тут решение неидеальное, но я решил пойти в лоб, а так реализовать можно по разному)

// frontend/src/lib/format.tsexport function baseLang(lang: string | undefined | null): 'ru' | 'uz' | 'en' {  const base = (lang ?? '').toLowerCase().split('-')[0]  return base === 'ru' ? 'ru' : base === 'en' ? 'en' : 'uz'}export function localeOf(lang: string): string {  const base = baseLang(lang)  return base === 'ru' ? 'ru-RU' : base === 'en' ? 'en-US' : 'uz-UZ'}

И то же самое — уже на входе, в детекторе языка i18next, чтобы в куку qoy_lang не осело значение с регионом:

// frontend/src/i18n/index.tsdetection: {  convertDetectedLanguage: (lng) => lng.toLowerCase().split('-')[0] ?? lng,}

Оба бага объединяет одно: браузер и ОС дают «сырые» значения (locale с регионом, время в своём поясе), и слой форматирования обязан явно нормализовать их, а не полагаться, что Intl и DateTimeOffset сами разберутся.

7. Один <path> без трансформов — потому что так проще парсить программно

Короткая история о том, как декоративная задача (наложить фирменный силуэт барана в центр QR-кода на бирке животного) уткнулась в неожиданное техническое ограничение.

QR-коды на бирках печатаются с уровнем коррекции ошибок H (30% избыточности), поэтому в центр можно спокойно врисовать небольшой брендовый бейдж — сканер всё равно считает код. Композиция делается на бэкенде через SkiaSharp (та же нативная зависимость, что уже тянется транзитивно через QuestPDF и ScottPlot, — версию SkiaSharp в .csproj зафиксировали явно именно поэтому, чтобы не «плавала»):

// backend/src/QoyHunter.Api/Animals/QrLogoOverlay.csusing var path = SKPath.ParseSvgPathData(LogoPathData);if (path is not null){    var inner = badge * 0.72f;    var scale = inner / LogoViewBox;    canvas.Translate(cx - inner / 2f, cy - inner / 2f);    canvas.Scale(scale);    canvas.DrawPath(path, markPaint);}

Загвоздка в SKPath.ParseSvgPathData: он парсит ровно один атрибут d одного <path> — не файл SVG целиком. А исходный логотип, отрисованный в векторном редакторе, — это набор из нескольких <path> в группах со своими transform="translate(...)" на каждый, как обычно и выглядит экспорт из Illustrator/Figma (Или из png2svg сайтов).

Чтобы получить одну строку d, которую можно скормить парсеру и потом отмасштабировать вручную по известному viewBox, силуэт пришлось свести к единственному пути без трансформов: координаты «впечатать» прямо в узлы, а не оставлять в атрибуте группы.

/// <summary>/// Единый путь силуэта барана (атрибут d из logo_without_text_one_path_dark.svg, viewBox 0 0 512 512)./// Встроен константой — детерминированно, без файлового IO/парсинга XML при рендере./// </summary>private const string LogoPathData =    "M126 88c-44 7-83 40-89 86 0 4-2 39 1 41 2 2 1-4 1-6l1-14c2-15 8-29 18-41...";private const float LogoViewBox = 512f;

Путь зашит константой в код, чтобы наложение логотипа не зависело от файлового I/O и XML-парсинга на горячем пути генерации бирки, и чтобы сборка не тянула лишний ассет в рантайм-образ.

Поверх — оборонительное программирование в духе «оформление не должно ронять функциональность»: любая ошибка при декодировании и наложении гасится, наружу уходит исходный QR без логотипа. Бирка обязана быть читаемой сканером в любом случае, бренд-бейдж — приятный бонус.

Вывод: если результат векторного редактора собираетесь скармливать не браузеру (который честно раскроет любые группы и трансформы), а минималистичному программному парсеру пути — закладывайтесь на то, что придётся руками сплющивать иллюстрацию в один плоский <path> с абсолютными координатами.

Результат, как это выглядит внешне можно увидеть на qoyhunter.com

В будущем планирую описать процесс подключения платежных систем, там тоже было немало подводных камней.

Если тема близка — про фермерскую сторону этой истории мы понемногу пишем в блоге.

ссылка на оригинал статьи https://habr.com/ru/articles/1057988/