Овцеводческая CRM, Узбекистан и .NET 10: семь инженерных историй с граблями. .NET.. .NET. .net 10.. .NET. .net 10. agritech.. .NET. .net 10. agritech. ASP.. .NET. .net 10. agritech. ASP. asp.net core.. .NET. .net 10. agritech. ASP. asp.net core. nginx.. .NET. .net 10. agritech. ASP. asp.net core. nginx. oauth2.. .NET. .net 10. agritech. ASP. asp.net core. nginx. oauth2. oidc.. .NET. .net 10. agritech. ASP. asp.net core. nginx. oauth2. oidc. PostgreSQL.. .NET. .net 10. agritech. ASP. asp.net core. nginx. oauth2. oidc. PostgreSQL. react.. .NET. .net 10. agritech. ASP. asp.net core. nginx. oauth2. oidc. PostgreSQL. react. ReactJS.. .NET. .net 10. agritech. ASP. asp.net core. nginx. oauth2. oidc. PostgreSQL. react. ReactJS. ssr.. .NET. .net 10. agritech. ASP. asp.net core. nginx. oauth2. oidc. PostgreSQL. react. ReactJS. ssr. telegram.. .NET. .net 10. agritech. ASP. asp.net core. nginx. oauth2. oidc. PostgreSQL. react. ReactJS. ssr. telegram. TypeScript.

Мы делаем 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.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 — тот же движок, что в проде, — но без системного сервиса, который висит в фоне и о котором через месяц забываешь, потребляя память и держа порт занятым.

Взяли 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 от прошлого аварийного запуска и убиваем его — но только если исполняемый файл процесса лежит именно в нашей распакованной директории инстанса (иначе легко зацепить чужой системный процесс с переиспользованным 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, с 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 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'
}

Один бандл — меньше дублирования сборки и деплоя. Да и по собственному опыту агенты лучше работают с монолитами, и они удобнее для разработки в одиночку, всякие (уже “немодные”) микросервисы советую использовать только если вам прям нужны микросервисы. Но у него есть врождённая проблема с 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 и dostavka.qoyhunter.com 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 видна на всех них сразу — в отличие от 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}, языковые префиксы) на этот эндпоинт бэкенда, а при любой ошибке — 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.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

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

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

Автор: AlexMorOR

Источник