AML/docs/justsolutionsWebV2/multi-region-i18n-design.html

756 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<title>justsolutionsWebV2 · 多地区多语言架构设计</title>
<style>
:root {
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
--font-mono: ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, "PingFang SC", monospace;
/* light — cool neutral biased toward the teal accent */
--paper: #eef1f0;
--surface: #ffffff;
--surface-2: #e6ebe9;
--ink: #16201e;
--ink-2: #495a56;
--ink-3: #7c8b86;
--line: #d7dedb;
--line-soft: #e3e8e6;
--accent: #0d7d73;
--accent-strong: #0a615a;
--accent-soft: #d9ebe8;
--warn: #a06a12;
--warn-soft: #f2e6cf;
--jp: #a8473c;
--jp-soft: #f3e0dc;
--hk: #3a5580;
--hk-soft: #dfe6f1;
--shadow: 0 1px 2px rgba(20,32,30,.05), 0 8px 28px -18px rgba(20,32,30,.28);
--radius: 12px;
}
@media (prefers-color-scheme: dark) {
:root {
--paper: #0d1211;
--surface: #141d1b;
--surface-2: #1a2523;
--ink: #e7ecea;
--ink-2: #aab7b3;
--ink-3: #73837e;
--line: #253331;
--line-soft: #1f2b29;
--accent: #34afa2;
--accent-strong: #57c7bb;
--accent-soft: #14302c;
--warn: #cf9b3f;
--warn-soft: #2c2413;
--jp: #e59486;
--jp-soft: #2f1f1c;
--hk: #97b1dc;
--hk-soft: #1b2636;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 12px 34px -20px rgba(0,0,0,.7);
}
}
:root[data-theme="light"] {
--paper: #eef1f0; --surface: #ffffff; --surface-2: #e6ebe9;
--ink: #16201e; --ink-2: #495a56; --ink-3: #7c8b86;
--line: #d7dedb; --line-soft: #e3e8e6;
--accent: #0d7d73; --accent-strong: #0a615a; --accent-soft: #d9ebe8;
--warn: #a06a12; --warn-soft: #f2e6cf;
--jp: #a8473c; --jp-soft: #f3e0dc; --hk: #3a5580; --hk-soft: #dfe6f1;
--shadow: 0 1px 2px rgba(20,32,30,.05), 0 8px 28px -18px rgba(20,32,30,.28);
}
:root[data-theme="dark"] {
--paper: #0d1211; --surface: #141d1b; --surface-2: #1a2523;
--ink: #e7ecea; --ink-2: #aab7b3; --ink-3: #73837e;
--line: #253331; --line-soft: #1f2b29;
--accent: #34afa2; --accent-strong: #57c7bb; --accent-soft: #14302c;
--warn: #cf9b3f; --warn-soft: #2c2413;
--jp: #e59486; --jp-soft: #2f1f1c; --hk: #97b1dc; --hk-soft: #1b2636;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 12px 34px -20px rgba(0,0,0,.7);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: var(--font-sans);
font-size: 16px;
line-height: 1.72;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.wrap {
max-width: 1160px;
margin: 0 auto;
padding: 0 24px 120px;
display: grid;
grid-template-columns: 232px minmax(0, 1fr);
gap: 48px;
align-items: start;
}
@media (max-width: 980px) {
.wrap { grid-template-columns: 1fr; gap: 0; }
.toc { display: none; }
}
/* ---------- masthead ---------- */
.masthead {
grid-column: 1 / -1;
padding: 56px 0 34px;
border-bottom: 1px solid var(--line);
margin-bottom: 44px;
}
.eyebrow {
font-family: var(--font-mono);
font-size: 12.5px;
letter-spacing: .04em;
color: var(--accent);
display: inline-flex;
align-items: center;
gap: 10px;
}
.eyebrow .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
.eyebrow .path { color: var(--ink-3); }
h1 {
font-size: clamp(28px, 4.4vw, 42px);
line-height: 1.15;
letter-spacing: -.02em;
font-weight: 760;
margin: 16px 0 14px;
text-wrap: balance;
}
.lede {
font-size: 17px;
color: var(--ink-2);
max-width: 60ch;
margin: 0;
}
.meta-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 22px;
}
.tag {
font-family: var(--font-mono);
font-size: 12px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--line);
color: var(--ink-2);
background: var(--surface);
}
.tag b { color: var(--accent); font-weight: 600; }
/* ---------- toc ---------- */
.toc {
position: sticky;
top: 28px;
align-self: start;
font-size: 13.5px;
}
.toc .toc-h {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--ink-3);
margin: 0 0 12px 12px;
}
.toc a {
display: flex;
gap: 10px;
padding: 5px 12px;
color: var(--ink-2);
text-decoration: none;
border-left: 2px solid transparent;
line-height: 1.35;
transition: color .15s, border-color .15s;
}
.toc a .n { font-family: var(--font-mono); color: var(--ink-3); font-size: 12px; min-width: 18px; }
.toc a:hover { color: var(--ink); }
.toc a.active { color: var(--accent); border-left-color: var(--accent); }
.toc a.active .n { color: var(--accent); }
/* ---------- content ---------- */
main { min-width: 0; }
section { margin-bottom: 52px; scroll-margin-top: 24px; }
.sec-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 18px; }
.sec-num {
font-family: var(--font-mono);
font-size: 13px;
color: var(--accent);
padding-top: 3px;
}
h2 {
font-size: 22px;
font-weight: 720;
letter-spacing: -.01em;
margin: 0;
text-wrap: balance;
}
h3 {
font-size: 15px;
font-weight: 680;
margin: 26px 0 12px;
color: var(--ink);
}
p { margin: 0 0 14px; max-width: 68ch; }
main a { color: var(--accent); text-decoration-color: color-mix(in oklab, var(--accent) 40%, transparent); text-underline-offset: 3px; }
strong { font-weight: 660; }
code, .mono { font-family: var(--font-mono); }
p code, li code, td code {
font-size: .88em;
background: var(--surface-2);
padding: 1px 6px;
border-radius: 5px;
color: var(--accent-strong);
}
ul, ol { margin: 0 0 14px; padding-left: 0; max-width: 68ch; }
ul { list-style: none; }
ul li { position: relative; padding-left: 20px; margin-bottom: 8px; }
ul li::before {
content: "";
position: absolute; left: 3px; top: .68em;
width: 5px; height: 5px; border-radius: 1px;
background: var(--accent);
}
ol { padding-left: 0; counter-reset: step; list-style: none; }
ol > li { position: relative; padding-left: 40px; margin-bottom: 12px; counter-increment: step; }
ol > li::before {
content: counter(step, decimal-leading-zero);
position: absolute; left: 0; top: 0;
font-family: var(--font-mono); font-size: 12px; font-weight: 600;
color: var(--accent);
background: var(--accent-soft);
width: 26px; height: 22px; border-radius: 6px;
display: inline-flex; align-items: center; justify-content: center;
}
/* ---------- cards / callouts ---------- */
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 22px 24px;
box-shadow: var(--shadow);
}
.verdict {
background: var(--surface);
border: 1px solid var(--line);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
padding: 22px 26px;
margin-bottom: 22px;
}
.verdict ul { margin-bottom: 0; }
.verdict li::before { display: none; }
.verdict li { padding-left: 26px; }
.yes {
position: absolute; left: 0; top: .2em;
color: var(--accent); font-weight: 700; font-family: var(--font-mono);
}
.note {
background: var(--accent-soft);
border-radius: 10px;
padding: 14px 18px;
font-size: 14.5px;
color: var(--ink-2);
margin: 16px 0;
}
.note.warn { background: var(--warn-soft); }
.note b { color: var(--ink); }
.kicker { font-size: 20px; line-height: 1.5; font-weight: 640; letter-spacing: -.01em; margin: 4px 0 0; text-wrap: balance; }
.kicker .u { color: var(--accent); }
/* dimension comparison */
.dims { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 22px 0 8px; }
@media (max-width: 620px) { .dims { grid-template-columns: 1fr; } }
.dim { border: 1px solid var(--line); border-radius: var(--radius); padding: 18px 20px; background: var(--surface); }
.dim .dh { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.dim .badge { font-family: var(--font-mono); font-size: 11.5px; font-weight: 600; padding: 3px 9px; border-radius: 6px; }
.dim.region .badge { color: var(--jp); background: var(--jp-soft); }
.dim.lang .badge { color: var(--hk); background: var(--hk-soft); }
.dim .dh strong { font-size: 15px; }
.dim p { font-size: 14px; color: var(--ink-2); margin: 0; max-width: none; }
.dim .how { font-family: var(--font-mono); font-size: 12.5px; color: var(--accent); margin-top: 10px; }
/* ---------- tables ---------- */
.table-scroll { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
table { border-collapse: collapse; width: 100%; font-size: 14px; min-width: 520px; }
th, td { text-align: left; padding: 11px 16px; vertical-align: top; border-bottom: 1px solid var(--line-soft); }
thead th {
font-family: var(--font-mono); font-size: 11.5px; letter-spacing: .04em; text-transform: uppercase;
color: var(--ink-3); font-weight: 600; background: var(--surface-2);
position: sticky; top: 0;
}
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: color-mix(in oklab, var(--accent) 4%, var(--surface)); }
td.mono, td .mono { font-size: 13px; }
.ok { color: var(--accent); font-weight: 600; }
/* region pills inline */
.pill { font-family: var(--font-mono); font-size: 12px; padding: 2px 8px; border-radius: 6px; font-weight: 600; white-space: nowrap; }
.pill.jp { color: var(--jp); background: var(--jp-soft); }
.pill.hk { color: var(--hk); background: var(--hk-soft); }
/* ---------- code / tree ---------- */
.code {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 18px 20px;
overflow-x: auto;
box-shadow: var(--shadow);
}
.code pre { margin: 0; font-family: var(--font-mono); font-size: 12.9px; line-height: 1.72; color: var(--ink-2); }
.code .c { color: var(--ink-3); }
.code .k { color: var(--accent); }
.code .s { color: var(--jp); }
.code .d { color: var(--hk); }
.code-cap { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-3); margin-bottom: 8px; display: block; }
/* route list */
.routes { display: flex; flex-direction: column; gap: 2px; }
.route {
display: grid; grid-template-columns: minmax(150px, 260px) 1fr; gap: 16px; align-items: baseline;
padding: 11px 16px; border-radius: 9px;
}
.route:nth-child(odd) { background: var(--surface-2); }
.route .u { font-family: var(--font-mono); font-size: 13px; color: var(--ink); font-weight: 600; }
.route .u .seg { color: var(--accent); }
.route .d { font-size: 13.5px; color: var(--ink-2); }
@media (max-width: 560px) { .route { grid-template-columns: 1fr; gap: 3px; } }
/* flow */
.flow { display: flex; flex-direction: column; gap: 0; }
.flow-row {
display: grid; grid-template-columns: 200px 1fr; gap: 16px; align-items: start;
padding: 13px 0; border-top: 1px dashed var(--line);
}
.flow-row:first-child { border-top: none; }
.flow-row .lhs { font-family: var(--font-mono); font-size: 13px; font-weight: 600; color: var(--accent); padding-top: 1px; }
.flow-row .rhs { color: var(--ink-2); font-size: 14px; }
.flow-row .rhs .sub { margin-top: 8px; padding-left: 16px; border-left: 2px solid var(--accent-soft); display: flex; flex-direction: column; gap: 4px; }
.flow-row .rhs .sub span { font-family: var(--font-mono); font-size: 12.5px; color: var(--ink-3); }
@media (max-width: 560px) { .flow-row { grid-template-columns: 1fr; gap: 4px; } }
/* open questions */
.q { display: flex; gap: 14px; padding: 16px 0; border-top: 1px solid var(--line-soft); }
.q:first-child { border-top: none; }
.q .qn { font-family: var(--font-mono); font-size: 12px; font-weight: 700; color: var(--accent); background: var(--accent-soft); border-radius: 6px; padding: 3px 8px; height: fit-content; }
.q.hot .qn, .q.done .qn { color: #fff; background: var(--accent); }
:root[data-theme="dark"] .q.hot .qn, :root[data-theme="dark"] .q.done .qn { color: #0d1211; }
@media (prefers-color-scheme: dark) { .q.hot .qn, .q.done .qn { color: #0d1211; } }
.q.pend .qn { color: var(--warn); background: var(--warn-soft); }
.q .ans { display: block; margin-top: 7px; font-size: 13.5px; font-weight: 600; color: var(--accent-strong); }
.q .ans.pend { color: var(--warn); }
.q .qb strong { display: block; margin-bottom: 3px; }
.q .qb { font-size: 14.5px; color: var(--ink-2); }
.q .qb .opts { font-family: var(--font-mono); font-size: 12.5px; color: var(--ink-3); margin-top: 4px; }
footer {
grid-column: 1 / -1;
margin-top: 40px; padding-top: 24px;
border-top: 1px solid var(--line);
font-size: 13px; color: var(--ink-3);
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px;
}
footer .mono { font-family: var(--font-mono); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 4px; }
</style>
<div class="wrap">
<header class="masthead">
<div class="eyebrow"><span class="dot"></span> justsolutionsWebV2 <span class="path">/ 架构设计</span></div>
<h1>多地区 · 多语言架构设计</h1>
<p class="lede">支持多个国家/地区站点,每地区提供「当地语言 + 英文」,通过路径前缀访问;<strong>静态前端按地区隔离</strong><strong>BFF 层保持共享</strong></p>
<div class="meta-row">
<span class="tag">路由 <b>/{region}/{lang}</b></span>
<span class="tag">站点 <b>Express + 静态托管</b></span>
<span class="tag">现有 <b>geoip · translations.js · /api/*</b></span>
<span class="tag">状态 <b>Q15 已定 · Q6/7 待定</b></span>
</div>
</header>
<nav class="toc" aria-label="目录">
<p class="toc-h">目录</p>
<a href="#s0"><span class="n">00</span> 结论 · 评价</a>
<a href="#s1"><span class="n">01</span> 现状 As-Is</a>
<a href="#s2"><span class="n">02</span> URL 与路由</a>
<a href="#s3"><span class="n">03</span> 目录结构</a>
<a href="#s4"><span class="n">04</span> 请求处理流程</a>
<a href="#s5"><span class="n">05</span> i18n 方案</a>
<a href="#s6"><span class="n">06</span> BFF 共享</a>
<a href="#s7"><span class="n">07</span> 在线支付</a>
<a href="#s8"><span class="n">08</span> 运行时配置</a>
<a href="#s9"><span class="n">09</span> 迁移路径</a>
<a href="#s10"><span class="n">10</span> 权衡取舍</a>
<a href="#s11"><span class="n">11</span> SEO · 缓存 · 运维</a>
<a href="#s12"><span class="n">12</span> 决策记录</a>
<a href="#sa"><span class="n">A</span> 组件清单</a>
</nav>
<main>
<!-- 0 -->
<section id="s0">
<div class="sec-head"><span class="sec-num">§00</span><h2>结论 · 对提案的评价</h2></div>
<p>提案的核心方向<strong>正确且符合业界标准做法</strong>(路径前缀隔离地区 + 共享 BFF</p>
<div class="verdict">
<ul>
<li><span class="yes"></span>地区用<strong>路径前缀</strong><code>/jp</code><code>/hk</code>)而非 IP/Cookie 隐式切换 —— 对 SEO、CDN 缓存、可分享链接都友好。</li>
<li><span class="yes"></span>静态层<strong>按地区硬隔离</strong> —— 契合「不同地区不同排版、不同页数」的现实。</li>
<li><span class="yes"></span>BFF 层<strong>共享</strong> —— 各地区业务本质一致,靠「地区上下文」做数据差异化即可。</li>
</ul>
</div>
<p>需在提案基础上<strong>补全一个维度</strong>地区region与语言lang<strong>两个正交维度</strong>,必须显式拆开——</p>
<div class="dims">
<div class="dim region">
<div class="dh"><span class="badge">region</span><strong>地区 · 目录级硬隔离</strong></div>
<p>jp / hk / … 各自拥有独立的页面集合与排版。排版、页面数量、甚至页面种类都可能不同。</p>
<div class="how">→ 各地区一个自包含目录</div>
</div>
<div class="dim lang">
<div class="dh"><span class="badge">lang</span><strong>语言 · 地区内软切换</strong></div>
<p>当地语 + 英文。同一地区两种语言通常共用排版,仅文案不同,用 i18n 字典切换。</p>
<div class="how">→ 地区目录内换字典,不复制 HTML</div>
</div>
</div>
<p class="kicker">一句话:<span class="u">地区</span>决定「有哪些页、长什么样」,<span class="u">语言</span>只决定「同一张页上显示什么文字」。</p>
</section>
<!-- 1 -->
<section id="s1">
<div class="sec-head"><span class="sec-num">§01</span><h2>现状 As-Is</h2></div>
<div class="table-scroll">
<table>
<thead><tr><th>方面</th><th>现状</th><th>问题</th></tr></thead>
<tbody>
<tr><td>静态页</td><td><code>public/</code> <strong>扁平</strong>,所有地区共用同一批 HTML</td><td>无法按地区差异化排版/页数</td></tr>
<tr><td>语言</td><td><code>public/js/translations.js</code> 单一全局字典 <code>window.T = {en, tc, jp}</code>,运行时切字符串</td><td>三语混在一个 2185 行文件,地区间无法独立演进</td></tr>
<tr><td>地区</td><td>打补丁:<code>/plans-jp</code> 别名 + 日本 IP 从 <code>/plans</code> 302 跳转</td><td>每加一个地区就要加一堆特判,不可扩展</td></tr>
<tr><td>geoip</td><td><code>server/geoip.js</code> 已能 IP→国家→默认语种并注入 <code>__APP_CONFIG__.geo</code></td><td>可直接复用为「裸域落地重定向」依据</td></tr>
<tr><td>BFF</td><td><code>server/routes/*</code> + <code>services/*</code> 统一挂 <code>/api/*</code>,走 mock 或代理上游</td><td>方向正确,仅需引入「地区上下文」</td></tr>
<tr><td>配置注入</td><td><code>/config.js</code> 动态生成 <code>window.__APP_CONFIG__</code></td><td>需补充 <code>region</code> / <code>lang</code> 字段</td></tr>
</tbody>
</table>
</div>
</section>
<!-- 2 -->
<section id="s2">
<div class="sec-head"><span class="sec-num">§02</span><h2>URL 与路由方案</h2></div>
<h3>URL 结构:<span class="mono">/{region}/{lang}/{page}</span></h3>
<div class="card">
<div class="routes">
<div class="route"><span class="u"><span class="seg">/jp</span></span><span class="d">302 → <code>/jp/ja</code>(地区裸路径 → 该地区默认语言)</span></div>
<div class="route"><span class="u"><span class="seg">/jp</span>/ja/plans</span><span class="d"><span class="pill jp">日本</span> 日文 · Plans 页</span></div>
<div class="route"><span class="u"><span class="seg">/jp</span>/en/plans</span><span class="d"><span class="pill jp">日本</span> 英文 · Plans 页</span></div>
<div class="route"><span class="u"><span class="seg">/hk</span></span><span class="d">302 → <code>/hk/zh</code></span></div>
<div class="route"><span class="u"><span class="seg">/hk</span>/zh/plans</span><span class="d"><span class="pill hk">香港</span> 繁中 · Plans 页</span></div>
<div class="route"><span class="u"><span class="seg">/hk</span>/en/plans</span><span class="d"><span class="pill hk">香港</span> 英文 · Plans 页</span></div>
<div class="route"><span class="u">/</span><span class="d">302 → geoip 决定的 <code>/{region}/{lang}</code>(认不出 → 兜底 <code>/hk/en</code></span></div>
</div>
</div>
<div class="note">语言代码<b>采用 BCP 47</b>(已定 · Q2<code>ja</code> / <code>zh-HK</code> / <code>en</code>。旧 <code>jp</code>/<code>tc</code> 迁移期做别名映射即可不断链。</div>
<h3>路由规则表</h3>
<div class="table-scroll">
<table>
<thead><tr><th>请求</th><th>处理</th><th>说明</th></tr></thead>
<tbody>
<tr><td class="mono"><code>/</code></td><td>302 → geoip 命中的 <code>/{region}/{lang}</code></td><td>无法识别 → 回落 <code>/hk/en</code>Q1</td></tr>
<tr><td class="mono"><code>/{region}</code></td><td>302 → <code>/{region}/{defaultLang}</code></td><td><code>/jp</code><code>/jp/ja</code></td></tr>
<tr><td class="mono"><code>/{region}/{lang}/…</code></td><td><code>public/{region}/</code> 提供静态内容</td><td>命中具体页面</td></tr>
<tr><td class="mono"><code>/_shared/…</code></td><td>提供跨地区共享资源</td><td>css / js / img 公共部分</td></tr>
<tr><td class="mono"><code>/api/…</code></td><td>共享 BFF带地区上下文</td><td>见 §06</td></tr>
<tr><td class="mono"><code>/config.js</code></td><td>动态注入运行时配置</td><td>no-store见 §08</td></tr>
<tr><td class="mono"><code>*.html</code></td><td>301 → 去 <code>.html</code> 干净 URL</td><td>沿用现有规范化逻辑</td></tr>
</tbody>
</table>
</div>
<h3>为什么「地区在前、语言显式成段」</h3>
<div class="table-scroll">
<table>
<thead><tr><th>方案</th><th>例子</th><th>评价</th></tr></thead>
<tbody>
<tr><td><span class="ok">A · 两段(推荐)</span></td><td class="mono">/jp/ja/plans · /hk/en/plans</td><td>与提案 <code>/jp</code> <code>/hk</code> 一致;语言可缓存、可 hreflang结构清晰</td></tr>
<tr><td>B · 合并 locale 单段</td><td class="mono">/ja-jp/plans · /en-hk/plans</td><td>也可行,但地区/语言耦合,与「<code>/jp</code> 作为地区入口」不吻合</td></tr>
<tr><td>C · 仅地区 + Cookie</td><td class="mono">/jp/plans?lang=en</td><td>SEO 差、CDN 缓存被 Cookie 打碎、链接无法指定语言</td></tr>
</tbody>
</table>
</div>
</section>
<!-- 3 -->
<section id="s3">
<div class="sec-head"><span class="sec-num">§03</span><h2>目录结构(静态资源隔离)</h2></div>
<div class="code">
<span class="code-cap">public/ — 地区自包含,语言用字典而非目录</span>
<pre><span class="k">public/</span>
├── <span class="k">_shared/</span><span class="c"> # 跨地区共享(唯一真源)</span>
│ ├── css/ <span class="c"># 基础样式reset / 变量 / 公共组件)</span>
│ ├── js/
│ │ ├── i18n.js <span class="c"># i18n 引擎(读地区字典 + 渲 data-i18n</span>
│ │ ├── api.js <span class="c"># BFF 客户端(自动带 region/lang</span>
│ │ └── main.js <span class="c"># 公共交互</span>
│ ├── img/ <span class="c"># 公共图片logo 等)</span>
│ └── i18n/en.js … <span class="c"># 跨地区共享文案nav/footer/cookie</span>
├── <span class="s">jp/</span><span class="c"> # ── 日本地区:独立页面 + 排版 ──</span>
│ ├── pages/ <span class="c"># index / plans / … 页数、页种可不同</span>
│ ├── css/ img/ <span class="c"># 地区专属覆盖(可选)</span>
│ └── i18n/
│ ├── ja.js <span class="c"># 日文文案</span>
│ └── en.js <span class="c"># 日本站的英文</span>
├── <span class="d">hk/</span><span class="c"> # ── 香港地区 ──</span>
│ ├── pages/ <span class="c"># 可以多几张页</span>
│ ├── css/ img/
│ └── i18n/
│ ├── zh-HK.js <span class="c"># 繁中文案</span>
│ └── en.js
└── <span class="c">(新增地区照抄一个目录即可,零特判)</span></pre>
</div>
<ul>
<li><strong>一个地区 = 一个自包含目录</strong>页面、专属样式、专属图片、i18n 字典都在里面 →「不同排版/页数」天然成立。</li>
<li><strong>共享的抽到 <code>_shared/</code></strong>i18n 引擎、BFF 客户端、公共样式/图片只维护一份。</li>
<li><strong>语言不建目录、用字典</strong>:仅当某地区某语言排版<em>确实</em>要分叉时,才在该地区目录内加语言变体页(局部特例)。</li>
</ul>
</section>
<!-- 4 -->
<section id="s4">
<div class="sec-head"><span class="sec-num">§04</span><h2>请求处理流程</h2></div>
<div class="card">
<div class="flow">
<div class="flow-row"><div class="lhs">/config.js</div><div class="rhs">动态注入 <code>window.__APP_CONFIG__</code>(含 region/langno-store</div></div>
<div class="flow-row"><div class="lhs">/_shared/*</div><div class="rhs"><code>express.static(public/_shared)</code></div></div>
<div class="flow-row"><div class="lhs">/api/*</div><div class="rhs">共享 BFF注入 region 上下文)→ mock / 代理上游</div></div>
<div class="flow-row"><div class="lhs">/ (裸域)</div><div class="rhs"><code>detectGeo()</code> → 302 <code>/{region}/{lang}</code></div></div>
<div class="flow-row"><div class="lhs">/{region}</div><div class="rhs">302 <code>/{region}/{defaultLang}</code></div></div>
<div class="flow-row"><div class="lhs">/{region}/{lang}/*</div><div class="rhs">region 解析中间件
<div class="sub">
<span>├ 校验 region 合法、lang ∈ 该地区支持语言</span>
<span>├ 剥掉 /{region}/{lang} 前缀</span>
<span>├ express.static(public/{region}/pages)</span>
<span>└ 页内加载 _shared/js/i18n.js + {region}/i18n/{lang}.js → 按 data-i18n 渲染</span>
</div>
</div></div>
</div>
</div>
<p class="note"><b>关键:</b>「地区+语言」解析是<b>一个中间件</b>,而非为每地区/每页写特判。新增地区 = 加一个目录 + 注册表加一行。</p>
</section>
<!-- 5 -->
<section id="s5">
<div class="sec-head"><span class="sec-num">§05</span><h2>i18n 方案(地区内语言切换)</h2></div>
<p>沿用现有「运行时按 <code>data-i18n</code> 换字符串」的机制(改造成本低),但把字典<strong>按地区拆分</strong></p>
<ul>
<li>共享文案(导航/页脚/Cookie<code>_shared/i18n/{lang}.js</code></li>
<li>地区专属文案 → <code>public/{region}/i18n/{lang}.js</code></li>
<li>页面加载:先加载共享字典,再加载地区字典(地区覆盖共享),由 <code>_shared/js/i18n.js</code> 统一渲染。</li>
</ul>
<p><strong>好处:</strong>各地区文案独立演进,互不影响;单文件体积可控(不再是 2185 行巨无霸);语言切换仍是纯前端行为,无需为每语言生成一套 HTML。</p>
<div class="note">备选:若未来页数暴涨或需更强 SEO可升级为<b>构建期 / 服务端注入</b>的 i18n服务端直出已翻译 HTML。当前规模用运行时方案性价比最高本设计保留升级空间。</div>
</section>
<!-- 6 -->
<section id="s6">
<div class="sec-head"><span class="sec-num">§06</span><h2>BFF 层:共享 + 地区上下文</h2></div>
<p>BFF<code>routes/*</code> + <code>services/*</code><strong>保持单一共享</strong>,通过「地区上下文」做数据差异化,而非每地区一套后端逻辑。</p>
<ul>
<li><strong>上下文传递</strong>:前端 <code>api.js</code> 每个 <code>/api/*</code> 请求自动带地区标识(推荐请求头 <code>X-Region: hk</code> / <code>X-Lang: en</code>,取自 <code>__APP_CONFIG__</code><code>/api/*</code> 前缀保持全局。</li>
<li><strong>服务端读取</strong>:轻量中间件把 <code>X-Region</code> 解析进 <code>req.region</code>,交给下游。</li>
<li><strong>按地区变化的数据</strong>:国家/币种/合规文案(<code>countries</code>/<code>industries</code>、Plans/Editions 目录(<code>plans</code>/<code>editions</code>)、上游代理可按地区选不同 <code>API_BASE_URL</code>/租户。</li>
<li><strong>在线支付方式</strong>:各地区接入不同网关/支付方式(<span class="pill jp">日本</span> PayPay·Konbini·JCB / <span class="pill hk">香港</span> FPS·AlipayHK·信用卡—— 作为重点单列 <a href="#s7">§07</a></li>
<li><strong>不变的部分</strong>鉴权、代理框架、mock 框架、错误处理 —— 全部复用。</li>
</ul>
<p class="note">即提案所说「BFF 层都是一样的」——成立。差异只体现在「同一套代码根据 <b>region</b> 返回不同数据」。</p>
</section>
<!-- 7 payment (NEW) -->
<section id="s7">
<div class="sec-head"><span class="sec-num">§07</span><h2>在线支付:地区可插拔的支付方式</h2></div>
<p>不同国家/地区接入不同的在线支付方式,这是<strong>「地区上下文」的又一维</strong>与币种、Plans 目录同类),<strong>不新增架构维度</strong> —— 用同一套共享 BFF + <strong>适配器Strategy 模式)</strong>承载即可。</p>
<div class="note warn"><b>现状:</b>plans-plus 已有「下单 + 收银台」骨架(<code>/api/subscribe</code><code>/api/payments/create</code><code>/pay-gateway</code> → webhook → <code>/pay-return</code> 轮询),但<b>硬编码了单一网关 <code>PayPartner</code>、货币 <code>HK$</code>、locale <code>zh-HK</code></b> —— 只能服务香港式单一支付。本节把它一般化。</div>
<h3>各地区声明自己的币种与支付方式</h3>
<div class="table-scroll">
<table>
<thead><tr><th>地区</th><th>币种</th><th>可用支付方式(示例,非最终)</th></tr></thead>
<tbody>
<tr><td><span class="pill jp">日本 JP</span></td><td class="mono">JPY</td><td>PayPay · JCB/信用卡 · Konbini 便利店</td></tr>
<tr><td><span class="pill hk">香港 HK</span></td><td class="mono">HKD</td><td>FPS 转数快 · AlipayHK · 信用卡Stripe</td></tr>
<tr><td>其它</td><td class="mono"></td><td>按落地地区补充</td></tr>
</tbody>
</table>
</div>
<p>写进 region 注册表:<code>region → { …, currency, paymentMethods: [...] }</code>,与 <code>availableLangs</code> 同理。前端从 <code>/config.js</code><code>GET /api/payments/methods</code> 拿到当前地区可用方式,渲染收银台。</p>
<h3>BFF 统一门面(网关无关),内部按 (region, method) 选适配器</h3>
<div class="card">
<div class="routes">
<div class="route"><span class="u"><span class="seg">GET</span> /api/payments/methods</span><span class="d">当前地区可用支付方式code / 名称 / 图标 / 币种)</span></div>
<div class="route"><span class="u"><span class="seg">POST</span> /api/payments/create</span><span class="d">选中适配器 → 归一化 <code>{ paymentId, gateway, redirectUrl | clientParams }</code></span></div>
<div class="route"><span class="u"><span class="seg">POST</span> /api/payments/webhook/:provider</span><span class="d">各网关各自回调/验签 → 归一化状态 + 回写后端订单</span></div>
<div class="route"><span class="u"><span class="seg">GET</span> /api/payments/:pid</span><span class="d">归一化状态轮询(<code>pay-return</code> 前端不变)</span></div>
</div>
</div>
<ul>
<li><strong>适配器模式</strong><code>server/services/payments/</code> 一网关一文件(<code>stripe</code> / <code>alipay-hk</code> / <code>payjp-konbini</code> / <code>fps</code> …),实现统一接口 <code>createPayment · verifyWebhook · getStatus</code>;注册表映射 <code>region → [providers]</code><code>providerCode → adapter</code></li>
<li><strong>新增支付方式 = 加一个适配器 + 注册表登记</strong>,不改前端、不改下单流程。</li>
<li><strong>归一化支付记录</strong>:沿用现有 mock 已定义的形状(<code>paymentId / orderId / amount / currency / status / events 时间线</code>),各适配器把第三方回调翻译成这个形状。</li>
<li><strong>前端去硬编码</strong><code>pay-gateway.js</code> / <code>pay-return.js</code> 里的 <code>HK$</code> / <code>PayPartner</code> / <code>zh-HK</code> 改为读地区 <code>currency</code> + 方式元数据;收银台按 <code>paymentMethods</code> 渲染。</li>
<li><strong>回调与地区解耦</strong>webhook 是服务端到服务端,路径按 <code>:provider</code> 与地区无关,通过 <code>paymentId</code> 反查地区/订单;用户可见的返回页地区化:<code>/{region}/{lang}/pay-return</code></li>
<li><strong>线上/线下并存</strong>:保留现有 <code>subscribe-offline</code>(联络我们/推荐人),作为暂无在线支付地区的兜底通道。</li>
</ul>
<p class="note">要点:<b>下单流程subscribe与前端收银台保持不变具体网关全部收敛到适配器背后</b> —— 加国家、换支付方式都不动主干。</p>
</section>
<!-- 8 -->
<section id="s8">
<div class="sec-head"><span class="sec-num">§08</span><h2>运行时配置注入(/config.js</h2></div>
<p>在现有 <code>window.__APP_CONFIG__</code> 基础上补充地区/语言上下文,让前端 JS 无需自己解析 URL</p>
<div class="code">
<span class="code-cap">/config.js 动态生成(示意,非最终实现)</span>
<pre>window.__APP_CONFIG__ = {
appEnv, apiPrefix, apiBaseUrl, mock, <span class="c">// 现有</span>
<span class="k">region</span>: <span class="s">"hk"</span>, <span class="c">// 当前地区(路径解析得出)</span>
<span class="k">lang</span>: <span class="s">"en"</span>, <span class="c">// 当前语言</span>
<span class="k">defaultLang</span>: <span class="s">"zh-HK"</span>, <span class="c">// 该地区默认语言</span>
<span class="k">availableLangs</span>: [<span class="s">"zh-HK"</span>, <span class="s">"en"</span>], <span class="c">// 语言切换器用</span>
<span class="k">currency</span>: <span class="s">"HKD"</span>, <span class="c">// 该地区币种§07</span>
<span class="k">paymentMethods</span>: [<span class="s">"fps"</span>, <span class="s">"alipay_hk"</span>, <span class="s">"card"</span>], <span class="c">// 该地区可用支付方式§07</span>
<span class="k">geo</span>: { country, lang, … } <span class="c">// 现有 geoip 结果(裸域落地判断)</span>
}</pre>
</div>
<ul>
<li><code>/config.js</code> 保持 <code>no-store</code>,按请求路径注入正确的 <code>region/lang</code></li>
<li>语言切换器读 <code>availableLangs</code> 渲染,切换即跳到 <code>/{region}/{targetLang}/{samePage}</code></li>
</ul>
</section>
<!-- 9 -->
<section id="s9">
<div class="sec-head"><span class="sec-num">§09</span><h2>从现状迁移的路径</h2></div>
<p>分阶段推进,每步可独立上线、可回滚:</p>
<ol>
<li><strong>抽公共层</strong>:新建 <code>public/_shared/</code>,迁入公共 css/js/img、i18n 引擎、<code>api.js</code>;页面引用改 <code>/_shared/…</code></li>
<li><strong>建 HK 基线地区</strong>:现有扁平页面复制进 <code>public/hk/pages/</code> 作为基线Q3/Q4验证 region 中间件。</li>
<li><strong>拆 i18n</strong><code>translations.js</code> 按「共享 / 地区」拆分,<code>tc→zh-HK</code><code>jp→ja</code> 做别名。</li>
<li><strong>接入 region 中间件</strong>:实现 <code>/{region}/{lang}</code> 解析 + 裸地区/裸域重定向;<strong>同时移除 <code>/plans-jp</code><code>/plans</code> 的日本特判</strong></li>
<li><strong>克隆日本地区</strong><code>public/jp/</code>,落地日文排版与 <code>ja.js</code>/<code>en.js</code></li>
<li><strong>BFF 引入地区上下文</strong><code>X-Region</code> 中间件 + 让 <code>countries/industries/plans</code> 等按地区返回。</li>
<li><strong>收尾</strong>:旧扁平 URL<code>/plans</code>301 到默认地区,保留一段时间兼容外链。</li>
</ol>
</section>
<!-- 10 -->
<section id="s10">
<div class="sec-head"><span class="sec-num">§10</span><h2>权衡取舍</h2></div>
<div class="table-scroll">
<table>
<thead><tr><th>取舍点</th><th>决策</th><th>理由 / 代价</th></tr></thead>
<tbody>
<tr><td>地区隔离 vs 单模板</td><td class="ok">地区目录硬隔离</td><td>契合「不同排版/页数」;代价是布局重复,用 <code>_shared</code> + 基线地区缓解</td></tr>
<tr><td>语言:字典 vs 复制 HTML</td><td class="ok">i18n 字典</td><td>同地区两语共排版,复制 HTML 维护翻倍;真分叉时才加变体页</td></tr>
<tr><td>URL显式语言段 vs Cookie</td><td class="ok">显式 /{region}/{lang}</td><td>利于 SEO/CDN/可分享;代价是 URL 多一段</td></tr>
<tr><td>i18n运行时 vs 构建期</td><td class="ok">先运行时</td><td>改造成本最低;保留升级到服务端直出的空间</td></tr>
<tr><td>BFF共享 vs 分地区</td><td class="ok">共享 + 上下文</td><td>逻辑一致,避免 N 套后端;差异靠 region 参数</td></tr>
<tr><td>语言码jp/tc vs BCP47</td><td class="ok">迁移到 BCP47</td><td>规范、利于 hreflang迁移期做别名兼容</td></tr>
</tbody>
</table>
</div>
</section>
<!-- 11 -->
<section id="s11">
<div class="sec-head"><span class="sec-num">§11</span><h2>SEO · 缓存 · 运维</h2></div>
<ul>
<li><strong>SEO</strong>:每个 <code>{region}/{lang}</code> 是独立可索引 URL<code>&lt;head&gt;</code><code>&lt;link rel="alternate" hreflang&gt;</code> 串联各语言并设 <code>x-default</code></li>
<li><strong>缓存/CDN</strong>:地区+语言进 URL → 天然可按路径缓存,不被 Cookie/geoip 打碎缓存键。</li>
<li><strong>重定向语义</strong>:裸域 <code>/</code> 与裸地区 <code>/{region}</code><strong>302</strong>(临时/个性化);旧扁平 URL 收敛用 <strong>301</strong></li>
<li><strong>可扩展性</strong>:新增地区 = 加一个 <code>public/{region}/</code> 目录 + 注册表一行,<strong>无需改路由代码</strong></li>
</ul>
</section>
<!-- 12 -->
<section id="s12">
<div class="sec-head"><span class="sec-num">§12</span><h2>决策记录Q1Q7</h2></div>
<p>Q1Q5 已拍板并回填到上文相关章节Q6Q7 待后续确认。</p>
<div class="card">
<div class="q done"><span class="qn">Q1</span><div class="qb"><strong>裸域 / 的兜底地区</strong>geoip 认不出来(本地/内网/未覆盖国家)时落到哪?<span class="ans">✓ 落 <b>HK</b>;语言按 geoip 语种,认不出 → <code>en</code>,即最终落 <code>/hk/en</code></span></div></div>
<div class="q done"><span class="qn">Q2</span><div class="qb"><strong>语言码规范</strong>是否规范化为 BCP47<span class="ans"><b>采用 BCP47</b><code>ja</code> / <code>zh-HK</code> / <code>en</code>;旧 <code>jp</code>/<code>tc</code> 迁移期做别名兼容</span></div></div>
<div class="q done"><span class="qn">Q3</span><div class="qb"><strong>首批上线地区</strong><span class="ans"><b>仅 jp + hk</b>;不单建 global 基线地区</span></div></div>
<div class="q done"><span class="qn">Q4</span><div class="qb"><strong>地区差异化起点</strong><span class="ans"><b>以现有 HK 页面为基线复制</b>,再逐地区改排版</span></div></div>
<div class="q done"><span class="qn">Q5</span><div class="qb"><strong>是否用独立域名</strong><span class="ans"><b>不用独立域名</b>纯路径前缀region 中间件无需预留「域名→地区」解析口</span></div></div>
<div class="q pend"><span class="qn">Q6</span><div class="qb"><strong>BFF 地区差异范围</strong>哪些接口/数据真的按地区不同(币种/Plans/合规文案/上游租户/支付方式)?据此确定 <code>X-Region</code> 影响哪些 service。<span class="ans pend">⏳ 待定 —— 实现时按接口逐一确认</span></div></div>
<div class="q pend"><span class="qn">Q7</span><div class="qb"><strong>各地区支付方式 · 集成方</strong>每地区 provider 清单/优先级、是否用统一 PSP 聚合商减少适配器、是否与线下支付(<code>subscribe-offline</code>)并存?<span class="ans pend">⏳ 待定</span></div></div>
</div>
</section>
<!-- appendix -->
<section id="sa">
<div class="sec-head"><span class="sec-num">§A</span><h2>核心组件清单(实现对照)</h2></div>
<div class="table-scroll">
<table>
<thead><tr><th>组件</th><th>位置(建议)</th><th>职责</th></tr></thead>
<tbody>
<tr><td>地区注册表</td><td class="mono">server/regions.js</td><td>region → { defaultLang, availableLangs, currency, paymentMethods, … }</td></tr>
<tr><td>region 解析中间件</td><td class="mono">server/index.js</td><td>解析 /{region}/{lang}、裸地区/裸域重定向、按地区托管静态</td></tr>
<tr><td>geoip 落地</td><td class="mono">server/geoip.js复用</td><td>裸域 / 决定落地 region/lang认不出 → hk/en</td></tr>
<tr><td>BFF 地区上下文中间件</td><td class="mono">server/index.js</td><td>读 X-Region → req.region</td></tr>
<tr><td>支付门面路由</td><td class="mono">server/routes/payments.js</td><td>统一 /api/payments/*,按 region + method 选适配器</td></tr>
<tr><td>支付适配器</td><td class="mono">server/services/payments/*.js</td><td>一网关一适配器createPayment · verifyWebhook · getStatus</td></tr>
<tr><td>i18n 引擎</td><td class="mono">public/_shared/js/i18n.js</td><td>加载共享 + 地区字典,渲染 data-i18n</td></tr>
<tr><td>BFF 客户端</td><td class="mono">public/_shared/js/api.js</td><td>/api/* 请求自动带 X-Region / X-Lang</td></tr>
<tr><td>配置注入</td><td class="mono">/config.jsserver/index.js</td><td>注入 region / lang / availableLangs / currency / paymentMethods</td></tr>
</tbody>
</table>
</div>
</section>
</main>
<footer>
<span>justsolutionsWebV2 · 多地区多语言架构设计</span>
<span class="mono">docs/justsolutionsWebV2/ · 设计评审稿</span>
</footer>
</div>
<script>
// TOC scroll-spy
(function () {
var links = Array.prototype.slice.call(document.querySelectorAll('.toc a'));
var map = {};
links.forEach(function (a) { map[a.getAttribute('href').slice(1)] = a; });
var sections = Array.prototype.slice.call(document.querySelectorAll('main section'));
if (!('IntersectionObserver' in window)) return;
var current = null;
var obs = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting) {
if (current) current.classList.remove('active');
var a = map[e.target.id];
if (a) { a.classList.add('active'); current = a; }
}
});
}, { rootMargin: '-10% 0px -75% 0px', threshold: 0 });
sections.forEach(function (s) { obs.observe(s); });
})();
</script>