AML/docs/justsolutionsWebV2/订单处理流程单.html

504 lines
46 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

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

<!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>