- BrainTools - https://www.braintools.ru -

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

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

А реальность такая: основной канал пользователей — Telegram, а не браузер; основной язык — не всегда русский (узбекский обязателен, английский — по явному выбору); часовой пояс один (Ташкент, UTC+5, без перехода на летнее время — тоже давало баги, но об этом ниже); а разработка идёт на Windows-ноутбуках без сервера БД под рукой. Из этих фактов вытекает добрая половина решений ниже. Стек: бэкенд на ASP.NET [2] 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.cs
private 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 — тот же движок, что в проде, — но без системного сервиса, который висит в фоне и о котором через месяц забываешь, потребляя память [3] и держа порт занятым.

Взяли 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.cs
private 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 [4] от прошлого аварийного запуска и убиваем его — но только если исполняемый файл процесса лежит именно в нашей распакованной директории инстанса (иначе легко зацепить чужой системный процесс с переиспользованным 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.LocalDbQoyHunter.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 [5], с 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.cs
public 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 [2] 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.ts
export 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'
}

Один бандл — меньше дублирования сборки и деплоя. Да и по собственному опыту [6] агенты лучше работают с монолитами, и они удобнее для разработки в одиночку, всякие (уже “немодные”) микросервисы советую использовать только если вам прям нужны микросервисы. Но у него есть врождённая проблема с 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.mjs
const 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(/(<metas+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.com
index 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 [7] и dostavka.qoyhunter.com [8] origin разный, хотя домен вроде бы «один и тот же». Чинится куки на корневом домене:

// frontend/src/i18n/index.ts
const 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 [9] видна на всех них сразу — в отличие от 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.cs
private 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, сервис api
Blog__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}, языковые префиксы) на этот эндпоинт бэкенда, а при любой ошибке [10] — 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-бандл, который дальше подхватывает комментарии и реакции [11] как обычное SPA. Гибрид «SSR только там, где решает SEO, SPA — во всём остальном» вместо полноценного Next.js-подобного фреймворка на React ради одного раздела сайта.

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

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

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

// backend/src/QoyHunter.Api/Configuration/AppClock.cs
public 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.ts
export 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.ts
detection: {
  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.cs
using 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 [12]

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

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

Автор: AlexMorOR

Источник [14]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/32942

URLs in this post:

[1] зрения: http://www.braintools.ru/article/6238

[2] ASP.NET: http://ASP.NET

[3] память: http://www.braintools.ru/article/4140

[4] postmaster.pid: http://postmaster.pid

[5] oauth.telegram.org: http://oauth.telegram.org

[6] опыту: http://www.braintools.ru/article/6952

[7] bazar.qoyhunter.com: http://bazar.qoyhunter.com

[8] dostavka.qoyhunter.com: http://dostavka.qoyhunter.com

[9] qoyhunter.com: http://qoyhunter.com

[10] ошибке: http://www.braintools.ru/article/4192

[11] реакции: http://www.braintools.ru/article/1549

[12] qoyhunter.com: https://qoyhunter.com

[13] блоге: https://blog.qoyhunter.com

[14] Источник: https://habr.com/ru/articles/1057988/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1057988

www.BrainTools.ru

Rambler's Top100