Перейти к содержанию

Общий код

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

Эта страница объясняет, как этот общий код распространяется.

Почему это понадобилось

Вначале каждая платформа копировалась из шаблона. В результате одно исправление приходилось повторять вручную в 8 репозиториях: пропустишь один — и эта платформа молча отстаёт.

Измерение показало, что ~95% фронтенда действительно общие; реальное расхождение составляло около 40 строк с названием бренда. То есть дублирование было не техническим требованием, а наследием копирования.

Три механизма

Общий код распространяется по-разному в зависимости от слоя. Ни один из способов не является «копипастом» — все версионируются и обратимы.

Слой Форма Механизм
Ядро backend модуль Go зависимость в go.mod
Слой frontend пакет npm зависимость в package.json
Каркас платформы история git git merge + ежедневный autosync

1. Ядро backend — модуль Go

Аутентификация, ролевой контроль доступа (RBAC), API gateway, аудит, AI-конвейер, интеграция eID/SSO — всё это находится в одном модуле Go. Файл main.go платформы обычно занимает около 30 строк: запустить ядро и добавить маршруты, специфичные для этой платформы.

Ядро имеет один прямой слой:

  • open-gerege-core — открытая основа, которую напрямую используют все backend-платформы государственной линии и Gerege.

До 2026-08-02 между ними находился private-gerege-core. Он не содержал дополнительной логики или миграций, поэтому был удалён из цепочки и архивирован. Коммерческая логика приложений остаётся в репозитории каждого продукта.

2. Слой frontend — @gerege/ui-core

Та же задача, которую ядро решило на backend, решена заново на frontend. Пакет содержит:

  • lib/** — клиент API, помощники BFF, словарь i18n, тема, сессия,
  • components/** — оболочка, админка, личный кабинет, eID, gateway,
  • api/** — логика 158 маршрутов BFF.

Пакет поставляется исходным кодом TypeScript (без сборки), поэтому приложение компилирует его через transpilePackages в Next.js. Распространение — открытый HTTPS-tarball: аутентификация не нужна, работает и внутри сборки Docker.

Почему у маршрутов BFF остаётся обёртка

Next.js регистрирует маршруты через файловую систему, поэтому приложение хранит однострочный реэкспорт на каждый путь:

// src/app/api/org/[id]/route.ts
export { GET, PUT, DELETE } from '@gerege/ui-core/api/org/[id]';
export const dynamic = 'force-dynamic';

Все 158 файлов можно было бы свернуть в один [...path], но это уничтожило бы список разрешений безопасности: перечень маршрутов определяет, какие пути backend вообще доступны браузеру. Обёртка — осознанная цена.

3. Каркас платформы — наследование git

То, что не входит в пакет (структура страниц, globals.css, конфигурация развёртывания), наследуется от шаблона через git merge. Ежедневный autosync забирает изменения из вышестоящего шаблона и открывает pull request в репозиториях приложений — каждое изменение, доходящее до production, проходит проверку человеком.

Файлы, которые должны оставаться собственными для платформы (бренд, развёртывание, CI, документация), защищены директивой merge=ours в .gitattributes.

merge=ours не защищает от односторонних изменений

Этот драйвер разрешает только конфликты. Если вышестоящий шаблон удалит файл, слияние последует за ним — драйвер не будет вызван. Настоящая защита в том, чтобы каждый файл бренда/конфигурации имел разное содержимое с обеих сторон.

Что остаётся собственностью платформы

В пакете / ядре Принадлежит платформе
lib/**, components/**, логика BFF brand.config.ts — имя, домен, цвета, адрес документации
Аутентификация, RBAC, gateway, аудит components/landing/** — маркетинговый текст
Интеграция eID / SSO app/**/page.tsx — регистрация маршрутов (тонкие обёртки)
Общий словарь i18n (846 ключей × 7 языков) lib/<platform>I18n.tsплатформенная терминология
Структура меню (AppShell) nav.config.ts — какие разделы платформа обслуживает
app/globals.css — токены фирменных цветов
deploy/**, .github/** — развёртывание, CI

Почему платформенная терминология остаётся в приложении

Правило: общий словарь знает только общую поверхность. Слова, относящиеся лишь к одной платформе — лексика выписок и IBAN в кошельке, каталог API портала разработчика, терминология бизнес-процессов Ring — лежат в собственном словаре приложения.

Причина — стоимость: если поместить 1,104 термина Ring в общий словарь, их понесут и kiosk, и POS, и кошелёк, причём с каждым добавленным языком эта стоимость вырастает семикратно.

Реализация выглядит одинаково во всех репозиториях:

// lib/walletI18n.ts — 15 терминов кошелька × 4 языка
export function useWalletT() {  }   // откат на английский для непереведённых языков

Если компонент передаёт T дочерним компонентам пропом, то разделение на две функции (T + wt) заставит разделить и каждый проп. В таком случае пишется один резолвер: если ключ платформенный — берётся из собственного словаря, иначе из пакета (lib/lang.ts в ring-dgov).

Меню — структура общая, набор услуг платформенный

AppShell устроен одинаково на всех платформах (Суперадмин · Админ · Менеджер · Гражданин), но каждая платформа реализует лишь его подмножество: у кошелька нет модулей gateway, relay и реестров.

Настройка Назначение
navRoutes Маршруты, которые приложение действительно обслуживает; по ним фильтруется меню
navSystemLabels Названия систем на rail (me → «Кошелёк»)
navExtra Пункты меню, существующие только на этой платформе (21 пункт BPM в Ring)

navExtra приходит из CLIENT-компонента

UiCoreProvider — это client-компонент, вызываемый из серверного root layout. Иконки меню (React-компоненты) и функции названий не пересекают границу server→client. Поэтому приложение создаёт тонкую client-обёртку и передаёт их изнутри неё:

// src/nav.config.tsx
'use client';
export default function AppNav({ children }) {
  return <UiCoreProvider navExtra={NAV_EXTRA}>{children}</UiCoreProvider>;
}

Если пункт меню, который обслуживают лишь немногие платформы, помечен в пакете как optIn: true, он появится только на тех платформах, которые явно перечислили его в navRoutes.

Три автоматических барьера

Общий код порождает три разных вида зависимости. При поломке каждый проявляется по-своему, поэтому и барьеров три:

Зависимость Барьер Что происходит при поломке
Код пакета ← код приложения tsc Компиляция падает — видно сразу
Маршрут пакета ← BFF-обёртка приложения check-routes Endpoint молча исчезает
Класс пакета ← CSS приложения check-styles Экран молча теряет оформление
  • check-brand — сборка падает, если название платформы встречается в коде вне brand.config.ts. Название своей платформы читается из brand.config.ts, поэтому список не устаревает вручную.

    Что поймал барьер

    Страница входа предлагала входить через «Gerege SSO (sso.gerege.mn)», хотя платформы государственной линии фактически перенаправляли на sso.dgov.mn. Теперь хост читается из SSO_ISSUER на backend.

  • check-routes — требует обёртку в приложении для каждого маршрута пакета. Без неё новый endpoint пакета молча исчезнет на этой платформе (логика не видна внутри приложения, поэтому визуально всё выглядит целым).

    Барьер не доказывает правильность EXCLUDE

    Если маршрут намеренно не открывается, его вносят в EXCLUDE — расхождение становится явным. Но ошибочную запись барьер не поймает. Именно так на одной платформе оказался закрыт public/languages, из-за чего переключатель языков стал пустым: все барьеры зелёные, а экран сломан.

  • check-styles — пакет не содержит CSS: оформление лежит в globals.css каждого репозитория. Когда пакет вводит новое имя класса или CSS репозитория устаревает, компонент молча теряет оформление — кнопка остаётся с серой отделкой браузера по умолчанию, а таблица без рамок. Этот барьер сверяет className пакета с CSS репозитория.

Версионирование

Все три механизма следуют semver. При выпуске новой версии Dependabot открывает pull request в репозиториях-потребителях; само обновление — однострочное изменение в go.mod или package.json.

Поднять версию в шаблоне недостаточно

Раз каждая платформа наследует от шаблона, кажется, будто «подними шаблон — и разойдётся ко всем». На деле два вида зависимостей работают по-разному:

Файл merge=ours? Расходится из шаблона?
backend/go.mod да ❌ никогда
frontend/package.json нет ✅ да

Защита go.modструктурное требование: строка module в каждом репозитории своя (…/gerege-app-mn/backend против …/wallet-gerege-mn/backend), поэтому любое слияние конфликтовало бы на самой первой строке. Значит, поднятие версии ядра backend в шаблоне не разносит ничего — в каждый репозиторий нужен свой pull request.

Дерево наследования к тому же трёхуровневое (public template → private template → приложение), так что даже способный расходиться файл добирается до листьев за несколько циклов autosync.

Ломающее изменение подвешивает pull request'ы по зависимостям

Расширение словаря с четырёх языков до семи сломало каждое место, где было написано Record<Lang, …>. В результате каждый pull request Dependabot падал на tsc, никто их не сливал, а следующий накладывался поверх — и флот разошёлся с v0.4.0 до v0.10.2.

Поэтому, внося в пакет ломающее изменение: (а) опишите порядок миграции в release notes, (б) выпустите исправление в репозиториях-потребителях одновременно. Автоматическое обновление требует ручного шага при ломающих изменениях.

Отставание происходит молча

Если pull request'ы по зависимостям накапливаются, платформы расходятся по разным версиям и обещание «одно исправление доходит до всех» нарушается. Своевременное закрытие этих pull request'ов — условие работы этой структуры, а не приятное дополнение.

Связанное