把 public/plans-plus.html 的單頁訂閱流程,從本地 mock 切換到 AML 後端(iCON.Abp.AMLPortal)真實 API,並配合本地 docker-compose-local-dev 環境落地(支付除外)。
plans-plus 前端目前依賴 3 條「只讀目錄」端點(editions / plans/catalog / countries)、2 條「交互查詢」端點(agents / tenants/lookup)、1 組單次查詢端點(single-query,新)、2 條「下單提交」端點(subscribe / payments)、以及 1 條「聯絡我們」通知端點(contact——不創建訂單,僅發送訂單摘要),共 4 種訂閱流程:new(新購)、renew(續費)、topup(加購)、single(按次查詢)。
| 前端端點 | 對應後端 | 復用程度 | 主要缺口 / 本次更正 |
|---|---|---|---|
GET /editions | Order/portal/GetEditionList | 改造 | 多語言 edition 名稱(後端僅 displayName);本地庫僅有 1 個 Standard |
GET /plans/catalog | plan/portal/GetPlanList | 改造 | 復刻 filterPlan;KYC 改月租、jQuota 改配套;bestValue/note/nameJP 後端無;本地庫 0 條 Plan |
GET /countries | Order/getCategoryByTypes | 已實現 | —(routes/countries.js 已可用) |
GET /agents/:code | SearchUserByCodeAndType + GetPlanList(agentUserId) | 改造 | 新增 tiers(可售方案級別)、email/phone、隱藏 agentorId |
GET /tenants/lookup | queryRenewableTenant ⟶ 新增 queryRenewableTenantByEmail | 需後端新增 | 已定:後端新增按郵箱端點。現端點 DB 層只按租戶名 Contains 搜、郵箱查後回填→不能按郵箱過濾;前端按郵箱、唯一命中(不再多租戶消歧);新端點直接聚合 currentSubscription + referrer(見 5.5) |
GET /single-query/optionsPOST /single-query | V2 只到「下單+收款」; 支付後 AML 後端內部 CreateConsumerLink+CreateConsumerOrder | 改造 | 更正:後端已支持(AML 即時檢測);兩步為後端內部編排,V2/瀏覽器均不調;PAYG 定價改由 GetPlanList(countryCode) 的 PAYG 方案價、檢測內容固定 4 項(V/E/A/D)純展示不可選(見 5.6) |
POST /subscribe | CreateOrder / TenantRenewal | 改造 | 補 planDetailId;BR/CI 非必填(已定);topup/個人主體待定 |
POST /contact(聯絡我們/推薦人) | customer/CreateFeedback | 復用現有 | 不創建訂單:訂單摘要經 contact us API 發管理員;填了推薦人則發推薦人(見 5.8) |
POST /payments/create | — | 暫緩 | 本輪支付除外:前端提交後直接顯示「已提交成功」(見 5.9) |
本次三個最重要的更正 / 硬缺口:
① BR/CI 非必填(業務決策,已定):plans-plus 新購(含個人主體 / 空 BR/CI)需能下單 ⇒ BR/CI 不作必填。實現=注释 CreateOrder 兩處必填校驗(OrderService.cs:154 與 :190);唯一性校驗 ExistsByOrganizationBRCI 對「BR、CI 皆空」返回 false(:1124),空值安全通過,無需其它改動。核對 當前 working copy 這兩處仍為 active——若對接/部署環境尚未放開,空 BR/CI 仍會被攔,需確認該改動已應用。注意此為全局改動(旧站共用 CreateOrder),「空 BR 不再攔截」影響請業務知悉(見第 9 章 Q1)。
② 單次查詢(single-query)後端已支持(本輪更正,原「零支持」結論作廢):走 AML 主模塊的 ConsumerPortal 即時檢測,且調用方為 AML 後端內部編排——訂單支付完成後,後端在 2C 租戶上下文內 CreateConsumerLink(建鏈接、拿 accessToken)→ CreateConsumerOrder(建訂單、即時檢測、結果郵件)。V2 只下單 + 收款,不調這兩個接口。本輪再定:PAYG 檢測內容固定為 證件核驗 / 名單篩查(ES) / AI 增強型篩查 / 信貸記錄篩查(失信人) 4 項純展示(用戶不可勾選/取消,確定性映射 V/E/A/D),收款金額取自 GetPlanList 按 countryCode(/plans-jp→JPN)返回的 PAYG(P2G) 方案價——原「op→功能碼非 1:1 / 公司查冊無碼 / V2 分項單價與後端 jQ 對齊」缺口收斂。CreateConsumerLink 非匿名(RealtimeScreeningManagement + AML.ConsumerPortal.Enable)在後端 2C 租戶上下文中天然滿足,故 V2 BFF 無需持該租戶憑證。改造 詳見 5.6。
③ 本地種子數據缺口:本地 docker 庫 AMLPortal_Plans / AMLPortal_PlanDetails 均為 0 條,SaasEditions 僅 1 條 Standard,AMLPortal_AgentUserPlans 為 0。⇒ 直接對接會拿到空目錄,必須先補種子數據才能跑通(見第 6 章)。
仍然成立的好消息:後端 CreateOrder 已忽略前端傳入的 AdminPassword,改用 appConfig.General.TenantAdminDefaultPassword(OrderService.cs:215,236)。因此 plans-plus 去掉「管理員密碼」步驟不構成阻塞——後端會給租戶管理員發激活/重置密碼郵件。續費 TenantRenewal 亦繼承上期 AdminPassword(:2262)。
justsolutionsWebV2 採「BFF(Backend-for-Frontend)」式:瀏覽器只調用本站 /api/*,由 Node/Express 依 APP_ENV 決定走 mock 還是真實後端,前端代碼零改動即可切換環境。
public/js/api.js:api.get('/editions') 實際請求 /api/editions。所有 plans-plus 端點均走此封裝。server/config.js:mockEnabled = (APP_ENV==='test');另有 plansPlusMock(PLANS_PLUS_MOCK)開關——在真實環境下仍讓 plans-plus 這組端點走 mock,其餘 /api/* 透傳後端。真實對接時把它置 false,並在 server/routes/ 補齊業務路由。server/index.js:真實環境依次掛 contact / trial-application / industries / countries 路由,再按 plansPlusMock 決定是否掛 mock,最後掛 proxy 兜底。新增 plans-plus 真實路由就照此在代理之前追加 app.use(apiPrefix, createXxxRouter(config))。server/services/auth.js:OAuth2 password 流程取 token 並緩存(提前 5 分鐘刷新),對應旧站硬編碼的門戶訪客憑證,現改為 .env 配置(AUTH_USERNAME/PASSWORD/CLIENT_ID/...)。BFF 每次調後端用 createAuthConfig() 加 Authorization: Bearer。關鍵差異(與旧站):旧站 Angular 直接從瀏覽器調 /api/amlPortal/*,token 存 localStorage。V2 改為瀏覽器不直接接觸後端,由服務端 BFF 持有憑證、收口後端調用並裁剪響應。因此本文每個端點都拆成「前端契約」與「BFF→後端映射」兩層。
支付本輪除外:當前 plans-plus.js 的 submit() 已臨時改為——POST /subscribe 成功後直接顯示「已提交成功」(showSubmitted()),不再調 /payments/create、不跳收銀台(源碼注釋標明「臨時改動…還原方法」)。⇒ 本輪落地只需打通到 /subscribe 為止;支付見 5.9 暫緩。
single-query 是 BFF 範式的例外:其餘端點都是「瀏覽器 → V2 /api/* → BFF 代理後端」;但單次查詢的即時檢測觸發(CreateConsumerLink/CreateConsumerOrder)不走 BFF 代理——由 AML 後端在支付完成後內部編排(2C 租戶上下文),V2 只到「下單 + 收款」為止。詳見 5.6。
以下是 plans-plus.js 實際讀寫的字段(即 BFF 必須產出/接受的契約,目前由 mock/plans-plus.js 滿足)。真實對接時 BFF 輸出必須與此逐字段一致,否則前端渲染/計價會出錯。
loadAll())並發請求 /editions、/plans/catalog、/countries、/single-query/options,四者均以 { success:true, data:… } 為成功標誌。
GET /editions → { success, data:{ editionList:[{id, displayName, nameCN, nameJP}],
jQSeparatedEditions:{ editionIds:[…] } } }
GET /plans/catalog→ { success, data:{ standard:[Plan], cpa:[Plan], addons:{
user:{ unitPrice, name* },
kyc:{ monthlyPrice, name* }, // ← 改為「月租」
jquota:{ packages:[{id, jq, price, name*}] } } } }
GET /countries → { success, data:[{code, name, nameTC, nameSC, nameJP, phoneCode}] }
GET /single-query/options → { success, data:{ operations:[
{id, nameCN/EN/JP, descCN/EN/JP}] } } // ← 固定 4 項純展示,不可勾選/取消;price 不再逐項計
// 固定 4 項:證件核驗(V) / 名單篩查ES(E) / AI 增強型篩查(A) / 信貸記錄篩查失信人(D)
// PAYG 收款價 → 取 GetPlanList(countryCode) 的 PAYG(P2G) 方案 price(單一整包價,非分項之和)
POST /amlPortal/plan/portal/GetPlanList body = { // ← PAYG 定價來源(沿用旧站請求體 + countryCode)
pageIndex:0, pageSize:100, filter:"", getAllItems:false,
tag1List:[], tag2List:[], tag3List:[], countryCode:"JPN" } // 目前 countryCode 僅日本一國
Plan = { planId, tag2Code, nameCN, nameEN, nameJP, periodMonths,
price, originalPrice, qCount(-1=無限), userCountLimit,
bestValue, noteCN, noteEN, noteJP }
KYC 改為「設備月租」:catalog 的 addons.kyc 不再是 unitPrice,而是 monthlyPrice。前端 kycUnit() = monthlyPrice × 所選方案 periodMonths(隨方案期數自動變動,一次付清、無套餐優惠價,見 plans-plus.js kycPriceForMonths())。jQuota 亦由「按量」改為「選配套」(jquotaPackageId)。
單次查詢(PAYG)目錄改為「固定展示 + 外部定價」:/single-query/options 的 4 項不再是可勾選的計費項,而是固定信息展示(證件核驗 / 名單篩查(ES) / AI 增強型篩查 / 信貸記錄篩查(失信人),用戶不可選/取消;名稱後續可能調整)——可由前端硬編碼、或後端返回固定 4 項,不含 price。PAYG 的收款金額單獨取自 GetPlanList 按 countryCode 返回的 PAYG(P2G) 方案價:/plans-jp(日本別名頁)傳 countryCode:"JPN"——目前 countryCode 僅日本一國(後端 GetPlanListParam.CountryCode 支持「該國專屬 + 全球通用(CountryCode=null)」過濾,將來擴國時沿用同一機制)。該價為單一整包價。⇒ 前端需依 URL/地域推導 countryCode 並在載入期取價(見 5.6)。
GET /tenants/lookup?email= // ← 只按郵箱;郵箱全局唯一 → 至多命中 1 個租戶
→ { success, match:'none'|'unique', tenant:Tenant|null }
// 註:前端已不再做「一郵箱多租戶」消歧;舊契約的 match:'multiple' / candidates[] 已廢棄
GET /agents/:code
→ { success, found:bool, data:{ code, name, tiers:[tag2Code…], email, phone }|null }
// tiers:該推薦人可售的方案級別(P2G/Std/Pre/CPA),前端據此再過濾行業方案集
Tenant = { tenantId, tenantName, editionId, editionName, jurisdiction, br, ci,
referrer:{code,name}|null, // ← 註冊時填寫的推薦人,續費頁展示
currentSubscription:{ planId, nameCN/EN/JP, periodMonths, price,
qCount, userCountLimit, startDate, expiryDate, usedQuota,
addons:{ users:int, kyc:bool, jquotaPackageId:string } } }
submit()(在線下單)與 submitContact()(聯絡我們/推薦人)共用 collectPayload() 收集當前表單。本輪支付除外——submit() 只 POST /subscribe,成功即顯示「已提交成功」;submitContact() 不創建任何訂單,把訂單摘要 POST 到聯絡端點(見 5.8),成功後顯示「已收到資料,將盡快聯絡」。(現網代碼仍 POST /subscribe-offline;本次語義調整後應改走 contact us API,見 5.8「與現網代碼的差異」。)
POST /subscribe body = {
type:'new'|'renew'|'topup', edition, isCpa, startDate,
plan:Plan, planPrice, isRenewalRate,
addons:{ users, userUnitPrice,
kyc, kycUnitPrice, kycRentalMonths, // ← 含租賃月數
jquotaPackageId, jquotaPackageName, jquotaUnits, jquotaPrice },
agentCode, subtotal, total,
// type=new 追加: subjectType('corp'|'individual'), company, jurisdiction, br,
// contact, phoneCode, phone, email, address
// (個人主體:contact=company,br/address 恒為空)
// type=renew/topup 追加: tenantId, company, email, currentSubscription
} → { success, data:{ orderId, status, createdAt } }
POST /contact body = { // ← 聯絡我們/推薦人:不創建訂單,僅發送摘要
name, email, company, // 客戶聯絡人 / 郵箱 / 公司(租戶/主體)名
subject:'訂閱諮詢 · {type}', // 可帶方案名
message: ,
agentUserId? // 有推薦人時附帶其後端 Guid → 後端解析郵箱、收件人改為該推薦人
} → { success, data:{ feedbackId } }
POST /single-query body = {
type:'single', subjectType:'individual'|'company', subject, email,
operations:[{id, name, price}], total
} → { success, data:{ orderId, status, createdAt } }
注意:/subscribe 的 body 不含 planDetailId,也不含各加值項(增加用戶/KYC/jQuota)對應的 planId/planDetailId。而後端 CreateOrder/TenantRenewal 的 PlanList 每項都必須同時帶 PlanId+PlanDetailId(OrderService.cs:176,2215 聯合校驗)。⇒ BFF 需在服務端依 /plans/catalog 結果反查補齊 planDetailId(見 5.7)。
提交按鈕門檻:前端 computeValidity() 在 terms 勾選 + 各類型必填齊備前禁用「付款」與「聯絡我們」兩個按鈕。new 要求 company/jurisdiction/email(corp 另需 contact)、方案已選、P2G 方案必選 jQuota;renew 要求已解析到租戶;topup 要求已解析租戶且至少選一個加值項且未過期;single 要求 subject/email/至少一項查詢。
1–11 均在 AML_Backend/modules/iCON.Abp.AMLPortal,並已被旧站 justsolutionsWeb/PlanService 使用驗證過。前綴 /api/amlPortal/*(agent 校驗端點在 Identity 模塊 /api/identity/*)。12–13 為單次查詢用到的方法,位於 AML 主模塊 iCON.Abp.AML(/api/aml/ConsumerPortal/*);但由 AML 後端在支付完成後內部編排調用,V2 BFF 不代理、瀏覽器不直調(見 5.6)。
| # | 方法 / 路由 | 用途 | 位置 |
|---|---|---|---|
| 1 | POST /api/amlPortal/plan/portal/GetPlanList | 取方案列表(Tag1: B/jQ/j/AdlU/KYC) | PlanController:37 · PlanService:54 |
| 2 | POST /api/amlPortal/Order/portal/GetEditionList | 取 edition(所屬行業)+ jQSeparatedEditions | OrderController:136 · OrderService:1103 |
| 3 | POST /api/amlPortal/Order/getCategoryByTypes | 取國家列表(typeCodes:['COUNTRY']) | OrderController:184 |
| 4 | GET /api/identity/users/SearchUserByCodeAndType/{code}/true | 按 UserCode 精確查用戶(校驗推薦人/代理) | CustomIdentityUserController:306 |
| 5 | POST /api/amlPortal/Order/portal/queryRenewableTenant | 查可續費租戶(按 keyword=租戶名 Contains) | OrderController:308 · OrderService:2353 |
| 6 | POST /api/amlPortal/Order/portal/ExistsByOrganizationBRCI | 校驗 BR/CI 是否已存在 | OrderController:150 · OrderService:1122 |
| 7 | POST /api/amlPortal/customer/portal/CheckEmailExists/{email} | 校驗管理員郵箱是否已用 | CustomerController |
| 8 | POST /api/amlPortal/Order/portal/CreateOrder | 新租戶下單 | OrderController:53 · OrderService:146 |
| 9 | POST /api/amlPortal/Order/portal/TenantRenewal | 租戶續費下單 | OrderController:321 · OrderService:2197 |
| 10 | POST /api/amlPortal/Order/portal/PaymentWebhook | 支付回調(支付方服務端調,非前端) | OrderController:163 |
| 11 | POST /api/amlPortal/customer/CreateFeedback | 聯絡我們/推薦人:接收訂單摘要(不下單);擬加 AssignedAgentUserId 收件人路由(見 5.8) | CustomerController · routes/contact.js 已封裝 |
| 12 | POST /api/aml/ConsumerPortal/CreateConsumerLink(後端內部編排調用) | 單次查詢第 1 步:建即時檢測鏈接(回 accessToken)·非匿名(RealtimeScreeningManagement + AML.ConsumerPortal.Enable)——在後端 2C 租戶上下文內滿足 | ConsumerPortalController:340 · ConsumerPortalService:964 |
| 13 | POST /api/aml/ConsumerPortal/CreateConsumerOrder(後端內部編排調用) | 單次查詢第 2 步:憑 accessToken 建訂單並即時檢測(異步跑模塊 + 結果郵件)·匿名 | ConsumerPortalController:78 · ConsumerPortalService:325 |
DTO 位置:Application.Contracts/PlanAppLayer/*(GetPlanListParam、PlanDto、PlanDetailDto)、Application.Contracts/OrderAppLayer/*(CreateOrderParam、SelectPlanItem、TenantRenewalParam、QueryRenewableTenantParam、TenantPropertyDto)。agent 返回 DTO:iCON.Abp.FX.Users/AppUserDto。單次查詢 DTO:iCON.Abp.AML/Application.Contracts/ConsumerPortalAppLayer/*(CreateConsumerLinkParam、CreateConsumerCustomerDto、CreateConsumerLinkDto、CreateConsumerOrderParam、ConsumerLinkDto)+ IndividualAppLayer/CreateIndividualDto、OrgLayer/CreateOrganizationDto。
每節:前端契約 → 對應後端 → 字段映射 → BFF 轉換要點 → 差異/缺口。新增的 BFF 文件建議放 server/routes/,與 countries.js 同範式(createAuthConfig() + axios + 字段映射 + {success,data} 包裝)。
後端返回 { code:0, data:{ editionList:[完整 Edition 實體], jQSeparatedEditions:{ EditionIds:[…], EditionNames:[…] } } }(OrderService.cs:1103 直接把 SaasEdition 實體與 _appConfig.Portal.jQSeparatedEditions 原樣返回)。前端要 editionList:[{id, displayName, nameCN, nameJP}] 與 jQSeparatedEditions.editionIds。
| 前端字段 | 後端來源 | 說明 |
|---|---|---|
id | editionList[].id(Guid) | 直接映射;後續作 OrganizationReference |
displayName | editionList[].displayName | 英文名(Saas Edition 的 DisplayName) |
nameCN / nameJP | 後端無 | 缺口,見下 |
jQSeparatedEditions.editionIds | jQSeparatedEditions.EditionIds | 驅動 CPA/標準方案集切換(state.isCpa);注意後端是 PascalCase,BFF 需轉小寫鍵名並 toLowerCase() 值 |
缺口:edition 多語言名稱。後端 GetEditionList 只有 displayName。plans-plus 的 editionName() 在 tc/jp 下優先取 nameCN/nameJP,缺失時 fallback 回 displayName ⇒ 不阻塞,但中日文會顯示英文。建議:BFF 維護 editionId/displayName → {nameCN,nameJP} 映射表(與 industries.js 現有靜態行業翻譯同思路)。
旧站行為(可選沿用):getEditionList() 會過濾掉 Standard、把 Others 排到末尾,並把 editionIds toLowerCase()。plans-plus 目前未做此整理;若要一致,這段邏輯應放 BFF。
本地現狀:本地庫 SaasEditions 只有 1 條 Standard(Id 3A220FC7-9577-660B-729A-4024B1E4BEAA),而 appsettings.local.json 的 Portal.jQSeparatedEditions.EditionIds/EditionOUMappings 引用的是另一組 GUID(3A1A2969-…/3A0149C4-…),與實庫不符。⇒ 若要在本地看到多行業 + CPA 切換,需先補 Edition 種子並對齊配置(見第 6 章)。
BFF 以旧站同款請求體調 GetPlanList,再復刻旧站 filterPlan() 把扁平 items 按 tag1Code 拆成 5 組,組裝成 {standard, cpa, addons}。
{ pageIndex:0, pageSize:9999, filter:'', getAllItems:false,
tag1List:['B','jQ','j','AdlU','KYC'], tag2List:[], tag3List:[], agentUserId:null }
// 後端 GetPlanList 對 Tag2List/Tag3List:命中或 Tag2Code 為空皆放行(OrderService/PlanService:78-80)
後端返回 { code:0, data:{ totalCount, items:[PlanDto{…, planDetails:[PlanDetailDto]}] } }(PlanService.cs:107 PagedResultDto<PlanDto>)。注意:getAllItems:false 時後端已過濾 Enabled 且僅保留在有效期內的 planDetails(PlanService.cs:99-104)。
| 前端 Plan | 後端來源(item / planDetails[0]) | 備註 |
|---|---|---|
planId | item.id | |
planDetailId ⚠️ | item.planDetails[0].id | 前端契約現缺此字段,但下單必需 ⇒ BFF 須補進 Plan,提交時回填(見 5.7) |
tag2Code | item.tag2Code | Std/P2G/CPA/Pre…,CPA 判定 + agent tiers 過濾用 |
nameCN/nameEN | item.nameCN/item.nameEN | 旧站會截掉「(…」後綴,可沿用 |
nameJP | 後端無(PlanDto 只有 CN/EN) | 缺口,fallback EN |
periodMonths | planDetails[0].periodMonths | |
price/originalPrice | planDetails[0].price/originalPrice | 劃線價用 originalPrice |
qCount | planDetails[0].qCount | -1 表無限 |
userCountLimit | planDetails[0].userCountLimit | |
bestValue | 後端無 | 缺口,見下 |
noteCN/EN/JP | item.description?(無多語言) | 缺口,見下 |
standard ← tag1Code==='B' && tag2Code!=='CPA';cpa ← tag1Code==='B' && tag2Code==='CPA'addons.user.unitPrice ← tag1Code==='AdlU' 的 planDetails[0].price(並記其 planId/planDetailId 備下單)addons.kyc.monthlyPrice ← tag1Code==='KYC' 的 planDetails[0].price(當作月租單價,見下方 KYC 映射)addons.jquota.packages[] ← tag1Code==='jQ'||'j':每項 { id:planId, jq:qCount, price:planDetails[0].price, name* },並各自記 planDetailIdKYC「月租」映射(本次新增難點):前端把 KYC 當設備月租:租金 = monthlyPrice × 方案 periodMonths,一次付清。後端 KYC plan 只有單一 Price、旧站按 PCS=1 購買。兩種對法:
① 把後端 KYC planDetails[0].price 當「月租單價」,下單時 PCS = kycRentalMonths(則後端 PaidAmount = price × 月數,與前端計價自洽)——推薦,且無需後端改動;
② 或後端為 KYC 按周期建多條 PlanDetail / 增月租字段。
⇒ 需與後端確認 KYC 計費口徑;BFF 反查 planDetailId 時把 kycRentalMonths 填入該行 PCS。
缺口:bestValue、note*、nameJP 後端 PlanDto/PlanDetailDto 無對應字段。短期在 BFF 按 systemCode 寫死映射(bestValue / 賣點文案 / 日文名),不阻塞;長期可後端補字段。
本地現狀(阻塞):本地庫 AMLPortal_Plans/AMLPortal_PlanDetails 均 0 條,GetPlanList 會返回空目錄。必須先補 Plan 種子(含 Tag1Code=B/jQ/j/AdlU/KYC、Tag2Code=Std/P2G/CPA/Pre 與對應 PlanDetail)才能渲染方案卡(見第 6 章)。
server/routes/countries.js 已實現並可直接用:取 typeCodes:['COUNTRY'],從 categoryTranslations 提 zh-hk/zh-cn/ja/en-us,輸出 {code, name, nameTC, nameSC, nameJP, phoneCode},正好滿足 plans-plus 的 countryDisplay() 與 phoneCode 自動填充。無需改動(種子含 249 個國家)。
前端要 { success, found, data:{ code, name, tiers:[tag2Code…], email, phone } }。後端 SearchUserByCodeAndType 按 UserCode 精確匹配,返回 List<AppUserDto>({Id, UserName, Email, Name, Surname, PhoneNumber, ExtraProperties})。BFF 取首條做轉換;無匹配 → found:false。
| 前端 | 後端來源(AppUserDto) | 備註 |
|---|---|---|
data.code | ExtraProperties.UserCode | agent code(EF 屬性 UserCode) |
data.name | Name(+Surname) / UserName | 拼接展示名 |
data.email | Email | 「聯絡推薦人」用 |
data.phone | PhoneNumber | 「聯絡推薦人」用 |
(下單用)agentorId | Id(Guid) | 提交時作 CreateOrderParam.AgentorId;建議 BFF 在 data 內附帶或服務端緩存 code→id |
data.tiers ⚠️ | 後端無直接字段 | 見下「tiers 推導」 |
tiers 推導(本次新增):plans-plus 用 state.agent.tiers(tag2Code 列表)在「所屬行業選定的方案集」上再過濾——只顯示該代理可售級別。後端 agent 的可售方案由 AMLPortal_AgentUserPlans 建模,並由 GetPlanList 的 agentUserId 過濾(PlanService.cs:87-92)。⇒ BFF 兩種實現:
① 推導 tiers:再調一次 GetPlanList{ agentUserId: agent.Id, tag1List:['B'] },取 distinct tag2Code 作 tiers(保持前端過濾邏輯);
② 或改為服務端過濾:/plans/catalog 帶上 agentUserId 直接返回該代理可售方案(則前端 tiers 過濾成空操作)。
本地缺 AgentUserPlan 數據,短期可讓 BFF 在 tiers 缺失時返回空數組 = 不過濾(顯示該行業全部方案),與前端 computePlanSet() 對「tiers 為空不過濾」的處理一致。
備註:AMLPortal 另有 Agentor/portal/GetAgentorByCode/GetAgentorByName,旧站與 plans-plus 均用 Identity 的 SearchUserByCodeAndType,沿用之。
前端按郵箱查租戶,返回 match:'none'|'unique' 兩態(不再多租戶消歧——註釋明確「租戶名與郵箱均全局唯一 → 一郵箱至多 1 個租戶」)。命中後帶出 currentSubscription + referrer 預填續費/加購表單。
但現有後端 queryRenewableTenant 只接受 {keyword} 且按 TenantName.Contains(keyword) 在 DB 層搜索(OrderService.cs:2362-2364),返回 List<TenantPropertyDto>——TenantAdminEmail 是查完命中租戶後、再 GetAllUsers() 遍歷全庫用戶按 UserName=='admin' && TenantId 逐條回填(:2369-2379),邏輯上「先有租戶、後有郵箱」,無法在 DB 層按郵箱過濾。⇒ 前端「給郵箱、要唯一命中」的查詢維度與現端點根本錯位。
已定決策:後端新增「按管理員郵箱查可續費租戶」端點。不採用 BFF 變通(以空 keyword 拉全部可續費租戶、再服務端按 TenantAdminEmail===email 過濾)——現端點按租戶名搜、返回整張可續費租戶列表,讓 BFF 承接全表列表並自拼 currentSubscription/referrer 的訂單反查,既非其職責、也易錯;查詢維度與聚合歸屬都更該落在後端。(注:問題不在掃描成本——後端側按郵箱全庫掃描本身可接受,租戶數少、低頻交互;而在查詢維度錯位與聚合歸屬。)新端點把查詢維度反過來(先按郵箱定位 admin 用戶 → 再取其租戶),順帶一次性聚合 plans-plus 續費/加購頁所需的 currentSubscription + referrer,避免 BFF 拼裝易錯的訂單反查。
QueryRenewableTenantByEmailParam { string Email }(新 DTO,置於 Application.Contracts/OrderAppLayer/,與 QueryRenewableTenantParam 並列)。鑑權沿用現 queryRenewableTenant 的 [AbpAutoAuth("Portal")],BFF 免自帶 token(見第 6 章)。_dataFilter.Disable<IMultiTenant>(),按郵箱定位租戶管理員用戶——沿用現端點「admin 郵箱 = UserName=='admin' 用戶的 Email」語義:可直接復用現 GetAllUsers() 全庫掃描(與現 queryRenewableTenant 一致),改在內存按 u.UserName=='admin' && u.Email==Email 過濾即可(郵箱全局唯一 → 至多 1 條)。全庫掃描在此可接受——租戶 / 管理員用戶數量少、續費查詢為低頻交互,無需為此下推 DB 過濾或加索引。無命中 → 回空 ⇒ 前端 match:'none'。TenantId 對應的 active TenantProperty(IsActive && TargetTenantID==tenantId,按 CreationTime 取最新一條),並校驗其可續費(TenantStatus / EffectiveEndTime——過期仍返回,由前端做「過期/臨期」展示,見 5.7 topup 阻斷)。referrer 直取 AgentorCode/AgentorName;currentSubscription 明細(planId / 名稱 / periodMonths / price / addons)沿用 GetCurrServiceInfo(OrderService.cs:398-418)的 Order → OrderDetails → Plan/PlanDetail 反查思路,區別是它以 CurrentTenant.Id 為基、此處以顯式 TargetTenantID 為基(Portal 上下文 + 禁多租戶過濾)。RenewableTenantDto = TenantPropertyDto 關鍵字段 + referrer + 明細化 currentSubscription),讓 BFF 幾乎直通轉發,不必自己再拼訂單明細。| 前端 | TenantPropertyDto | 備註 |
|---|---|---|
tenantId | TargetTenantID | |
tenantName | TenantName | |
editionId/editionName | EditionId/EditionName | |
referrer | {AgentorCode, AgentorName} | 新增映射 |
currentSubscription.expiryDate | EffectiveEndTime | 過期判定(前端 daysUntil) |
currentSubscription.startDate | EffectiveStartTime | 未過期續費 → 默認接續此日 |
currentSubscription.qCount/usedQuota | QCountPurchase / QCountPurchase−QCountLeft | 已用 = 購買 − 剩餘 |
currentSubscription.userCountLimit | UserCountLimit | |
currentSubscription.addons.kyc | EnableKYC | topup 時鎖「已購買」 |
currentSubscription.planId/name*/price/periodMonths | 經 Order/OrderDetail 反查 | 推導,續費續價(沿用上期 price)依賴此 |
currentSubscription.addons.users/jquotaPackageId | 經訂單明細反查 | 推導 |
聚合由誰拼裝:上表「可得部分」是 TenantPropertyDto 直出字段;而 currentSubscription 的 planId / 名稱 / 上期 price / periodMonths 與 addons.users / jquotaPackageId 均需經 Order → OrderDetails(按 Tag1Code:B=基礎、AdlU=增購用戶、jQ/j=jQuota、KYC)反查、usedQuota = QCountPurchase − QCountLeft。按本次決策,這段反查應落在新端點 queryRenewableTenantByEmail 內(服務端一次算好),而非 BFF——後端已有 GetCurrServiceInfo(OrderService.cs:398)反查 ActiveBPlans 的現成範式可借用,只需擴出加值項明細與上期價。BFF routes/tenants.js 因此退化為字段直通 + 命中態(none/unique)包裝。
本輪定案(PAYG 定價 + 固定檢測項):隨付即用的檢測內容固定為 4 項、純信息展示,用戶不可勾選/取消——證件核驗、名單篩查(即 ES 檢測)、AI 增強型篩查、信貸記錄篩查(即失信人檢測)(叫法後續可能再調整),確定性映射後端功能碼 V / E / A / D(無 S 風險評估問卷、R CDD 報告、O OCR)。收款金額不再由 V2 分項單價相加,而是取自 AML GetPlanList 按 countryCode 返回的 PAYG(P2G) 方案價:/plans-jp 頁傳 countryCode:"JPN"(目前 countryCode 僅日本一國,前端依 URL/地域推導),該價為單一整包價(非分項之和)。⇒ 原「op→功能碼非 1:1、公司查冊無獨立碼、V2 分項單價與後端 jQ 對齊」三處缺口收斂:檢測項固定 4 = E/A/V/D,定價口徑統一到 GetPlanList。
本輪更正(原「後端零支持」結論作廢;並釐清調用方):單次查詢由 AML 主模塊的 ConsumerPortal「即時檢測」承接(iCON.Abp.AML)。調用方已確認:這兩步不是 V2 BFF、也不是瀏覽器發起,而是 AML 後端在「訂單支付完成後」、於 2C 租戶上下文內部編排執行——CreateConsumerLink(建鏈接拿 accessToken)→ CreateConsumerOrder(建訂單、即時檢測、結果郵件)。justsolutionsWebV2 的職責止於「下單 + 收款」,完全不觸碰這兩個接口。
為何不能放在 V2 BFF:CreateConsumerLink 非匿名([Authorize(RealtimeScreeningManagement)] + [RequiresFeature(AML.ConsumerPortal.Enable)],ConsumerPortalController.cs:337-340),且在 CurrentTenant 下建鏈接。V2 BFF 只持「門戶訪客」憑證(__tenant=Portal),既無實時篩查權限、也非 2C 租戶上下文 ⇒ 調不動。放到後端內部後,2C 租戶上下文與功能開關天然滿足,鑑權不再是 BFF 的問題。CreateConsumerOrder 雖是 [AllowAnonymous](:76-78,憑 accessToken),但既然第 1 步已在後端,第 2 步同處後端內部串接最自然。
待後端明確的銜接點(由後端定義,非 V2 落地):「支付完成」如何把單次查詢單的資料(郵箱/主體/所選項目)+ 支付結果交給後端這段內部編排,常見兩種接法:
① 後端持單:V2 在 POST /single-query 時即把單推給後端一個「2C 收單/意向」端點落庫;支付 webhook 直接打到後端,後端據單觸發 ①②。
② V2 持單、支付後通知後端:V2 收到支付成功後,調後端一個專門的 2C 觸發端點(其鑑權/形態由後端定義),該端點內部再跑 ①②。
無論哪種,①② 本身都是後端內部行為,V2 始終不直接調 CreateConsumerLink/CreateConsumerOrder。
第 1 步 · 建即時檢測鏈接 CreateConsumerLink(ConsumerPortalService.cs:964):以「結果接收郵箱」upsert ConsumerCustomer,在當前 2C 租戶下建鏈接 → 回 ConsumerLinkDto(含 accessToken)。
{ "createConsumerCustomerDto": { "email":"result@company.com", "name":null, "phone":null, "remark":null },
"createConsumerLinkDto": { "functionCodes":"EAVD", "validHours":72,
"validEndTime":"2026-07-09T06:00:47.000Z" } }
// 回:OperationDto.Success(ConsumerLinkDto{ accessToken, functionCodes, expireTime, tenantId, … })
第 2 步 · 建訂單並即時檢測 CreateConsumerOrder(:325):據 token 定位鏈接與租戶 → 計價(jQ) → 建 Individual/Organization 實體 → 建 ConsumerPortalOrder(直接置 PaymentStatus=Paid、OrderStatus=Pendding,:388)→ 每個 functionCode 建一條 DetectionTask → 發 RabbitMQ 消息,由 ProcessConsumerPortalOrder 異步跑檢測模塊,完成後 SendConsumerLinkResultMail 發結果郵件。
{ "accessToken":"xxx", "objectType":"Individual",
"individual": { "fullNameEN":"张三", "fullNameZH":"", "dateOfBirth":{"year":0,"month":0,"day":0},
"address":"", "addressOfLiving":"", "nationalityCode":null,
"documentRequiredFileIds":[], "identityDocumentTypeCode":"", "identityDocumentNumber":"",
"gender":"", "phone":"", "email":"", "occupation":"", "entityIdentityDocuments":[] },
"organization": null,
"functionCodes": ["E","A","V","D"] } // ← 固定 4 項(PAYG 檢測內容不可選)
// 回:OperationDto.Success({ entity, order }) // order.Id / order.OrderCode 可作結果查詢鍵
V2 通過 POST /single-query(或銜接端點)把下列字段交給後端;後端在內部編排時據此組裝上面兩個 payload。
| V2 收集字段 | 後端入參 | 說明 |
|---|---|---|
email(結果接收郵箱) | createConsumerCustomerDto.email | 必填;後端按 email upsert ConsumerCustomer,並作結果郵件收件人 |
subjectType 'individual'|'company' | objectType 'Individual'|'Organization' | 枚舉 ObjectTypeEnum(Individual=0 / Organization=1);company→Organization |
subject(單一名稱字段) | individual.fullNameEN 或 organization.fullNameEN | ⚠️ V2 只收一個名稱;後端 CreateIndividualDto/CreateOrganizationDto 字段眾多但均可空 → 最小可用只填全名,其餘留空/默認 |
| 固定 4 項檢測(V2 不收選擇) | 兩處 functionCodes(鏈接=字符串 "EAVD"、訂單=數組 ["E","A","V","D"]) | ✅ 固定映射:證件核驗→V、名單篩查ES→E、AI 增強→A、失信→D;兩步一致 |
| (鏈接有效期) | createConsumerLinkDto.validHours | 後端據此算 ExpireTime;樣例中的 validEndTime 後端 DTO 無此字段、被忽略 |
功能碼映射(本輪固定,非 1:1 問題消除):PAYG 檢測內容固定 4 項、用戶不可選,直接確定性對應後端功能碼(後端只認 E/A/V/D/S/R,O=OCR 排除;PAYG 僅用前 4 個):
證件核驗 → V(證件核驗)名單篩查(即 ES 檢測) → E(ES 實體篩查)AI 增強型篩查 → A(AI 檢測) (E、A 在後端合併為一次 EntitiesInvestigation 計費,ConsumerPortalService.cs:277)信貸記錄篩查(即失信人檢測) → D(失信查詢)⇒ 原「多個 op 坍縮為同一 E/A 計費」「公司查冊無獨立碼」的映射難點不復存在(新方案無「公司查冊」項,S/R 不在 PAYG)。定價口徑亦統一:V2 收款金額 = GetPlanList 按 countryCode 的 PAYG(P2G) 方案價,不再與後端逐碼 jQ 扣費逐項對賬——後端 CreateConsumerOrder 仍按 2C 租戶 jQ 計費/扣減(僅 CHN/HKG 計 KYC jQ,:297),但那是後端內部帳,與 V2 對外報價解耦。
| 方法 | 鑑權 | 後端內部編排下的落地 |
|---|---|---|
CreateConsumerLink | 非匿名:[Authorize(RealtimeScreeningManagement)] + [RequiresFeature(AML.ConsumerPortal.Enable)](ConsumerPortalController.cs:337-340) | 在後端 2C 租戶上下文內執行 ⇒ 租戶上下文與功能開關天然滿足,無需 V2 憑證。待確認的只是「用哪個 2C 租戶」及其 ConsumerPortal 功能/權限已開 |
CreateConsumerOrder | 匿名 [AllowAnonymous](:76-78) | 憑 accessToken 定位租戶;後端內部緊接第 1 步串行調用 |
POST /api/aml/ConsumerPortal/Get2CRecordDetail/{accessToken} 或 /Get2CRecordDetailByOrderId/{orderId}(均匿名)取檢測記錄;/CheckAndGenerateReport 生成 CDD 報告 PDF。(此類只讀回取因匿名,才可能由 V2/瀏覽器直取。)仍存在的缺口 / 落地要點:
① 銜接點:V2「支付完成」與後端內部編排的對接(上方 ①/② 兩種接法)由後端定義端點/webhook 承接。
② op→功能碼映射已收斂:檢測內容固定 4 項、確定性映射 E/A/V/D(無「公司查冊」、無 S/R);V2 只需固定傳 functionCodes="EAVD"(或後端直接寫死該 4 項)。
③ 2C 租戶:確認/建立隨付即用專用租戶、開 AML.ConsumerPortal.Enable 功能、配實時篩查權限——供後端內部編排落上下文(不再需要在 V2 BFF 配該租戶憑證)。
④ 對外定價已確認:V2 收款金額 = GetPlanList 按 countryCode(/plans-jp→JPN)返回的 PAYG(P2G) 方案價,為單一整包價(非分項之和),前端載入期取價、純展示 4 項檢測。後端 CreateConsumerOrder 仍把訂單直接置 Paid 並按 2C 租戶 jQ 扣費(GetPriceTwoC/線上支付分支已注釋 :196)——屬後端內部帳,與 V2 對外報價解耦。現階段 countryCode 僅日本(JPN):即只有 /plans-jp 有 PAYG 定價,其餘國家待種 P2G 方案 + PlanDetail 後再開(機制沿用,不需改前端契約)。
⑤ 本地環境:需該租戶 + 檢測引擎(iCS)/RabbitMQ 就緒,ProcessConsumerPortalOrder 才能真正跑出結果;否則本地 single-query 可繼續走 mock,僅在對接環境驗證真調用。
V2 側建議形態:/single-query/options 收斂為固定 4 項純展示(可前端硬編碼或後端返回固定項,不含 price);PAYG 收款價由前端載入期依 URL/地域推導 countryCode 調 GetPlanList取 PAYG(P2G) 方案價(或經 BFF /plans/catalog 帶 countryCode 一併取回)。/single-query(收單,返回 orderId)契約不變,不新增對 ConsumerPortal 的代理路由。V2 只需把「支付完成」與後端銜接(見上 ①/②),兩步真調用全部在後端內部——前端與 BFF 無需感知 CreateConsumerLink/CreateConsumerOrder,也不傳檢測項選擇(固定 4 項在後端寫死或由 V2 固定傳 EAVD)。
BFF 依 body.type 分流;核心是組裝 PlanList(基礎方案 + 各加值項,每項補 planDetailId)。
| CreateOrderParam | 前端 payload | 備註 |
|---|---|---|
PlanList[] | plan + addons | 見下「PlanList 組裝」 |
TenantName | company | 必填;後端校驗重名(:183) |
TenantAdminEmail | email | 必填;後端 CheckEmailExists 校驗未註冊(:159) |
Jurisdiction | jurisdiction | 必填(:153) |
OrganizationReference | edition(Guid) | 必填;校驗 edition 存在(:200) |
OrganizationBR / OrganizationCI | br / —(前端不收 CI) | 非必填 BR/CI 已定非必填,空值可下單(見下) |
EffectiveStartTime | startDate | |
ContactPerson | contact | 個人主體 = company |
CompanyAddress | address | 可選(個人主體為空) |
Phone | phoneCode + phone | 可選;BFF 拼接 |
AgentorId | 由 agentCode 解析的 Guid | 見 5.4;空則後端指派頂級 salesAdmin(:283-287) |
AdminPassword | — | 忽略(後端用默認密碼,發激活郵件) |
IsOfflinePayment/IsAgentBehalf | — | 在線下單固定 false |
BR/CI 非必填(已定決策):plans-plus 新購允許空 BR/CI——含 corp 主體(BR 維持「可選」、可空)與 individual 主體(br 恒空、無 CI)。實現=注释 CreateOrder 的 OrderService.cs:154 與 :190 兩處必填校驗(if (BR 空 && CI 空) return "…required");緊隨的唯一性校驗 ExistsByOrganizationBRCI 對「BR、CI 皆空」返回 false(:1124),空值安全通過,無需其它改動。
⚠️ 核對現狀:當前 working copy 的 :154/:190 仍為 active;若對接/部署環境尚未放開,空 BR/CI 會被攔,需先應用該改動。
| TenantRenewalParam | 前端 payload |
|---|---|
TargetTenantID | tenantId |
PlanList[] | plan + addons(同下組裝) |
TenantAdminEmail | email(後端校驗與租戶 admin 郵箱一致 :2239) |
AgentorId | 空 → 延續原代理(:2321-2323) |
IsOfflinePayment/IsAgentBehalf | 固定 false |
續費續價:後端按 planDetail 的 Price 計;前端的「續費續價(沿用上期 price)」屬展示層優惠,後端不認前端傳入的 price。若要真正續價,需後端支持自定義價 / 專屬續費 PlanDetail——本輪按目錄價下單,前端續費折扣僅為展示(待業務確認)。
純加購(無基礎方案,只買 增加用戶/KYC/jQuota)。後端無專屬端點。候選 A(推薦):復用 TenantRenewal,PlanList 只含加值項(不含 B 類)。源碼上 TenantRenewal 只對 Tag1Code==='B' 的行設服務起止期(:2300-2314),非 B 行只疊加配額/用戶 ⇒ 理論上「只疊配額不延期」語義成立,但需確認 ProcessTenantEventQueue 下游對「無 B 行的續費單」處理正確。候選 B:後端新增 TenantTopup(可借鑒 admin 的 UpdateTenantProperty :2389,它就是疊 PlanList 重算配額)。
PlanList = []
// 基礎方案(topup 不加)
if (type!=='topup') PlanList.push({ PlanId: plan.planId,
PlanDetailId: plan.planDetailId, PCS: 1 })
// 增加用戶:PCS = addons.users
if (addons.users>0) PlanList.push({ PlanId: AdlU.planId,
PlanDetailId: AdlU.planDetailId, PCS: addons.users })
// KYC(設備月租):PCS = kycRentalMonths(把 KYC planDetail.price 當月租單價;見 5.2)
if (addons.kyc) PlanList.push({ PlanId: KYC.planId,
PlanDetailId: KYC.planDetailId, PCS: addons.kycRentalMonths })
// jQuota:選中的配套 ×1
if (addons.jquotaPackageId)
PlanList.push({ PlanId: jqPack.planId,
PlanDetailId: jqPack.planDetailId, PCS: 1 })
planDetailId 從哪來:前端 payload 沒有 planDetailId 與各加值項 planId。BFF 須持有 /plans/catalog 的服務端版本(含 planDetailId),按 plan.planId 與加值項類型反查補齊。建議把 catalog 結果在 BFF 內存緩存(隨 token 一併刷新),/subscribe 時直接查表。
後端返回(OrderDto):下單成功返回 OperationDto.Success(OrderDto),含 orderCode、goodsName、orderStatus、planPrice 等。BFF 應把 orderId/orderCode 回給前端的 data.orderId。免費/0 元訂單後端直接進 PendingActive 並發激活郵件(:296-303);在線非 0 元走 MockupPayment(本地佔位,見 5.9)。
訂單摘要下方的「聯絡我們 / 聯絡推薦人」按鈕與付款按鈕同樣的有效性門檻(由 updateSubmitEnabled() 控制),但不創建任何訂單/租戶/線索——只是把用戶當前已填寫的訂單摘要(collectPayload() 產出的 type/行業/方案/加值項/期間/總價與聯絡資料)整理成一段留言,經既有 contact us API 發出,讓管理員(或推薦人)主動跟進。成功後前端顯示「已收到資料,將盡快聯絡」(showOfflineSubmitted())。
好消息——復用已實現端點:站內已有 server/routes/contact.js(POST /api/contact → 後端 customer/CreateFeedback,免 token,見 contact.js:40,143),plans-plus 的「聯絡我們」直接復用它即可,無需新增後端 lead 表/端點。BFF 只需接受一段摘要 message(+ 收件人路由參數)並轉為 CreateFeedback。
type≠'new'):發給平台管理員——即現有 CreateFeedback 的既定收件邏輯(後端反饋 + ADMIN_EMAIL 通知,contact.js:17)。state.agent 已解析;前端 hasReferrer() 僅在 new 流程為真):改發給該推薦人。推薦人 email/phone 已由 /agents/:code 帶出(見 5.4),前端可隨摘要一併提交。| contact 端點入參 | plans-plus 來源 | 對應 CreateFeedback |
|---|---|---|
company | payload.company(新購=公司/個人名;續費/加購=租戶名;單次=查詢主體) | companyName |
name | payload.contact / 公司名 / 主體名 | customerName |
email | payload.email(客戶郵箱) | customerEmail(供回覆) |
subject | 固定文案「訂閱諮詢 · {type}」(可帶方案名) | typeOfQuery |
message 未組裝 | 訂單摘要文本:type/行業/方案+價/加值項(用戶·KYC月租·jQuota)/期間/小計/總價/推薦人碼 —— 現網尚未產出此文本(見下方缺口) | Message(需把 collectPayload() 序列化為可讀文字;後端限 ≤1000 字) |
agentUserId 擬新增 | state.agent 的後端 Guid(/agents/:code 帶出,見 5.4) | → 擬新增的 CreateFeedback.AssignedAgentUserId:收件人路由,後端據此解析推薦人郵箱(見下) |
關鍵缺口——摘要 message 目前「未組裝」:現網 submitContact()(plans-plus.js:1243)送的是 collectPayload() 的結構化對象(type/edition/plan/addons/total…)+ channel + referrer,並無任何 message 文本;renderSummary() 只把摘要畫進右側 DOM 面板(#ppSum* 的 textContent),不產出可提交字符串。⇒ 要把「訂單摘要」當作 contact us 的 message,必須新增一步序列化:
· 推薦:BFF 端拼裝——前端照舊送結構化字段,/api/contact 路由把 type/行業/方案+價/加值項/期間/小計/總價/推薦人碼格式化為可讀多行文本填入 message(順帶多語言與脫敏);
· 或前端拼裝(複用 renderSummary() 的同源計算:currentPlan()/computeSubtotal()/各加值項)。
長度上限:後端 CreateFeedback 限 Message ≤ 1000 字(CustomerService.cs:507),BFF contact.js 亦要求 message 非空 ⇒ 序列化摘要需精簡到 1000 字內。
與現網代碼的差異(需前端小改):當前 plans-plus.js 的 submitContact() 仍 POST /subscribe-offline(帶 channel:'offline' + referrer、且無 message),語義是「線下對接線索」。按本次決策應改為:組裝訂單摘要文本 + POST /api/contact(帶 message 摘要 + agentUserId 路由),不再創建 lead/訂單。BFF 側可保留 /subscribe-offline 作向後兼容別名(轉調同一 contact 處理),或直接改前端調用點與 mock。
「送推薦人」路由 —— 已定方案②(後端加收件人字段),待實現:方案細節(供實作參照,尚未落碼):
① CreateFeedbackDto 與 Feedback 實體各加可空 AssignedAgentUserId(Guid?,CreateMap<CreateFeedbackDto,Feedback> 同名自動映射,無需改 profile);
② CustomerService.CreateFeedback(CustomerService.cs:496)於其有值時,用 ICustomIdentityUserRepository.GetUsersByIDs 解析該推薦人郵箱,作為 SendEmailOnPortalFeedback 第 6 參數 salesEmail 的收件人(推薦人無郵箱時回退平台銷售 _appConfig.Portal.SalesEmailAddress);「給客戶本人」的確認郵件不變;
③ EF 遷移為 AMLPortal_Feedbacks 增一列 uniqueidentifier NULL(SQL Server 遷移工程;MySQL 遷移工程停在 2021 Initial、未並行維護,如啟用該 provider 需補同名列)。
BFF 對接:/api/contact 把 agentUserId(/agents/:code 帶出的後端 Guid,見 5.4)透傳為 AssignedAgentUserId,推薦人郵箱由後端解析、前端/BFF 不必自行取。
為何不走「BFF 抄送、零改後端」(原備選①已否決):曾設想 BFF 拿 agentEmail 自行把摘要抄送推薦人、不動後端。核對後不成立:① contact API 的 CreateFeedbackDto 無 cc/bcc 字段;② 底層 CreateEmailQueue(subject, to, cc, bcc, …) 雖有 cc/bcc(EmailMessageService.cs:44),但 SendEmailOnPortalFeedback 調用時均傳 null,且該方法對外調不到,唯一可發任意郵件的 EmailQueueController.CreateEmailQueue 端點已被注釋(EmailQueueController.cs:51);③ V2 BFF 自身無發信能力(無 SMTP/nodemailer,只轉調後端)。⇒「零改後端」需給 BFF 加 SMTP 或解開後端發信端點(皆非零改動),故以方案②(加 AssignedAgentUserId)為準。
當前 plans-plus.js 的 submit() 已臨時移除支付鏈:/subscribe 成功後直接 showSubmitted() 顯示「已提交成功」,不調 /payments/create、不跳收銀台。⇒ 本輪不實現支付端點。
後端側:在線非 0 元訂單 CreateOrder/TenantRenewal 目前走 MockupPayment(newOrder)(本地佔位、非真實支付方),配合 PaymentWebhook(端點 10)。真實支付(QFPay 收銀台簽名 URL + webhook)留待後續階段;屆時 QFPay 簽名邏輯應在 BFF 服務端完成(API key 不可暴露前端),mock 的 pay-gateway/pay-return//payments/:id 輪詢鏈可作參照。
本地棧(AML_Backend/docker-compose-local-dev/)已把後端 iCON.Abp.FX.HttpApi.Host 起在 http://localhost:44331(Swagger 同址),SQL Server 在 localhost,11433。要把 V2 的 plans-plus BFF 指到它,需三件事:配 .env、對齊 Portal 訪客憑證、補種子數據。
# 便捷腳本(已加入 package.json):APP_ENV=dev + 本地後端 + plans-plus mock 兜底 + PORT 8090
npm run start:local
# 等價於:
cross-env APP_ENV=dev API_BASE_URL=http://localhost:44331 PLANS_PLUS_MOCK=true PORT=8090 node server/index.js
實測結論(免 token):plans-plus 依賴的門戶端點 GetPlanList / GetEditionList / getCategoryByTypes / CreateOrder / TenantRenewal / queryRenewableTenant 均為 [AbpAutoAuth("Portal")]——後端 AbpAutoAuthMiddleware 在服務端注入門戶訪客 token 並覆蓋調用方 Authorization;SearchUserByCodeAndType 為 [AllowAnonymous]。⇒ BFF 無需自帶 token,直接匿名 POST 即可。因此本地不必配 AUTH_*(且本地 Portal 租戶未種子化,connect/token?__tenant=Portal 反而會報 Tenant not found)。新增的 5 個真實路由與已改的 countries.js 均按此免 token 實現。
本地庫當前(實測):
| 表 | 本地現狀 | plans-plus 需要 |
|---|---|---|
AMLPortal_Plans | 0 條 | B(Std/P2G/Pre/CPA) + jQ/j + AdlU + KYC 各若干 |
AMLPortal_PlanDetails | 0 條 | 每 Plan 至少 1 條(含 price/periodMonths/qCount/userCountLimit) |
SaasEditions | 1 條 Standard(3A220FC7-…) | 多行業(VASP/TCSP/MSO/CPA…);至少 1 個 CPA edition 對齊 jQSeparatedEditions |
AMLPortal_AgentUserPlans | 0 條 | (可選)測 agent tiers 過濾時需要 |
配置 Portal.jQSeparatedEditions.EditionIds | 3A1A2969-…(庫中不存在) | 對齊到實際 CPA edition 的 GUID |
補種子的兩條路:① 直接寫 SQL 往 SaasEditions/AMLPortal_Plans/AMLPortal_PlanDetails 插測試數據(快,適合本地聯調;注意 AMLPortal_Plans 需正確的 Tag1Code/Tag2Code/Enabled=1/IsActive=1,並讓 PlanDetail.EffectTime/ExpireTime 覆蓋當前);② 加後端 DataSeedContributor(AMLPortalDataSeedContributor 目前只 seed TenantProperty)補 Plan/Edition 種子,跑 db-migrator 幂等注入(更可復現,改動後端代碼)。本地聯調建議 ①,並把 CPA edition 的 GUID 同步進 appsettings.local.json 後重啟 host。
| 項 | 狀態 | 說明 |
|---|---|---|
| Edition + Plan/PlanDetail 種子 | 已補 | docker-compose-local-dev/seed-plans-plus.sql(幂等,鏡像 mock 目錄;CPA edition GUID 對齊 jQSeparatedEditions,down -v 後重跑) |
| 後端 BR/CI 必填放開 | 待應用/核對 | 決策=非必填:注释 OrderService.cs:154,190 兩處必填校驗(保留 :191 唯一性,空值安全)。當前 working copy 這兩處仍 active——需確認已在對接/部署環境放開 |
| BFF 真實路由 | 已寫 | 新增 routes/editions.js · plans.js · agents.js · tenants.js · subscribe.js + services/catalog.js;countries.js 改為免 token;index.js 掛載於 mock/代理之前 |
| 訂閱三態 | new 已驗 renew/topup 待數據 | new(corp/個人/CPA/加值項)實測返回 orderId、PlanPrice 正確(含 KYC 月租 PCS=月數);renew/topup 路由已寫,但本地無可續費租戶數據,待後端「按郵箱查」或先建租戶 |
| single-query(PAYG 隨付即用) | 前端+mock 已實現 後端對接待接 | 本輪落地新模型:檢測內容固定 4 項純展示、用戶不可選(/single-query/options 去單價、加 code=V/E/A/D);收款價取 GetPlanList(countryCode) 的 PAYG(P2G) 單一整包價——新增原始端點 POST /amlPortal/plan/portal/GetPlanList mock,返回結構與真後端逐字段一致({code,msg,data:{totalCount,items:[…planDetails[].price]},version}),切真後端免改前端。/plans-jp→JPN(目前唯一有價國家,mock ¥3,000 佔位);提交帶 functionCodes="EAVD"、幣種隨方案(JPY)。改動:public/js/plans-plus.js · plans-plus.html · translations.js + server/mock/plans-plus.js。支付本輪除外 |
| 聯絡我們(不下單) | 改接 contact | 復用 routes/contact.js→CreateFeedback;前端調用點由 /subscribe-offline 改 /contact,推薦人路由由 BFF 補 |
端到端驗證(npm run start:local + 本地 docker 後端):/editions(10 行業 + CPA 切換)· /plans/catalog(standard/cpa/addons 全對)· /countries(249)· /agents/:code(無數據 found:false)· /subscribe(new 各變體均返回 orderId、訂單入庫)。頁面 GET /plans-plus HTTP 200。
PAYG(single-query)本輪驗證(APP_ENV=test mock 模式):/single-query/options 返固定 4 項、無單價;POST /amlPortal/plan/portal/GetPlanList{countryCode:"JPN"}→P2G ¥3,000(JPY),{HKG}→空目錄(價格不可用);抽取 plans-plus.js 實際 extractPaygPlan/paygPlanPrice/fmtMoneyCur 跑真實響應:P2G / 3000 / JPY / ¥3,000 通過、HKG→null/0(禁用提交)。GET /plans-jp HTTP 200、含 ppSqPriceValue 橫幅。(真後端 GetPlanList 對接與 ConsumerPortal 支付後編排見 5.6,仍待後端。)
| # | 新增/變更 | 對接影響 | 狀態 |
|---|---|---|---|
| 1 | 單次查詢 single-query(隨付即用,第 4 種流程) | 後端已支持(ConsumerPortal 即時檢測);兩步由 AML 後端在支付完成後內部編排(CreateConsumerLink→CreateConsumerOrder),V2 只下單+收款。檢測內容固定 4 項純展示不可選(V/E/A/D);定價取 GetPlanList(countryCode) 的 PAYG 方案價(/plans-jp→JPN),映射與 2C 租戶均在後端(見 5.6) | 改造 |
| 2 | 聯絡我們/推薦人(發訂單摘要 · 不創建訂單) | 復用既有 contact/CreateFeedback;「送推薦人」需後端加 AssignedAgentUserId 收件人字段(小改)+ BFF 拼裝 message | 復用+後端小改 |
| 3 | agent tiers(推薦人可售級別過濾方案) | SearchUser 不含 tiers,需經 GetPlanList(agentUserId) 推導 | BFF |
| 4 | agent email/phone(聯絡推薦人) | AppUserDto 已含 Email/PhoneNumber,BFF 帶出 | BFF |
| 5 | 主體類型 corp/individual(新租戶) | 個人主體無 BR/CI;BR/CI 已定非必填 ⇒ 不再阻塞(待 :154/:190 放開已應用) | 非阻塞 |
| 6 | BR/CI 非必填(已定) | 注释 OrderService.cs:154,190(保留唯一性);當前 working copy 仍 active,待確認已放開 | 待應用/核對 |
| 7 | KYC 改設備月租(monthlyPrice × 月數) | 後端 KYC 單價 ⇒ 下單 PCS=月數(見 5.2) | BFF/後端確認 |
| 8 | jQuota 改選配套(package,非按量) | 後端 jQ plan 天然離散,BFF 映 packages | BFF |
| 9 | 按郵箱查租戶 + 郵箱唯一單命中 | 已定:後端新增 queryRenewableTenantByEmail(現端點 DB 層只按租戶名搜、郵箱查後回填→不能按郵箱過濾);不再多租戶消歧;新端點聚合 currentSubscription+referrer(見 5.5) | 需後端新增 |
| 10 | 續費頁展示 referrer | TenantPropertyDto.AgentorCode/Name 映射 | BFF |
| 11 | 續費續價 / 顯示舊方案(Req 9) | 續價後端不認前端 price;舊方案卡屬前端渲染 | 後端確認 |
| 12 | 過期加購阻斷(須先續費) | 純前端門檻,無對接影響 | 無影響 |
| 13 | 去掉管理員密碼步驟 | 後端忽略 AdminPassword,發激活郵件 | 無影響 |
| 14 | bestValue / note / edition&plan 多語言 | 後端無字段,BFF 配置或後端補 | BFF/後端 |
| 15 | 支付端點化(本輪除外) | submit 暫不跳支付,直接顯示已提交 | 暫緩 |
| 文件 | 職責 | 後端依賴 |
|---|---|---|
routes/editions.js(新) | GET /editions + 多語言/過濾整理 + jQSeparated 鍵名轉換 | GetEditionList |
routes/plans.js(新) | GET /plans/catalog + filterPlan 拆分 + KYC 月租/jQ 配套 + 緩存 planDetailId | GetPlanList |
routes/countries.js | 已存在,直接掛載 | getCategoryByTypes |
routes/agents.js(新) | GET /agents/:code → {code,name,tiers,email,phone,agentorId} | SearchUserByCodeAndType (+GetPlanList) |
routes/tenants.js(新) | GET /tenants/lookup?email= → 字段直通 + 命中態(none/unique) 包裝(currentSubscription/referrer 聚合由後端算好) | 新增 queryRenewableTenantByEmail |
routes/subscribe.js(新) | POST /subscribe 分流 + PlanList 組裝(含 KYC PCS=月數) | CreateOrder / TenantRenewal / (Topup) |
routes/contact.js | 已存在;「聯絡我們」復用之——把結構化 payload 序列化為 message 摘要 + 透傳 agentUserId,轉 CreateFeedback(收件人路由由後端 AssignedAgentUserId 承接) | CreateFeedback |
mock/plans-plus.js | single-query / payments 暫留 mock;subscribe-offline 改由 contact 承接(可保留為兼容別名) | — |
index.js | 代理之前追加各真實路由 app.use(apiPrefix, createXxxRouter(config)) | — |
services/auth.js / .env.* | 門戶訪客憑證 + __tenant=Portal;edition/plan 文案映射 | — |
CreateOrder 的 BR/CI 必填校驗(注释 :154/:190,保留唯一性);請確認已在對接/部署環境應用(當前 working copy 仍 active)。POST Order/portal/queryRenewableTenantByEmail(入參 {Email},[AbpAutoAuth("Portal")])——先按 UserName=='admin' && Email== 定位租戶(可復用現 GetAllUsers() 全庫掃描、改按郵箱過濾,掃描成本可接受),返回聚合 currentSubscription(planId / 上期 price / 已購加值項,反查思路借用 GetCurrServiceInfo)+ referrer(AgentorCode/Name)。詳見 5.5。TenantRenewal(PlanList 僅加值項)是否「只疊配額不延期」,或新增 TenantTopup。PCS=月數 計月租。GetPlanList(countryCode) 的 PAYG 方案價。待後端確認:① V2「支付完成」與後端編排的銜接端點/webhook;② 隨付即用專用「2C 租戶」+ AML.ConsumerPortal.Enable 功能 + 實時篩查權限(供後端落上下文,V2 無需持該租戶憑證);③ countryCode 僅日本(JPN),僅 /plans-jp 有 P2G 方案,餘國待種 PlanDetail 後再開。CreateFeedback 增 AssignedAgentUserId 收件人字段(DTO + 實體 + 服務解析郵箱 + EF 遷移):有值發推薦人、否則發平台銷售。bestValue/多語言 note/nameJP;edition 增多語言名稱。| 階段 | 內容 | 可獨立交付 |
|---|---|---|
| P0 · 本地種子 | 補 Edition + Plan/PlanDetail 種子,對齊配置(第 6 章) | 後端返回非空目錄,可對接 |
| P1 · 只讀目錄 | editions + plans/catalog + countries(已就緒)+ agents | 頁面渲染方案/行業/國家,校驗推薦人 |
| P2 · 新購下單 | subscribe(new)(BR/CI 已定非必填:注释 :154/:190) | 新租戶下單至「已提交成功」(支付除外) |
| P3 · 續費/加購 | tenants/lookup(依賴後端新端點 queryRenewableTenantByEmail)+ subscribe(renew/topup) | 續費/加購閉環(支付除外);阻塞於後端新端點 |
| P4 · 單次查詢/聯絡/支付 | single-query(支付後接 ConsumerPortal 兩步:CreateConsumerLink→CreateConsumerOrder)+ 聯絡我們(接 contact API + 推薦人路由)+ payments(後端就緒後) | 完整流程 + 在線支付 |
CreateOrder 的 OrderService.cs:154,190(保留 :191 唯一性,空值安全)。待辦:確認該放開已在對接/部署環境生效(當前 working copy 兩處仍 active);並知悉全局影響(旧站共用 CreateOrder,空 BR 不再攔截)。CreateConsumerLink→CreateConsumerOrder,V2 只下單+收款,見 5.6)。本輪再定:檢測內容固定 4 項純展示不可選(證件核驗V / 名單篩查ES=E / AI 增強A / 失信D);收款金額取 GetPlanList 按 countryCode 的 PAYG(P2G) 方案價(/plans-jp→JPN)。待後端確認:① V2「支付完成」與後端編排的銜接方式(後端持單 + 收 webhook,或 V2 支付後調後端專門 2C 觸發端點);② 隨付即用專用「2C 租戶」+ AML.ConsumerPortal.Enable + 實時篩查權限(供後端落上下文,V2 無需該租戶憑證);③ functionCodes="EAVD" 由 V2 固定傳、還是後端寫死;④ 已確認 PAYG = 單一整包價(非分項之和);目前 countryCode 僅日本(JPN),僅 /plans-jp 有 P2G 方案 + PlanDetail,餘國後續再種。contact/CreateFeedback 發送訂單摘要(不創建訂單/線索);「送推薦人」擬由後端 CreateFeedback.AssignedAgentUserId 承接(待實現,見 5.8)。待辦:後端加字段 + 服務路由 + EF 遷移;前端調用點由 /subscribe-offline 改 POST /api/contact 並透傳 agentUserId;BFF 映為 AssignedAgentUserId。queryRenewableTenantByEmail(email) 直接返回「租戶 + currentSubscription(planId/上期價/已購加值項)+ referrer」聚合(見 5.5)。待後端確認:① 「admin 郵箱」是否恆等於 UserName=='admin' 用戶的 Email(現 queryRenewableTenant 即此語義)——若存在非 admin 用戶名的租戶管理員,需改按角色定位;② 過期租戶是否照樣返回(供前端做臨期/過期展示 + topup 阻斷);③ 聚合 DTO 形態(新 RenewableTenantDto vs 擴 TenantPropertyDto)。TenantRenewal(PlanList 僅加值項)後端是否接受、語義是否「只疊配額不延期」?PCS=租賃月數 計費(planDetail.price 當月租單價)?TenantRenewal 是否支持沿用上期價/自定義價?否則前端續費折扣僅展示、實際按目錄價。GetPlanList(agentUserId) 推導 tiers,還是改為服務端直接按 agent 過濾 catalog?/agents/:code 響應附帶 agent Guid id,避免下單時二次查詢?Portal@iconsz.com,__tenant=Portal)權限是否覆蓋 CreateOrder / queryRenewableTenant / SearchUserByCodeAndType?AMLPortalDataSeedContributor?CPA edition GUID 是否同步進 appsettings.local.json?
本分析基於源碼靜態閱讀 + 本地 docker 庫實測(2026-07):plans-plus.js / server/*(含 mock 契約)/ AMLPortal Controllers 與 Service / iCON.Abp.AML 的 ConsumerPortalController 與 ConsumerPortalService(單次查詢即時檢測)/ CustomIdentityUserController / 旧站 PlanService / appsettings.local.json 與本地數據庫。涉及後端行為(BR/CI 校驗、續費續價、topup / KYC 計費語義、單次查詢 2C 計費與鑑權)以實際接口 + 後端確認為準。