把 public/plans-plus.html 的單頁訂閱流程,從本地 mock 切換到 AML 後端(iCON.Abp.AMLPortal)真實 API。
plans-plus 前端共依賴 7 個業務端點(外加支付狀態輪詢 / webhook 2 個)。其中大部分可直接復用旧站 justsolutionsWeb 已經對接好的同一批 AMLPortal 後端端點;落地工作主要是在 justsolutionsWebV2/server/routes/ 新增 BFF 處理器,做「後端原始響應 → 前端精簡契約」的字段轉換(與既有 countries.js / industries.js 完全同一範式)。
| 前端端點 | 對應後端 | 復用程度 | 主要缺口 |
|---|---|---|---|
GET /editions | Order/portal/GetEditionList | 改造 | 多語言 edition 名稱(後端只有 displayName) |
GET /plans/catalog | plan/portal/GetPlanList | 改造 | 需復刻旧站 filterPlan 拆分;bestValue/note 後端無 |
GET /countries | Order/getCategoryByTypes | 已實現 | —(routes/countries.js 已可用) |
GET /agents/:code | identity/users/SearchUserByCodeAndType | 改造 | 響應 → {code,name} 轉換 |
GET /tenants/lookup | Order/portal/queryRenewableTenant | 缺口大 | 後端按租戶名搜,前端按郵箱查;currentSubscription 需重建 |
POST /subscribe | CreateOrder / TenantRenewal | 改造 | 需補 planDetailId;topup 類型無對應端點 |
POST /payments/create | QFPay 收銀台(旧站前端拼 URL) | 缺口大 | 需服務端簽名 + PaymentWebhook 回調 |
好消息:後端 CreateOrder 已忽略前端傳入的 AdminPassword,改用 appConfig.General.TenantAdminDefaultPassword(OrderService.cs:215,236)。因此 plans-plus 去掉「管理員密碼」步驟不構成阻塞——後端會發重置密碼郵件給租戶管理員。
兩個真正的硬缺口:
① 按郵箱查租戶:queryRenewableTenant 只支持 keyword(按 TenantName.Contains)。plans-plus 用郵箱查、且要支持「一郵箱多租戶」選擇——需新增後端端點,或在 BFF 全量拉取後按郵箱過濾(不可取)。
② topup(加購)流程:plans-plus 新增的純加購(不含基礎方案、只買 增加用戶/KYC/jQuota)旧站沒有,後端亦無直接端點。需確認映射到 TenantRenewal(僅加值項 planList)還是新增端點。
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');真實環境需配 API_BASE_URL 與 OAuth 憑證(.env.*)。server/index.js:真實環境先掛 routes/*.js 業務路由,再掛 proxy 兜底;mock 環境掛 mock/routes.js。新增 plans-plus 的真實路由就照此追加 app.use(apiPrefix, createXxxRouter(config))。server/services/auth.js:OAuth2 password 流程取 token 並緩存(提前 5 分鐘刷新),對應旧站硬編碼的 customer1 訪客憑證,現改為 .env 配置。BFF 每次調後端用 createAuthConfig() 加 Authorization: Bearer。關鍵差異(與旧站):旧站 Angular 直接從瀏覽器調 https://api-aml.iconsz.com/api/amlPortal/*,token 存 localStorage。V2 改為瀏覽器不直接接觸後端,由服務端 BFF 持有憑證、收口後端調用並裁剪響應。因此本文每個端點都拆成「前端契約」與「BFF→後端映射」兩層。
以下是 plans-plus.js 實際讀寫的字段(即 BFF 必須產出/接受的契約,目前由 mock/routes.js 滿足)。真實對接時 BFF 輸出必須與此逐字段一致,否則前端渲染/計價會出錯。
loadAll() 並發請求 /editions、/plans/catalog、/countries,三者均以 { 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,…}, kyc:{unitPrice,…},
jquota:{ packages:[{id, jq, price, nameCN/EN/JP}] } } } }
GET /countries → { success, data:[{code, name, nameTC, nameSC, nameJP, phoneCode}] }
Plan = { planId, tag2Code, nameCN, nameEN, nameJP, periodMonths,
price, originalPrice, qCount(-1=無限), userCountLimit,
bestValue, noteCN, noteEN, noteJP }
GET /tenants/lookup?email=&name=
→ { success, match:'none'|'unique'|'multiple',
tenant:Tenant|null, candidates:[{tenantId, tenantName}] }
GET /agents/:code → { success, found:bool, data:{code, name}|null }
Tenant = { tenantId, tenantName, editionId, editionName, jurisdiction, br, ci,
currentSubscription:{ planId, nameCN/EN/JP, periodMonths, price,
qCount, userCountLimit, startDate, expiryDate, usedQuota,
addons:{ users:int, kyc:bool, jquotaPackageId:string } } }
submit() 先 POST /subscribe 拿 orderId,再 POST /payments/create 拿 redirectUrl 跳轉支付。
POST /subscribe body = {
type:'new'|'renew'|'topup', edition, isCpa, startDate,
plan:Plan, planPrice, isRenewalRate,
addons:{ users, userUnitPrice, kyc, kycUnitPrice,
jquotaPackageId, jquotaPackageName, jquotaUnits, jquotaPrice },
agentCode, subtotal, total,
// type=new 追加: company, jurisdiction, br, contact, phoneCode, phone, email, address
// type=renew/topup 追加: tenantId, company, email, currentSubscription
} → { success, data:{ orderId, status, createdAt } }
POST /payments/create body = { orderId, amount, currency:'HKD', company, email }
→ { success, data:{ paymentId, status, gateway, redirectUrl } }
注意:/subscribe 的 body 不含 planDetailId,也不含各加值項(增加用戶/KYC/jQuota)對應的 planId/planDetailId。而後端 CreateOrder/TenantRenewal 的 PlanList 每項都必須同時帶 PlanId+PlanDetailId(OrderService.cs:176 聯合校驗)。⇒ BFF 需在服務端依 /plans/catalog 結果反查補齊 planDetailId(見 5.6)。
以下端點均在 AML_Backend/modules/iCON.Abp.AMLPortal,並已被旧站 justsolutionsWeb/PlanService 使用驗證過。前綴 /api/amlPortal/*,鑑權走 [AbpAutoAuth("Portal")](門戶訪客 token)。
| # | 方法 / 路由 | 用途 | Controller |
|---|---|---|---|
| 1 | POST /api/amlPortal/plan/portal/GetPlanList | 取方案列表(B/jQ/j/AdlU/KYC) | PlanController:37 |
| 2 | POST /api/amlPortal/Order/portal/GetEditionList | 取 edition(所屬行業)+ jQSeparatedEditions | OrderController:136 |
| 3 | POST /api/amlPortal/Order/getCategoryByTypes | 取國家列表(typeCodes:['COUNTRY']) | OrderController:184 |
| 4 | GET /api/identity/users/SearchUserByCodeAndType/{code}/true | 校驗推薦人/代理 code | Identity(非 AMLPortal) |
| 5 | POST /api/amlPortal/Order/portal/queryRenewableTenant | 查可續費租戶(按 keyword=租戶名) | OrderController:308 |
| 6 | POST /api/amlPortal/Order/portal/ExistsByOrganizationBRCI | 校驗 BR/CI 是否已存在 | OrderController:150 |
| 7 | POST /api/amlPortal/customer/portal/CheckEmailExists/{email} | 校驗管理員郵箱是否已用 | CustomerController:121 |
| 8 | POST /api/amlPortal/Order/portal/CreateOrder | 新租戶下單 | OrderController:53 |
| 9 | POST /api/amlPortal/Order/portal/TenantRenewal | 租戶續費下單 | OrderController:321 |
| 10 | POST /api/amlPortal/Order/portal/PaymentWebhook | 支付回調(QFPay 服務端調,非前端) | OrderController:163 |
DTO 位置:Application.Contracts/PlanAppLayer/*(GetPlanListParam、PlanDto、PlanDetailDto)、Application.Contracts/OrderAppLayer/*(CreateOrderParam、SelectPlanItem、TenantRenewalParam、QueryRenewableTenantParam、TenantPropertyDto)。
每節:前端契約 → 對應後端 → 字段映射 → BFF 轉換要點 → 差異/缺口。新增的 BFF 文件建議放 server/routes/,與 countries.js 同範式(createAuthConfig() + axios + 字段映射 + {success,data} 包裝)。
後端返回 { code:0, data:{ editionList:[{id, displayName, …}], jQSeparatedEditions:{editionIds:[…]} } }。前端要 editionList:[{id, displayName, nameCN, nameJP}] 與 jQSeparatedEditions.editionIds。
| 前端字段 | 後端來源 | 說明 |
|---|---|---|
id | editionList[].id(Guid) | 直接映射;後續作 OrganizationReference |
displayName | editionList[].displayName | 英文名 |
nameCN / nameJP | 後端無 | 缺口,見下 |
jQSeparatedEditions.editionIds | 同名字段 | 驅動 CPA/標準方案集切換(state.isCpa) |
缺口:edition 多語言名稱。後端 GetEditionList 只有 displayName,旧站也僅用 displayName。plans-plus 的 editionName() 在 tc/jp 下會優先取 nameCN/nameJP,缺失時已能 fallback 回 displayName ⇒ 不阻塞,但中日文會顯示英文。
建議:BFF 維護一份 editionId/displayName → {nameCN,nameJP} 映射表(與 industries.js 現有靜態行業翻譯同思路),或後端在 edition 上補多語言字段。
旧站行為(可選沿用):getEditionList() 會過濾掉 Standard、並把 Others 排到列表末尾,同時把 editionIds toLowerCase()。plans-plus 目前未做此整理;若要與旧站一致,這段邏輯應放 BFF。
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 }
| 前端 Plan | 後端來源(item / planDetails[0]) | 備註 |
|---|---|---|
planId | item.id | |
planDetailId ⚠️ | item.planDetails[0].id | 前端契約現缺此字段,但下單必需 ⇒ BFF 須補進 Plan,提交時回填(見 5.6) |
tag2Code | item.tag2Code | Std/P2G/CPA/Pre…,CPA 判定用 |
nameCN/nameEN | item.nameCN/nameEN | 旧站會截掉「(…」後綴,可沿用 |
nameJP | 後端無 | 缺口,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.unitPrice ← tag1Code==='KYC' 同上addons.jquota.packages[] ← tag1Code==='jQ'||'j':每項 { id:planId, jq:qCount, price:planDetails[0].price, name* },並各自記 planDetailId缺口:bestValue(最超值標記)、note*(賣點文案)後端 PlanDto/PlanDetailDto 無對應字段。建議:① BFF 配置(按 systemCode 標 bestValue / 文案);或 ② 後端在 Plan 增 bestValue 與多語言 note。短期可在 BFF 寫死映射,不阻塞。
提示:plans-plus 的 jQuota 改為「選配套」(jquotaPackageId),不再是旧站的「按數量」。後端 jQ 計劃本就是離散 plan,天然契合——每個 jQ plan 即一個 package。
server/routes/countries.js 已實現並可直接用:取 typeCodes:['COUNTRY'],從 categoryTranslations 提 zh-hk/zh-cn/ja/en-us,輸出 {code, name, nameTC, nameSC, nameJP, phoneCode},正好滿足 plans-plus 的 countryDisplay() 與 phoneCode 自動填充。無需改動。
前端要 { success, found, data:{code, name} }。後端(Identity)返回匹配到的用戶(含 name/userName/extraProperties.code 等)。BFF 取首條 → found:true, data:{code, name};無匹配 → found:false, data:null。
| 前端 | 後端用戶字段 |
|---|---|
data.code | 用戶 code(agent code) |
data.name | name / surname / userName |
(下單用)agentorId | 用戶 id(Guid)→ 提交時作 CreateOrderParam.AgentorId |
關鍵:前端只展示 name,但下單需要 agent 的 Guid id(CreateOrderParam.AgentorId)。BFF 應在 /agents/:code 響應內順帶緩存 code→id(或前端 data 內含 id),否則 /subscribe 階段需再查一次。建議 data 增隱藏字段 agentorId。
備註:AMLPortal 另有 Agentor/portal/GetAgentorByCode,但已標【廢棄】;旧站用的是 Identity 的 SearchUserByCodeAndType,沿用之。
前端按郵箱查租戶,支持「none / unique / multiple」三態,命中後帶出 currentSubscription 預填續費表單。但後端 queryRenewableTenant 只接受 {keyword} 且按 TenantName.Contains(keyword) 搜索(OrderService.cs:2363),返回 List<TenantPropertyDto>(內部已把 admin 郵箱填入 TenantAdminEmail)。
TenantAdminEmail===email 過濾並按郵箱分組(⚠️ 全量拉取、性能與越權風險,僅臨時)。TenantPropertyDto 結構)。currentSubscription(planId、name、periodMonths、price、qCount、userCountLimit、startDate、expiryDate、usedQuota、addons)並非 TenantPropertyDto 直接字段,需由 TenantProperty + 其 Order + 正在生效的 plan 推導。可參考 GetCurrServiceInfo(CurrServiceInfoDto.ActiveBPlans、EffectiveEndTime、QCountLeft 等)。| 前端 | TenantPropertyDto | 備註 |
|---|---|---|
tenantId | TargetTenantID | |
tenantName | TenantName | |
editionId/editionName | EditionId/EditionName | |
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 | 需經 Order/OrderDetail 反查 | 推導,續費續價(沿用上期 price)依賴此 |
currentSubscription.addons.users/jquotaPackageId | 需經訂單明細反查 | 推導 |
建議後端改動:新增端點 queryRenewableTenantByEmail(email[, tenantName]),直接返回 plans-plus 所需的「租戶 + currentSubscription(含 planId/上期價/已購加值項)」聚合結構,避免在 BFF 拼裝易錯的訂單反查。
BFF 依 body.type 分流到不同後端端點,並把前端的「人類友好」payload 翻成後端 DTO;核心是組裝 PlanList(基礎方案 + 各加值項,每項補 planDetailId)。
| CreateOrderParam | 前端 payload | 備註 |
|---|---|---|
PlanList[] | plan + addons | 見下「PlanList 組裝」 |
TenantName | company | 必填;後端校驗重名 |
TenantAdminEmail | email | 必填;後端再校驗未註冊 |
Jurisdiction | jurisdiction | 必填 |
OrganizationReference | edition(Guid) | 必填;校驗 edition 存在 |
OrganizationBR / OrganizationCI | br / — | BR 或 CI 至少一個(OrderService.cs:154);plans-plus 僅收 BR 且標可選 ⇒ 缺口 |
EffectiveStartTime | startDate | |
ContactPerson | contact | |
CompanyAddress | address | 可選 |
Phone | phoneCode + phone | 可選;BFF 拼接 |
AgentorId | 由 agentCode 解析的 Guid | 見 5.4 |
AdminPassword | — | 忽略(後端用默認密碼,發重置郵件) |
IsOfflinePayment/IsAgentBehalf | — | 固定 false |
| TenantRenewalParam | 前端 payload |
|---|---|
TargetTenantID | tenantId |
PlanList[] | plan + addons(同下組裝) |
TenantAdminEmail | email |
AgentorId | 空 → 延續原代理 |
IsOfflinePayment/IsAgentBehalf | 固定 false |
純加購(無基礎方案,只買 增加用戶/KYC/jQuota)。後端無直接端點。候選方案:
TenantRenewal,PlanList 只含加值項(不含 B 類基礎方案),由後端確認是否允許「無基礎方案」的續費單並只疊加配額/開關。需後端確認語義。TenantTopup 端點,明確「在現有有效訂閱上疊加配額/用戶/KYC,不延長有效期」。UpdateTenantProperty(admin)邏輯可借鑒(它就是疊加 PlanList 並重算配額),但屬 admin 線下流程。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 = 1
if (addons.kyc) PlanList.push({ PlanId: KYC.planId,
PlanDetailId: KYC.planDetailId, PCS: 1 })
// 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 時直接查表。
後端返回(OperationDto):下單成功返回含 orderCode、planName、paymentStatus、txamt 等(旧站 plan4.component 用其拼 QFPay URL)。BFF 應把其中 orderId/orderCode 回給前端的 data.orderId,並暫存 orderCode/amount 供下一步支付用。
旧站沒有獨立的 create-payment 後端端點:plan4.component.payFn() 在前端直接用 orderCode/planName/txamt 拼 QFPay checkstand URL 並 sha256 簽名後 location.href 跳轉。plans-plus 改成調 /payments/create 拿 redirectUrl ⇒ 這段簽名邏輯應移到 BFF 服務端(API key 不可暴露前端)。
| QFPay 參數 | 來源 |
|---|---|
appcode | 環境配置(.env) |
goods_name | 下單返回 planName / 前端 planName |
out_trade_no | 下單返回 orderCode |
txamt | apis.test ? 10 : amount*100(單位:分) |
txcurrcd | HKD |
return_url/failed_url | V2 的 pay-return / 失敗頁 |
notify_url | {API_BASE_URL}/api/amlPortal/Order/portal/PaymentWebhook(端點 10,QFPay 服務端回調後端) |
sign | sha256(排序參數串 + api_key),服務端計算 |
BFF 返回 { success, data:{ redirectUrl:<QFPay checkstand URL> } },前端 location.href 跳轉。支付結果由 QFPay 異步回調後端 PaymentWebhook 落單(權威來源);前端返回頁可輪詢訂單狀態。
mock 對照:當前 mock 用 /payments/create + /payments/:pid(輪詢)+ /payments/:pid/webhook 模擬「下單→收銀台→webhook→返回頁輪詢」全鏈路。真實環境收銀台與 webhook 由 QFPay 提供,BFF 只需:① 拼簽名 URL;② 提供查單(透傳後端訂單狀態)給返回頁輪詢。
免支付分支:旧站當訂單 paymentStatus===200 或 txamt===0(如純免費/0 元)時直接跳成功頁,不去 QFPay。plans-plus 的 P2G / 0 元情形需在 BFF 比照處理(redirectUrl 直接給成功頁)。
以下是 plans-plus 單頁流程相對旧站 4 步嚮導新增或改變的點,逐一標注對接影響。
| # | 新增/變更 | 對接影響 | 狀態 |
|---|---|---|---|
| 1 | topup 加購類型(無基礎方案,只買加值項) | 後端無端點,需確認映射 TenantRenewal 或新增端點 | 需後端 |
| 2 | 按郵箱查租戶 + 一郵箱多租戶選擇 | 後端 queryRenewableTenant 按名字搜,需新增按郵箱端點 | 需後端 |
| 3 | jQuota 改為選配套(package,非按量) | BFF 把 jQ plan 映成 packages 即可 | BFF |
| 4 | KYC「已購買」鎖定(topup 已有則禁買) | 依賴租戶 EnableKYC,BFF 帶出即可 | BFF |
| 5 | 續費續價(沿用上期 price,顯示續費折扣) | 依賴 currentSubscription.price,需訂單反查 | 需後端 |
| 6 | 去掉管理員密碼步驟 | 後端已忽略 AdminPassword,發重置郵件 | 無影響 |
| 7 | bestValue / 賣點 note(方案卡片標記與文案) | 後端 Plan 無此字段,BFF 配置或後端補 | BFF/後端 |
| 8 | edition 多語言名稱(中/日) | 後端只有 displayName,BFF 補映射 | BFF |
| 9 | BR 標為可選(UI) | 後端要求 BR 或 CI 至少一個;需 UI 補 CI 或設必填 | 需對齊 |
| 10 | 支付 create 端點化(前端拿 redirectUrl) | QFPay 簽名邏輯移到 BFF 服務端 | BFF |
| 文件 | 職責 | 後端依賴 |
|---|---|---|
routes/editions.js(新) | GET /editions + 多語言/過濾整理 | GetEditionList |
routes/plans.js(新) | GET /plans/catalog + filterPlan 拆分 + 緩存 planDetailId | GetPlanList |
routes/countries.js | 已存在,直接掛載 | getCategoryByTypes |
routes/agents.js(新) | GET /agents/:code → {code,name,agentorId} | SearchUserByCodeAndType |
routes/tenants.js(新) | GET /tenants/lookup(依賴後端新端點) | queryRenewableTenant(ByEmail) |
routes/subscribe.js(新) | POST /subscribe 分流 + PlanList 組裝 | CreateOrder / TenantRenewal / (Topup) |
routes/payments.js(新) | POST /payments/create 拼 QFPay 簽名 URL + 查單 | PaymentWebhook(回調) |
index.js | 在真實環境段 app.use(apiPrefix, createXxxRouter(config)) 追加各路由 | — |
.env.* / config.js | 補 QFPay appcode/api_key、edition/plan 文案映射等 | — |
bestValue 與多語言 note;edition 增多語言名稱。| 階段 | 內容 | 可獨立交付 |
|---|---|---|
| P1 · 只讀目錄 | editions + plans/catalog + countries(已就緒)+ agents | 頁面可正常渲染方案/行業/國家,校驗推薦人 |
| P2 · 新購下單 | subscribe(new) + payments/create(QFPay) | 新租戶完整下單支付閉環 |
| P3 · 續費 | tenants/lookup(後端新端點)+ subscribe(renew) | 續費閉環,含續價 |
| P4 · 加購 | topup(後端確認後) | 加購閉環 |
appcode/api_key/收銀台域名?簽名算法是否一致(sha256 排序串 + key)?paymentStatus===200 或 txamt===0)是否照搬?/agents/:code 響應是否可附帶 agent 的 Guid id,避免下單時二次查詢?.env)權限是否覆蓋 CreateOrder / queryRenewableTenant(均 [AbpAutoAuth("Portal")],應可)?
本分析基於源碼靜態閱讀:plans-plus.js / server/* / AMLPortal Controllers 與 DTO / 旧站 PlanService。涉及後端行為(續費續價、topup 語義)以實際接口為準。