跳转至

共享代码

生态中的各平台外表不同、内里如一。在用户看来,每个平台都有自己的品牌、域名 和服务——但九成以上的代码其实来自同一个源头。

本页说明这些共享代码是如何分发的。

为什么会需要它

最初,每个平台都是从模板复制出来的。结果是:一处修复必须在 8 个仓库里手工 重复;漏掉一个,那个平台就会悄无声息地落后。

实测表明,前端约 95% 确实是共享的;真正的差异只有约 40 行携带品牌名称的代码。 换句话说,重复并非技术上的必需,而是复制留下的遗产

三种机制

共享代码按层次以不同方式传播。三者都不是「复制粘贴」——全部带版本、可回退。

层次 形式 机制
后端内核 Go 模块 go.mod 依赖
前端层 npm 包 package.json 依赖
平台骨架 git 历史 git merge + 每日 autosync

1. 后端内核 —— 一个 Go 模块

认证、基于角色的访问控制(RBAC)、API 网关、审计、AI 流水线、eID/SSO 集成——全部 位于同一个 Go 模块中。平台的 main.go 通常只有 30 行左右:启动内核,然后加上 该平台特有的路由。

内核现在只有一个直接层

  • open-gerege-core —— 政务线与 Gerege 线的所有后端都直接使用的开放基座。

在 2026-08-02 之前,中间曾有 private-gerege-core。由于它不包含额外逻辑或 迁移,现已从依赖链中移除并归档。商业应用逻辑保留在各产品仓库中。

2. 前端层 —— @gerege/ui-core

内核在后端解决的问题,在前端被再解决一次。该包包含:

  • lib/** —— API 客户端、BFF 辅助函数、i18n 词典、主题、会话;
  • components/** —— 外壳、管理端、用户区、eID、网关;
  • api/** —— 158 条 BFF 路由的逻辑。

该包以 TypeScript 源码形式发布(不预先构建),由使用方应用通过 Next.js 的 transpilePackages 自行编译。分发采用开放的 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] 通配路由,但那会摧毁一份安全允许 清单:路由清单界定了浏览器究竟能触达后端的哪些路径。这层壳是有意付出的代价。

3. 平台骨架 —— git 继承

不属于包的部分(页面结构、globals.css、部署配置)通过 git merge 从模板继承。 每日一次的 autosync 会拉取上游模板的变更,并在应用仓库中开启 pull request—— 每一处进入生产的改动都经过人工审核。

必须归各平台自有的文件(品牌、部署、CI、文档)由 .gitattributes 中的 merge=ours 保护。

merge=ours 无法防范单方面变更

该驱动只解决冲突。若上游模板删除了某个文件,合并会跟随删除——驱动 根本不会被调用。真正的保护是让每个品牌/配置文件在两端内容不同

哪些仍归平台所有

在包/内核中 归平台所有
lib/**components/**、BFF 逻辑 brand.config.ts —— 名称、域名、配色、文档地址
认证、RBAC、网关、审计 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 的业务流程术语——都放在应用自己的 词典里。

原因是成本:若把 Ring 的 1,104 条术语放进共享词典,kiosk、POS、钱包就都得 背上它们,而且每新增一种语言,这份成本就翻七倍

各仓库的实现方式如出一辙:

// lib/walletI18n.ts —— 钱包的 15 条术语 × 4 种语言
export function useWalletT() {  }   // 未翻译的语言回退到英文

若组件是把 T 当作 prop 向下传递给子组件,那么拆成两个函数(T + wt) 就意味着每个 prop 都得跟着拆。这种情况下只写一个解析器:键属于平台就取自自己的 词典,否则取自包(ring-dgovlib/lang.ts)。

菜单——结构共享,服务归平台

AppShell 在所有平台上的组织方式相同(超级管理员 · 管理员 · 经理 · 公民), 但每个平台只实现其中的一个子集:钱包没有 gateway、relay 和登记模块。

配置 用途
navRoutes 应用确实提供的路由;菜单据此过滤
navSystemLabels rail 上的系统名称(me → 「钱包」)
navExtra 仅该平台才有的菜单(Ring 的 21 条 BPM 菜单)

navExtra 来自 CLIENT 组件

UiCoreProvider 是由服务端 root layout 调用的 client 组件。菜单图标 (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 端点悄无声息地消失
包的类名 ← 应用的 CSS check-styles 界面悄无声息地失去样式
  • check-brand —— 若平台名称出现在 brand.config.ts 之外的代码中,构建即 失败。该平台自己的名称是brand.config.ts 读取的,因此这份清单不会因 手工维护而过时。

    闸门抓到过什么

    登录页曾提示使用「Gerege SSO (sso.gerege.mn)」登录,而政务线的平台实际 上跳转到 sso.dgov.mn。现在主机名改为从后端的 SSO_ISSUER 读取。

  • check-routes —— 要求包中的每条路由在应用侧都有对应的壳。否则包新增的 端点会在该平台上悄无声息地消失(逻辑不在应用内可见,看上去一切正常)。

    闸门无法证明 EXCLUDE 是对的

    若某条路由是有意不开放的,就写进 EXCLUDE——差异因此变得显式。但 写错的条目闸门抓不到。曾有一个平台就这样把 public/languages 排除掉,导致语言切换器变成空的:所有闸门全绿,界面却坏了。

  • check-styles —— 包中不含 CSS:样式位于各仓库的 globals.css。当包 引入新的类名,或仓库的 CSS 陈旧时,组件就会悄无声息地失去样式——按钮只剩 浏览器默认的灰色装饰,表格没有边框。这道闸门会把包里的 className 与仓库的 CSS 相互比对。

版本管理

三种机制均遵循 semver。新版本发布后,Dependabot 会在使用方仓库中开启 pull request;更新本身只是 go.modpackage.json 中的一行改动。

抬升模板还不够

既然每个平台都从模板继承,人们容易以为「把模板抬升一下就会扩散到所有平台」。 实际上两类依赖的行为并不相同:

文件 merge=ours 会从模板扩散吗
backend/go.mod ❌ 永不
frontend/package.json ✅ 会

go.mod 的保护是一项结构性要求module 行在每个仓库中都不同 (…/gerege-app-mn/backend…/wallet-gerege-mn/backend),因此每次合并都会 在第一行冲突。所以在模板里抬升后端内核版本什么也扩散不了——每个仓库都需要 各自的 pull request。

继承树还是三级的public template → private template → 应用),因此即便是 能够扩散的文件,也要经过几个 autosync 周期才能抵达叶子节点。

破坏性变更会让依赖的 pull request 卡住

把词典从四种语言扩到七种,凡是写着 Record<Lang, …> 的地方全都断了。结果 每个 Dependabot 的 pull request 都卡在 tsc 上,没人合并,下一个又堆在上面 ——整个机群从 v0.4.0 一直散到 v0.10.2

因此在包中做破坏性变更时,要 (a) 把迁移指引写进 release notes, (b) 同时在使用方仓库中发布对应的修复。自动更新在破坏性变更面前需要人工 步骤。

落后是无声的

若依赖的 pull request 堆积,各平台就会散落在不同版本上,「一处修复惠及所有人」 的承诺随之破裂。及时合并这些 pull request 是该结构的运行条件,而非可选项。

相关