Compare commits

..

No commits in common. "57ee96e1bf79ebfb428eca3677ddd7476d0c82f1" and "c71cfb4c0255f33ff77dd2fae4801ea30853221e" have entirely different histories.

4 changed files with 5 additions and 1591 deletions

11
.gitignore vendored
View File

@ -1,6 +1,5 @@
/docs/AML_Backend/docker-compose-local-dev/AML-local-dev.bak
/justsolutionsWebV2/
/AML_Backend/
/AML_Frontend/
/justsolutionsWeb/
docs/AML_Backend/docker-compose-local-dev/AML-local-dev.bak
justsolutionsWebV2/
AML_Backend/
AML_Frontend/
justsolutionsWeb/

View File

@ -1,327 +0,0 @@
<!DOCTYPE html>
<html lang="zh-HK">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>justsolutionsWebV2 · plans-plus KYC 訂閱欄位與租用邏輯分析</title>
<style>
:root {
--bg: #f6f8fa;
--card: #ffffff;
--text: #24292f;
--muted: #57606a;
--border: #d0d7de;
--accent: #0969da;
--accent-soft: #ddf4ff;
--code-bg: #f0f3f6;
--th-bg: #f0f3f6;
--row-alt: #fafbfc;
--warn-bg: #fff8c5;
--warn-border: #d4a72c;
--note-bg: #ddf4ff;
--note-border: #54aeff;
--gap-bg: #ffebe9;
--gap-border: #ff8182;
--ok-bg: #dafbe1;
--ok-border: #4ac26b;
--get: #1a7f37;
--post: #9a6700;
--new: #8250df;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "PingFang HK", "Hiragino Sans GB", "Microsoft YaHei", Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.65;
font-size: 15px;
}
.wrap { max-width: 1100px; margin: 0 auto; padding: 32px 24px 80px; }
header.page {
background: var(--card); border: 1px solid var(--border);
border-radius: 12px; padding: 28px 32px; margin-bottom: 24px;
}
header.page h1 { margin: 0 0 10px; font-size: 26px; }
header.page p { margin: 4px 0; color: var(--muted); }
header.page .meta { font-size: 13px; }
.toc {
background: var(--card); border: 1px solid var(--border);
border-radius: 12px; padding: 18px 28px; margin-bottom: 28px;
}
.toc h2 { font-size: 13px; margin: 0 0 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
.toc ol { margin: 0; padding-left: 20px; columns: 2; column-gap: 36px; }
.toc li { margin: 4px 0; break-inside: avoid; }
.toc a { color: var(--accent); text-decoration: none; }
.toc a:hover { text-decoration: underline; }
section { margin-bottom: 36px; }
h2.sec {
font-size: 21px; border-bottom: 2px solid var(--border);
padding-bottom: 8px; margin: 0 0 18px; scroll-margin-top: 16px;
}
h3 { font-size: 17px; margin: 24px 0 10px; scroll-margin-top: 16px; }
h4 { font-size: 15px; margin: 16px 0 6px; color: var(--muted); }
p { margin: 10px 0; }
table {
border-collapse: collapse; width: 100%; margin: 14px 0;
font-size: 14px; background: var(--card);
border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
}
th, td { border: 1px solid var(--border); padding: 8px 11px; text-align: left; vertical-align: top; }
th { background: var(--th-bg); font-weight: 600; }
tr:nth-child(even) td { background: var(--row-alt); }
code {
background: var(--code-bg); padding: 1.5px 6px; border-radius: 5px;
font-family: "SF Mono", "JetBrains Mono", "Fira Code", Consolas, monospace; font-size: 12.5px;
}
pre {
background: #0d1117; color: #e6edf3; padding: 16px 18px;
border-radius: 8px; overflow-x: auto; font-size: 12.5px; line-height: 1.55;
}
pre code { background: none; padding: 0; color: inherit; font-size: 12.5px; }
.tag { font-size: 11px; padding: 1px 7px; border-radius: 20px; border: 1px solid var(--border); color: var(--muted); white-space: nowrap; }
.callout { border-radius: 8px; padding: 12px 16px; margin: 14px 0; border: 1px solid; }
.callout p { margin: 4px 0; }
.callout.warn { background: var(--warn-bg); border-color: var(--warn-border); }
.callout.note { background: var(--note-bg); border-color: var(--note-border); }
.callout.gap { background: var(--gap-bg); border-color: var(--gap-border); }
.callout.ok { background: var(--ok-bg); border-color: var(--ok-border); }
.callout .lbl { font-weight: 700; }
.pill { display:inline-block; font-size:11px; font-weight:700; padding:1px 8px; border-radius:20px; }
.pill.done { background: var(--ok-bg); color:#1a7f37; border:1px solid var(--ok-border);}
.pill.partial { background: var(--warn-bg); color:#7a5c00; border:1px solid var(--warn-border);}
.pill.no { background: var(--gap-bg); color:#cf222e; border:1px solid var(--gap-border);}
.small { font-size: 13px; color: var(--muted); }
ul.tight { margin: 8px 0; padding-left: 22px; }
ul.tight li { margin: 5px 0; }
.flow { font-family:"SF Mono",Consolas,monospace; font-size:13px; background:var(--code-bg); padding:10px 14px; border-radius:8px; border:1px solid var(--border); overflow-x:auto; white-space:nowrap;}
hr.soft { border:none; border-top:1px dashed var(--border); margin: 26px 0; }
.path { font-family:"SF Mono",Consolas,monospace; font-size:12.5px; color:var(--muted); }
</style>
</head>
<body>
<div class="wrap">
<header class="page">
<h1>plans-plus · <span style="color:#1a7f37">KYC 訂閱欄位與租用邏輯分析</span></h1>
<p>釐清 <code>plans-plus.html</code> 續費查詢頁「上次訂閱資訊」中的 <b>KYC</b> 欄位到底代表什麼、KYC 功能是否受「有沒有租設備」限制、以及是否每個租戶預設開啟;並記錄本次為此做的文案修正。</p>
<p class="meta">範圍:<code>AML_Backend</code><code>iCON.Abp.AMLPortal</code> / <code>iCON.Abp.AML</code>)· <code>AML_Frontend</code>KYC section· <code>justsolutionsWebV2</code>plans-plus 前端文案)</p>
<p class="meta">結論一句話:<b>「上次訂閱資訊」的 KYC 上次訂閱是否含「KYC 讀卡設備租用」加購項</b>;租不租設備<b>只影響前端的「機器掃描」入口</b>,不影響上傳/手動錄入 KYC<b>非每個租戶預設開啟</b></p>
</header>
<nav class="toc">
<h2>目錄</h2>
<ol>
<li><a href="#tldr">一、結論速覽</a></li>
<li><a href="#chain">二、欄位資料鏈路(前端 → 後端)</a></li>
<li><a href="#assign">三、EnableKYC 何時被賦值為 true</a></li>
<li><a href="#gate">四、租設備是否影響「使用 KYC 功能」</a></li>
<li><a href="#default">五、是否每個租戶預設開啟</a></li>
<li><a href="#fix">六、本次文案修正</a></li>
<li><a href="#refs">七、關鍵代碼位置索引</a></li>
</ol>
</nav>
<!-- ───────────────────────────────────────────── -->
<section id="tldr">
<h2 class="sec">一、結論速覽</h2>
<table>
<thead>
<tr><th style="width:38%">問題</th><th style="width:12%">答案</th><th>說明</th></tr>
</thead>
<tbody>
<tr>
<td>「上次訂閱資訊」的 KYC 應該是「是否租用 KYC 設備」嗎?</td>
<td><span class="pill done">基本是</span></td>
<td>該值 <code>TenantProperty.EnableKYC</code>,而它由「下單方案是否含 <code>Tag1Code = KYC</code> 的方案行」決定;此方案行在 plans-plus 對應加購項「<b>KYC 讀卡設備租用</b>」。故它實質表示「上次訂閱有無租 KYC 讀卡設備」。</td>
</tr>
<tr>
<td>有沒有租 KYC 設備,會影響用戶在系統中使用 KYC 功能嗎?</td>
<td><span class="pill partial">只影響掃描入口</span></td>
<td><code>enableKYC</code> 在前端<b>只控制「ID Scan機器掃描」按鈕</b>的顯隱。「上傳」與「手動錄入」兩條路徑始終可用;後端 <code>KYCService/KYCController</code> <b>完全不校驗</b> EnableKYC。</td>
</tr>
<tr>
<td>「能否使用 KYC 功能」是每個租戶預設開啟的嗎?</td>
<td><span class="pill no">不是</span></td>
<td><code>EnableKYC</code> 對一般租戶<b>預設 false</b>,僅在下單方案含 KYC 加購時為 true續費沿用上次值。唯一預設 <code>true</code> 的是 <b>Host 宿主租戶</b>。但因前端僅擋掃描按鈕,所有租戶預設仍可用「上傳/錄入」做 KYC。</td>
</tr>
</tbody>
</table>
</section>
<!-- ───────────────────────────────────────────── -->
<section id="chain">
<h2 class="sec">二、欄位資料鏈路(前端 → 後端)</h2>
<p>plans-plus 續費頁「上次訂閱資訊」卡片的「已購增值 / Add-ons」列經由這條鏈路取得 KYC 標記:</p>
<div class="flow">plans-plus.js <code>previousAddonText()</code><code>currentSubscription.addons.kyc</code> → BFF 直通 → <code>CurrentSubscriptionAddonsDto.Kyc</code><code>tp.EnableKYC</code></div>
<table>
<thead><tr><th style="width:34%"></th><th>內容</th></tr></thead>
<tbody>
<tr>
<td>前端顯示<br><span class="path">plans-plus.js:570-581 / plans-plus.html:347</span></td>
<td><code>showTenantInfo()</code><code>previousAddonText(s)</code> 寫入 <code>#ppTiAddons</code>;當 <code>addons.kyc === true</code> 時,列表加入一個 KYC 標籤。</td>
</tr>
<tr>
<td>續費查詢 DTO<br><span class="path">RenewableTenantByEmailDto.cs:46-54</span></td>
<td><code>CurrentSubscriptionAddonsDto.Kyc</code>,欄位註解明寫「<b>是否已購 KYCTenantProperty.EnableKYC</b>」。</td>
</tr>
<tr>
<td>後端聚合<br><span class="path">OrderService.cs:2460</span></td>
<td><code>Addons = new CurrentSubscriptionAddonsDto { Kyc = tp.EnableKYC }</code>——直接取當前生效 <code>TenantProperty.EnableKYC</code></td>
</tr>
<tr>
<td>底層欄位<br><span class="path">TenantProperty.cs:69-71</span></td>
<td><code>public bool EnableKYC</code>,註解「是否啟用了 KYC 功能」。<b>命名雖是「功能開關」,但賦值來源只綁定「有沒有買 KYC 設備加購」(見下節)。</b></td>
</tr>
</tbody>
</table>
<div class="callout note">
<p><span class="lbl">加購項對應:</span>plans-plus 第 4 步的 KYC 加購項在 <span class="path">plans-plus.html:468</span> 標題為「<b>KYC ID Reader Rental / KYC 身份讀取設備租用</b>」——一台<b>實體讀卡機</b>,按訂閱期租用。這正是產生 <code>Tag1Code = KYC</code> 方案行、進而把 <code>EnableKYC</code> 置 true 的東西。</p>
</div>
</section>
<!-- ───────────────────────────────────────────── -->
<section id="assign">
<h2 class="sec">三、EnableKYC 何時被賦值為 true</h2>
<p><code>EnableKYC</code> 全庫僅有 3 種寫入來源(其餘皆為 EF 遷移快照),<b>沒有任何「按功能開關」的入口</b></p>
<table>
<thead><tr><th style="width:26%">場景</th><th style="width:30%">代碼</th><th>取值</th></tr></thead>
<tbody>
<tr>
<td>新租戶下單</td>
<td><span class="path">OrderService.cs:763</span></td>
<td><code>EnableKYC = checkHasEnableKYCInPlan(purchasePlanDetailList)</code></td>
</tr>
<tr>
<td>續費(新開訂單路徑)</td>
<td><span class="path">OrderService.cs:2633</span></td>
<td><code>EnableKYC = checkHasEnableKYCInPlan(purchasePlanDetailList)</code></td>
</tr>
<tr>
<td>續費(事件隊列生效路徑)</td>
<td><span class="path">OrderService.cs:872</span></td>
<td><code>EnableKYC = lastTenantProperty.EnableKYC</code><b>沿用上一份</b></td>
</tr>
<tr>
<td>Host 宿主初始化</td>
<td><span class="path">TenantPropertyDataSeeder.cs:63-79</span></td>
<td><code>EnableKYC = true</code><b>僅當 <code>currentTenant.Id == null</code>,即 Host</b></td>
</tr>
</tbody>
</table>
<p>判定函式只看方案的 Tag1</p>
<pre><code>// OrderService.cs:2694-2697
private bool checkHasEnableKYCInPlan(List&lt;PlanDetail&gt; purchasePlanDetailList)
{
return purchasePlanDetailList
.Where(a =&gt; a.Plan.Tag1Code == PlanTag1Enums.KYC.ToString()).Any();
}</code></pre>
<div class="callout ok">
<p><span class="lbl">推論:</span><code>EnableKYC</code> 語義上等同「<b>該訂閱是否含 KYC 讀卡設備租用</b>而不是一個獨立的「KYC 功能總開關」。所以把「上次訂閱資訊」裡的它讀作「是否租用 KYC 設備」是準確的。</p>
</div>
</section>
<!-- ───────────────────────────────────────────── -->
<section id="gate">
<h2 class="sec">四、租設備是否影響「使用 KYC 功能」</h2>
<h3>4.1 前端:只擋「機器掃描」按鈕</h3>
<p>AML_Frontend 的 KYC section 讀取當前租戶的 <code>enableKYC</code></p>
<pre><code>// kyc-section.component.ts:147-148
this.amlTenant.getCurrentTenantProperty$().subscribe(res =&gt; {
this.enableKYC = res?.enableKYC ?? false
})</code></pre>
<p>而它<b>只作用在「ID Scan機器讀卡掃描」這一個按鈕</b>上(<code>switchType === 1</code></p>
<table>
<thead><tr><th style="width:20%">按鈕</th><th style="width:16%">switchType</th><th style="width:22%">受 enableKYC 控制?</th><th>說明</th></tr></thead>
<tbody>
<tr><td>ID Scan機器掃描</td><td>1</td><td><span class="pill partial">是(*ngIf="enableKYC"</span></td><td>插實體讀卡機掃描證件;未租設備則隱藏。<span class="path">kyc-section.component.html:10</span></td></tr>
<tr><td>Upload2上傳</td><td>2</td><td><span class="pill done">否 · 始終顯示</span></td><td>上傳證件圖片走 OCR校驗。<span class="path">kyc-section.component.html:18-24</span></td></tr>
<tr><td>Input手動錄入</td><td>3</td><td><span class="pill done">否 · 始終顯示</span></td><td>手動輸入證件資訊。<span class="path">kyc-section.component.html:26-32</span></td></tr>
</tbody>
</table>
<h3>4.2 後端:無任何門檻</h3>
<p>後端 <code>KYCService</code> / <code>KYCController</code> <b>從不讀取 <code>EnableKYC</code></b>。全庫對該欄位的非遷移讀取僅有兩處:續費沿用(<span class="path">OrderService.cs:872</span>)與續費查詢上報(<span class="path">OrderService.cs:2460</span>)。因此它<b>純粹是前端那顆掃描按鈕的顯隱旗標</b>,不構成服務端鑑權。</p>
<div class="callout warn">
<p><span class="lbl">結論:</span>沒租設備的租戶<b>照樣能用完整 KYC</b>(上傳 / 手動錄入 → OCR、校驗、檢索只是看不到「插讀卡機掃證件」這個入口。<br>「租 KYC 設備」= 解鎖前端硬體掃描按鈕,而非解鎖 KYC 功能本身。</p>
</div>
</section>
<!-- ───────────────────────────────────────────── -->
<section id="default">
<h2 class="sec">五、是否每個租戶預設開啟</h2>
<ul class="tight">
<li><b>一般業務租戶:</b><code>EnableKYC</code> 預設 <b>false</b>,僅當下單方案含 KYC 設備加購才為 true續費時沿用上一份的值<span class="path">OrderService.cs:872</span>)。</li>
<li><b>Host 宿主租戶:</b>是唯一被 seeder 預設 <code>true</code> 的(<span class="path">TenantPropertyDataSeeder.cs:63-79</span><code>currentTenant.Id == null</code>),並非每個業務租戶。</li>
<li><b>但「使用 KYC 功能」廣義上仍預設可用:</b>因前端只擋掃描按鈕,所有租戶預設可走「上傳 / 錄入」完成 KYC。</li>
</ul>
<div class="callout note">
<p><span class="lbl">一句話:</span>廣義 KYC上傳錄入對所有租戶預設可用<code>EnableKYC</code>(=是否租了 KYC 讀卡設備)預設關閉,它只額外解鎖前端「機器掃描證件」按鈕。</p>
</div>
</section>
<!-- ───────────────────────────────────────────── -->
<section id="fix">
<h2 class="sec">六、本次文案修正</h2>
<p>原本 plans-plus「上次訂閱資訊」把 KYC 加購項顯示為含糊的「<code>KYC/KYB</code>」(易被誤解成一種核查功能)。已改為與加購項一致的「<b>KYC 設備租用</b>」表述,以準確反映「是否租用 KYC 設備」。<b>後端無改動。</b></p>
<table>
<thead><tr><th style="width:34%">位置</th><th style="width:33%">修改前</th><th style="width:33%">修改後</th></tr></thead>
<tbody>
<tr>
<td><span class="path">plans-plus.js:575</span><br><code>previousAddonText</code>,「已購增值」列)</td>
<td><code>parts.push('KYC/KYB')</code></td>
<td><code>parts.push(tr('ppx_addon_kyc_label'))</code></td>
</tr>
<tr>
<td><span class="path">plans-plus.html:579</span><br>(訂單摘要 <code>#ppSumKyc</code> 靜態回退文字)</td>
<td><code>KYC/KYB Verification</code></td>
<td><code>KYC ID Reader Rental</code></td>
</tr>
</tbody>
</table>
<p class="small">改用的 i18n 鍵 <code>ppx_addon_kyc_label</code> 三語皆已是租用表述繁中「KYC 身份讀取設備租用」/ EN「KYC ID Reader Rental」 日「KYC IDリーダー機器レンタル」故翻譯本體無需改動。訂單摘要 <code>ppx_sum_kyc</code> 的翻譯值原本就已是租用表述,本次僅同步修正其過期的 HTML 靜態回退文字。</p>
<div class="callout ok">
<p><span class="lbl">驗證:</span>本地以 <code>npm run start:local</code> 啟動 <code>justsolutionsWebV2</code> 即可看到「上次訂閱資訊 → 已購增值」與訂單摘要處的 KYC 顯示為「KYC 設備租用」表述。</p>
</div>
</section>
<!-- ───────────────────────────────────────────── -->
<section id="refs">
<h2 class="sec">七、關鍵代碼位置索引</h2>
<table>
<thead><tr><th style="width:30%">角色</th><th>檔案 : 行</th></tr></thead>
<tbody>
<tr><td>底層欄位定義</td><td class="path">AML_Backend/modules/iCON.Abp.AMLPortal/…/Domain/DbEntity/TenantProperty.cs:69-71</td></tr>
<tr><td>賦值:新租戶 / 續費 / 判定函式</td><td class="path">…/AMLPortal.Application/OrderService.cs:763、2633、872、2694-2697</td></tr>
<tr><td>續費查詢上報 KYC</td><td class="path">…/AMLPortal.Application/OrderService.cs:2460</td></tr>
<tr><td>續費查詢 DTO</td><td class="path">…/AMLPortal.Application.Contracts/OrderAppLayer/RenewableTenantByEmailDto.cs:46-54</td></tr>
<tr><td>Host 預設 true</td><td class="path">…/AMLPortal.Domain/Seeders/TenantPropertyDataSeeder.cs:63-79</td></tr>
<tr><td>前端讀 enableKYC / 掃描按鈕門檻</td><td class="path">AML_Frontend/…/kyc-section/kyc-section.component.ts:147-148、.html:10</td></tr>
<tr><td>AML DTO 映射</td><td class="path">…/iCON.Abp.AML.Application/AMLAppAutoMapperProfile.cs:78 · …/TenantConfigLayer/TenantPropertyDto.cs:118</td></tr>
<tr><td>plans-plus 顯示 / 加購項</td><td class="path">justsolutionsWebV2/public/js/plans-plus.js:570-581 · public/plans-plus.html:468、579</td></tr>
</tbody>
</table>
</section>
<hr class="soft">
<p class="small">本文為程式碼閱讀分析結論,隨代碼演進可能變動;引用行號以撰寫時的倉庫狀態為準。</p>
</div>
</body>
</html>

View File

@ -1,755 +0,0 @@
<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>

View File

@ -1,503 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>plans-plus · 订单处理流程单(下单 → 支付 → 开通)</title>
<style>
:root {
--bg: #f6f8fa;
--card: #ffffff;
--text: #24292f;
--muted: #57606a;
--border: #d0d7de;
--accent: #0969da;
--code-bg: #f0f3f6;
--th-bg: #f0f3f6;
--row-alt: #fafbfc;
--warn-bg: #fff8c5;
--warn-border: #d4a72c;
--note-bg: #ddf4ff;
--note-border: #54aeff;
--gap-bg: #ffebe9;
--gap-border: #ff8182;
--ok-bg: #dafbe1;
--ok-border: #4ac26b;
/* layer colors */
--fe: #8250df; --fe-bg: #f3eefe;
--bff: #0969da; --bff-bg: #ddf4ff;
--be: #1a7f37; --be-bg: #dafbe1;
--job: #9a6700; --job-bg: #fff5d6;
--db: #57606a; --db-bg: #eef1f4;
--mq: #bc4c00; --mq-bg: #ffe7d1;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "PingFang HK", "Hiragino Sans GB", "Microsoft YaHei", Helvetica, Arial, sans-serif;
background: var(--bg); color: var(--text); line-height: 1.65; font-size: 15px;
}
.wrap { max-width: 1180px; margin: 0 auto; padding: 32px 24px 90px; }
header.page { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 28px 32px; margin-bottom: 24px; }
header.page h1 { margin: 0 0 10px; font-size: 26px; }
header.page p { margin: 4px 0; color: var(--muted); }
header.page .meta { font-size: 13px; }
.toc { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 18px 28px; margin-bottom: 28px; }
.toc h2 { font-size: 13px; margin: 0 0 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
.toc ol { margin: 0; padding-left: 20px; columns: 2; column-gap: 36px; }
.toc li { margin: 4px 0; break-inside: avoid; }
.toc a { color: var(--accent); text-decoration: none; }
.toc a:hover { text-decoration: underline; }
section { margin-bottom: 40px; }
h2.sec { font-size: 21px; border-bottom: 2px solid var(--border); padding-bottom: 8px; margin: 0 0 18px; scroll-margin-top: 16px; }
h3 { font-size: 17px; margin: 26px 0 10px; scroll-margin-top: 16px; }
h4 { font-size: 14px; margin: 16px 0 6px; color: var(--muted); text-transform: uppercase; letter-spacing: .4px; }
p { margin: 10px 0; }
table { border-collapse: collapse; width: 100%; margin: 14px 0; font-size: 13.5px; background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
th, td { border: 1px solid var(--border); padding: 8px 11px; text-align: left; vertical-align: top; }
th { background: var(--th-bg); font-weight: 600; }
tr:nth-child(even) td { background: var(--row-alt); }
code { background: var(--code-bg); padding: 1.5px 6px; border-radius: 5px; font-family: "SF Mono", "JetBrains Mono", "Fira Code", Consolas, monospace; font-size: 12.5px; }
.ref { color: var(--muted); font-size: 12px; font-family: "SF Mono", Consolas, monospace; }
.callout { border-radius: 8px; padding: 12px 16px; margin: 14px 0; border: 1px solid; }
.callout.note { background: var(--note-bg); border-color: var(--note-border); }
.callout.warn { background: var(--warn-bg); border-color: var(--warn-border); }
.callout.gap { background: var(--gap-bg); border-color: var(--gap-border); }
.callout.ok { background: var(--ok-bg); border-color: var(--ok-border); }
.callout b { display:block; margin-bottom:4px; }
/* ── flow diagram ── */
.flow { display: flex; flex-direction: column; align-items: stretch; gap: 0; margin: 18px 0; }
.frow { display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; }
.node {
border: 1.5px solid var(--border); border-radius: 9px; padding: 10px 14px;
background: var(--card); font-size: 13px; line-height: 1.45; position: relative;
flex: 1 1 0; min-width: 210px; max-width: 520px;
}
.node .tag { display:inline-block; font-size: 10.5px; font-weight: 700; letter-spacing:.4px; text-transform: uppercase; padding: 1px 7px; border-radius: 20px; margin-bottom: 6px; }
.node .t { font-weight: 700; font-size: 13.5px; }
.node .d { color: var(--muted); font-size: 12px; margin-top: 3px; }
.node code { font-size: 11.5px; }
.n-fe { border-color: var(--fe); background: var(--fe-bg); } .n-fe .tag { background: var(--fe); color:#fff; }
.n-bff { border-color: var(--bff); background: var(--bff-bg); } .n-bff .tag { background: var(--bff); color:#fff; }
.n-be { border-color: var(--be); background: var(--be-bg); } .n-be .tag { background: var(--be); color:#fff; }
.n-job { border-color: var(--job); background: var(--job-bg); } .n-job .tag { background: var(--job); color:#fff; }
.n-db { border-color: var(--db); background: var(--db-bg); } .n-db .tag { background: var(--db); color:#fff; }
.n-mq { border-color: var(--mq); background: var(--mq-bg); } .n-mq .tag { background: var(--mq); color:#fff; }
.arrow { text-align: center; color: var(--muted); font-size: 18px; line-height: 1; padding: 5px 0; }
.arrow small { display:block; font-size: 11px; margin-top: 2px; }
.legend { display: flex; flex-wrap: wrap; gap: 10px; margin: 14px 0; }
.legend span { font-size: 12px; padding: 3px 10px; border-radius: 20px; border: 1.5px solid; }
.lg-fe{border-color:var(--fe);background:var(--fe-bg);color:var(--fe);}
.lg-bff{border-color:var(--bff);background:var(--bff-bg);color:var(--bff);}
.lg-be{border-color:var(--be);background:var(--be-bg);color:var(--be);}
.lg-job{border-color:var(--job);background:var(--job-bg);color:var(--job);}
.lg-mq{border-color:var(--mq);background:var(--mq-bg);color:var(--mq);}
.lg-db{border-color:var(--db);background:var(--db-bg);color:var(--db);}
.pill { display:inline-block; font-size:11px; font-weight:600; padding:1px 8px; border-radius:20px; background:var(--code-bg); border:1px solid var(--border); }
ul.tight { margin: 8px 0; padding-left: 22px; }
ul.tight li { margin: 4px 0; }
.status-chain { display:flex; align-items:center; flex-wrap:wrap; gap:6px; margin:10px 0; font-size:12.5px; }
.status-chain .s { padding:3px 10px; border-radius:6px; background:var(--code-bg); border:1px solid var(--border); font-family:"SF Mono",Consolas,monospace; }
.status-chain .sep { color:var(--muted); }
hr { border:none; border-top:1px solid var(--border); margin: 28px 0; }
.small { font-size: 12.5px; color: var(--muted); }
/* ── sequence diagram ── */
.seqwrap { overflow-x: auto; margin: 16px 0; border: 1px solid var(--border); border-radius: 10px; background: var(--card); padding: 10px 6px; }
.seqwrap svg { display: block; width: 100%; height: auto; min-width: 880px; }
ol.seqlist { margin: 10px 0 18px; padding-left: 24px; font-size: 13px; }
ol.seqlist li { margin: 5px 0; }
ol.seqlist li b { color: var(--text); }
</style>
</head>
<body>
<div class="wrap">
<header class="page">
<h1>plans-plus · 订单处理流程单</h1>
<p>用户在 <code>plans-plus.html</code> 提交订单后,后端如何处理;支付完成后又如何开通 / 续期 / 执行检测。</p>
<p>覆盖四类:<b>新增租户new</b> · <b>续费renew</b> · <b>增值服务topup</b> · <b>即用即付 / 单次查询single</b></p>
<p class="meta">分层:前端 plans-plusjustsolutionsWebV2/public → Node BFFjustsolutionsWebV2/server → 后端 AMLPortal.OrderService / AML.ConsumerPortalService。</p>
<p class="meta">依据源码撰写;关键实现均标注 <span class="ref">文件:行号</span>。基于当前 main 分支代码。</p>
</header>
<nav class="toc">
<h2>目录</h2>
<ol>
<li><a href="#arch">0 · 架构分层与请求链路</a></li>
<li><a href="#overview">1 · 四类订单总览</a></li>
<li><a href="#new">2 · 新增租户new</a></li>
<li><a href="#renew">3 · 续费renew</a></li>
<li><a href="#topup">4 · 增值服务topup</a></li>
<li><a href="#single">5 · 即用即付 / 单次查询single</a></li>
<li><a href="#pay">6 · 支付回调 → 开通(订阅侧通用时序)</a></li>
<li><a href="#state">7 · 状态机</a></li>
<li><a href="#details">8 · 关键细节 · 边界 · 幂等</a></li>
<li><a href="#current">9 · 当前原型的真实运行状态(重要)</a></li>
</ol>
</nav>
<section id="arch">
<h2 class="sec">0 · 架构分层与请求链路</h2>
<p>所有请求先打到本站 <code>/api/*</code>,由 justsolutionsWebV2 的 Node/Express 服务器按环境分流:<code>test</code> 走本地 mock其他环境把订阅类接口转成真实后端调用订阅路由已实现真实对接单次查询 / 在线支付本轮仍由 mock 兜底)。</p>
<div class="legend">
<span class="lg-fe">前端 plans-plus.js</span>
<span class="lg-bff">Node BFFserver/routes、server/mock</span>
<span class="lg-be">后端同步OrderService / ConsumerPortalService</span>
<span class="lg-mq">消息队列 / 支付回调</span>
<span class="lg-job">后台 Job周期 / 异步开通)</span>
<span class="lg-db">数据 / 状态落库</span>
</div>
<div class="flow">
<div class="frow">
<div class="node n-fe"><span class="tag">FE</span><div class="t">plans-plus 表单</div><div class="d"><code>collectPayload()</code> 汇总 type + 方案 + 加值项 + 主体信息<br><span class="ref">public/js/plans-plus.js:1221</span></div></div>
</div>
<div class="arrow"><small><code>api.post()</code><code>/api/*</code></small></div>
<div class="frow">
<div class="node n-bff"><span class="tag">BFF</span><div class="t">Express 分流</div><div class="d"><code>APP_ENV</code> / <code>MOCK</code>:真实订阅路由 vs mock 兜底<br><span class="ref">server/index.js</span></div></div>
</div>
<div class="arrow"></div>
<div class="frow">
<div class="node n-be"><span class="tag">BE · 订阅</span><div class="t">AMLPortal · OrderService</div><div class="d">new → <code>CreateOrder</code>renew/topup → <code>TenantRenewal</code><br><span class="ref">amlPortal/Order/portal/*</span></div></div>
<div class="node n-be"><span class="tag">BE · 即用即付</span><div class="t">AML · ConsumerPortalService</div><div class="d">single → <code>CreateConsumerLink</code><code>CreateConsumerOrder</code><br><span class="ref">aml/ConsumerPortal/*</span></div></div>
</div>
<div class="arrow"><small>支付成功后(回调 / mock 支付 / 消息队列)</small></div>
<div class="frow">
<div class="node n-job"><span class="tag">异步 · 订阅</span><div class="t">TenantEventQueueJob</div><div class="d">周期跑 <code>ProcessTenantEventQueue</code> → 建/续租户<br><span class="ref">Basic/Jobs/TenantEventQueueJob.cs</span></div></div>
<div class="node n-mq"><span class="tag">异步 · 检测</span><div class="t">RabbitMQ 消费者</div><div class="d"><code>ProcessConsumerPortalOrder</code> → 按 functionCodes 跑检测<br><span class="ref">Basic/RabbitMQConsumerService.cs:186</span></div></div>
</div>
</div>
<p class="small">下文每类订单单独给出完整链路。订阅三类new/renew/topup共用同一套「订单 → 支付回调 → 租户事件队列 Job」骨架即用即付走另一套「即时检测链接 → 订单 → RabbitMQ → 检测编排」骨架。</p>
</section>
<section id="overview">
<h2 class="sec">1 · 四类订单总览</h2>
<table>
<thead><tr><th>类型</th><th>前端 <code>type</code></th><th>BFF 路由</th><th>后端接口</th><th>后端服务方法</th><th>订单枚举</th><th>支付后动作</th></tr></thead>
<tbody>
<tr>
<td><b>新增租户</b></td><td><code>new</code></td>
<td><code>POST /api/subscribe</code><br><span class="ref">routes/subscribe.js</span></td>
<td><code>/api/amlPortal/Order/portal/CreateOrder</code></td>
<td><code>OrderService.CreateOrder</code><br><span class="ref">OrderService.cs:146</span></td>
<td><code>OrderType=NewReg</code></td>
<td>新建 ABP 租户 + 按 edition 开关 feature + 建 TenantProperty</td>
</tr>
<tr>
<td><b>续费</b></td><td><code>renew</code></td>
<td><code>POST /api/subscribe</code></td>
<td><code>/api/amlPortal/Order/portal/TenantRenewal</code></td>
<td><code>OrderService.TenantRenewal</code><br><span class="ref">OrderService.cs:2199</span></td>
<td><code>OrderType=Renewal</code></td>
<td>作废旧 TenantProperty建新的期限往后叠加、额度累加</td>
</tr>
<tr>
<td><b>增值服务</b></td><td><code>topup</code></td>
<td><code>POST /api/subscribe</code></td>
<td><code>/api/amlPortal/Order/portal/TenantRenewal</code></td>
<td><code>OrderService.TenantRenewal</code>PlanList 只含加值项)</td>
<td><code>OrderType=Renewal</code></td>
<td>同续费分支;但无基础方案 → 只叠加额度 / 用户数 / KYC不延长期限</td>
</tr>
<tr>
<td><b>即用即付</b></td><td><code>single</code></td>
<td><code>POST /api/single-query</code><br><span class="gap">当前 mock</span></td>
<td>目标:<code>/api/aml/ConsumerPortal/CreateConsumerLink</code><code>CreateConsumerOrder</code></td>
<td><code>ConsumerPortalService</code><br><span class="ref">ConsumerPortalService.cs:964 / 325</span></td>
<td><code>ConsumerPortalOrder</code></td>
<td>发 RabbitMQ → 按 <code>functionCodes</code> 逐模块检测 → 邮件发结果</td>
</tr>
</tbody>
</table>
<div class="callout note"><b>前端如何分流</b>
<code>ppSubmit()</code> <span class="ref">plans-plus.js:1342</span><code>type==='single'</code><code>submitSingle()</code>(走 <code>/single-query</code> + <code>/payments/create</code>);否则 → <code>POST /subscribe</code>。BFF 的 <code>subscribe.js</code> 再按 <code>payload.type</code> 决定调 <code>CreateOrder</code>new还是 <code>TenantRenewal</code>renew / topup
</div>
</section>
<section id="new">
<h2 class="sec">2 · 新增租户new</h2>
<div class="flow">
<div class="frow"><div class="node n-fe"><span class="tag">FE</span><div class="t">提交新购订单</div><div class="d">主体信息(公司/个人)+ 行业 edition + 方案 + 加值项 + 推荐人代码。<code>POST /subscribe</code><code>type='new'</code></div></div></div>
<div class="arrow"></div>
<div class="frow"><div class="node n-bff"><span class="tag">BFF</span><div class="t">subscribe.js 组装后端入参</div>
<div class="d"><code>getCatalogBundle(countryCode)</code> 取目录 → <code>buildPlanList()</code> 把方案 + 各加值项映射为 <code>{PlanId, PlanDetailId, PCS}</code><code>resolveAgentId()</code> 把推荐人代码解析成后端用户 Guid<code>AgentorId</code>。KYC 按订阅月数选期限变体。<span class="ref">routes/subscribe.js</span></div></div></div>
<div class="arrow"><small><code>POST /api/amlPortal/Order/portal/CreateOrder</code></small></div>
<div class="frow"><div class="node n-be"><span class="tag">BE</span><div class="t">OrderService.CreateOrder <span class="ref">:146</span></div>
<div class="d">校验邮箱未占用、Agentor / Plan / PlanDetail 合法、TenantName 未重复、BR/CI 不重复BR/CI 允许为空、edition 合法。<br><code>Customer</code>(默认密码)→ 建 <code>Order</code><code>PenddingPaid</code> / <code>UnPaid</code><code>OrderType=NewReg</code>+ <code>OrderDetail</code>(记录期限 / JCount / QCount / 用户上限)。未指定 agent → 挂到顶级 salesAdmin。</div></div></div>
<div class="arrow"><small>按金额 / 支付方式分三条支路</small></div>
<div class="frow">
<div class="node n-be"><span class="tag">A · 免费单</span><div class="d"><code>PlanPrice≤0</code><code>PendingActive</code>,发「激活邮件」,用户点邮件里的 <code>ActiveFreeOrder</code> 链接后再开通。</div></div>
<div class="node n-be"><span class="tag">B · 线下支付</span><div class="d"><code>IsOfflinePayment=true</code><code>PenddingAudit</code>,等管理员 <code>OrderAudit</code> 审核后进入开通。</div></div>
<div class="node n-be"><span class="tag">C · 线上支付</span><div class="d"><code>MockupPayment()</code> <span class="ref">:2916</span>:若 <code>IsMockupPayment=true</code>,读取 mock webhook 数据、内联直接调 <code>OnPaymentSuccess()</code>(等价于「立即支付成功」)。真实支付则等支付方回调。</div></div>
</div>
<div class="arrow"><small>支付成功(回调 / mock 支付)</small></div>
<div class="frow"><div class="node n-be"><span class="tag">BE · 回调</span><div class="t">OnPaymentSuccess <span class="ref">:1174</span></div>
<div class="d"><code>out_trade_no</code>=OrderCode 找单;写 <code>PaymentInfo</code>(含 InvoiceNo<code>Order.OrderStatus=Paid</code> / <code>PaymentStatus=Paid</code> / 记 <code>PaidAmount</code> / <code>PaymentTime</code><b>幂等</b>:已有 PaymentInfo 则直接 return。最后 <code>InsertTenantEventQueue(order)</code> 插入一条 <code>EventType=Create</code> / <code>Status=Pending</code> 的租户事件队列 <span class="ref">:1371</span></div></div></div>
<div class="arrow"><small>后台 Job 周期扫描队列(异步,非请求内完成)</small></div>
<div class="frow"><div class="node n-job"><span class="tag">JOB</span><div class="t">ProcessTenantEventQueueNewReg 分支) <span class="ref">:528 / 570</span></div>
<div class="d">
① 若同名租户已存在 → 只补发通知邮件、队列置 <code>Success</code>(幂等)。<br>
② 否则调 <code>CreateTenant()</code>ABP SaaS 建租户 + admin<br>
③ 关 <code>AMLPortal.Enable</code>,按 <code>edition → feature 映射</code> 启用 / 禁用业务功能(行业决定可用模块)。<br>
④ 建 <code>TenantProperty</code>:期限(结合前端 EffectiveStartTime + 各方案 period 叠加)、<code>JCountLeft/QCountLeft</code>(累加)、<code>UserCountLimit</code>=max(各方案)+附加用户、<code>EnableKYC</code><br>
⑤ 写 <code>TenantInfo</code>(邮箱 / 国别 / BR/CI / 地址 / 联系人)。<br>
⑥ 发邮件给买家(含改密链接)+ 逐级 agent。<br>
<code>Order=Completed</code>、队列 <code>Success</code>。失败按 <code>RetryTimes</code> 重试,超限 <code>Failed</code></div></div></div>
</div>
<div class="callout note"><b>期限计算NewReg</b> 若该租户已有生效中的 TenantProperty 且未过期 → 沿用旧的起止时间再往后叠加本次 period前端选的开始日作废已过期 / 无历史 → 以前端 <code>EffectiveStartTime</code>(默认今天)为起点叠加。<span class="ref">OrderService.cs:692-751</span></div>
</section>
<section id="renew">
<h2 class="sec">3 · 续费renew</h2>
<p>续费前会先查回当前订阅:前端在第 1 步用管理员邮箱 <code>ppLookupTenant()</code> 命中 <code>queryRenewableTenantByEmail</code>,带出租户、当前方案、到期日、额度、推荐人,并默认沿用上次方案。</p>
<div class="flow">
<div class="frow"><div class="node n-fe"><span class="tag">FE</span><div class="t">提交续费</div><div class="d"><code>type='renew'</code>payload 带 <code>tenantId</code> + 管理员邮箱 + 新方案 + 加值项。<code>POST /subscribe</code></div></div></div>
<div class="arrow"></div>
<div class="frow"><div class="node n-bff"><span class="tag">BFF</span><div class="t">subscribe.jsrenew 支)</div><div class="d">同样 <code>buildPlanList()</code>(含基础方案 + 加值项)→ 组 <code>TenantRenewalParam</code><code>TargetTenantID</code> + <code>TenantAdminEmail</code> + PlanList<span class="ref">routes/subscribe.js</span></div></div></div>
<div class="arrow"><small><code>POST /api/amlPortal/Order/portal/TenantRenewal</code></small></div>
<div class="frow"><div class="node n-be"><span class="tag">BE</span><div class="t">OrderService.TenantRenewal <span class="ref">:2199</span></div>
<div class="d">校验 Plan/PlanDetail、目标租户存在、找到 <code>IsActive</code> 的当前 TenantProperty校验 admin 邮箱与租户 admin 一致。<b>复用</b> lastOrder 的公司 / 邮箱 / 行业 / BR/CI 等,建 <code>Order</code><code>Renewal</code> / <code>PenddingPaid</code> / <code>UnPaid</code>+ OrderDetail。基础方案Tag1=B的服务起点当前 <code>EffectiveEndTime</code> 未过期→接续到期日之后;已过期→从今天起。未指定 agent → 沿用上次 agent。</div></div></div>
<div class="arrow"><small>线上→ MockupPayment / 线下→ PenddingAudit</small></div>
<div class="frow"><div class="node n-be"><span class="tag">BE · 回调</span><div class="t">OnPaymentSuccess → InsertTenantEventQueue</div><div class="d">与新购完全一致:写 PaymentInfo、订单置 Paid、插入 <code>EventType=Create</code> 队列同一队列Job 内按 <code>OrderType</code> 区分分支)。</div></div></div>
<div class="arrow"></div>
<div class="frow"><div class="node n-job"><span class="tag">JOB</span><div class="t">ProcessTenantEventQueue续费分支 <span class="ref">:848-989</span></div>
<div class="d">
不新建租户。找到当前 <code>IsActive</code> TenantProperty → 置 <code>IsActive=false</code> → 建<b>新一条</b> TenantProperty<br>
· <code>JCountLeft/QCountLeft = 旧余额 + 本次购买</code>(额度累加,续费不清零)<br>
· 期限:未过期→在原到期日基础上叠加本次 period已过期→从今天起算 period<br>
· <code>UserCountLimit = max(旧, 各方案) + 附加用户</code><code>EnableKYC</code> 沿用<br>
· 按 edition 再次启用对应 feature<br>
· 发续费邮件给买家 + agent<code>Order=Completed</code>、队列 <code>Success</code></div></div></div>
</div>
<div class="callout ok"><b>额度语义</b> 续费是「续期 + 叠加额度」,不是重置:<code>JCountLeft/QCountLeft</code> 在旧余额上累加,期限从原到期日往后接续(未过期时)。<span class="ref">OrderService.cs:879-880, 888-929</span></div>
</section>
<section id="topup">
<h2 class="sec">4 · 增值服务topup</h2>
<p>增值服务(加购)与续费共用同一后端接口 <code>TenantRenewal</code>,区别只在 <b>PlanList 不含基础方案</b>,只含加值项(增加使用人 / KYC 设备租赁 / jQuota 加量)。</p>
<div class="flow">
<div class="frow"><div class="node n-fe"><span class="tag">FE</span><div class="t">提交加购</div><div class="d"><code>type='topup'</code>;只勾选加值项。若订阅已过期 → 前端拦截,提示「先续费」(<code>ppTopupBlocked</code>)。</div></div></div>
<div class="arrow"></div>
<div class="frow"><div class="node n-bff"><span class="tag">BFF</span><div class="t">buildPlanListtopup 特判)</div><div class="d"><code>if (payload.type !== 'topup')</code> 才 push 基础方案 → topup 时<b>跳过</b>基础方案,只映射 users / kyc / jquota 三类加值项的 <code>PlanId/PlanDetailId/PCS</code><span class="ref">routes/subscribe.js buildPlanList()</span></div></div></div>
<div class="arrow"><small><code>POST /api/amlPortal/Order/portal/TenantRenewal</code>(同续费接口)</small></div>
<div class="frow"><div class="node n-be"><span class="tag">BE</span><div class="t">OrderService.TenantRenewal <span class="ref">:2199</span></div><div class="d">同续费:建 <code>Renewal</code> 订单 + OrderDetail。因 PlanList 无 Tag1=B 的基础方案,<code>CurrentOrderServiceStartTime/EndTime</code> 不会被设置。</div></div></div>
<div class="arrow"><small>支付成功 → 队列</small></div>
<div class="frow"><div class="node n-job"><span class="tag">JOB</span><div class="t">ProcessTenantEventQueue续费分支</div>
<div class="d">走与续费相同的分支,但因订单里<b>没有</b>带 period 的基础方案:<br>
· 期限不延长(<code>dtEffectiveEndTime</code> 无 period 可叠加,沿用旧到期日)<br>
· 只叠加:<code>JCountLeft/QCountLeft</code>(若买了 jQuota<code>UserCountLimit</code>(若买了增加使用人 AdlU、KYC若租设备<br>
· 发通知邮件,<code>Order=Completed</code></div></div></div>
</div>
<div class="callout warn"><b>加购 = 续费接口的子集</b> 后端不区分 renew / topup都是 <code>OrderType=Renewal</code>),差异完全由 <b>PlanList 里有没有基础方案</b>决定。因此「只加额度 / 只加用户 / 只租 KYC」而不延长期限是 topup 的天然结果,而非后端另写了一套逻辑。</div>
</section>
<section id="single">
<h2 class="sec">5 · 即用即付 / 单次查询single</h2>
<p>即用即付不创建租户、不做订阅,而是「付一次、查一次、邮件发结果」。它对应后端 AML 模块的 <b>即时检测链接ConsumerPortal</b>一套流程,与订阅三类完全不同。</p>
<h3>5.1 前端当前实现mock</h3>
<div class="flow">
<div class="frow"><div class="node n-fe"><span class="tag">FE</span><div class="t">submitSingle() <span class="ref">:1279</span></div><div class="d">固定 4 检测项(<code>DEFAULT_SQ_OPS</code>:证件核验 V / 名单筛查 E / AI 增强 A / 失信 D<code>functionCodes='VEAD'</code>。整包价来自 <code>GetPlanList</code> 的 PAYG(P2G) 方案。</div></div></div>
<div class="arrow"><small><code>POST /single-query</code><code>POST /payments/create</code> → 跳转 mock 收银台</small></div>
<div class="frow"><div class="node n-bff"><span class="tag">BFF · mock</span><div class="t">plans-plus mock</div><div class="d"><code>/single-query</code> 只回一个 <code>mock-sq-*</code> 订单号;<code>/payments/create</code> 回 mock 收银台 URL<code>/payments/:pid/webhook</code> 模拟支付成功事件。<b>不触发任何真实检测</b><span class="ref">server/mock/plans-plus.js</span></div></div></div>
</div>
<h3>5.2 后端真实流程(目标 / ConsumerPortal</h3>
<div class="flow">
<div class="frow"><div class="node n-be"><span class="tag">BE</span><div class="t">CreateConsumerLink <span class="ref">:964</span></div><div class="d">按邮箱建 / 更 <code>ConsumerCustomer</code>,生成带 <code>AccessToken</code><code>ConsumerLink</code>,把 <code>FunctionCodes</code> 存到链接上(该链接允许 / 预设的检测项),发链接邮件。</div></div></div>
<div class="arrow"><small>用户经链接提交主体</small></div>
<div class="frow"><div class="node n-be"><span class="tag">BE</span><div class="t">CreateConsumerOrder <span class="ref">:325</span></div>
<div class="d"><code>FunctionCodes</code> 用计费表算价 → 建 <code>ConsumerPortalOrder</code><code>PaymentStatus=Paid</code><code>OrderStatus=Pendding</code>)→ <b>每个 functionCode 建一条 <code>DetectionTask</code></b><code>BatchId=订单Id</code>)→ 更新 ConsumerLink → 发 RabbitMQ 消息(订单 Id<span class="ref">:398-411, :446</span></div></div></div>
<div class="arrow"><small>RabbitMQ 消费</small></div>
<div class="frow"><div class="node n-mq"><span class="tag">MQ</span><div class="t">RabbitMQConsumerService <span class="ref">:186</span></div><div class="d">消费订单 Id → 调 <code>ProcessConsumerPortalOrder(orderId)</code></div></div></div>
<div class="arrow"></div>
<div class="frow"><div class="node n-be"><span class="tag">BE</span><div class="t">ProcessConsumerPortalOrder <span class="ref">:634</span></div><div class="d">委托 <code>DetectionService.RunDetectionModulesAsync(FunctionCodes=order.FunctionCodes, WaitForCompletion=true)</code>;全部模块完成后 <code>Order=Success</code> 并发结果邮件。</div></div></div>
<div class="arrow"><small>逐模块按 functionCode 门控</small></div>
<div class="frow"><div class="node n-be"><span class="tag">BE</span><div class="t">RunDetectionModulesAsync <span class="ref">DetectionService.cs:175</span></div>
<div class="d">每个模块 <code>functionCodes.Contains(...)</code> 才执行:含 E/A → ES/AI 筛查;含 V → 证件核验;含 D → 失信查询;含 O → OCR含 S → 风评;含 R → CDD 报告。<b>未包含的 code 对应模块直接跳过</b><span class="ref">:230, :281, :363, :435, :448</span></div></div></div>
</div>
<div class="callout ok"><b>「只执行对应项目」成立</b> 后端严格按订单 <code>functionCodes</code> 建任务并逐模块门控,计费也基于同一批 code。因此前端展示的检测项目应与真正传入的 <code>functionCodes</code> 同源,否则会「展示了却不跑 / 跑了却没展示」。<code>FunctionCodeEnums</code>E=ES、A=AI、O=OCR、V=证件核验、D=失信、S=风评、R=CDD 报告。<span class="ref">Basic/Enums.cs:2365</span></div>
</section>
<section id="pay">
<h2 class="sec">6 · 支付回调 → 开通(订阅侧通用时序)</h2>
<p>订阅三类new/renew/topup支付成功后的开通链路一致关键在于「回调只置订单已付 + 入队;真正建 / 续租户由后台 Job 异步完成」。这样解耦是为了幂等与失败重试。</p>
<div class="flow">
<div class="frow"><div class="node n-mq"><span class="tag">回调</span><div class="t">PaymentWebhook <span class="ref">OrderController / OrderService:1160</span></div><div class="d"><code>lock</code> 串行化;调 <code>OnPaymentSuccess</code>。mock 支付时由 <code>MockupPayment</code> 内联触发同一方法。</div></div></div>
<div class="arrow"></div>
<div class="frow"><div class="node n-be"><span class="tag">同步</span><div class="t">OnPaymentSuccess <span class="ref">:1174</span></div><div class="d">写 PaymentInfo幂等已存在则 return→ Order 置 Paid → <code>InsertTenantEventQueue</code>Create/Pending</div></div></div>
<div class="arrow"><small>请求到此返回;开通由 Job 异步完成</small></div>
<div class="frow"><div class="node n-db"><span class="tag">队列</span><div class="t">TenantEventQueue</div><div class="d">一条 <code>OrderId + EventType=Create + Status=Pending</code></div></div></div>
<div class="arrow"><small>周期 <code>IntervalSeconds</code></small></div>
<div class="frow"><div class="node n-job"><span class="tag">JOB</span><div class="t">TenantEventQueueJob → ProcessTenantEventQueue</div><div class="d"><code>OrderType</code> 走 NewReg / 续费分支,建 / 续租户、开 feature、写 TenantProperty、发邮件、订单置 Completed。失败重试 <code>RetryTimes</code> 次后 <code>Failed</code><span class="ref">Basic/Jobs/TenantEventQueueJob.cs</span></div></div></div>
</div>
<h3>6.1 详细时序图(回调 ↔ 落库 ↔ 队列 ↔ Job</h3>
<p class="small">纵向泳道从左到右:支付方 / 后端入口 / 服务方法 / 数据库 / 后台 Job / SaaS 建租户 / 邮件。上半部A<b>同步、发生在 HTTP 请求内</b>;虚线是<b>异步边界</b>——请求返回时租户尚未创建下半部B<b>后台周期 Job</b> 稍后完成的真正开通。活性条表示该泳道在此期间处于活动状态。</p>
<div class="seqwrap">
<svg viewBox="0 0 1275 590" role="img" aria-label="支付回调到租户开通的详细时序图">
<defs>
<marker id="ah" markerWidth="10" markerHeight="10" refX="7.5" refY="3" orient="auto"><path d="M0,0 L7.5,3 L0,6 Z" fill="#57606a"/></marker>
</defs>
<!-- lifelines -->
<g stroke="#d0d7de" stroke-width="1" stroke-dasharray="4 4">
<line x1="95" y1="56" x2="95" y2="566"/>
<line x1="285" y1="56" x2="285" y2="566"/>
<line x1="470" y1="56" x2="470" y2="566"/>
<line x1="650" y1="56" x2="650" y2="566"/>
<line x1="840" y1="56" x2="840" y2="566"/>
<line x1="1010" y1="56" x2="1010" y2="566"/>
<line x1="1180" y1="56" x2="1180" y2="566"/>
</g>
<!-- activation bars -->
<rect x="282" y="126" width="6" height="182" fill="#ffe7d1" stroke="#bc4c00" stroke-width="1"/>
<rect x="467" y="160" width="6" height="118" fill="#dafbe1" stroke="#1a7f37" stroke-width="1"/>
<rect x="837" y="364" width="6" height="186" fill="#fff5d6" stroke="#9a6700" stroke-width="1"/>
<!-- participant boxes -->
<g font-size="10.5" font-weight="700" text-anchor="middle" fill="#24292f">
<rect x="35" y="16" width="120" height="40" rx="7" fill="#ffe7d1" stroke="#bc4c00"/><text x="95" y="40">支付方 / Mock</text>
<rect x="210" y="16" width="150" height="40" rx="7" fill="#ffe7d1" stroke="#bc4c00"/><text x="285" y="40">PaymentWebhook</text>
<rect x="390" y="16" width="160" height="40" rx="7" fill="#dafbe1" stroke="#1a7f37"/><text x="470" y="40">OnPaymentSuccess</text>
<rect x="565" y="16" width="170" height="40" rx="7" fill="#eef1f4" stroke="#57606a"/><text x="650" y="33">DB</text><text x="650" y="47" font-size="8.5" font-weight="500" fill="#57606a">Order·PaymentInfo·Queue</text>
<rect x="755" y="16" width="170" height="40" rx="7" fill="#fff5d6" stroke="#9a6700"/><text x="840" y="40">TenantEventQueueJob</text>
<rect x="930" y="16" width="160" height="40" rx="7" fill="#dafbe1" stroke="#1a7f37"/><text x="1010" y="40">CreateTenant · SaaS</text>
<rect x="1135" y="16" width="90" height="40" rx="7" fill="#eef1f4" stroke="#57606a"/><text x="1180" y="40">邮件</text>
</g>
<!-- phase labels -->
<text x="22" y="96" font-size="11" font-weight="700" fill="#bc4c00">A · 同步(请求内)</text>
<text x="22" y="362" font-size="11" font-weight="700" fill="#9a6700">B · 异步(周期 Job</text>
<!-- messages -->
<g stroke="#57606a" stroke-width="1.5">
<line x1="95" y1="100" x2="285" y2="100" marker-end="url(#ah)"/>
<line x1="285" y1="134" x2="470" y2="134" marker-end="url(#ah)"/>
<line x1="470" y1="168" x2="650" y2="168" marker-end="url(#ah)"/>
<line x1="470" y1="202" x2="650" y2="202" marker-end="url(#ah)"/>
<line x1="470" y1="236" x2="650" y2="236" marker-end="url(#ah)"/>
<line x1="470" y1="270" x2="650" y2="270" marker-end="url(#ah)"/>
<line x1="285" y1="304" x2="95" y2="304" marker-end="url(#ah)" stroke-dasharray="5 4"/>
<line x1="840" y1="372" x2="650" y2="372" marker-end="url(#ah)"/>
<line x1="840" y1="406" x2="1010" y2="406" marker-end="url(#ah)"/>
<line x1="1010" y1="440" x2="840" y2="440" marker-end="url(#ah)" stroke-dasharray="5 4"/>
<line x1="840" y1="474" x2="1010" y2="474" marker-end="url(#ah)"/>
<line x1="840" y1="508" x2="650" y2="508" marker-end="url(#ah)"/>
<line x1="840" y1="542" x2="1180" y2="542" marker-end="url(#ah)"/>
</g>
<!-- message labels -->
<g font-size="11" fill="#24292f" text-anchor="middle">
<text x="190" y="95">① PaymentWebhook (status=1)</text>
<text x="377" y="129">② OnPaymentSuccess · lock</text>
<text x="560" y="163">③ 查 Order (OrderCode)</text>
<text x="560" y="197">④ 写 PaymentInfo幂等</text>
<text x="560" y="231">⑤ Order = Paid</text>
<text x="560" y="265">⑥ 入队 Create / Pending</text>
<text x="190" y="299">⑦ 200 OK未建租户</text>
<text x="745" y="367">⑧ 扫描队列 + 载入 Order</text>
<text x="925" y="401">⑨ CreateTenant</text>
<text x="925" y="435">⑩ tenantId</text>
<text x="925" y="469">⑪ EnableFeatures(edition)</text>
<text x="745" y="503">⑫ 建 TenantProperty · Completed</text>
<text x="1010" y="537">⑬ 通知邮件 买家 + agent</text>
</g>
<!-- async boundary -->
<line x1="20" y1="332" x2="1255" y2="332" stroke="#bc4c00" stroke-width="1.2" stroke-dasharray="6 5"/>
<text x="637" y="326" font-size="11" font-weight="700" fill="#bc4c00" text-anchor="middle">═ 异步边界 · HTTP 请求已返回(租户尚未创建);以下由后台 Job 稍后执行 ═</text>
</svg>
</div>
<h4>A · 同步HTTP 请求内)</h4>
<ol class="seqlist">
<li><b></b> 支付方回调 <code>POST /api/amlPortal/Order/portal/PaymentWebhook</code><code>status=1</code>、带 <code>syssn</code>);入口用 <code>lock</code> 串行化。mock 支付时由 <code>MockupPayment</code> 用本地 mock 数据<b>内联触发同一入口</b>,等价于「立即支付成功」。<span class="ref">OrderService.cs:1160 / 2916</span></li>
<li><b></b> <code>PaymentWebhook</code><code>OnPaymentSuccess(param)</code><span class="ref">:1174</span></li>
<li><b></b><code>out_trade_no = OrderCode</code> 查订单(禁多租户过滤 + 允许软删)。<span class="ref">:1181</span></li>
<li><b></b><code>PaymentInfo</code>(含 <code>InvoiceNo</code><b>幂等点</b>:若该订单已有 PaymentInfo → 直接 <code>return</code>不重复处理webhook 可能重复投递)。<span class="ref">:1197-1217</span></li>
<li><b></b> <code>Order.OrderStatus=Paid</code><code>PaymentStatus=Paid</code>、记 <code>PaidAmount</code>/<code>PaymentTime</code>(曾软删则恢复)。<span class="ref">:1225-1241</span></li>
<li><b></b> <code>InsertTenantEventQueue(order)</code> 插入 <code>EventType=Create</code> / <code>Status=Pending</code>(已存在则不重复插)。<span class="ref">:1249 / 1371</span></li>
<li><b></b> <code>PaymentWebhook</code> 返回 200。<b>此刻租户 / 续期尚未生效</b> —— 真正开通在后台 Job 完成。</li>
</ol>
<h4>B · 异步(<code>TenantEventQueueJob</code><code>IntervalSeconds</code> 跑一次)</h4>
<ol class="seqlist" start="8">
<li><b></b> <code>ProcessTenantEventQueue</code> 扫描 <code>Pending</code>/<code>Retry</code> 队列,载入 <code>Order</code>(含 OrderDetails / PlanDetail<code>edition</code><code>Retry</code> 项按 <code>RetryMinutes × TryTimes</code> 退避。<span class="ref">:528-556, 542</span></li>
<li><b></b> <b>NewReg 分支</b>:同名租户已存在 → 只补发邮件并置 <code>Success</code>(幂等);否则 <code>CreateTenant(name, editionId, adminEmail, adminPassword)</code>ABP SaaS 建租户 + admin<span class="ref">:570-627</span></li>
<li><b></b> <code>CreateTenant</code> 返回 <code>tenantId</code><span class="ref">:630</span></li>
<li><b></b> 关闭 <code>AMLPortal.Enable</code>,按 <code>edition → feature 映射</code>启用 / 禁用业务功能(行业决定可用模块)。<span class="ref">:632-661</span></li>
<li><b></b><code>TenantProperty</code>(期限叠加、<code>JCountLeft/QCountLeft</code> 累加、<code>UserCountLimit</code><code>EnableKYC</code>)→ 写 <code>TenantInfo</code><code>Order=Completed</code>、队列 <code>Success</code>、清缓存。<span class="ref">:677-801, 964-967</span></li>
<li><b></b> 发通知邮件:买家(含改密链接)+ 逐级 agent。<b>失败</b> → 队列 <code>Retry</code><code>TryTimes+1</code>),超 <code>RetryTimes</code><code>Failed</code><span class="ref">:803-844, 972-986</span></li>
</ol>
<div class="callout note"><b>续费 / 加购分支(⑧ 之后)</b> 不建租户:作废旧 <code>IsActive</code> 的 TenantProperty新建一条额度 / 期限 / 用户在旧值上叠加),启用 edition feature发续费邮件<code>Order=Completed</code><span class="ref">OrderService.cs:848-989</span></div>
<h3>6.2 幂等 · 重试 · 清理 · mock 支付</h3>
<table>
<thead><tr><th>关注点</th><th>机制</th><th>位置</th></tr></thead>
<tbody>
<tr><td>支付幂等</td><td>OnPaymentSuccess 若已写过 PaymentInfo 直接 returnwebhook 可重复投递</td><td class="ref">OrderService.cs:1213-1217</td></tr>
<tr><td>开通幂等</td><td>Job 内若同名租户已存在,只补发邮件并置 Success不重复建租户</td><td class="ref">OrderService.cs:578-615</td></tr>
<tr><td>失败重试</td><td>队列 <code>Retry</code> + <code>TryTimes</code> + 退避RetryMinutes×TryTimes<code>RetryTimes</code> 置 Failed</td><td class="ref">OrderService.cs:542, 831-843</td></tr>
<tr><td>未支付清理</td><td><code>ClearExpiredOrderCustomer</code>:超 <code>ExpireOrderMins</code> 未支付的订单NewReg 删客户信息</td><td class="ref">OrderService.cs:1271</td></tr>
<tr><td>mock 支付</td><td><code>IsMockupPayment=true</code> 时 CreateOrder / TenantRenewal 内联触发 OnPaymentSuccess无需真实网关</td><td class="ref">OrderService.cs:2916</td></tr>
</tbody>
</table>
</section>
<section id="state">
<h2 class="sec">7 · 状态机</h2>
<h4>订阅订单 Order.OrderStatus</h4>
<div class="status-chain">
<span class="s">PenddingPaid</span><span class="sep">──付款成功──▶</span>
<span class="s">Paid</span><span class="sep">──Job 建/续租户──▶</span>
<span class="s">Completed</span>
</div>
<div class="status-chain">
<span class="sep">旁路:</span><span class="s">PlanPrice≤0 → PendingActive</span><span class="sep">(点激活邮件)</span>
<span class="s">线下 → PenddingAudit</span><span class="sep">(管理员审核)</span>
<span class="s">超时未付 → Expired / 清理</span>
</div>
<h4>租户事件队列 TenantEventQueue.Status</h4>
<div class="status-chain">
<span class="s">Pending</span><span class="sep">─▶</span><span class="s">Success</span>
<span class="sep"></span><span class="s">Retry</span><span class="sep">× TryTimes─▶</span><span class="s">Failed</span>
</div>
<h4>即用即付订单 ConsumerPortalOrder.OrderStatus</h4>
<div class="status-chain">
<span class="s">Pendding</span><span class="sep">──RabbitMQ→检测编排──▶</span><span class="s">Success</span>
<span class="sep">异常 ▶</span><span class="s">Failed</span>
</div>
<p class="small">注:即用即付订单建单即 <code>PaymentStatus=Paid</code>(当前实现下支付被前置 / 简化),真正的耗时在检测编排;订阅订单则是「先建单未付 → 付款 → 异步开通」。</p>
</section>
<section id="details">
<h2 class="sec">8 · 关键细节 · 边界 · 幂等</h2>
<ul class="tight">
<li><b>行业 → 功能:</b>新购 / 续费在开通时按 <code>edition → feature 映射</code>启用对应业务功能realestate / DPMS / VASP 等),行业决定租户可用的检测模块。<span class="ref">OrderService.cs:636-661, 935-953</span></li>
<li><b>期限叠加:</b>未过期续费从原到期日往后接续;已过期从今天起。新购首单以前端选的开始日为起点。加购(无基础方案)不延长期限。</li>
<li><b>额度累加:</b><code>JCountLeft/QCountLeft = 旧余额 + Σ(本次购买 × PCS)</code><code>JCountLeftInitEffected</code> 记录本次生效初值。</li>
<li><b>用户数:</b><code>UserCountLimit = max(旧值, 各基础方案 UserCountLimit) + Σ 附加用户(AdlU)×PCS</code></li>
<li><b>KYC</b>基础方案是否含 KYC → <code>EnableKYC</code>KYC 设备按订阅月数选期限变体固定总价PCS=1或回退按月租×月数。<span class="ref">routes/subscribe.js buildPlanList()</span></li>
<li><b>推荐人agent</b>前端推荐人代码 → BFF <code>resolveAgentId</code> → 后端 <code>AgentorId</code>;开通后逐级向上给 agent 发通知邮件。未填 → 挂顶级 salesAdmin。</li>
<li><b>BR/CI 可空:</b>plans-plus 新购允许个人主体 / 空 BR/CI仅在 BR/CI 非空时做唯一性校验。<span class="ref">OrderService.cs:154-196</span></li>
<li><b>即用即付「只跑对应项」:</b>每个 <code>functionCode</code> 一条 DetectionTask<code>RunDetectionModulesAsync</code> 逐模块 <code>Contains</code> 门控;计费同源。<span class="ref">DetectionService.cs:175</span></li>
<li><b>解耦异步:</b>订阅开通落在后台 Job即用即付检测落在 RabbitMQ 消费者。两者都做了幂等与重试,避免回调 / 消息重复导致重复开通 / 重复检测。</li>
</ul>
</section>
<section id="current">
<h2 class="sec">9 · 当前原型的真实运行状态(重要)</h2>
<div class="callout gap"><b>在线支付未开通 · plans-plus 当前实际不会走上面的「支付 → 开通」全链路</b>
前端 <code>ONLINE_PAYMENT_ENABLED = false</code> <span class="ref">plans-plus.js:13</span>「Confirm &amp; Subscribe」按钮恒置灰<code>ppSubmit()</code> 一进来就 <code>return</code> <span class="ref">:1342</span>。用户实际只能点「联络我们 / 联络推荐人」。
</div>
<table>
<thead><tr><th>能力</th><th>设计 / 目标链路</th><th>当前原型实际</th></tr></thead>
<tbody>
<tr><td>订阅下单new/renew/topup</td><td><code>/subscribe</code><code>CreateOrder</code>/<code>TenantRenewal</code> → 支付 → 开通</td><td><b>走线下</b><code>ppContactSubmit</code><code>POST /subscribe-offline</code> → 后端 <code>customer/CreateFeedback</code>(只发线索,不建订单)<span class="ref">routes/subscribe.js</span></td></tr>
<tr><td>即用即付single</td><td><code>CreateConsumerLink</code><code>CreateConsumerOrder</code> → RabbitMQ → 检测</td><td><b>纯 mock</b><code>/single-query</code> + <code>/payments/*</code> 只回模拟数据,不触发真实检测<span class="ref">server/mock/plans-plus.js</span></td></tr>
<tr><td>真实订阅路由</td><td>editions / plans / agents / tenants / <b>subscribe</b> 已对接 AMLPortal</td><td>已实现,<code>ONLINE_PAYMENT_ENABLED=true</code> 后即可启用在线下单</td></tr>
</tbody>
</table>
<div class="callout note"><b>要打通到真实后端时</b>
① 前端置 <code>ONLINE_PAYMENT_ENABLED=true</code>;② 订阅链路已就绪(<code>subscribe.js</code> → CreateOrder/TenantRenewal后端配 <code>IsMockupPayment</code> 或接真实支付;③ 即用即付需把 <code>/single-query</code> 从 mock 换成真正的 <code>ConsumerPortal.CreateConsumerLink/CreateConsumerOrder</code>,并保证前端展示的检测项与传入 <code>functionCodes</code> 同源。
</div>
</section>
<hr>
<p class="small">本流程单基于源码撰写,仅描述订单 / 支付 / 开通 / 检测编排主链路邮件模板、发票生成、agent 组织树、额度预警等旁路仅在需要处点到为止。若后端代码调整,请以最新 <span class="ref">OrderService.cs / ConsumerPortalService.cs / DetectionService.cs</span> 为准。</p>
</div>
</body>
</html>