Vue 3 After Login Action 設計:未登入導頁後如何恢復原本動作

前言

很多產品流程都有這種情境:

  1. 使用者還沒登入
  2. 使用者點了一個需要登入的操作
  3. 系統要求登入
  4. 登入成功後,使用者期待「剛剛那個動作」繼續執行

例如:

  • 未登入時點遊戲,登入後直接啟動該遊戲
  • 未登入時點促銷 CTA,登入後回到該促銷
  • 未登入時點收藏,登入後完成收藏

如果沒有統一設計,常見做法會變成每個頁面自己處理:

1
2
3
4
if (!isLogin) {
openLoginDialog();
// TODO: login 後要回來做什麼?
}

這很快會失控。

這個專案用 useAfterLoginAction 把「登入後恢復動作」封裝成 composable,目前主要應用在 game launch。

相關檔案

1
2
3
4
client/packages/common/composables/useAfterLoginAction.ts
client/packages/common/utils/gameLaunchCheck.ts
client/packages/common/stores/root.ts
client/packages/common/composables/useProduct.ts

核心概念

After Login Action 的核心是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Before Login
|
v
Save intended action to sessionStorage
|
v
Open login dialog
|
v
Login success
|
v
Root store checks login status
|
v
Replay saved action

這讓原本中斷的使用者行為可以在登入後接續。

Action Name 設計

目前 composable 裡定義:

1
2
3
enum ActionNameEnum {
LaunchGame = 'launchGame',
}

現在只有 LaunchGame,但這個 enum 預留了擴充空間。

未來如果要支援其他行為,可以加入:

1
2
3
4
5
enum ActionNameEnum {
LaunchGame = 'launchGame',
ClaimPromotion = 'claimPromotion',
AddFavorite = 'addFavorite',
}

重點是 action name 不應該散落成 magic string,而是集中在 composable。

保存動作:openLoginToContinueDialog

使用者未登入時,呼叫:

1
2
3
4
5
6
7
8
9
10
11
12
13
function openLoginToContinueDialog(actionName, data) {
if (isLogin) {
return;
}

eventBus.emit('triggerLogin', 'loginToContinue');

const expirationTime = new Date().getTime() + 5 * 60 * 1000;
sessionStorage.setItem(
'afterLoginActionData',
JSON.stringify({ actionName, data, expirationTime }),
);
}

這裡做了三件事:

  1. 如果已登入,直接 return
  2. 用 event bus 開啟 login dialog
  3. 把 action payload 存到 sessionStorage

存起來的資料長這樣:

1
2
3
4
5
6
7
8
{
"actionName": "launchGame",
"data": {
"prod": "casino",
"launchKey": "xxx"
},
"expirationTime": 1716000000000
}

為什麼用 sessionStorage

這裡選 sessionStorage,不是 localStorage

理由很清楚:

  • after-login action 只屬於目前這個 tab
  • 使用者關掉 tab 後不應該保留
  • 不應跨 tab 共享,避免 A tab 點遊戲,B tab 登入後啟動錯誤遊戲
  • 比 memory state 更能承受 login dialog / route change 造成的 component unmount

這是介於 memory state 與 long-lived storage 之間剛好的選擇。

5 分鐘過期時間

1
const expirationTime = new Date().getTime() + 5 * 60 * 1000;

這個 timeout 很重要。

如果沒有過期時間,使用者可能:

  1. 點遊戲
  2. 打開 login dialog
  3. 放著不登入
  4. 幾十分鐘後登入
  5. 系統突然啟動很久以前點過的遊戲

這會讓使用者很困惑。

5 分鐘代表:這是一個短期 intent,不是永久任務。

讀取資料:computed + sessionStorage

1
2
3
4
const afterLoginActionData = computed(() => {
const afterLoginActionStorage = sessionStorage.getItem('afterLoginActionData');
return afterLoginActionStorage ? JSON.parse(afterLoginActionStorage) : null;
});

這裡每次讀取 computed 都會從 sessionStorage parse 最新資料。

因為 action 可能在不同 component / store 裡被寫入,直接從 storage 讀可以避免 composable instance 之間狀態不同步。

清除資料

1
2
3
function removeAfterLoginActionData() {
sessionStorage.removeItem('afterLoginActionData');
}

並且在 component unmount 時清掉:

1
2
3
onBeforeUnmount(() => {
removeAfterLoginActionData();
});

這代表這個 composable 的生命週期偏保守:如果所在頁面被銷毀,就不保留 pending action。

LoginForm 也會使用它:

1
2
// if not login, remove afterLoginActionData in sessionStorage
useAfterLoginAction();

這確保 login form mount/unmount 時會帶到清理邏輯。

檢查是否可以執行

1
2
3
4
5
6
7
8
9
10
11
12
13
function checkAfterLoginStatus() {
if (!afterLoginActionData.value) {
return false;
}

const currentTime = new Date().getTime();
if (!isLogin || dialogQueue.length || currentTime > afterLoginActionData.value.expirationTime) {
removeAfterLoginActionData();
return false;
}

return true;
}

這裡有三個 gate:

1. 必須已登入

1
!isLogin

登入沒完成就不能 replay。

2. 系統 Dialog Queue 必須為空

1
dialogQueue.length

如果登入後還有 Change Password、Terms、Verify Email 這些 system dialog,不能立刻啟動遊戲。

這個細節非常重要。

登入成功不代表使用者已經可以自由操作,系統級流程要先完成。

3. 不能過期

1
currentTime > afterLoginActionData.value.expirationTime

過期就移除,避免舊 intent 被執行。

執行動作:doActionAfterLogin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function doActionAfterLogin() {
if (!checkAfterLoginStatus()) {
return;
}

switch (afterLoginActionData.value.actionName) {
case ActionNameEnum.LaunchGame: {
const params = afterLoginActionData.value.data;
const { launchGameByProd } = useProduct(params.prod);

launchGameByProd(params);
removeAfterLoginActionData();
break;
}
}
}

目前唯一實作的是 LaunchGame

  1. 取出保存的 params
  2. 根據 params.prod 建立 useProduct
  3. 呼叫 launchGameByProd(params)
  4. 清除 storage

這裡的清除時機也很重要:成功進入 action 分支後就清掉,避免使用者重整或 login status 再檢查時重複啟動。

與 GameLaunchCheck 的整合

gameLaunchCheck.ts 中,遊戲啟動前會檢查登入狀態:

1
2
3
4
if (!store.uv.sessionD.login) {
const { fullGameData } = this.options;
openLoginToContinueDialog(ActionNameEnum.LaunchGame, fullGameData);
}

完整行為是:

1
2
3
4
5
6
7
8
9
10
11
12
User clicks game
|
v
GameLaunchCheck.GeneralCheck
|
+-- not login
|
v
save fullGameData to sessionStorage
|
v
eventBus.emit('triggerLogin', 'loginToContinue')

登入後,root.ts 會呼叫:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async checkLoginStatus() {
const { doActionAfterLogin, removeAfterLoginActionData } = useAfterLoginAction();

if (!this.uv.sessionD.loginId) {
removeAfterLoginActionData();
return;
}

if (!param) {
await this.checkLoginSystemDialogDisplay();
doActionAfterLogin();
return;
}
}

這裡的順序是:

  1. 如果沒有 loginId,清掉 pending action
  2. 如果沒有 _a error param
  3. 先檢查登入後系統 dialog
  4. 再執行 after-login action

這個順序避免「登入後需要改密碼,但遊戲先啟動」的問題。

為什麼透過 root store 執行

doActionAfterLogin 註解寫得很直接:

1
// doActionAfterLoginGlobally, only usage in roots store

原因是 after-login action 屬於全局流程,不應綁死在某個頁面:

  • Login dialog 可能從任何頁面打開
  • 登入成功後可能 route 沒變,也可能 route 已變
  • 系統 dialog 狀態在 root / global store 才看得到
  • game launch 需要 global user variables

所以把 replay 放在 root store 比放在原本點擊的 component 更穩。

Event Bus 開 Login Dialog

1
eventBus.emit('triggerLogin', 'loginToContinue');

這表示 useAfterLoginAction 不直接 import LoginDialog,也不直接改 login store 的 UI state。

它只發出事件:

1
I need login to continue

由外層 UI 決定如何顯示 login dialog。

這種做法讓 composable 保持在「流程層」,避免和具體 UI component 耦合。

擴充其他 Action 的方式

目前註解中保留了一個 page-level API:

1
2
3
4
5
// function doActionAfterLoginOnPage(
// actionName: string,
// action?: (data: any) => void,
// params?: any,
// )

如果未來要擴充,例如 claim promotion,可以走兩種設計。

方案一:全局 action

適合任何頁面都能觸發的動作:

1
2
3
4
enum ActionNameEnum {
LaunchGame = 'launchGame',
ClaimPromotion = 'claimPromotion',
}

doActionAfterLogin 加 case:

1
2
3
4
case ActionNameEnum.ClaimPromotion:
claimPromotion(afterLoginActionData.value.data);
removeAfterLoginActionData();
break;

方案二:頁面 action

適合只有特定頁面知道怎麼執行的動作:

1
doActionAfterLoginOnPage('claimPromotion', claimPromo, params);

不過這種方式要小心 route change 後 component 是否還存在。

邊界情境

使用者打開 login dialog 後取消

資料會暫時留在 sessionStorage,但 5 分鐘後過期。

如果 LoginForm unmount,也會清除。

使用者登入失敗

root.checkLoginStatus 如果沒有 loginId

1
2
removeAfterLoginActionData();
return;

不會 replay。

登入後有系統 Dialog

1
dialogQueue.length

如果有 dialog,action 會被清掉並不執行。

這是保守策略:避免在尚未完成必要登入後流程時啟動遊戲。

使用者跨 tab 登入

因為使用 sessionStorage,pending action 不會跨 tab 共享。

這是正確行為。A tab 的「點遊戲」不應該讓 B tab 登入後啟動遊戲。

可以優化的地方

目前 openLoginToContinueDialog 裡使用:

1
2
const { isLogin } = rootStore;
const { dialogQueue } = systemDialogQueueStore;

這是在 composable 初始化時解構出來的值。如果 store state 後續更新,這種解構在某些情境下可能不是 reactive ref。

更穩的寫法可以用 storeToRefs 或直接讀 store:

1
2
3
if (rootStore.isLogin) {
return;
}

以及:

1
2
3
if (!rootStore.isLogin || systemDialogQueueStore.dialogQueue.length || expired) {
...
}

不過目前實際主要由 root store 在登入狀態更新後呼叫,所以未必會立即出問題。若未來 after-login action 擴充到更多頁面,這點值得整理。

完整流程圖

graph TD
    A["User clicks protected action
e.g. launch game"] --> B["GameLaunchCheck.GeneralCheck"] B --> C{"Is user logged in?"} C -->|Yes| D["Continue normal launch checks"] C -->|No| E["openLoginToContinueDialog"] E --> F["eventBus.emit('triggerLogin', 'loginToContinue')"] E --> G["sessionStorage.setItem('afterLoginActionData')"] F --> H["Login dialog opens"] H --> I{"Login success?"} I -->|No| J["root.checkLoginStatus removes pending action"] I -->|Yes| K["root.checkLoginStatus"] K --> L["checkLoginSystemDialogDisplay"] L --> M["doActionAfterLogin"] M --> N{"Valid pending action?"} N -->|No| O["Remove / skip"] N -->|Yes| P["switch actionName"] P --> Q["LaunchGame: useProduct(prod).launchGameByProd(params)"] Q --> R["removeAfterLoginActionData"]

這個流程最重要的是兩個檢查點:

  • 未登入時只保存 intent,不直接執行 action
  • 登入後也不是馬上執行,而是先確認系統 Dialog、登入狀態、過期時間

Sequence Diagram:從點遊戲到登入後啟動

sequenceDiagram
    participant User as User
    participant Game as GameLaunchCheck
    participant ALA as useAfterLoginAction
    participant Bus as eventBus
    participant Login as Login Dialog
    participant Root as root store
    participant Product as useProduct

    User->>Game: Click game
    Game->>Game: Check login state
    alt Not logged in
        Game->>ALA: openLoginToContinueDialog("launchGame", fullGameData)
        ALA->>Bus: emit("triggerLogin", "loginToContinue")
        ALA->>ALA: sessionStorage.setItem(afterLoginActionData)
        Bus->>Login: Open login dialog
        User->>Login: Submit credentials
        Login-->>Root: Login status updated
        Root->>Root: checkLoginSystemDialogDisplay()
        Root->>ALA: doActionAfterLogin()
        ALA->>ALA: checkAfterLoginStatus()
        ALA->>Product: launchGameByProd(savedParams)
        ALA->>ALA: removeAfterLoginActionData()
    else Logged in
        Game->>Product: Continue launch flow
    end

這張圖可以放在文章中段,幫讀者理解為什麼 replay action 不放在原本的 component,而是放在 root store。

State Machine:Pending Action 的生命週期

stateDiagram-v2
    [*] --> Empty
    Empty --> Pending: save action to sessionStorage
    Pending --> Expired: currentTime > expirationTime
    Pending --> Cancelled: login failed / loginId missing
    Pending --> Blocked: system dialog queue not empty
    Pending --> Executed: login success + no dialog + not expired
    Executed --> Empty: removeAfterLoginActionData
    Expired --> Empty: removeAfterLoginActionData
    Cancelled --> Empty: removeAfterLoginActionData
    Blocked --> Empty: removeAfterLoginActionData

這個 state machine 補上了文章前面比較隱性的設計:pending action 不是一直存在,它只有很短的生命週期。

afterLoginActionData Schema

目前 storage 裡的資料可以視為這個 schema:

1
2
3
4
5
6
7
8
9
10
11
12
interface AfterLoginActionData<T = unknown> {
actionName: ActionNameEnum;
data: T;
expirationTime: number;
}

interface LaunchGameActionData {
prod: string;
launchKey?: string;
fullGameData?: unknown;
[key: string]: unknown;
}

實際保存:

1
2
3
4
sessionStorage.setItem(
'afterLoginActionData',
JSON.stringify({ actionName, data, expirationTime }),
);

如果未來 action 變多,建議讓不同 action 對應不同 payload 型別:

1
2
3
4
5
type AfterLoginActionPayloadMap = {
launchGame: LaunchGameActionData;
claimPromotion: ClaimPromotionActionData;
addFavorite: AddFavoriteActionData;
};

這樣 doActionAfterLogin 裡的 switch case 可以更安全。

為什麼要等 System Dialog Queue

登入成功後不代表使用者可以馬上回到原本動作。系統可能還需要顯示:

  • Change Password
  • Terms and Conditions
  • Verify Email
  • Idle Logout recovery
  • Welcome dialog

因此 checkAfterLoginStatus 檢查:

1
2
3
4
if (!isLogin || dialogQueue.length || currentTime > afterLoginActionData.value.expirationTime) {
removeAfterLoginActionData();
return false;
}

這個設計是保守的:只要有 system dialog,就清掉 pending action。

另一種策略是保留 pending action,等 dialog 全部結束後再 replay。但那會讓流程更複雜,因為要監聽 queue 變化,也要避免使用者在 dialog 中改變狀態。以遊戲啟動這種高風險動作來說,直接清掉是比較穩的選擇。

和 Route Redirect 的差異

很多人會用 URL query 做 after-login redirect:

1
/login?redirect=/casino/game/abc

但這個專案使用 storage action,而不是單純 redirect URL,原因是「啟動遊戲」不一定等於導頁。

啟動遊戲可能包含:

  • product launch check
  • responsible gambling check
  • partner launch key
  • window.open / iframe / native launch
  • event tracking
  • maintenance dialog

所以保存的是 action payload,而不是 URL。

1
2
3
4
5
6
7
8
{
"actionName": "launchGame",
"data": {
"prod": "casino",
"launchKey": "gameName",
"fullGameData": {}
}
}

這就是 deferred action 和 redirect URL 最大的差異。

Error Handling 與資料安全

目前 computed 直接 parse:

1
return afterLoginActionStorage ? JSON.parse(afterLoginActionStorage) : null;

如果 storage 被手動改壞,JSON.parse 可能 throw。未來可補強成:

1
2
3
4
5
6
7
8
9
10
11
const afterLoginActionData = computed(() => {
const raw = sessionStorage.getItem('afterLoginActionData');
if (!raw) return null;

try {
return JSON.parse(raw);
} catch {
removeAfterLoginActionData();
return null;
}
});

這不是目前必要 bug,但如果要把 after-login action 擴大成通用機制,這種保護會更穩。

擴充流程圖:多 Action Dispatch

graph TD
    A["doActionAfterLogin"] --> B{"checkAfterLoginStatus"}
    B -->|false| C["return"]
    B -->|true| D{"actionName"}
    D -->|launchGame| E["useProduct(params.prod).launchGameByProd(params)"]
    D -->|claimPromotion| F["promoStore.claimPromotion(params)"]
    D -->|addFavorite| G["gameCollectionStore.add(params)"]
    D -->|unknown| H["remove invalid action"]
    E --> I["removeAfterLoginActionData"]
    F --> I
    G --> I
    H --> I

目前只有 launchGame,但這張圖可以說明未來怎麼擴充,而不會讓 login flow 被各頁面各自發明。

小結

After Login Action 的本質是保存使用者 intent:

1
2
3
4
5
User intent
-> temporary storage
-> login
-> validate state
-> replay

這套設計值得借鑑的地方:

  • sessionStorage 保存 tab-scoped intent
  • 用 expiration time 避免舊動作被意外執行
  • 用 Event Bus 打開 login dialog,避免 composable 綁 UI
  • 登入後由 root store 全局 replay
  • replay 前檢查 login state、system dialog queue、expiration
  • 目前先支援 LaunchGame,保留後續 action 擴充點

好的 after-login flow 會讓使用者覺得「登入只是流程的一部分」,而不是「我剛剛想做的事情被打斷了」。

Vue 3 WebView Native Bridge 設計:同時支援 Android window.App 與 iOS WKWebView

前言

WebView 專案最容易長歪的地方,是前端到處直接呼叫:

1
2
3
window.App.nativeRedirect('lobby');
window.App.logout();
window.App.webviewRedirect('deposit', '', '');

短期看起來很快,長期會出現幾個問題:

  • Web 瀏覽器環境沒有 window.App,直接噴錯
  • Android 與 iOS bridge API 不一定長一樣
  • 有些 native method 是 fire-and-forget,有些會回傳資料
  • 同一個功能在 RWD 與 WebView 需要不同 fallback
  • App team 改 method 或部分版本未支援時,前端會直接壞掉

這個專案用 webviewUtil.tsnativeBridge.ts 把 native bridge 包成一層 utility,讓呼叫點先檢查能力,再決定走 Native 還是 Web fallback。

相關檔案

1
2
3
4
5
6
client/packages/common/utils/webviewUtil.ts
client/packages/common/utils/nativeBridge.ts
client/packages/common/utils/appEventsHelper.ts
client/packages/common/stores/account-security.ts
client/packages/star4webview/src/pages/NewDeviceVerifyPage.vue
client/packages/star4webview/src/layout/HeaderFooterLayout.vue

第一層:最基本的能力檢查

checkWebviewMethod 是最基礎的保護:

1
2
3
export function checkWebviewMethod(methodName) {
return typeof window.App !== 'undefined' && typeof window.App[methodName] !== 'undefined';
}

這個函式看起來很小,但價值很高。

它避免所有呼叫點直接假設 window.App 一定存在:

1
2
3
if (checkWebviewMethod('logout')) {
window.App.logout();
}

這對共用 package 很重要,因為同一份 common code 同時會跑在:

  • Desktop Web
  • Mobile Web
  • App WebView
  • Storybook / test environment

沒有這層檢查,common code 很容易在非 WebView 環境直接 crash。

Android 與 iOS Bridge 差異

Android WebView 常見 bridge 形式:

1
2
window.App.getDeviceInfo();
window.App.nativeRedirect('lobby');

iOS WKWebView 常見 bridge 形式:

1
window.webkit.messageHandlers.getDeviceInfo.postMessage({});

因此 nativeBridge.ts 把「檢查 method」分成兩種。

有回傳資料的 method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export function checkWebviewMethodWithResponseData(
methodName: string,
iosHandlerName?: string,
): boolean {
const userAgent = navigator.userAgent;
const iosHandler = iosHandlerName || methodName;

if (isIOS(userAgent)) {
return !!window.webkit?.messageHandlers?.[iosHandler];
}

if (isAndroid(userAgent)) {
return checkWebviewMethod(methodName);
}

return false;
}

這個函式的重點是:iOS 不查 window.App,而是查 window.webkit.messageHandlers

同一個功能在不同平台可能不是同一個 bridge 入口,這也是 WebView utility 需要存在的原因。

統一呼叫入口:callNativeMethod

callNativeMethod 把 iOS 與 Android 的呼叫方式包起來:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
export async function callNativeMethod<T>(
methodName: string,
params?: unknown,
iosHandlerName?: string,
): Promise<T | null> {
const userAgent = navigator.userAgent;
const iosHandler = iosHandlerName || methodName;

if (isIOS(userAgent) && window.webkit?.messageHandlers?.[iosHandler]) {
try {
const result = await window.webkit.messageHandlers[iosHandler].postMessage(params ?? {});
return result as T;
} catch (error) {
console.warn(`Failed to call iOS native method ${iosHandler}:`, error);
return null;
}
}

if (isAndroid(userAgent) && checkWebviewMethod(methodName)) {
try {
const result =
params !== undefined ? window.App[methodName](params) : window.App[methodName]();
return result as T;
} catch (error) {
console.warn(`Failed to call Android native method ${methodName}:`, error);
return null;
}
}

return null;
}

這裡有幾個設計重點:

  • 回傳 Promise<T | null>,呼叫方一定要處理 method 不存在
  • iOS 與 Android 都用 try/catch 包住
  • iOS handler name 可以和 Android method name 不同
  • 不支援的平台回傳 null,而不是 throw

這樣可以讓上層流程寫得比較乾淨:

1
2
3
4
5
const result = await callNativeMethod<boolean>('checkAppSchemeAction', {
scheme: appSchemeName,
});

return result ?? false;

Case 1:取得 Native Device Info

Trusted Device 功能需要取得裝置資訊:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export async function getDeviceInfo(): Promise<WebviewDeviceInfo | null> {
const result = await callNativeMethod<string | WebviewDeviceInfo>('getDeviceInfo');

if (!result) return null;

if (typeof result === 'string') {
try {
return JSON.parse(result) as WebviewDeviceInfo;
} catch {
console.warn('Failed to parse device info JSON');
return null;
}
}

return result as WebviewDeviceInfo;
}

這段處理了 App bridge 常見的資料格式不一致:

  • 有些 native 版本直接回 object
  • 有些 native 版本回 JSON string

前端不把這個差異丟給 feature code,而是在 bridge utility 裡做 parse 與 fallback。

account-security.ts 中使用:

1
2
3
4
5
6
7
8
9
10
11
12
if (checkWebviewMethodWithResponseData('getDeviceInfo')) {
const webviewDeviceInfo = await getDeviceInfo();

if (webviewDeviceInfo) {
fingerPrintId = webviewDeviceInfo.fingerPrintId || fingerPrintId;
deviceModel = webviewDeviceInfo.deviceModel || deviceModel;
screenResolution = webviewDeviceInfo.screenResolution || screenResolution;
deviceLanguage = webviewDeviceInfo.deviceLanguage || deviceLanguage;
countryCode = webviewDeviceInfo.countryCode || countryCode;
deviceType = webviewDeviceInfo.deviceType;
}
}

這裡的 fallback 很關鍵:

1
deviceModel = webviewDeviceInfo.deviceModel || deviceModel;

如果 App 沒回某個欄位,仍然保留 Web 環境原本可取得的資料,不會把 request 組壞。

Case 2:WebView Redirect 與 Web Fallback

redirectWrapper 把 WebView 與一般 Web 導頁合併:

1
2
3
4
5
6
7
8
9
10
11
export function redirectWrapper(router, path, openWindow = false, type = 'Star', referUrl = '') {
if (checkWebviewMethod('webviewRedirect')) {
window.App.webviewRedirect(type, path, referUrl);
} else {
if (openWindow) {
window.open(`/${window.gv.lan}/${path}`);
} else {
router.push(`/${window.gv.lan}/${path}`);
}
}
}

在 WebView:

1
window.App.webviewRedirect(type, path, referUrl);

在一般 Web:

1
router.push(`/${window.gv.lan}/${path}`);

這種寫法避免 feature component 裡到處寫:

1
2
3
4
5
if (isWebView) {
// native
} else {
// router
}

webviewRedirect 的語意不是單一路徑

webviewUtil.ts 裡的註解其實很有價值,因為它說明 webviewRedirect 不是單純 router path,而是 App 與 Web 約定好的 command:

1
2
3
4
5
6
7
8
9
10
11
App.webviewRedirect('Browser', path, '', '')
App.webviewRedirect('Star', path, referUrl)
App.webviewRedirect('PCH', path, referUrl)
App.webviewRedirect('profileVerification', '', '')
App.webviewRedirect('faq', '', '')
App.webviewRedirect('deposit', '', '')
App.webviewRedirect('withdrawal', '', '')
App.webviewRedirect('termsConditions', '', '')
App.webviewRedirect('responsibleGambling', '', '')
App.webviewRedirect('profile', '', '')
App.webviewRedirect('withdrawalTransHistory', '', '')

這代表 bridge API 的第一個參數其實有 routing command 的味道。

如果把這些呼叫散落在 component,很快就會變成 App/Web 約定難以追蹤。集中放在 utility 或封裝成 domain function 會更安全。

Case 3:New Device Verify 成功後通知 App

在 New Device 驗證流程中,WebView 完成 MFA 後會拿到 grantId

1
2
3
4
5
6
7
8
9
10
11
12
13
function handleVerified(result: {
success: boolean;
passport: string;
recDomain: Array<{ domain: string }>;
grantId?: string;
reachLimitTrustedDevice?: boolean;
}) {
if (result.grantId && checkWebviewMethod('verifyNewDeviceSuccess')) {
window.App.verifyNewDeviceSuccess(result.grantId, result.reachLimitTrustedDevice);
} else {
window.location.href = `${rootStore.gv.appScheme}${AppSchemePath.Login}`;
}
}

這段的設計邏輯是:

  1. WebView 完成驗證
  2. 後端回 grantId
  3. 如果 App 支援 verifyNewDeviceSuccess,把 grantId 交給 App
  4. App 再用 grantId 兌換完整 login response
  5. 如果 method 不存在,fallback 到 app scheme login

這是前後端與 Native App 三方協作的典型場景。

Web 不直接拿完整 session,而是把短效 grant 交回 App,讓 App 走原本的登入完成流程。

Case 4:WebView Header Title

HeaderFooterLayout.vue 會把頁面 title 傳給 App:

1
2
3
if (checkWebviewMethod('webViewTitle')) {
window.App.webViewTitle(title);
}

這類 method 屬於 UI sync:

  • Web 決定目前頁面 title
  • App native shell 負責顯示 header
  • Web 只在 method 存在時通知 App

這種小同步很多,但如果沒有統一能力檢查,也很容易在瀏覽器測試時炸掉。

Case 5:Native Redirect

在 WebView router 或 Sports direct launch 中:

1
2
3
if (checkWebviewMethod('nativeRedirect')) {
window.App.nativeRedirect('lobby');
}

或:

1
window.App.nativeRedirect('sbk');

nativeRedirect 通常不是普通 URL,而是 App 底部 tab 或 native module 的 navigation command。

這類行為不能用 web router 模擬,所以最安全的策略是:

  • 有 native method 就交給 App
  • 沒有 native method 就不做 native redirect,改走 Web fallback 或結束流程

Bridge 設計原則

從這個專案可以整理出幾個原則:

1. 任何 window.App 呼叫前都要 check

1
2
3
if (checkWebviewMethod('logout')) {
window.App.logout();
}

這是最低限度的保護。

2. Android 與 iOS bridge 要分開抽象

Android:

1
window.App[methodName](params)

iOS:

1
window.webkit.messageHandlers[handlerName].postMessage(params)

如果只封裝 Android,iOS 後續會長出一堆特殊 case。

3. 回傳值要允許 null

1
Promise<T | null>

Native method 可能不存在、失敗、版本太舊、回傳格式錯誤。前端應該把這些都當成正常分支。

4. Feature code 不應該理解所有 native 細節

Feature code 應該像這樣:

1
const webviewDeviceInfo = await getDeviceInfo();

而不是:

1
2
3
4
5
if (isIOS) {
window.webkit...
} else if (isAndroid) {
window.App...
}

平台差異應該收斂在 bridge utility。

5. Web fallback 要明確

例如:

1
2
3
4
5
if (checkWebviewMethod('webviewRedirect')) {
window.App.webviewRedirect(type, path, referUrl);
} else {
router.push(`/${window.gv.lan}/${path}`);
}

不是所有 native method 都能 fallback,但能 fallback 的應該在 utility 層處理。

整體架構圖

graph TD
    A["Feature code
component / store / page"] --> B{"需要呼叫 Native 嗎?"} B -->|一般導頁| C["redirectWrapper"] B -->|需要回傳資料| D["nativeBridge.callNativeMethod"] B -->|單純通知 App| E["checkWebviewMethod + window.App.method"] C --> F{"webviewRedirect exists?"} F -->|Yes| G["window.App.webviewRedirect(type, path, referUrl)"] F -->|No| H["router.push / window.open"] D --> I{"Platform"} I -->|iOS| J["window.webkit.messageHandlers[handler].postMessage(params)"] I -->|Android| K["window.App[methodName](params)"] I -->|Browser / unsupported| L["return null"] E --> M{"method exists?"} M -->|Yes| N["window.App.method(...)"] M -->|No| O["skip or fallback"]

這張圖可以把整篇文章的核心濃縮成一句話:feature code 不直接理解平台差異,而是把平台差異交給 bridge layer

iOS / Android 呼叫序列圖

sequenceDiagram
    participant Feature as Feature Code
    participant Bridge as nativeBridge.ts
    participant UA as UserAgent Check
    participant IOS as iOS WKWebView
    participant Android as Android window.App

    Feature->>Bridge: callNativeMethod("getDeviceInfo")
    Bridge->>UA: isIOS / isAndroid
    alt iOS
        Bridge->>IOS: messageHandlers.getDeviceInfo.postMessage({})
        IOS-->>Bridge: device info / JSON string
    else Android
        Bridge->>Android: window.App.getDeviceInfo()
        Android-->>Bridge: device info / JSON string
    else Unsupported
        Bridge-->>Feature: null
    end
    Bridge-->>Feature: WebviewDeviceInfo | null

這個設計讓 feature code 可以只處理資料結果:

1
const deviceInfo = await getDeviceInfo();

不用在每個 feature 裡重複寫 isIOSisAndroidwindow.webkitwindow.App

New Device Verify 三方流程

New Device Verify 是這套 Bridge 最典型的場景,因為它不是單純 Web 導頁,而是 Web、後端、Native App 三方協作。

sequenceDiagram
    participant App as Native App
    participant WebView as WebView Page
    participant API as Web MFA API
    participant SPI as App Login SPI

    App->>WebView: Open new-device-verify with verificationId/accountId
    WebView->>WebView: parse query params
    WebView->>API: trigger / verify MFA
    API-->>WebView: grantId + reachLimitTrustedDevice
    alt App supports verifyNewDeviceSuccess
        WebView->>App: window.App.verifyNewDeviceSuccess(grantId, reachLimitTrustedDevice)
        App->>SPI: exchange login grant
        SPI-->>App: LoginResponse(token/session/profile)
    else method unavailable
        WebView->>App: redirect by app scheme login
    end

這裡最關鍵的是 grantId 的責任邊界:

  • WebView 負責完成 MFA
  • Web API 負責簽發短效 grant
  • Native App 負責把 grant 兌換成正式 login session

WebView 不直接保存完整 login session,這樣可以維持 App 原本的登入主控權,也能降低 WebView token 外洩的風險。

Bridge Method 分類

這個專案裡的 native method 大致可以分成幾類:

類型 範例 特性
Navigation nativeRedirect, webviewRedirect, externalBrowser 多半沒有回傳值,語意像 command
UI Sync webViewTitle, updateUI, showSnackBar Web 通知 App shell 更新 UI
Auth / Session logout, verifyNewDeviceSuccess 會影響登入狀態,需要非常保守
Device Data getDeviceInfo, checkAppSchemeAction 需要回傳值,iOS / Android 差異最大
Feature Entry startChatbotWithParam, chatNowEvent, jumioLostEmail 通常綁特定產品功能

分類後會更清楚:不是所有 native method 都該用同一種呼叫策略。

  • command 型 method 可以用 checkWebviewMethod
  • data 型 method 應該用 callNativeMethod<T>
  • redirect 型 method 應該提供 Web fallback
  • auth 型 method 必須檢查存在且避免重複呼叫

失敗情境與處理策略

Method 不存在

1
2
3
if (!checkWebviewMethod('webviewRedirect')) {
router.push(`/${window.gv.lan}/${path}`);
}

這通常發生在:

  • 使用者用瀏覽器開啟
  • App 版本太舊
  • Android / iOS method 名稱不一致
  • 測試環境沒有注入 bridge

策略是:能 fallback 就 fallback,不能 fallback 就靜默跳過或導向安全頁。

Native 回傳格式不一致

getDeviceInfo 同時支援 object 與 JSON string:

1
2
3
4
if (typeof result === 'string') {
return JSON.parse(result) as WebviewDeviceInfo;
}
return result as WebviewDeviceInfo;

這是 WebView 常見現實:不同 App 版本或平台可能回傳不同型別。Bridge layer 應該吸收這個差異,而不是讓每個 feature 自己 parse。

Native method throw exception

callNativeMethod 用 try/catch 包住:

1
2
3
4
5
6
7
try {
const result = window.App[methodName](params);
return result as T;
} catch (error) {
console.warn(...);
return null;
}

回傳 null 比 throw 更適合 bridge layer,因為 Native method 失敗通常是環境能力問題,不應讓整個 Vue app crash。

可以再演進的方向

目前 checkWebviewMethod 回傳 boolean,但 method payload 與 response type 還是靠呼叫方記得。若後續 bridge method 越來越多,可以再演進成 registry:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type NativeBridgeMethodMap = {
getDeviceInfo: {
params: void;
response: WebviewDeviceInfo;
};
checkAppSchemeAction: {
params: { scheme: string };
response: boolean;
};
verifyNewDeviceSuccess: {
params: { grantId: string; reachLimitTrustedDevice?: boolean };
response: void;
};
};

再包一層 typed caller:

1
callNative<'getDeviceInfo'>('getDeviceInfo');

這樣可以把「method name、params、response」都收斂成型別契約,減少 App/Web 協作時的口頭約定。

小結

這套 WebView Native Bridge 的價值不在於某個函式很複雜,而在於它建立了一個邊界:

1
2
3
4
5
6
7
8
9
10
Feature Component / Store
|
v
webviewUtil / nativeBridge
|
+-- Android window.App
|
+-- iOS window.webkit.messageHandlers
|
+-- Web fallback

幾個值得借鑑的點:

  • checkWebviewMethod 防止非 WebView crash
  • checkWebviewMethodWithResponseData 處理 iOS / Android 差異
  • callNativeMethod<T> 統一回傳型別與錯誤處理
  • getDeviceInfo 處理 object / JSON string 兩種回傳
  • redirectWrapper 把 native redirect 與 web router fallback 放在同一層
  • New Device Verify 用 grantId 回傳 App,保留 App 登入流程主控權

WebView 架構最怕「到處直接呼叫 native」。一旦專案長大,Bridge utility 不是選配,而是前端與 App 協作的防火牆。

ASP.NET AccountState 設計:跨 Channel 登入狀態同步與踢出機制

前言

在一般網站裡,登入狀態通常只是一個 session flag:

1
Session["IsLogin"] = true;

但大型會員平台通常不會這麼單純。使用者可能同時存在於 Desktop Web、Mobile Web、Native App WebView,不同 Channel 各自有 session、cookie、cache 與登入 token。

這時候問題會變成:

  • 使用者在 App 登出,Web 是否也要失效?
  • 使用者被風控踢出,所有平台是否都要同步?
  • Self Exclusion / Time Out 後,其他 Channel 是否要強制 reload 或 logout?
  • Redis session 還在,但帳號狀態已失效時怎麼處理?

這個專案透過 AccountStateManager 建立一層獨立於 ASP.NET Session 的登入狀態控制層,集中處理 session cache、SSO token 與跨 Channel logout。

Read More

ASP.NET MVC PageGuard 與 RouteConstraint:動態頁面權限管線設計

前言

大型網站通常不是只有「登入」與「未登入」兩種頁面。

真實平台會有更多分類:

  • Public Page:首頁、註冊、FAQ
  • Private Page:我的帳戶、Inbox、裝置確認
  • Product Page:Sports、Casino、Lotto 等遊戲頁
  • NoBlock Page:Live Chat、Pre Chat
  • DepositOnly / 特殊狀態頁

再加上維護狀態、產品地區限制、Responsible Gambling 限制,頁面權限就不能只靠一個 [Authorize] 解決。

這個專案用 PagePermissionDetectorRouteConstraintPageGuardActionFilterResponsibleChain 組成一條後端頁面權限管線。

相關檔案

1
2
3
4
5
6
7
src/AgileBet.Cash.Portal.Web/Utilities/PagePermissionDetector.cs
src/AgileBet.Cash.Portal.Web/Routing/PublicPageRouteConstraint.cs
src/AgileBet.Cash.Portal.Web/Routing/PrivatePageRouteConstraint.cs
src/AgileBet.Cash.Portal.Web/Routing/ProductRouteConstraint.cs
src/AgileBet.Cash.Portal.Web/HttpActionFilter/PageGuardActionFilter.cs
src/AgileBet.Cash.Portal.Web/Helper/PageCheckHelper.cs
src/AgileBet.Cash.Portal.Web/Utilities/UserPagePermissionChecker.cs

第一層:PagePermissionDetector

PagePermissionDetector 負責把 URL page 判斷成權限類型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public PagePermission DetectPagePermission(string page, string path)
{
if (string.IsNullOrEmpty(page))
{
return PagePermission.Public;
}
if (Check(privatePageRules, page, path))
{
return PagePermission.Private;
}
if (Check(publicPageRules, page, path))
{
return PagePermission.Public;
}
if (IsProductPage(page))
{
return PagePermission.Product;
}
if (Check(noBlockPageRules, page, path))
{
return PagePermission.NoBlock;
}
return PagePermission.None;
}

這裡不是用 route name 判斷,而是用 page/path 對應規則。

例如 private page:

1
2
3
4
5
6
7
8
9
10
11
12
private static readonly List<PageRoutingModel> privatePageRules =
new List<PageRoutingModel>()
{
new PageRoutingModel()
{
PageName = "my-account",
IgnorePath = new List<string>() { "sports" }
},
new PageRoutingModel() { PageName = "inbox" },
new PageRoutingModel() { PageName = "privilege-club" },
new PageRoutingModel() { PageName = "device-confirmation" }
};

Public page:

1
2
3
4
5
6
new PageRoutingModel() { PageName = "home" },
new PageRoutingModel() { PageName = "user" },
new PageRoutingModel() { PageName = "faqs" },
new PageRoutingModel() { PageName = "support" },
new PageRoutingModel() { PageName = "sign-up" },
new PageRoutingModel() { PageName = "new-device-verify" }

IgnorePath 是這裡很實用的細節。像 my-account 通常是 private,但某些 path 包含 sports 時要排除,避免過度攔截。

Product Page 由設定驅動

Product page 不是 hardcode list,而是從 AppConfigManager.RegionProducts 判斷:

1
2
3
4
5
6
7
8
9
10
11
private bool IsProductPage(string page)
{
var prodSetting = AppConfigManager.RegionProducts
.FirstOrDefault(x =>
x.Name.Equals(page) ||
(string.IsNullOrEmpty(x.RegexPath) ? false :
Regex.IsMatch(page, $"{x.RegexPath.Replace("|", "$|")}$",
RegexOptions.IgnoreCase))
);
return prodSetting != null;
}

這代表新增產品或 region product mapping 時,不一定要改 route 程式碼,而是可以透過 config 擴充。

第二層:RouteConstraint

MVC route 用 IRouteConstraint 決定該 URL 是否符合某種頁面類型。

Public route:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public bool Match(
HttpContextBase httpContext,
Route route,
string parameterName,
RouteValueDictionary values,
RouteDirection routeDirection)
{
var page = values[parameterName]?.ToString();
if (page == null)
{
return false;
}
if (values.ContainsKey("path"))
{
var path = values["path"]?.ToString();
return Detector.DetectPagePermission(page, path) == PagePermission.Public;
}
return false;
}

Private route 特別排除 logout:

1
2
3
4
if (page == null || page == "logout")
{
return false;
}

Product route 排除 sports 和 lobby:

1
2
3
4
5
6
7
8
9
10
11
if (page == "sports")
{
return false;
}

if (path != null && path.Contains("lobby"))
{
return false;
}

return Detector.DetectPagePermission(page, path) == PagePermission.Product;

這些小規則看起來零碎,但正是大型站點 routing 的真實樣貌:不是所有頁面都能用一條泛用 route 吃掉。

第三層:PageGuardActionFilter

前面 RouteConstraint 解決的是 MVC route matching;API 或前端導頁時,仍需要後端檢查目標頁是否可進入。

PageGuardActionFilter 會讀 x-destination-path

1
2
3
4
5
6
7
8
9
10
11
12
13
private Tuple<string, string> GetPageAndPath(HttpActionContext actionContext)
{
if (actionContext.Request.Headers.TryGetValues("x-destination-path",
out var destinationPathValues))
{
var destinationPath = destinationPathValues.FirstOrDefault();
var page = destinationPath != null
? destinationPath.ToString().Split('/').Skip(2).FirstOrDefault()
: "";
return Tuple.Create(page, destinationPath);
}
return Tuple.Create(string.Empty, string.Empty);
}

也就是說前端可以在 API request 帶上即將前往的 path,後端再根據該 path 判斷是否允許。

Access Gate:先判斷是否允許進站

第一道關卡:

1
2
3
4
5
6
7
8
9
10
var isAllowToAccess = trading.IsAllowedToAccess.HasValue
? trading.IsAllowedToAccess.Value
: false;

if ((isAllowToAccess || trading.IsCrawler) == false)
{
actionContext.Response =
actionContext.Request.CreateErrorResponse(HttpStatusCode.Forbidden,
new HttpError());
}

這裡允許兩種身份:

  • 正常可訪問使用者
  • crawler

如果兩者都不是,直接 403。

權限類型對應不同 Checker

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var permission = detector.DetectPagePermission(page, path);

switch (permission)
{
case PagePermission.Private:
var privateChecker =
new PrivatePageChecker(new ResponsibleGamblingChecker(null));
break;

case PagePermission.Public:
var publicChecker =
new PublicPageChecker(new ResponsibleGamblingChecker(null));
break;

case PagePermission.Product:
var productChecker =
new ProductPageChecker(new ResponsibleGamblingChecker(null));
break;
}

每種頁面先跑自己的 checker,再接 Responsible Gambling checker。

這是一個 Chain of Responsibility:

1
2
3
4
5
6
7
8
PrivatePageChecker
-> ResponsibleGamblingChecker

PublicPageChecker
-> ResponsibleGamblingChecker

ProductPageChecker
-> ResponsibleGamblingChecker

PageCheckHelper:實際權限檢查

Private page:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public Tuple<HttpStatusCode, string> CheckPrivatePage(SessionData session)
{
var trading = session.Trading;

if (checker.IsMaintenance(session))
{
return Tuple.Create(HttpStatusCode.ServiceUnavailable, "");
}

if (!trading.IsAuthenticated || trading.ChangePasswordFlag)
{
return Tuple.Create(HttpStatusCode.Unauthorized,
"please login to continue");
}

var (isValid, remark) = checker.IsAccountValidate(session);
if (isValid == false)
{
return Tuple.Create(HttpStatusCode.Unauthorized,
((int)remark).ToString());
}

return Tuple.Create(HttpStatusCode.OK, "");
}

Product page:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public Tuple<HttpStatusCode, string> CheckProductPage(
SessionData session,
string prod)
{
if (checker.IsMaintenance(session))
{
return Tuple.Create(HttpStatusCode.ServiceUnavailable, "");
}

if (checker.IsProdForbidden(session, prod))
{
return Tuple.Create(HttpStatusCode.Forbidden,
$"prodforbidden={prod}");
}

return Tuple.Create(HttpStatusCode.OK, "");
}

Public page 只檢查 maintenance:

1
2
3
4
5
6
7
8
public Tuple<HttpStatusCode, string> CheckPublicPage(SessionData session)
{
if (checker.IsMaintenance(session))
{
return Tuple.Create(HttpStatusCode.ServiceUnavailable, "");
}
return Tuple.Create(HttpStatusCode.OK, "");
}

Responsible Gambling 作為最後一層

ResponsibleGamblingChecker 不關心頁面是 Public、Private 還是 Product,只關心這個使用者狀態是否允許進入目標 path:

1
2
3
4
5
6
7
8
9
10
if (permissionChecker.IsInDisallowPath(
setting.Session,
setting.DisAllowRgStatus,
setting.DestinationPath,
setting.ByPassPathChecker))
{
return new Tuple<HttpStatusCode, string>(
HttpStatusCode.Forbidden,
LimitHttpStatus.LimitAccess.ToString().ToLower());
}

這個設計的好處是 Responsible Gambling 不需要散落到各種 checker 裡,它是一個獨立的最後關卡。

if/else 可以怎麼收斂

這套設計的方向是對的,但目前有幾個地方容易隨規則增加而變成 if/else 堆疊:

  • PagePermissionDetector 用固定順序判斷 private / public / product / no block
  • RouteConstraint 各自處理 page/path 解析與特例
  • PageGuardActionFilterswitch 建立不同 checker chain
  • PageCheckHelper 裡面有很多 guard clause

我會保留 PageCheckHelper 的 guard clause。maintenance、login、account validate 都是線性關卡,直接 return 反而清楚。真正值得收斂的是「會隨規則新增而一直長大」的分派邏輯。

1. PagePermissionDetector 改成規則列

目前寫法是程式碼控制順序:

1
2
3
4
if (Check(privatePageRules, page, path)) return PagePermission.Private;
if (Check(publicPageRules, page, path)) return PagePermission.Public;
if (IsProductPage(page)) return PagePermission.Product;
if (Check(noBlockPageRules, page, path)) return PagePermission.NoBlock;

可以改成 ordered rule list,讓順序與判斷集中在一個資料結構:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private readonly IReadOnlyList<PagePermissionRule> rules = new[]
{
new PagePermissionRule(PagePermission.Private,
(page, path) => Check(privatePageRules, page, path)),
new PagePermissionRule(PagePermission.Public,
(page, path) => Check(publicPageRules, page, path)),
new PagePermissionRule(PagePermission.Product,
(page, path) => IsProductPage(page)),
new PagePermissionRule(PagePermission.NoBlock,
(page, path) => Check(noBlockPageRules, page, path)),
};

public PagePermission DetectPagePermission(string page, string path)
{
if (string.IsNullOrEmpty(page))
{
return PagePermission.Public;
}

return rules.FirstOrDefault(rule => rule.IsMatch(page, path))?.Permission
?? PagePermission.None;
}

這樣新增 DepositOnly 或特殊 page type 時,主要是加 rule,不是在 method 裡繼續加 if

2. Permission 到 checker chain 改成 factory map

PageGuardActionFilterswitch 可以改成 dictionary,把「PagePermission 對應哪條 chain」變成配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private readonly IReadOnlyDictionary<PagePermission, Func<IPageChecker>> checkerFactories =
new Dictionary<PagePermission, Func<IPageChecker>>
{
[PagePermission.Private] = () =>
new PrivatePageChecker(new ResponsibleGamblingChecker(null)),
[PagePermission.Public] = () =>
new PublicPageChecker(new ResponsibleGamblingChecker(null)),
[PagePermission.Product] = () =>
new ProductPageChecker(new ResponsibleGamblingChecker(null)),
};

if (!checkerFactories.TryGetValue(permission, out var createChecker))
{
return Allow();
}

var checker = createChecker();
var result = checker.Check(context);

如果頁面類型越來越多,這個 map 還可以抽到 PageCheckerFactory,讓 action filter 只負責管線,不負責組裝細節。

3. RouteConstraint 抽出共用 base

Public / Private / Product route 都在做相似的事:解 page、解 path、套特例、呼叫 detector。可以留下各自的特例,但把重複流程收到 base class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
protected abstract PagePermission TargetPermission { get; }

protected virtual bool ShouldSkip(string page, string path)
{
return false;
}

public bool Match(...)
{
var routePage = GetPage(values, parameterName);
var routePath = GetPath(values);

if (string.IsNullOrEmpty(routePage) || ShouldSkip(routePage, routePath))
{
return false;
}

return Detector.DetectPagePermission(routePage, routePath) == TargetPermission;
}

ProductRouteConstraint 只要 override ShouldSkip 處理 sports / lobby,主流程不用重複寫。

完整流程

graph TD
    A["Request with x-destination-path"] --> B["PageGuardActionFilter"]
    B --> C["PagePermissionDetector"]
    C --> D{"PagePermission"}
    D -->|Public| E["PublicPageChecker"]
    D -->|Private| F["PrivatePageChecker"]
    D -->|Product| G["ProductPageChecker"]
    D -->|NoBlock / DepositOnly| H["Pass"]
    E --> I["ResponsibleGamblingChecker"]
    F --> I
    G --> I
    I --> J{"Allowed?"}
    J -->|Yes| K["Continue request"]
    J -->|No| L["Return 401 / 403 / 503"]

小結

這套設計的核心不是單一技術,而是責任拆分:

  • PagePermissionDetector:判斷頁面類型
  • RouteConstraint:讓 MVC route 選對 controller
  • PageGuardActionFilter:API 層集中做頁面權限檢查
  • PageCheckHelper:封裝 maintenance / login / product forbidden
  • ResponsibleGamblingChecker:補上法規與風控限制

如果只用 [Authorize],這些邏輯最後會全部塞進 Controller 或前端 router guard。這個專案把它們拆成後端管線,讓頁面權限可以被測試、被擴充,也能在前端繞過時仍然生效。

Responsible Gambling 後端防護設計:Self Exclusion / Time Out 不是前端按鈕

前言

Responsible Gambling 功能看起來像會員中心裡的一個設定頁:

  • 使用者選擇 Self Exclusion
  • 使用者選擇 Time Out
  • 系統顯示限制期間

但從後端角度看,這不是一般的 profile setting。

它牽涉到:

  • 帳號狀態更新
  • Session cache 更新
  • 其他平台踢出
  • 頁面權限限制
  • 通知會員與 risk team
  • Product / Deposit / Promotion 類頁面的存取限制

因此 Responsible Gambling 不能只靠前端 UI 隱藏,必須由後端狀態與權限管線共同保護。

Read More

用 Job Name Hash 防止 Thundering Herd:30 支排程 Job 的自動錯開機制

問題:Thundering Herd(驚群效應)

系統有 30 支以上的 Hangfire 排程 Job,每支 Job 都會對外部 SPI API 發出多個 HTTP 請求。

當所有 Job 都設定在「整點」或「每 10 分鐘的 :00 秒」執行時,每次觸發瞬間:

  • 30 支 Job 同時啟動
  • 每支 Job 平均 3 個並行 HTTP 請求
  • 瞬間湧入 90+ 個 API 請求

結果:API 被打爆 → timeout → Job 失敗 → Hangfire 自動重試 → 更多請求 → 更多 timeout,雪崩成立。


人工錯開的問題

最直覺的解法是人工設定每支 Job 的秒數偏移:

1
2
3
4
{ "Name": "SportsCache",  "Cron": "0 */10 * * * *"  },
{ "Name": "CasinoCache", "Cron": "15 */10 * * * *" },
{ "Name": "LiveCache", "Cron": "30 */10 * * * *" },
{ "Name": "LottoCache", "Cron": "45 */10 * * * *" }

但這有幾個問題:

  1. 人工維護成本高:每新增 Job 都要手動分配秒數,確保不衝突
  2. 容易出錯:兩支 Job 設了同一個秒數,管理員不一定發現
  3. 無法規模化:60 支 Job 要人工分配 60 個不同秒數

解法:以 Job 名稱的 Hash 決定偏移秒數

核心想法:讓每支 Job 自己計算自己的偏移秒數,不需要人工指定。

條件:

  • 決定論式(Deterministic):相同 Job 名稱永遠算出相同偏移,App 重啟後行為不變
  • 分散性:不同 Job 名稱算出的偏移值盡量均勻分散在 0-59 之間
  • 穩定性:無需持久化,純計算

GetHashCode() 剛好滿足這三點:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public string CronExpression => GetAdjustedCronExpression();

private string GetAdjustedCronExpression()
{
// 取得基礎 Cron,例如 "*/10 * * * *"(每 10 分鐘)
var baseCron = CacheTime.GetDescription();

if (ExecutionDelaySecondsForInitializing <= 0)
return baseCron;

// 用 Job 名稱 Hash 算出 0-59 的固定偏移
var jobHash = Name.ToString().GetHashCode();
var offsetSeconds = Math.Abs(jobHash % 60);

// Hangfire 支援 6-part cron(含秒):"{second} {minute} {hour} {day} {month} {weekday}"
return $"{offsetSeconds} {baseCron}";
}

實際效果驗證

以設定檔中幾個 Job 為例(Name.GetHashCode() % 60 的結果):

Job 名稱 Hash(示意) offsetSeconds 最終 Cron
SportsCache 17 17 */10 * * * *
CasinoCache 43 43 */10 * * * *
LiveCache 5 5 */10 * * * *
LottoCache 29 29 */10 * * * *
Maintenance 52 52 */3 * * * *
FaqContent 11 11 */15 * * * *

各 Job 自動分散在不同秒數,無需任何人工介入。


Hangfire 的 6-Part Cron

標準 Cron 表達式只有 5 個欄位(分 時 日 月 週),Hangfire 擴充支援第 6 個欄位(秒)放在最前面:

1
2
標準 Cron(5 欄位):  {分} {時} {日} {月} {週}
Hangfire Cron(6 欄位):{秒} {分} {時} {日} {月} {週}

範例:

1
2
3
*/10 * * * *         → 每 10 分鐘整,於 :00 秒執行
17 */10 * * * * → 每 10 分鐘,於 :17 秒執行
43 */10 * * * * → 每 10 分鐘,於 :43 秒執行

設計細節:Math.Abs 的必要性

GetHashCode() 可能回傳負數(例如 -1234567890)。直接對負數取模:

1
-1234567890 % 60 = -30  // 負數!不能作為秒數偏移

必須先取絕對值:

1
var offsetSeconds = Math.Abs(jobHash % 60);  // 保證 0-59

為何不用 Random?

1
2
// ❌ 不能這樣寫
var offsetSeconds = new Random().Next(0, 60);

Random 產生的是隨機值,每次 App 重啟時偏移都不同。這帶來兩個問題:

  1. 排程時間漂移:Hangfire Dashboard 顯示的「下次執行時間」每次重啟後都變,難以觀察和除錯
  2. 重啟後偏移衝突:前一次分散得很好,重啟後可能有多支 Job 剛好算到同一秒

GetHashCode() 的決定論特性確保「相同名稱 → 相同偏移 → 相同排程時間」,跨重啟穩定一致。


設定檔整合

ExecutionDelaySecondsForInitializing 控制是否啟用 Hash 偏移機制:

1
2
3
4
5
6
{
"Name": "CasinoCache",
"CacheTime": "10",
"QueueName": "game-cache-queue",
"ExecutionDelaySecondsForInitializing": 1
}
  • > 0(預設值 1):啟用 Hash 偏移,自動計算秒數
  • <= 0:關閉偏移,回傳原始 Cron 表達式

這讓少數需要精確整點執行的 Job 可以個別關閉此機制。


完整資料流

flowchart TD
    A["appCacheSetting.json
Name: CasinoCache, CacheTime: 10"] B["CacheTimeSetting.CronExpression
jobHash = GetHashCode()
offsetSeconds = Math.Abs(jobHash % 60) → 43
baseCron = */10 * * * *
result = 43 */10 * * * *"] C["JobInitializeService.RegisterRecurringJob()
RecurringJob.AddOrUpdate(CasinoCache, game-cache-queue,
43 */10 * * * *)"] D["Hangfire Scheduler
每 10 分鐘的 :43 秒執行 CasinoCacheJob.DoJob()"] A --> B B --> C C --> D

效益總結

人工設定偏移 Hash 自動偏移
新增 Job 時需設秒數 是,且要確保不重複 否,自動計算
重啟後排程時間 固定(人工設定) 固定(Hash 決定論)
大量 Job 的維護成本 隨 Job 數線性增加 恆定
偏移衝突風險 存在,需人工檢查 極低(Hash 分散性)
需要修改程式碼的情境 每次新增 Job 永不需要

這個機制的精妙之處在於,它用一行數學運算(Math.Abs(hashCode % 60))解決了一個需要持續人工維護的協調問題,而且完全透明——工程師不需要知道這個機制存在,它就自動生效。

Hangfire Multi-Queue 資源隔離:讓重量級 Job 不拖垮整個排程系統

問題:重量級 Job 拖垮輕量 Job

所有 Hangfire Job 都跑在同一個 Worker Pool 時,有一個隱藏的資源競爭問題。

以本系統的 Job 分類為例:

輕量 Job(毫秒~秒級):

  • Maintenance:查一個 API,存一個 key
  • FaqContent:幾篇 FAQ 文章
  • OTPValidationSetting:幾筆設定值

重量 Job(秒~十幾秒):

  • CasinoCache:拉全部 Casino 遊戲清單(數千筆)+ Jackpot feed + 多語言轉換
  • LiveCache:拉全部 Live Casino 遊戲 + 即時賠率 feed
  • EsportsCache:拉 Esports 遊戲清單 + 賽事 feed

如果 8 個 Worker 同時執行 CasinoCacheLiveCacheEsportsCache⋯⋯

1
2
3
4
5
6
7
問題 1:SPI API Timeout
重量 Job 並行呼叫同一個 API,超過 API Rate Limit → Timeout
Hangfire 自動 Retry → 更多並行請求 → 更多 Timeout → 雪崩

問題 2:輕量 Job 被餓死
8 個 Worker 全被重量 Job 佔用
Maintenance(3 分鐘更新一次)無法執行 → 快取過期

解法:多 Queue + 獨立 Worker Pool

Hangfire 支援 Multi-Server + Multi-Queue 架構:每個 BackgroundJobServer 實例可以只監聽特定 Queue,並有自己獨立的 Worker 數量。

flowchart TD
    subgraph DS["Default Queue Server"]
        D["Worker Count: ProcessorCount(e.g., 8)
監聽 Queue: default
處理:Maintenance / FaqContent / OTP / Country / ..."] end subgraph GS["Game Cache Queue Server"] G["Worker Count: 1(序列執行)
監聽 Queue: game-cache-queue
處理:CasinoCache / LiveCache / EsportsCache / LottoCache"] end

重量 Job 被隔離到 game-cache-queue,獨立 1 個 Worker 序列執行,對外 API 的壓力從「N 個並行請求」降為「1 個串行請求」。輕量 Job 的 8 個 Worker 完全不受影響。


設定驅動的 Multi-Queue(hangfire.json)

Queue 設定完全由設定檔控制,不需要修改程式碼:

1
2
3
4
5
6
7
8
9
10
11
{
"HangfireSetting": {
"SpecialQueues": [
{
"QueueName": "game-cache-queue",
"WorkerCount": 1
}
],
"DefaultSpecialQueueWorkerCount": 1
}
}

未來新增一個 payment-queue 只需加一筆 JSON,完全不碰 C#。


核心實作

Worker Count 決策邏輯(HangfireSetting)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static int GetWorkerCountForQueue(string queueName)
{
// 優先查明確設定的特殊 Queue
var specialQueue = GetQueueByPattern(queueName);
if (specialQueue != null)
return specialQueue.WorkerCount;

// 名稱符合 -queue 後綴但沒有明確設定,使用預設值(通常為 1)
if (IsSpecialQueue(queueName))
return DefaultSpecialQueueWorkerCount;

// Default Queue:使用 CPU 核心數,充分利用硬體並行能力
return Environment.ProcessorCount;
}

public static bool IsSpecialQueue(string queueName)
{
return queueName.EndsWith("-queue", StringComparison.OrdinalIgnoreCase);
}

命名慣例:Queue 名稱以 -queue 結尾即為「特殊 Queue」,自動套用 Worker 限制。

動態建立多個 BackgroundJobServer(ServiceCollectionExtension)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public static IServiceCollection AddHangfireServices(this IServiceCollection services)
{
// 設定 Hangfire 使用 Redis 作為 Job Storage
services.AddHangfire((serviceProvider, config) =>
{
config.UseRedisStorage(
HangfireSetting.RedisConnectionString,
new RedisStorageOptions { Prefix = HangfireSetting.PrefixKey }
).WithJobExpirationTimeout(TimeSpan.FromDays(1));
});

// 預設 Server(處理 default queue,Worker 數 = CPU 核心數)
services.AddHangfireServer();

// 為每個特殊 Queue 建立獨立 Server
var specialQueues = HangfireSetting.GetAllSpecialQueueNames();
foreach (var queueName in specialQueues)
{
var workerCount = HangfireSetting.GetWorkerCountForQueue(queueName);

services.AddHangfireServer(options =>
{
options.Queues = new[] { queueName };
options.WorkerCount = workerCount;
// Server 命名便於在 Hangfire Dashboard 辨識
options.ServerName = $"{queueName.Replace("-", "").ToUpper()}Server-{Environment.MachineName}";
});
}

return services;
}

ServerName 格式 GAMECACHEQUEUE Server-MYSERVER01 讓 Dashboard 一眼看出每個 Server 負責哪個 Queue。

Job 指派到 Queue(appCacheSetting.json)

1
2
3
4
5
6
7
8
9
10
{
"Name": "CasinoCache",
"CacheTime": "10",
"QueueName": "game-cache-queue" ← 明確路由到特殊 Queue
},
{
"Name": "Maintenance",
"CacheTime": "3"
// QueueName 未設定,預設為 "default"
}

CacheTimeSettingQueueName 預設值為 "default"

1
2
3
4
5
6
7
public class CacheTimeSetting
{
public AppDataCacheNameEnum Name { get; set; }
public HangFireJobTimeEnum CacheTime { get; set; }
public string QueueName { get; set; } = "default"; // 預設 default queue
// ...
}

設定驗證(Fail Fast)

Hangfire Server 建立時若 WorkerCount <= 0 會在運行期才爆,很難 debug。把驗證移到啟動期:

1
2
3
4
5
6
7
8
9
10
11
12
13
public void Validate()
{
if (DefaultSpecialQueueWorkerCount <= 0)
throw new ArgumentException("DefaultSpecialQueueWorkerCount must be > 0");

foreach (var queue in SpecialQueues ?? Enumerable.Empty<SpecialQueueConfig>())
{
if (string.IsNullOrEmpty(queue.QueueName))
throw new ArgumentException("SpecialQueue QueueName cannot be empty");
if (queue.WorkerCount <= 0)
throw new ArgumentException($"Queue '{queue.QueueName}' WorkerCount must be > 0");
}
}

設定錯誤 → 啟動失敗 → 明確錯誤訊息,不會等到 Job 執行時才無聲失敗。


實戰:如何新增一支 Job 並指定 Queue

以下用新增一支 SmsNotification Job 為例,完整走一次四個步驟。


情境一:加入已有的 Queue(例如 game-cache-queue

只需改動 三個地方


Step 1 — 定義介面

路徑:Application/Interfaces/ISmsNotificationJob.cs

1
2
3
4
5
6
7
namespace Application.Interfaces
{
public interface ISmsNotificationJob
{
Task DoJob();
}
}

命名規則固定為 I{JobName}JobJobInitializeService 會用 Reflection 自動用這個名稱找到你的介面,名稱不符合就找不到。


Step 2 — 實作 Job 類別

路徑:Application/Jobs/SmsNotificationJob.cs

1
2
3
4
5
6
7
8
9
10
namespace Application.Jobs
{
public class SmsNotificationJob : ISmsNotificationJob
{
public async Task DoJob()
{
// 實際業務邏輯
}
}
}

Step 3 — 在 DI 容器註冊

路徑:Application/DependencyInjection/ServiceCollectionExtension.cs

AddApplicationServices() 裡加一行:

1
2
// 找到其他 Job 的註冊行,在附近加入
services.AddTransient<ISmsNotificationJob, SmsNotificationJob>();

沒有這行的話,JobInitializeServiceValidateServiceRegistration() 階段就會拋錯,App 無法啟動。


Step 4 — 在設定檔新增排程設定

路徑:Presentation/webspi.frontend.cache/Configs/appCacheSetting.json

1
2
3
4
5
6
7
8
9
10
11
12
{
"AppCacheSetting": {
"CacheTimeSettings": [
// ... 其他 Job ...
{
"Name": "SmsNotification",
"CacheTime": "5",
"QueueName": "game-cache-queue"
}
]
}
}
欄位 說明
Name 必須和介面名稱 I{Name}Job 對應,大小寫一致
CacheTime 執行頻率(分鐘),對應 HangFireJobTimeEnum 的數值
QueueName 要路由到哪個 Queue;不填預設為 "default"

完成。重啟 App 後 JobInitializeService 會自動在 Hangfire 掛上這支排程。


情境二:新增一個全新的 Queue

如果 game-cache-queue 的 Worker=1 不適合你的需求(例如你需要 2 個 Worker),或你想把某類 Job 完全獨立出來,就需要建立新 Queue。

在情境一的四個步驟之外,多加一個步驟:

Step 0(額外)— 在 hangfire.json 宣告新 Queue

路徑:Presentation/webspi.frontend.cache/Configs/hangfire.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"HangfireSetting": {
"SpecialQueues": [
{
"QueueName": "game-cache-queue",
"WorkerCount": 1
},
{
"QueueName": "sms-queue",
"WorkerCount": 2
}
]
}
}

加入後,ServiceCollectionExtension.AddHangfireServices() 會在啟動時自動為 sms-queue 建立一個獨立的 BackgroundJobServer,配置 2 個 Worker。

然後 Step 4 的 appCacheSetting.jsonQueueName 設為 "sms-queue" 即可:

1
2
3
4
5
{
"Name": "SmsNotification",
"CacheTime": "5",
"QueueName": "sms-queue"
}

命名慣例:Queue 名稱結尾要加 -queue(例如 sms-queuepayment-queue),HangfireSetting.IsSpecialQueue() 靠這個後綴來識別特殊 Queue。不符合慣例的 Queue 名稱會被視為 default queue,不會建立獨立 Server。


新增 Job 的完整檔案清單

情境 需要改動的檔案
加入已有 Queue ① 新增 Interfaces/I{Name}Job.cs ② 新增 Jobs/{Name}Job.csDependencyInjection/ServiceCollectionExtension.csConfigs/appCacheSetting.json
新增全新 Queue 同上,再加 ⑤ Configs/hangfire.json

實際 Queue 分配一覽

依據 appCacheSetting.json 的設定:

Queue Job Worker 數 執行模式
default Maintenance, BankMaintenance, OTPValidation, FaqContent, PmsSetting, Affiliate, … ProcessorCount 並行
game-cache-queue EsportsCache, CasinoCache, LiveCache, LottoCache, PromoCache, VirtualCache, ChessCache 1 序列

多實例部署的擴展方式

當單台 Server 的 game-cache-queue 處理不過來(Job 積壓),不需要修改程式碼,只需部署第二台 Server 並讓它只監聽 game-cache-queue

1
2
Server A:default queue(8 workers)+ game-cache-queue(1 worker)
Server B:game-cache-queue only(1 worker)

兩台 Server 共享同一個 Redis Job Storage,Hangfire 自動做 Job 分配,game-cache-queue 的吞吐量翻倍。


效益對比

場景 單一 Queue Multi-Queue
重量 Job 執行中 輕量 Job 被排隊等待 輕量 Job 不受影響
SPI API 並行壓力 N 個 Worker 同時打 API Game Queue 序列,一次一個
Job 積壓時的擴展 加 Worker → 更多 API 壓力 加 Game Server → 獨立擴展
Queue 設定變更 改程式碼 + 部署 改 JSON + 重啟
Dashboard 可見度 所有 Job 混在一起 各 Queue 分開監控

小結

Multi-Queue 架構的核心價值是資源隔離:不讓一類工作的資源消耗影響到另一類工作的 SLA。

設計要點:

  1. -queue 後綴作為特殊 Queue 的命名慣例,便於自動識別
  2. 每個特殊 Queue 對應獨立的 BackgroundJobServer,Worker 數可獨立設定
  3. 設定驅動:新增 Queue 只需改 JSON,程式碼本身是通用的
  4. 啟動期驗證:錯誤設定立即爆出,不等到執行期

Vue 3 新裝置 MFA 驗證架構:從登入攔截到 WebView 完成驗證的完整設計

背景:為什麼需要新裝置驗證

App 的登入安全升級需求:當使用者從「未曾登入過的新裝置」登入時,需要完成 MFA 驗證,才能取得完整的登入 token 和 session。

這個場景有幾個特殊的工程挑戰:

  1. MFA 流程在 WebView 中完成:App 收到「需要驗證」的訊號後開啟 WebView,驗證 UI 完全由 Web 端負責
  2. 三種驗證方式共用一套 UI 邏輯:Email OTP、TOTP、Push Notification,切換時不能有狀態殘留
  3. Push Notification 需要前端輪詢:使用者在另一台設備點「允許」,前端要主動查詢結果
  4. 驗證成功後產生短效 grantId:App 用 grantId 換取正式 token,WebView 本身不持有最終憑證

整體登入流程(App + WebView 協作)

flowchart TD
    A["[App] POST /api/member/login"] --> B{"設備狀態"}
    B -->|"已知設備"| C["正常返回 LoginResponse(含 token)"]
    B -->|"新設備"| D["HTTP 200 ErrorCode = LOGN0020
MfaChallenge: VerificationId / AccountId / AvailableMethods"] D --> E["[App] 開啟 WebView
傳入 VerificationId + AccountId + AvailableMethods"] E --> F["[WebView] 渲染 MFA UI
useVerificationFlow 管理狀態"] F --> G["使用者完成驗證(任一方式)"] G --> H["[WebView] 呼叫 Web MFA verify API
成功 → 後端寫入 Redis MFA:LoginGrant:{grantId}
TTL 60~180s"] H --> I["[WebView] 通知 App(帶回 grantId)"] I --> J["[App] POST /api/member/exchange-login-grant
Body: { GrantId: 'xxx' }"] J --> K["後端從 Redis 讀 MFA:LoginGrant:{grantId}
刪除 Grant Key(single-use)
呼叫 InitSessionAndProfile"] K --> L["回傳完整 LoginResponse(含 token/session/profile)
[App] 登入完成"]

關鍵設計:grantId 的一次性機制

grantId 是短效(60~180 秒)一次性憑證。這樣設計的原因:

  • WebView 完成驗證後立即簽發,App 用完即失效
  • 就算 grantId 在傳遞過程中被攔截,時間窗口極短
  • 重放攻擊(replay attack)無效:用過一次就從 Redis 刪除

後端:MFA 方式的決定邏輯

哪些 MFA 方式可用,由「API 回應」和「Config 設定」共同決定,兩者都 true 才啟用:

1
可用 MFA 方式 = API 回應 ∩ Config 開關
方式 API 條件 Config 開關 啟用條件
Push Notification pushNotificationEnable = true NewDeviceMfaEnablePushNotification = true 兩者皆 true
Email OTP 無條件(預設可用) NewDeviceMfaEnableEmailOtp = true Config 為 true 即可
TOTP totpEnable = true NewDeviceMfaEnableTotp = true 兩者皆 true

這個設計讓「會員沒有設定 TOTP」時 TOTP 不出現在列表,「Push Notification 功能全域關閉」時即使會員有綁定也不顯示。


功能開關與版本控制

新設備驗證功能透過 App 版本控制分階段上線:

1
2
3
4
<!-- FeatureVersionControl.config -->
<FeatureVersion Name="AllowNewDeviceCheck"
AndroidMinVersion="1.2.0" AndroidMaxVersion="*.*.*"
iOSMinVersion="1.2.0" iOSMaxVersion="*.*.*" />
App 版本 AllowNewDeviceCheck 行為
< 1.2.0 false 完全不觸發新設備檢查,走原有登入流程
≥ 1.2.0 true 新設備觸發 MFA,回傳 LOGN0020

分階段上線策略:初期將版本門檻設為 1.9.0(只有最新版測試),確認穩定後降至 1.2.0。緊急關閉只需將版本門檻調高至 99.0.0


前端:useVerificationFlow Composable 設計

WebView 裡的 MFA UI 完全由 useVerificationFlow 管理。設計目標:

  1. 三種方式共用一套狀態,切換時自動清理
  2. Push Notification 的輪詢邏輯完全封裝在 Composable 內
  3. Callback 注入:Composable 不直接呼叫 API,由呼叫方提供實作
  4. onUnmounted 自動清理 Polling

型別設計

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export enum LoginVerificationType {
Email = 1,
PushNotification = 2, // 注意:後端 enum 和前端順序不同
TOTP = 3,
}

export enum VerificationErrorCode {
InvalidCode = 1001,
MaxAttemptsReached = 1002,
PushPending = 2001, // 輪詢回應:還在等待,不是錯誤
Exception = 9999,
}

export interface UseVerificationFlowOptions {
onTrigger?: (method: LoginVerificationType) => Promise<TriggerResult>;
onVerify?: (code: string, securityCodeId: string, method: LoginVerificationType) => Promise<VerifyResult>;
onCheckPushStatus?: (verificationId: string, accountId: number, securityCodeId: string) => Promise<PushStatusResult>;
onPushApproved?: (data: { passport: string; recDomain: Array<{ domain: string }> }) => void;
onTimeout?: () => void;
onError?: (errorCode: number) => void;
onMaxAttemptsReached?: () => void;
}

Callback 模式的工程理由

  • 同一個 useVerificationFlow 可以在「登入驗證」和「裝置驗證」兩個不同頁面使用,注入不同的 API 函式
  • Composable 本身可以被獨立單元測試,無需 mock HTTP client
  • 驗證成功後的導頁邏輯(onPushApproved)由呼叫方決定,Composable 不需要知道 router

狀態設計:為什麼用 timestamp 而非 countdown

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ❌ 常見但有問題的設計
const resendCooldown = ref(60); // 秒數遞減
setInterval(() => { resendCooldown.value--; }, 1000); // 需要管理 interval 清理

// ✅ 本實作:timestamp 比對
const resendCooldownEndTime = ref(0); // 到期時間點(ms)
const canResend = computed(() => resendCooldownEndTime.value <= Date.now());

// 設定:
resendCooldownEndTime.value = Date.now() + 60 * 1000;

// UI 顯示剩餘秒數(由 UI 層自行計算,不需要 Composable 管理 interval):
const secondsLeft = computed(() =>
Math.max(0, Math.ceil((resendCooldownEndTime.value - Date.now()) / 1000))
);

這個設計的優點:

  • Composable 不需要持有任何 setInterval(倒數的驅動責任移交給 UI 層)
  • 頁面重整後恢復:只要把 endTime 存到 localStorage 就能重建狀態
  • 多個計時器(resend cooldown + verification expiry)各自獨立,不互相干擾

Push Notification Polling 的完整狀態機

Push Notification 是三種方式中最複雜的,因為它是非同步等待使用者在另一台設備操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
const PUSH_POLLING_INTERVAL = 3000; // 每 3 秒輪詢一次

function startPolling(verificationId: string, accountId: number): void {
if (!onCheckPushStatus || isPolling.value) return; // 冪等:重複呼叫無副作用

verificationIdForPolling.value = verificationId;
accountIdForPolling.value = accountId;
isPolling.value = true;

// 立即執行一次,不等第一個 3 秒 interval
// (使用者可能已在另一台設備預先點了允許)
checkPushStatus();

pollingIntervalId.value = window.setInterval(() => {
// 每個 tick 都先檢查 session 是否已過期
// 不依賴 setInterval 的 timing 精度(可能因 tab 背景化而延遲)
if (verificationExpiryEndTime.value > 0 && Date.now() >= verificationExpiryEndTime.value) {
stopPolling();
onTimeout?.();
return;
}
checkPushStatus();
}, PUSH_POLLING_INTERVAL);
}

async function checkPushStatus() {
if (!onCheckPushStatus || !verificationIdForPolling.value) return { success: false };

try {
const result = await onCheckPushStatus(
verificationIdForPolling.value,
accountIdForPolling.value,
securityCodeId.value,
);

if (result.success) {
stopPolling();
if (onPushApproved && result.data) {
onPushApproved(result.data as { passport: string; recDomain: Array<{ domain: string }> });
}
return { success: true };

} else if (result.errorCode === VerificationErrorCode.PushPending) {
// 還在等待:什麼都不做,繼續下一個 tick
return { success: false };

} else {
// 真正的錯誤(過期、使用者拒絕等)
stopPolling();
onError?.(result.errorCode!);
return { success: false, errorCode: result.errorCode };
}
} catch {
// 網路錯誤:不停止 Polling,下一個 3 秒 tick 自動重試
return { success: false };
}
}

三種 Push 狀態的不同處理

API 回應 isPolling UI 行為 呼叫 Callback
success: true 停止 成功畫面 onPushApproved(data)
errorCode: PushPending 繼續 等待動畫
其他 errorCode 停止 錯誤 Dialog onError(code)
Network error (catch) 繼續 等待動畫

網路錯誤不停 Polling 的設計理由

Push Notification 的場景中,使用者已在等待介面。網路短暫抖動不應該強制使用者重新操作。3 秒後自動重試,對 UX 更友好。只有真正的業務邏輯錯誤(session 過期、Push 被拒絕)才停止。


精確的過期偵測:為什麼 setInterval 的 3 秒間隔不夠

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 問題:setInterval 在 Tab 背景化時可能被 throttle 到 1 分鐘執行一次
// 就算正常執行,3 秒的間隔意味著最多 3 秒的過期判斷誤差

// 解法:額外加一個 1 秒精確檢查的 watcher
watch(
() => verificationExpiryEndTime.value,
(newEndTime) => {
if (newEndTime > 0 && isPolling.value) {
const expiryCheckInterval = setInterval(() => {
if (Date.now() >= newEndTime || !isPolling.value) {
clearInterval(expiryCheckInterval);
if (Date.now() >= newEndTime) {
stopPolling();
onTimeout?.();
}
}
}, 1000); // 1 秒精度
}
},
);

這個 watchverificationExpiryEndTime 被設定時(trigger 成功後)才啟動,避免在 Push 尚未開始時就跑多餘的 interval。


setMethod:切換方式時的狀態清理

1
2
3
4
5
6
7
8
9
10
11
12
13
function setMethod(method: LoginVerificationType) {
// 從 Push 切換走:必須先停止 Polling
// 不停的話 Polling 會在背景繼續,成功後呼叫 onPushApproved,但 UI 已換成 Email 畫面
if (currentMethod.value === LoginVerificationType.PushNotification && isPolling.value) {
stopPolling();
}
currentMethod.value = method;
// 清除輸入狀態,但保留 verificationExpiryEndTime
// 原因:session 還沒過期,切換方式不應重置過期時間
verificationCode.value = '';
errorMessage.value = '';
securityCodeId.value = ''; // 需要重新 trigger 才能取得新的 securityCodeId
}

錯誤訊息的責任邊界

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function getErrorMessage(errorCode?: number, failCountValue?: number): string {
switch (errorCode) {
case VerificationErrorCode.InvalidCode:
// 只有「驗證碼錯誤」顯示在 input 下方(inline error)
if (failCountValue !== undefined && maxFailCount.value > 0) {
const attemptsLeft = maxFailCount.value - failCountValue;
if (attemptsLeft > 0) {
// 語言複數處理:1 次用單數文案,多次用複數文案
const key = attemptsLeft > 1
? 'txtAccountInvalidCodeErrors'
: 'txtAccountInvalidCodeError';
return t(key, { attemptsLeft });
}
}
return ''; // attemptsLeft = 0 時,MaxAttemptsReached 由 Dialog 處理
default:
// 所有其他錯誤(session 過期、網路錯誤、後端錯誤)
// 由 UI 層的 Dialog 或 Toast 處理,Composable 不知道 Dialog 的存在
return '';
}
}

這個邊界的工程意義

  • getErrorMessage 只返回「應該顯示在 input 旁的 inline 訊息」
  • 其他所有錯誤的呈現方式(Dialog、Toast、頁面跳轉)由 Composable 的 callback(onErroronTimeoutonMaxAttemptsReached)通知 UI 層
  • 這讓 Composable 不依賴任何 UI 框架,可以在不同的設計系統下複用

verify:失敗計數與自動觸發 onMaxAttemptsReached

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
async function verify() {
if (!isCodeValid.value) return { success: false };
errorMessage.value = '';

try {
const result = await onVerify!(
verificationCode.value,
securityCodeId.value,
currentMethod.value,
);

if (result.success) {
return { success: true, data: result.data };
}

if (result.failCount !== undefined) {
failCount.value = result.failCount;
}

// 達到上限:通知 UI 層(通常是鎖定 Dialog),不顯示 inline error
if (result.failCount !== undefined && maxFailCount.value > 0) {
const attemptsLeft = maxFailCount.value - result.failCount;
if (attemptsLeft <= 0) {
onMaxAttemptsReached?.();
return { success: false, errorCode: result.errorCode, failCount: result.failCount };
}
}

// 未達上限:顯示 inline error(還剩 N 次)
errorMessage.value = getErrorMessage(result.errorCode, result.failCount);
verificationCode.value = ''; // 清空輸入,讓使用者重新輸入
return { success: false, errorCode: result.errorCode, failCount: result.failCount };

} catch {
return { success: false, errorCode: VerificationErrorCode.Exception };
}
}

maxFailCounttrigger 的 API 回應取得,而非硬編碼。這讓後端可以根據風險等級動態調整(例如風險較高的帳號縮短至 3 次)。


reset 與 onUnmounted

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function reset() {
stopPolling();
clearVerificationExpiryTimer();
clearResendCooldown();
verificationCode.value = '';
errorMessage.value = '';
securityCodeId.value = '';
maskedEmail.value = '';
failCount.value = 0;
// 注意:不重置 maxFailCount,它是由 API 決定的業務規則
}

// 組件 unmount 時自動清理 Polling
// 防止:使用者導頁後 setInterval 仍在背景執行,觸發 onPushApproved 並試圖操作已銷毀的 vue instance
onUnmounted(() => {
stopPolling();
});

WebView 的使用方式(完整範例)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<script setup lang="ts">
import { useRoute } from 'vue-router';

const route = useRoute();
// WebView URL 從 App 傳入:?verificationId=xxx&accountId=12345&methods=1,2,3
const verificationId = route.query.verificationId as string;
const accountId = Number(route.query.accountId);
const availableMethods = (route.query.methods as string)
.split(',')
.map(Number) as LoginVerificationType[];

const {
currentMethod,
verificationCode,
errorMessage,
canResend,
isPolling,
verificationExpiryEndTime,
setMethod,
trigger,
verify,
resend,
startPolling,
handleTimeout,
reset,
} = useVerificationFlow({
onTrigger: async (method) => {
return await mfaApi.trigger({ verificationId, accountId, method });
},
onVerify: async (code, securityCodeId, method) => {
return await mfaApi.verify({ verificationId, accountId, code, securityCodeId, method });
},
onCheckPushStatus: async (verificationId, accountId, securityCodeId) => {
return await mfaApi.checkPushStatus({ verificationId, accountId, securityCodeId });
},
onPushApproved: (data) => {
// 透過 App Bridge 通知 App,帶回 grantId
nativeBridge.notifyVerificationComplete({ grantId: data.passport });
},
onTimeout: () => {
showTimeoutDialog.value = true;
},
onError: (errorCode) => {
showErrorDialog(errorCode);
},
onMaxAttemptsReached: () => {
showLockedDialog.value = true;
},
});

// 初始化:根據 availableMethods 設定預設方式,並觸發第一次驗證碼發送
onMounted(async () => {
// Push 優先(使用者體驗最佳),其次 Email,最後 TOTP
const preferredOrder = [
LoginVerificationType.PushNotification,
LoginVerificationType.Email,
LoginVerificationType.TOTP,
];
const defaultMethod = preferredOrder.find(m => availableMethods.includes(m))
?? LoginVerificationType.Email;

setMethod(defaultMethod);
const result = await trigger();

if (defaultMethod === LoginVerificationType.PushNotification && result.success) {
startPolling(verificationId, accountId);
}
});
</script>

Redis 儲存結構(前後端對應)

前端完成 MFA 驗證後,後端在 Redis 寫入兩種 key:

1
2
3
4
5
6
7
8
9
10
MFA:ActiveSession:{AccountId}   TTL 30 分鐘
└─ MfaContext (JSON)
├─ VerificationId, AccountId, LoginId, MemberCode
├─ ClientIP, UserAgent, CountryCode, City
├─ FingerPrintId, ScreenResolution, DeviceLanguage
├─ DeviceType, BlackBox, DeviceId, DeviceModel
└─ LightSpeedToken, BundleId, WebAuthnCredentialId

MFA:LoginGrant:{GrantId} TTL 60~180 秒(一次性)
└─ MfaContext 全量 snapshot(同上)

MFA:ActiveSession 在同一 AccountId 重複登入時覆蓋舊記錄(踢單機制)。MFA:LoginGrant 兌換成功後立即刪除,防止重放攻擊。


架構全景

flowchart LR
    subgraph BE["後端"]
        LM["LoginMfaManager
HandleLoginMfaFlow
GetAvailableMfaMethods
CreateMfaSession"] AS["MFA:ActiveSession:{AccountId}"] ELG["AccountService.ExchangeLoginGrant
讀取 + 刪除 MFA:LoginGrant
InitSessionAndProfile
回傳 LoginResponse"] LG["MFA:LoginGrant:{GrantId}
TTL 60~180s,一次性"] end subgraph FE["前端 WebView"] VF["useVerificationFlow
currentMethod / code / timers / polling
trigger / verify / startPolling"] CB["Callbacks
onTrigger / onVerify
onCheckPushStatus / onPushApproved"] end LM --> AS LM --> LG LG --> ELG VF --> CB CB -->|"成功 → grantId"| ELG