共享代码¶
生态中的各平台外表不同、内里如一。在用户看来,每个平台都有自己的品牌、域名 和服务——但九成以上的代码其实来自同一个源头。
本页说明这些共享代码是如何分发的。
为什么会需要它¶
最初,每个平台都是从模板复制出来的。结果是:一处修复必须在 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、钱包就都得 背上它们,而且每新增一种语言,这份成本就翻七倍。
各仓库的实现方式如出一辙:
若组件是把 T 当作 prop 向下传递给子组件,那么拆成两个函数(T + wt)
就意味着每个 prop 都得跟着拆。这种情况下只写一个解析器:键属于平台就取自自己的
词典,否则取自包(ring-dgov 的 lib/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.mod 或 package.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 是该结构的运行条件,而非可选项。