用 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

Nuxt 3 生產級 Session 管理:Redis + Singleton + 多種登入流程

Session 管理是後台系統的核心安全基礎。
一個生產級的 Session 架構需要:

  • 安全的 Cookie 設定(httpOnly、secure、sameSite)
  • 高可用的 Session 儲存(Redis Sentinel)
  • 多種登入方式的統一處理
  • 使用者狀態的即時同步(Redis Cache)
  • 活動式 TTL 重置(Rolling Session)

本文拆解一個真實 Nuxt 3 後台系統的完整 Session 架構。

Read More

RedLock 分散式鎖在 CacheLayerManager 的實作案例

背景問題:Cache Stampede(快取雪崩)

在高並發環境下,當某個 Redis cache key 同時過期,大量請求會同時發現 cache miss 並全部打向後端 DB / Provider,這就是 Cache Stampede

flowchart TD
    E[Cache Key 過期] --> A[Request A Cache Miss]
    E --> B[Request B Cache Miss]
    E --> C[Request C Cache Miss]
    E --> D[Request D Cache Miss]
    A --> P[Call Provider]
    B --> P
    C --> P
    D --> P
    P --> DB[DB 或 Provider 被打爆]

解決方案: 在更新 Cache 時加 分散式鎖(RedLock),確保同一時間只有一個 instance 去 Provider 取資料並回填 Cache。

Read More

WatcherUtil — 用 Delegate 實現 AOP 效能監控,不污染業務邏輯

問題:效能監控程式碼到處散落

一般做法是在每個方法手動埋計時:

1
2
3
4
5
6
7
8
9
// 到處複製貼上,業務邏輯被監控代碼淹沒
var start = DateTime.Now;
try {
result = db.GetSomething(key);
var ms = (DateTime.Now - start).TotalMilliseconds;
log.Performance("REDIS", "GetSomething", ms.ToString(), "");
} catch (Exception ex) {
log.Exception(ex);
}

這違反了 單一職責原則(SRP),監控邏輯和業務邏輯耦合在一起。

Read More

雙層快取架構:LazyCache (L1) + Redis (L2) 防止 Cache Stampede

為什麼需要雙層快取?

使用場景

典型情境是 多台 App Server 水平擴展,讀取路徑上會先查本機記憶體、再查 共用的 Redis(L2) 當成跨機器的快取來源。業務資料可能在後端更新後寫回 Redis,或依 TTL 在 Redis 上過期;各機的 L1 也會依自己的 TTL 失效。只要出現「同一時間很多請求都認為快取不可用」——例如熱點 key 的 TTL 一起到期、部署後快取清空、或 Redis 短暫不可用後恢復——每一台機、每一條執行緒若都各自回源,後端與 Redis 會在瞬間承受 N 台 × 每機併發 的壓力。

這套 L1(LazyCache)+ L2(Redis) 的組合,目的就是在這種 多機共享快取、又需要週期或事件驅動更新 的架構下,把「miss 之後誰負責載入」收斂成 單機內合併請求,必要時再在 L2 層用分散式鎖(例如 RedLock,見 CacheLayerManager)避免多機同時把同一個 key 打穿到資料庫。它不是取代「資料在業務上如何同步」的設計,而是降低 快取重建瞬間 的並發災難。

Read More

Debug Mode 下動態抓取 manifest.json — _Layout.cshtml 的 JS 版本管理機制

背景

正式環境下,前端 JS/CSS 經 Webpack 打包後會帶 content hash 檔名;ASP.NET MVC 的 _Layout.cshtml 必須依 manifest.json 注入正確的 <script> / <link>。本篇說明在 Debug 時如何 不必重啟後端 也能對齊 Webpack 即時編譯 的輸出。

專案型態:前後端未分離的 .NET Framework MVC

我們的專案 不是「獨立前端站 + 獨立 API 站」那種典型前後端分離:

  • 後端.NET Framework 上的 ASP.NET MVC。頁面骨架仍由 Razor(例如 _Layout.cshtml)在伺服器端渲染,路由與部分畫面由 MVC 負責。
  • 前端互動:實際的 SPA/模組化 UI 由 Webpack 打包的 JS、CSS 在瀏覽器端啟動;正式環境這些靜態檔會放到 CDN,透過 hash 檔名 做快取破壞。
  • Debug 開發:本機不強制走「部署到 CDN + 重讀 manifest」那條路,而是啟動 Webpack Dev Server(或同等 dev 流程),由它 動態編譯並提供 最新的 vendor.jsindex.js 等。_LayoutDebug 組態下改把資源 URL 指到 本機 dev server,因此 重新 build / HMR 後只要重新整理頁面,即可載入新 bundle,無須為了更新 hash 而去重啟 IIS 或整個 .NET 站台

簡單講:Release=後端讀記憶體裡的 manifest.json,對應 CDN 上帶 hash 的檔;Debug=後端略過 manifest,改連 dev server 上「當下編譯出來」的固定路徑(無 hash 或 dev 產物),讓「改前端 → 立刻看到畫面」。

Read More