justsolutionsWebV2 / 架构设计

多地区 · 多语言架构设计

支持多个国家/地区站点,每地区提供「当地语言 + 英文」,通过路径前缀访问;静态前端按地区隔离BFF 层保持共享

路由 /{region}/{lang} 站点 Express + 静态托管 现有 geoip · translations.js · /api/* 状态 Q1–5 已定(Q1·Q3 2026-07-27 修订)· Q6/7 待定
§00

结论 · 对提案的评价

提案的核心方向正确且符合业界标准做法(路径前缀隔离地区 + 共享 BFF):

  • 地区用路径前缀/jp/hk)而非 IP/Cookie 隐式切换 —— 对 SEO、CDN 缓存、可分享链接都友好。
  • 静态层按地区硬隔离 —— 契合「不同地区不同排版、不同页数」的现实。
  • BFF 层共享 —— 各地区业务本质一致,靠「地区上下文」做数据差异化即可。

需在提案基础上补全一个维度:地区(region)与语言(lang)是两个正交维度,必须显式拆开——

region地区 · 目录级硬隔离

jp / hk / … 各自拥有独立的页面集合与排版。排版、页面数量、甚至页面种类都可能不同。

→ 各地区一个自包含目录
lang语言 · 地区内软切换

当地语 + 英文。同一地区两种语言通常共用排版,仅文案不同,用 i18n 字典切换。

→ 地区目录内换字典,不复制 HTML

一句话:地区决定「有哪些页、长什么样」,语言只决定「同一张页上显示什么文字」。

§01

现状 As-Is

方面现状问题
静态页public/ 扁平,所有地区共用同一批 HTML无法按地区差异化排版/页数
语言public/js/translations.js 单一全局字典 window.T = {en, tc, jp},运行时切字符串三语混在一个 2185 行文件,地区间无法独立演进
地区打补丁:/plans-jp 别名 + 日本 IP 从 /plans 302 跳转每加一个地区就要加一堆特判,不可扩展
geoipserver/geoip.js 已能 IP→国家→默认语种,并注入 __APP_CONFIG__.geo可直接复用为「裸域落地重定向」依据
BFFserver/routes/* + services/* 统一挂 /api/*,走 mock 或代理上游方向正确,仅需引入「地区上下文」
配置注入/config.js 动态生成 window.__APP_CONFIG__需补充 region / lang 字段
§02

URL 与路由方案

URL 结构:/{region}/{lang}/{page}

/jp302 → /jp/ja(地区裸路径 → 该地区默认语言)
/jp/ja/plans 日文 · Plans 页
/jp/en/plans 英文 · Plans 页
/hk302 → /hk/zh
/hk/zh/plans香港 繁中 · Plans 页
/hk/en/plans香港 英文 · Plans 页
/global/en/plans其他國家 英文 · Plans 页(营销版,无在线订阅)
/302 → geoip 决定的 /{region}/{lang}(认不出 → 兜底 /global/en
语言代码采用 BCP 47(已定 · Q2):ja / zh-HK / en。旧 jp/tc 迁移期做别名映射即可不断链。

路由规则表

请求处理说明
/302 → geoip 命中的 /{region}/{lang}无法识别 → 回落 /global/en(Q1 · 2026-07-27 修订)
/{region}302 → /{region}/{defaultLang}/jp/jp/ja
/{region}/{lang}/…public/{region}/ 提供静态内容命中具体页面
/{region}/{lang}/plans该地区在线订阅 → 内部改写成 plans-plus没有 → 营销页 plans依据 region.subscription(非 geoip 国家);地址栏不变
/_shared/…提供跨地区共享资源css / js / img 公共部分
/api/…共享 BFF(带地区上下文)见 §06
/config.js动态注入运行时配置no-store,见 §08
*.html301 → 去 .html 干净 URL沿用现有规范化逻辑

地区切换:页头 / 页尾的「國家選項」2026-07-27 增补

geoip 只是落地猜测,访客必须能自己改。因此在页头(语言按钮左侧)与页尾各放一个国家选项,与语言切换器同构:选择进 URL,由服务端按路径重新渲染,可缓存、可分享、利于 hreflang。

站点URL语言在线订阅
香港/hk/{zh-HK,en}/繁中 · 英文有(plans-plus,市场 HKG)
/jp/{ja,en}/日文 · 英文有(plans-plus,市场 JPN)
其他國家/global/en/仅英文无 —— Plans 给英文营销页
  • 清单来自注册表/config.js 下发 regionsregionList()),切换器据此渲染;站点名走 i18n 键 region_{code}(缺键回落注册表 name)。新增地区仍只改 regions.js 一处,页面与切换器代码不动。
  • 换站映射:语言沿用当前,目标站不支持则用其默认语言(/jp/ja/x → 香港站取 zh-HK);页面同名平移,目标站没有的页面回落(plans-plusplanspay-return → 首页);不带查询串 / hash —— 换国家是换上下文,另一个站读不懂本站参数。
  • 挂载方式:由 _shared/js/main.js 在地区页运行时注入到 .nav-innerfooter .footer-inner,不逐页改 HTML(plans-plus 这类不再由生成器管理的页面同样自动获得)。
  • 选择不持久化:状态只在 URL 里,裸域访问仍走 geoip。如需「记住上次选的国家」,另加 cookie 层。

为什么「地区在前、语言显式成段」

方案例子评价
A · 两段(推荐)/jp/ja/plans · /hk/en/plans与提案 /jp /hk 一致;语言可缓存、可 hreflang;结构清晰
B · 合并 locale 单段/ja-jp/plans · /en-hk/plans也可行,但地区/语言耦合,与「/jp 作为地区入口」不吻合
C · 仅地区 + Cookie/jp/plans?lang=enSEO 差、CDN 缓存被 Cookie 打碎、链接无法指定语言
§03

目录结构(静态资源隔离)

public/ — 地区自包含,语言用字典而非目录
public/
├── _shared/                 # 跨地区共享(唯一真源)
│   ├── css/                 # 基础样式(reset / 变量 / 公共组件)
│   ├── js/
│   │   ├── i18n.js          # i18n 引擎(读地区字典 + 渲 data-i18n)
│   │   ├── api.js           # BFF 客户端(自动带 region/lang)
│   │   └── main.js          # 公共交互
│   ├── img/                 # 公共图片(logo 等)
│   └── i18n/en.js …         # 跨地区共享文案(nav/footer/cookie)
│
├── jp/                      # ── 日本地区:独立页面 + 排版 ──
│   ├── pages/               # index / plans / … 页数、页种可不同
│   ├── css/  img/           # 地区专属覆盖(可选)
│   └── i18n/
│       ├── ja.js            # 日文文案
│       └── en.js            # 日本站的英文
│
├── hk/                      # ── 香港地区 ──
│   ├── pages/               # 可以多几张页
│   ├── css/  img/
│   └── i18n/
│       ├── zh-HK.js         # 繁中文案
│       └── en.js
│
├── global/                  # ── 其他國家(纯英文,无在线订阅)──
│   ├── pages/               # 不含 plans-plus / pay-return
│   └── i18n/en.js
│
└── (新增地区照抄一个目录即可,零特判)
  • 一个地区 = 一个自包含目录:页面、专属样式、专属图片、i18n 字典都在里面 →「不同排版/页数」天然成立。
  • 共享的抽到 _shared/:i18n 引擎、BFF 客户端、公共样式/图片只维护一份。
  • 语言不建目录、用字典:仅当某地区某语言排版确实要分叉时,才在该地区目录内加语言变体页(局部特例)。
§04

请求处理流程

/config.js
动态注入 window.__APP_CONFIG__(含 region/lang,no-store)
/_shared/*
express.static(public/_shared)
/api/*
共享 BFF(注入 region 上下文)→ mock / 代理上游
/ (裸域)
detectGeo() → 302 /{region}/{lang}
/{region}
302 /{region}/{defaultLang}
/{region}/{lang}/*
region 解析中间件
├ 校验 region 合法、lang ∈ 该地区支持语言 ├ 剥掉 /{region}/{lang} 前缀 ├ express.static(public/{region}/pages) └ 页内加载 _shared/js/i18n.js + {region}/i18n/{lang}.js → 按 data-i18n 渲染

关键:「地区+语言」解析是一个中间件,而非为每地区/每页写特判。新增地区 = 加一个目录 + 注册表加一行。

§05

i18n 方案(地区内语言切换)

沿用现有「运行时按 data-i18n 换字符串」的机制(改造成本低),但把字典按地区拆分

  • 共享文案(导航/页脚/Cookie)→ _shared/i18n/{lang}.js
  • 地区专属文案 → public/{region}/i18n/{lang}.js
  • 页面加载:先加载共享字典,再加载地区字典(地区覆盖共享),由 _shared/js/i18n.js 统一渲染。

好处:各地区文案独立演进,互不影响;单文件体积可控(不再是 2185 行巨无霸);语言切换仍是纯前端行为,无需为每语言生成一套 HTML。

备选:若未来页数暴涨或需更强 SEO,可升级为构建期 / 服务端注入的 i18n(服务端直出已翻译 HTML)。当前规模用运行时方案性价比最高,本设计保留升级空间。
§06

BFF 层:共享 + 地区上下文

BFF(routes/* + services/*保持单一共享,通过「地区上下文」做数据差异化,而非每地区一套后端逻辑。

  • 上下文传递:前端 api.js 每个 /api/* 请求自动带地区标识(推荐请求头 X-Region: hk / X-Lang: en,取自 __APP_CONFIG__);/api/* 前缀保持全局。
  • 服务端读取:轻量中间件把 X-Region 解析进 req.region,交给下游。
  • 按地区变化的数据:国家/币种/合规文案(countries/industries)、Plans/Editions 目录(plans/editions)、上游代理可按地区选不同 API_BASE_URL/租户。
  • 在线支付方式:各地区接入不同网关/支付方式( PayPay·Konbini·JCB / 香港 FPS·AlipayHK·信用卡)—— 作为重点单列 §07
  • 不变的部分:鉴权、代理框架、mock 框架、错误处理 —— 全部复用。

即提案所说「BFF 层都是一样的」——成立。差异只体现在「同一套代码根据 region 返回不同数据」。

§07

在线支付:地区可插拔的支付方式

不同国家/地区接入不同的在线支付方式,这是「地区上下文」的又一维(与币种、Plans 目录同类),不新增架构维度 —— 用同一套共享 BFF + 适配器(Strategy 模式)承载即可。

现状:plans-plus 已有「下单 + 收银台」骨架(/api/subscribe/api/payments/create/pay-gateway → webhook → /pay-return 轮询),但硬编码了单一网关 PayPartner、货币 HK$、locale zh-HK —— 只能服务香港式单一支付。本节把它一般化。

各地区声明自己的币种与支付方式

地区币种可用支付方式(示例,非最终)
JPYPayPay · JCB/信用卡 · Konbini 便利店
香港 HKHKDFPS 转数快 · AlipayHK · 信用卡(Stripe)
其他國家无在线支付 —— 只走线下渠道(试用申请 / 联络我们)
其它按落地地区补充

写进 region 注册表:region → { …, currency, paymentMethods: [...] },与 availableLangs 同理。前端从 /config.jsGET /api/payments/methods 拿到当前地区可用方式,渲染收银台。

BFF 统一门面(网关无关),内部按 (region, method) 选适配器

GET /api/payments/methods当前地区可用支付方式(code / 名称 / 图标 / 币种)
POST /api/payments/create选中适配器 → 归一化 { paymentId, gateway, redirectUrl | clientParams }
POST /api/payments/webhook/:provider各网关各自回调/验签 → 归一化状态 + 回写后端订单
GET /api/payments/:pid归一化状态轮询(pay-return 前端不变)
  • 适配器模式server/services/payments/ 一网关一文件(stripe / alipay-hk / payjp-konbini / fps …),实现统一接口 createPayment · verifyWebhook · getStatus;注册表映射 region → [providers]providerCode → adapter
  • 新增支付方式 = 加一个适配器 + 注册表登记,不改前端、不改下单流程。
  • 归一化支付记录:沿用现有 mock 已定义的形状(paymentId / orderId / amount / currency / status / events 时间线),各适配器把第三方回调翻译成这个形状。
  • 前端去硬编码pay-gateway.js / pay-return.js 里的 HK$ / PayPartner / zh-HK 改为读地区 currency + 方式元数据;收银台按 paymentMethods 渲染。
  • 回调与地区解耦:webhook 是服务端到服务端,路径按 :provider 与地区无关,通过 paymentId 反查地区/订单;用户可见的返回页地区化:/{region}/{lang}/pay-return
  • 线上/线下并存:保留现有 subscribe-offline(联络我们/推荐人),作为暂无在线支付地区的兜底通道。

要点:下单流程(subscribe)与前端收银台保持不变,具体网关全部收敛到适配器背后 —— 加国家、换支付方式都不动主干。

§08

运行时配置注入(/config.js)

在现有 window.__APP_CONFIG__ 基础上补充地区/语言上下文,让前端 JS 无需自己解析 URL:

/config.js 动态生成(示意,非最终实现)
window.__APP_CONFIG__ = {
  appEnv, apiPrefix, apiBaseUrl, mock,       // 现有
  region: "hk",                      // 当前地区(路径解析得出)
  lang: "en",                        // 当前语言
  defaultLang: "zh-HK",               // 该地区默认语言
  availableLangs: ["zh-HK", "en"],    // 语言切换器用
  currency: "HKD",                    // 该地区币种(§07)
  paymentMethods: ["fps", "alipay_hk", "card"], // 该地区可用支付方式(§07)
  subscription: true,                 // 该地区是否有在线订阅(决定 Plans 入口给哪张页)
  regions: [{ code, name, defaultLang, availableLangs, subscription }, …], // 全部站点,国家选项用(§02)
  markets: { HKG: {…}, JPN: {…} },      // 市场码 → 站点,跨站续费/加购用
  geo: { country, lang, … }             // 现有 geoip 结果(裸域落地判断)
}
  • /config.js 保持 no-store,按请求路径注入正确的 region/lang
  • 语言切换器读 availableLangs 渲染,切换即跳到 /{region}/{targetLang}/{samePage}
  • 国家选项读 regions 渲染,切换即跳到 /{targetRegion}/{lang}/{samePage}(见 §02)。
§09

从现状迁移的路径

分阶段推进,每步可独立上线、可回滚:

  1. 抽公共层:新建 public/_shared/,迁入公共 css/js/img、i18n 引擎、api.js;页面引用改 /_shared/…
  2. 建 HK 基线地区:现有扁平页面复制进 public/hk/pages/ 作为基线(Q3/Q4),验证 region 中间件。
  3. 拆 i18ntranslations.js 按「共享 / 地区」拆分,tc→zh-HKjp→ja 做别名。
  4. 接入 region 中间件:实现 /{region}/{lang} 解析 + 裸地区/裸域重定向;同时移除 /plans-jp/plans 的日本特判
  5. 克隆日本地区public/jp/,落地日文排版与 ja.js/en.js
  6. BFF 引入地区上下文X-Region 中间件 + 让 countries/industries/plans 等按地区返回。
  7. 收尾:旧扁平 URL(如 /plans)301 到默认地区,保留一段时间兼容外链。
  8. 补建 global 站 + 国家选项(2026-07-27,Q1/Q3 修订):regions.jsglobal 一行 → 重跑 build-regions.js(生成 public/global/pages)+ split-i18n.js(生成 global/i18n/en.js);裸域兜底改 globalmain.js 注入页头/页尾国家选项。
改动这一层时的顺序固定为:node scripts/build-regions.jsnode scripts/split-i18n.js(后者要读前者产出的地区页来收 data-i18n 键)。两者都幂等。
§10

权衡取舍

取舍点决策理由 / 代价
地区隔离 vs 单模板地区目录硬隔离契合「不同排版/页数」;代价是布局重复,用 _shared + 基线地区缓解
语言:字典 vs 复制 HTMLi18n 字典同地区两语共排版,复制 HTML 维护翻倍;真分叉时才加变体页
URL:显式语言段 vs Cookie显式 /{region}/{lang}利于 SEO/CDN/可分享;代价是 URL 多一段
i18n:运行时 vs 构建期先运行时改造成本最低;保留升级到服务端直出的空间
BFF:共享 vs 分地区共享 + 上下文逻辑一致,避免 N 套后端;差异靠 region 参数
语言码:jp/tc vs BCP47迁移到 BCP47规范、利于 hreflang;迁移期做别名兼容
§11

SEO · 缓存 · 运维

  • SEO:每个 {region}/{lang} 是独立可索引 URL;<head><link rel="alternate" hreflang> 串联各语言并设 x-default
  • 缓存/CDN:地区+语言进 URL → 天然可按路径缓存,不被 Cookie/geoip 打碎缓存键。
  • 重定向语义:裸域 / 与裸地区 /{region}302(临时/个性化);旧扁平 URL 收敛用 301
  • 可扩展性:新增地区 = 加一个 public/{region}/ 目录 + 注册表一行,无需改路由代码
§12

决策记录(Q1–Q7)

Q1–Q5 已拍板并回填到上文相关章节;Q6–Q7 待后续确认。Q1 与 Q3 已于 2026-07-27 因「页头/页尾国家选项」需求修订——原决策保留在下方以便追溯。

Q1
裸域 / 的兜底地区geoip 认不出来(本地/内网/未覆盖国家)时落到哪?2026-07-27 修订:落 /global/en(其他國家站)—— 与页头国家选项显示的站点一致,Plans 自然是英文营销版。
原:落 HK,语言按 geoip,认不出 → /hk/en
Q2
语言码规范是否规范化为 BCP47?采用 BCP47ja / zh-HK / en;旧 jp/tc 迁移期做别名兼容
Q3
首批上线地区2026-07-27 修订:jp + hk + global —— 有了国家选项就必须有「其他國家」这个可选项,故补建 global 基线站(纯英文、无在线订阅,页面同样以 HK 为基线克隆)。
原:仅 jp + hk,不单建 global 基线地区
Q4
地区差异化起点以现有 HK 页面为基线复制,再逐地区改排版
Q5
是否用独立域名不用独立域名,纯路径前缀;region 中间件无需预留「域名→地区」解析口
Q6
BFF 地区差异范围哪些接口/数据真的按地区不同(币种/Plans/合规文案/上游租户/支付方式)?据此确定 X-Region 影响哪些 service。⏳ 待定 —— 实现时按接口逐一确认
Q7
各地区支付方式 · 集成方每地区 provider 清单/优先级、是否用统一 PSP 聚合商减少适配器、是否与线下支付(subscribe-offline)并存?⏳ 待定
§A

核心组件清单(实现对照)

组件位置(建议)职责
地区注册表server/regions.jsregion → { defaultLang, availableLangs, currency, market, paymentMethods, subscription };键序即国家选项的展示顺序
region 解析中间件server/index.js解析 /{region}/{lang}、裸地区/裸域重定向、按地区托管静态
geoip 落地server/geoip.js(复用)裸域 / 决定落地 region/lang(认不出 → global/en)
国家选项(切换器)public/_shared/js/main.js页头 + 页尾运行时注入,读 __APP_CONFIG__.regions,换站做语言/页面回落
BFF 地区上下文中间件server/index.js读 X-Region → req.region
支付门面路由server/routes/payments.js统一 /api/payments/*,按 region + method 选适配器
支付适配器server/services/payments/*.js一网关一适配器:createPayment · verifyWebhook · getStatus
i18n 引擎public/_shared/js/i18n.js加载共享 + 地区字典,渲染 data-i18n
BFF 客户端public/_shared/js/api.js/api/* 请求自动带 X-Region / X-Lang
配置注入/config.js(server/index.js)注入 region / lang / availableLangs / currency / paymentMethods / subscription / regions / markets