Refactor code structure for improved readability and maintainability

main
fengruixiang 2026-07-08 14:43:21 +08:00
parent c71cfb4c02
commit c021b936d9
3 changed files with 836 additions and 5 deletions

11
.gitignore vendored
View File

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

View File

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

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