791 lines
50 KiB
HTML
791 lines
50 KiB
HTML
<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); }
|
||
.pill.gl { color: var(--ink-3); background: var(--line-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 .ans s { color: var(--ink-3); font-weight: 500; } /* 被推翻的旧决策:保留可追溯,但压低视觉权重 */
|
||
.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>Q1–5 已定(Q1·Q3 2026-07-27 修订)· 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 class="seg">/global</span>/en/plans</span><span class="d"><span class="pill gl">其他國家</span> 英文 · Plans 页(营销版,无在线订阅)</span></div>
|
||
<div class="route"><span class="u">/</span><span class="d">302 → geoip 决定的 <code>/{region}/{lang}</code>(认不出 → 兜底 <code>/global/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>/global/en</code>(Q1 · 2026-07-27 修订)</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>/{region}/{lang}/plans</code></td><td>该地区<b>有</b>在线订阅 → 内部改写成 <code>plans-plus</code>;<b>没有</b> → 营销页 <code>plans</code></td><td>依据 <code>region.subscription</code>(非 geoip 国家);地址栏不变</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>地区切换:页头 / 页尾的「國家選項」<span class="pill gl">2026-07-27 增补</span></h3>
|
||
<p>geoip 只是<b>落地猜测</b>,访客必须能自己改。因此在页头(语言按钮左侧)与页尾各放一个国家选项,与语言切换器同构:<b>选择进 URL</b>,由服务端按路径重新渲染,可缓存、可分享、利于 hreflang。</p>
|
||
<div class="table-scroll">
|
||
<table>
|
||
<thead><tr><th>站点</th><th>URL</th><th>语言</th><th>在线订阅</th></tr></thead>
|
||
<tbody>
|
||
<tr><td><span class="pill hk">香港</span></td><td class="mono">/hk/{zh-HK,en}/</td><td>繁中 · 英文</td><td>有(<code>plans-plus</code>,市场 HKG)</td></tr>
|
||
<tr><td><span class="pill jp">日本</span></td><td class="mono">/jp/{ja,en}/</td><td>日文 · 英文</td><td>有(<code>plans-plus</code>,市场 JPN)</td></tr>
|
||
<tr><td><span class="pill gl">其他國家</span></td><td class="mono">/global/en/</td><td>仅英文</td><td>无 —— Plans 给英文营销页</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<ul>
|
||
<li><strong>清单来自注册表</strong>:<code>/config.js</code> 下发 <code>regions</code>(<code>regionList()</code>),切换器据此渲染;站点名走 i18n 键 <code>region_{code}</code>(缺键回落注册表 <code>name</code>)。<b>新增地区仍只改 <code>regions.js</code> 一处</b>,页面与切换器代码不动。</li>
|
||
<li><strong>换站映射</strong>:语言沿用当前,目标站不支持则用其默认语言(<code>/jp/ja/x</code> → 香港站取 <code>zh-HK</code>);页面同名平移,目标站没有的页面回落(<code>plans-plus</code> → <code>plans</code>,<code>pay-return</code> → 首页);不带查询串 / hash —— 换国家是换上下文,另一个站读不懂本站参数。</li>
|
||
<li><strong>挂载方式</strong>:由 <code>_shared/js/main.js</code> 在地区页运行时注入到 <code>.nav-inner</code> 与 <code>footer .footer-inner</code>,不逐页改 HTML(<code>plans-plus</code> 这类不再由生成器管理的页面同样自动获得)。</li>
|
||
<li><strong>选择不持久化</strong>:状态只在 URL 里,裸域访问仍走 geoip。如需「记住上次选的国家」,另加 cookie 层。</li>
|
||
</ul>
|
||
|
||
<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="d">global/</span><span class="c"> # ── 其他國家(纯英文,无在线订阅)──</span>
|
||
│ ├── pages/ <span class="c"># 不含 plans-plus / pay-return</span>
|
||
│ └── i18n/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/lang,no-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><span class="pill gl">其他國家</span></td><td class="mono">—</td><td>无在线支付 —— 只走线下渠道(试用申请 / 联络我们)</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">subscription</span>: <span class="s">true</span>, <span class="c">// 该地区是否有在线订阅(决定 Plans 入口给哪张页)</span>
|
||
<span class="k">regions</span>: [{ code, name, defaultLang, availableLangs, subscription }, …], <span class="c">// 全部站点,国家选项用(§02)</span>
|
||
<span class="k">markets</span>: { HKG: {…}, JPN: {…} }, <span class="c">// 市场码 → 站点,跨站续费/加购用</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>
|
||
<li>国家选项读 <code>regions</code> 渲染,切换即跳到 <code>/{targetRegion}/{lang}/{samePage}</code>(见 §02)。</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>
|
||
<li><strong>补建 global 站 + 国家选项</strong>(2026-07-27,Q1/Q3 修订):<code>regions.js</code> 加 <code>global</code> 一行 → 重跑 <code>build-regions.js</code>(生成 <code>public/global/pages</code>)+ <code>split-i18n.js</code>(生成 <code>global/i18n/en.js</code>);裸域兜底改 <code>global</code>;<code>main.js</code> 注入页头/页尾国家选项。</li>
|
||
</ol>
|
||
<div class="note warn"><b>改动这一层时的顺序固定为:</b><code>node scripts/build-regions.js</code> → <code>node scripts/split-i18n.js</code>(后者要读前者产出的地区页来收 <code>data-i18n</code> 键)。两者都幂等。</div>
|
||
</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><head></code> 加 <code><link rel="alternate" hreflang></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>决策记录(Q1–Q7)</h2></div>
|
||
<p>Q1–Q5 已拍板并回填到上文相关章节;Q6–Q7 待后续确认。<b>Q1 与 Q3 已于 2026-07-27 因「页头/页尾国家选项」需求修订</b>——原决策保留在下方以便追溯。</p>
|
||
<div class="card">
|
||
<div class="q done"><span class="qn">Q1</span><div class="qb"><strong>裸域 / 的兜底地区</strong>geoip 认不出来(本地/内网/未覆盖国家)时落到哪?<span class="ans">✓ <b>2026-07-27 修订:落 <code>/global/en</code></b>(其他國家站)—— 与页头国家选项显示的站点一致,Plans 自然是英文营销版。<br><s>原:落 HK,语言按 geoip,认不出 → <code>/hk/en</code></s></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>2026-07-27 修订:jp + hk + global</b> —— 有了国家选项就必须有「其他國家」这个可选项,故补建 global 基线站(纯英文、无在线订阅,页面同样以 HK 为基线克隆)。<br><s>原:仅 jp + hk,不单建 global 基线地区</s></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, market, paymentMethods, subscription };键序即国家选项的展示顺序</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(认不出 → global/en)</td></tr>
|
||
<tr><td>国家选项(切换器)</td><td class="mono">public/_shared/js/main.js</td><td>页头 + 页尾运行时注入,读 <code>__APP_CONFIG__.regions</code>,换站做语言/页面回落</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.js(server/index.js)</td><td>注入 region / lang / availableLangs / currency / paymentMethods / subscription / regions / markets</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>
|