TheDialogQueue — 用 Pinia Store 實作全站彈窗佇列機制

前言

在平台系統中,操作過程經常同時觸發多個彈窗——HTTP 錯誤、銀行維護提示、KYC 驗證結果、交易確認等。如果各元件各自管理 v-model,多個 overlay 會同時疊加在畫面上,不僅使用者體驗混亂,對開發者來說也極度難以維護。

我們需要一個統一的 Dialog 管理機制:所有需要彈窗的地方都不自己開 Dialog,而是把「要顯示什麼」丟進佇列,由一個元件依序一個一個渲染。

Read More

Vue.js BroadcastChannel 跨分頁通訊:從踩坑到 Singleton Event Bus 架構設計

前言

在多分頁的 Web 應用中,跨 Tab 同步狀態是常見需求——例如閒置登出同步、Session 過期通知、T&C 接受後其他 Tab 強制刷新等。瀏覽器原生的 BroadcastChannel API 是最直覺的方案,搭配 VueUse 的 useBroadcastChannel 封裝更是方便。

但在實際開發中,我們踩了一個非常隱晦的坑——同一個 Tab 內建立多個 BroadcastChannel 實例,會互相收到訊息。本文記錄我們從「能用」到「好用」的架構演進過程。

Read More

跨框架 Partial View 架構設計 — 用 Vue 3 打造可嵌入任何頁面的 Header/Footer

前言

在多產品平台中,每個產品(體育、娛樂城、棋牌等)可能由不同的合作夥伴(Provider)開發,使用的技術棧也各不相同——React、Angular、甚至純 JS。但平台需要在所有產品頁面上維持一致的品牌形象、導航列、會員狀態和法律聲明。

要求每個 Provider 自行實作這些 UI 元件既不現實也難以維護。因此我們設計了 star4partialview — 一個獨立打包的 Vue 3 應用,輸出單一 partialView.js,Provider 只需引入一個 <script> 標籤就能獲得完整的 Header + Footer。

兩個獨立的 JS Runtime 透過 window.postMessage 通訊,實現了一種輕量級的 Micro Frontend 模式。

Read More

用 Elasticsearch Watcher 打造生產環境即時告警 — Game Launch 失敗率與 Error Log 成長監控

前言

在生產環境中,很多問題不會立即被人發現——部署後新版本的 Error Log 悄悄飆升、第三方 Provider 突然故障導致遊戲啟動連續失敗、甚至遭受攻擊產生大量異常請求。如果只靠人工盯 Kibana Dashboard,往往等到使用者回報時已經影響了大量用戶。

本文分享我們實際使用的兩個 Elasticsearch Watcher 告警規則,透過定時查詢 + 條件判斷 + 自動寄信,讓團隊在問題擴大前就能收到通知並介入處理。

Read More

Git Revert 後再次 Merge 失效?正式環境退版後的正確處理流程

前言

在團隊協作中,經常會遇到這種情境:你的功能分支已經 merge 到正式環境的 release branch,但上線前一天被通知要抽單退版。這時候如果直接在 release branch 上 git revert 那筆 merge commit,後續想把同一個功能分支重新 merge 回去時,Git 會認為「這些 commit 已經被 merge 過了」而跳過所有變更——導致程式碼根本沒有被帶回來。

本文記錄這個問題的成因,以及在不遺漏檔案的前提下,如何正確退版並繼續推進功能。

Read More

ERR_CONTENT_DECODING_FAILED — 當 Status 200 卻解碼失敗的踩坑紀錄

前言

之前在正式環境遇到一個棘手的問題:使用者回報某支 API 回傳 HTTP 200,瀏覽器卻直接噴出 ERR_CONTENT_DECODING_FAILED。當下排查了很久才發現問題出在 Response Header 中多了一個不該出現的 gzip,特別記錄一下整個排查過程。

問題現象

打開 DevTools 檢查異常 API 的 Response Headers,可以看到幾個關鍵資訊:

  • Content-Encoding: gzip — 伺服器宣稱回傳內容經過 gzip 壓縮
  • Transfer-Encoding: chunked — 資料以分段方式傳輸
  • X-Powered-By: ASP.NET — 請求可能直接打到後端 .NET 伺服器,或經過了某層 Proxy 轉發

問題核心:空 Body + gzip 標頭

瀏覽器收到 Content-Encoding: gzip 後,會自動嘗試用 gzip 解碼器解壓內容。但實際上這支 API 的 Response Body 是空的(長度為 0),解碼器無法處理一個空的壓縮流,因此直接報出 ERR_CONTENT_DECODING_FAILED

為什麼 Body 會是空的?根據 Header 中的 X-Powered-By: ASP.NET,可能有兩種原因:

  1. 後端程式異常中斷 — ASP.NET 拋出 Exception 後沒有回傳 500,而是直接結束了 Response,導致 Body 為空。
  2. 查詢超時 — 後端資料庫查詢耗時過久,連線中斷,最終只傳回了 Headers 就結束了。

關鍵線索:對比正常與異常的 API

進一步對比同專案中兩支 API 的 Response Headers,差異一目了然:

欄位 異常 API 正常 API
Content-Encoding gzip (無)
X-Powered-By ASP.NET (無)
X-Aspnet-Version 4.0.30319 (無)

正常的 API 回傳的 Headers 很乾淨,是經過 Nuxt BFF(Node.js)處理後回傳的;而異常的 API 卻直接暴露了 ASP.NET 的資訊。這代表:

  • 情況 A:這支 API 繞過了 Nuxt BFF,直接打到了後端 .NET 伺服器。
  • 情況 B:後端 .NET 伺服器強制啟動了 gzip 壓縮,但因資料量過大或超時導致傳輸中斷,最終變成「空 Body + gzip 標頭」的組合。

真相:Nitro 透傳 Response 物件導致 gzip 標頭外洩

後端伺服器其實沒有噴錯,它正常回傳了資料(只是內容為空陣列或空物件)。真正的問題出在 Nuxt Server(Nitro)端的 API Handler 寫法。

檢查 Server 端程式碼後,發現異常的 API Handler 是這樣寫的:

1
2
const response = await api.memberGetMemberPriorityHistories(query);
return response; // 直接回傳整個 Response 物件

這裡 api.memberGetMemberPriorityHistories() 回傳的是一個繼承自原生 Response 的物件。當直接 return response 時,Nitro 引擎會將其視為透傳(Proxy),把後端的所有原始標頭原封不動地轉發給瀏覽器。

整個流程是這樣的:

  1. 後端 ASP.NET 回傳帶有 Content-Encoding: gzip 的壓縮內容
  2. Nuxt Server(Node.js)在接收時已經自動解壓了 gzip 內容
  3. 但因為直接 return response,Nitro 把後端的原始 Headers(包含 Content-Encoding: gzip)照搬給瀏覽器
  4. 瀏覽器收到 Content-Encoding: gzip 標頭,嘗試解壓 Body —— 但 Body 早就被 Node.js 解壓過了,已經不是 gzip 格式
  5. 解碼失敗,噴出 ERR_CONTENT_DECODING_FAILED

簡單來說:gzip 被 Nuxt Server Side 解開了一次,但 gzip 標頭卻還在,瀏覽器又試圖解第二次,自然就失敗了。

這也解釋了為什麼對比表中異常 API 會帶有 X-Powered-By: ASP.NETX-Aspnet-Version — 這些都是後端的原始標頭被透傳出來的結果。

修正方式

根因找到後,修正方式很明確:不要直接回傳整個 Response 物件,改為只回傳 response.data 這樣 Nitro 會重新封裝成乾淨的 JSON Response,由 Nuxt 統一處理壓縮,後端的 ASP.NET 標頭就不會外洩。

修改前:

1
2
3
4
5
6
export default defineLogAndAuthHandler(async (event) => {
const query = getQuery(event);
const api = new GetMemberPriorityHistories();
const response = await api.memberGetMemberPriorityHistories(query);
return response; // 透傳整個物件,標頭外洩
});

修改後:

1
2
3
4
5
6
export default defineLogAndAuthHandler(async (event) => {
const query = getQuery(event);
const api = new GetMemberPriorityHistories();
const response = await api.memberGetMemberPriorityHistories(query);
return response.data; // 只回傳資料,Nitro 重新封裝 JSON
});

這個修正需要系統性地檢查所有 Server API Handler,只要有直接 return response 的地方都應該改為 return response.data,避免同樣的問題在其他 API 上重演。

結論

這個問題的根因不在後端、也不在前端元件,而是 Nuxt Server(Nitro)的 API Handler 直接透傳了後端 Response 物件。Node.js 在接收後端回應時已經解開了 gzip,但透傳卻把原始的 Content-Encoding: gzip 標頭一併帶給瀏覽器,導致瀏覽器嘗試二次解壓而失敗。

排查這類問題的關鍵:對比正常與異常 API 的 Response Headers。當你發現某支 API 突然多了 X-Powered-By: ASP.NETContent-Encoding: gzip,而其他 API 都沒有,很可能就是 Nitro 把後端的原始標頭透傳出來了。回去檢查 Server API Handler 的回傳值,把 return response 改成 return response.data 就能解決。

在 Nuxt 3 中打造統一的 API Handler:認證、日誌與錯誤處理的優雅封裝

前言

在開發後台管理系統時,我們經常會遇到一個問題:每個 API Handler 都需要處理認證驗證日誌記錄錯誤捕獲等重複性邏輯。如果在每個檔案中都寫一遍,不僅冗長,還容易遺漏。
本文將介紹如何在 Nuxt 3 的 Server API 中,利用 Higher-Order Function (高階函式) 的概念,打造一個統一的 defineLogAndAuthHandler,讓所有 API 自動擁有以下功能:

  • ✅ Session 認證與過期檢查
  • ✅ 請求/回應日誌記錄
  • ✅ 敏感資料自動遮蔽
  • ✅ 效能監控 (API 執行時間)
  • ✅ 統一錯誤處理與格式化

核心概念:Higher-Order Function

在 JavaScript/TypeScript 中,高階函式 (HOF) 是指「接收函式作為參數」或「回傳函式」的函式。我們的設計正是利用這個概念:

export const defineLogAndAuthHandler = <T extends EventHandlerRequest>(
  handler: EventHandler<T>
): EventHandler<T> => defineEventHandler<T>(async (event) => {
  // 在這裡加入認證、日誌等邏輯
  const response = await handler(event); // 執行原本的 Handler
  // 在這裡加入回應處理邏輯
  return response;
});
這就像是在原本的 API Handler 外面「包一層糖衣」,讓它自動具備額外的能力。

功能拆解
1. Session 認證與過期檢查
// 檢查是否已認證
if (!event.context?.session?.isAuthenticated && !isPublicRoute) {
  return createError({
    statusCode: 401,
    statusText: 'Session Expired',
  });
}
// 檢查 Session 是否已過期
const currentTime = new Date().getTime();
const lastLoginTimestamp = event.context?.session?.lastLoginTime;
const maxTimeDiff = (currentTime - lastLoginTimestamp) / 1000;
if (maxTimeDiff >= parseInt(maxExpiryInSeconds, 10)) {
  // 清除 Session 並回傳 401
  event.context.session.isAuthenticated = false;
  return createError({ statusCode: 401, statusText: 'Session Expired' });
}
設計重點:

白名單機制:登入、登出等 API 不需要認證
雙重檢查:不只檢查是否登入,還檢查 Session 是否超時
2. 請求日誌記錄與敏感資料遮蔽
const ignoreLogParams = ['password', 'newPassword', 'oldPassword'];
// 遮蔽敏感欄位
if (typeof body === 'object') {
  const filterBody = Object.assign({}, body);
  for (const key in filterBody) {
    if (ignoreLogParams.includes(key)) {
      filterBody[key] = '***mask***';
    }
  }
  logger.log('info', `[POST] ${JSON.stringify(filterBody)}`);
}
設計重點:

自動遮蔽密碼等敏感欄位,避免日誌外洩
支援 GET/POST/PUT/DELETE 不同方法的日誌格式
3. 效能監控
const startTime = Date.now();
const response = await handler(event);
const endTime = Date.now();
const duration = endTime - startTime;
logger.performance.log('info', `[${pathWithoutQueryString}] [${duration}ms]`);
設計重點:

記錄每個 API 的執行時間
方便後續分析效能瓶頸
4. 回應日誌與大型回應處理
const byPassLogUrls = ['/api/general/settings', '/api/promotion/promotions'];
function truncateString(body: string) {
  const resultSizeLimit = 1024;
  const stringSize = Buffer.byteLength(body);
  
  if (stringSize > resultSizeLimit) {
    return `Result size - ${stringSize} bytes`;
  }
  return body;
}
設計重點:

大型回應只記錄大小,避免日誌檔案爆炸
特定 API 可跳過輸出日誌(如設定檔、大量資料查詢)
5. 統一錯誤處理
catch (error: any) {
  console.error('API Error:', error);
  
  logger.error.log('error', `URL: ${event.path}, Message: ${error.message}`);
  
  return createError({
    statusCode: error.statusCode || 500,
    statusMessage: error.statusMessage || 'Internal Server Error',
    message: error.message || 'An unexpected error occurred',
  });
}
設計重點:

所有未捕獲的異常都會被統一處理
錯誤資訊會被記錄到日誌中,方便追蹤
使用方式
使用這個封裝後,原本的 API Handler 變得非常簡潔:

// server/api/member/summary.get.ts
import { GetMemberSummary } from '@/bospi/GetMemberSummary';
export default defineLogAndAuthHandler(async (event) => {
  const query = getQuery(event);
  const api = new GetMemberSummary();
  const response = await api.getMemberSummary(query);
  return response.data;
});
只需要專注在業務邏輯,認證、日誌、錯誤處理全部自動搞定!

架構圖
┌─────────────────────────────────────────────────────────┐
│                    Frontend (Browser)                    │
└─────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────┐
│              defineLogAndAuthHandler (Wrapper)           │
│  ┌─────────────────────────────────────────────────────┐│
│  │ 1. Session 認證檢查                                  ││
│  │ 2. 請求日誌記錄 (含敏感資料遮蔽)                      ││
│  │ 3. 效能計時開始                                      ││
│  └─────────────────────────────────────────────────────┘│
│                          │                               │
│                          ▼                               │
│  ┌─────────────────────────────────────────────────────┐│
│  │           實際的 API Handler (業務邏輯)              ││
│  └─────────────────────────────────────────────────────┘│
│                          │                               │
│                          ▼                               │
│  ┌─────────────────────────────────────────────────────┐│
│  │ 4. 效能計時結束                                      ││
│  │ 5. 回應日誌記錄                                      ││
│  │ 6. 錯誤捕獲與格式化                                  ││
│  └─────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────┐
│                    Backend SPI (ASP.NET)                 │
└─────────────────────────────────────────────────────────┘


這個設計模式其實就是 AOP (Aspect-Oriented Programming) 的體現——將「橫切關注點」(如日誌、認證、錯誤處理)從業務邏輯中抽離出來。

使用 Redis Sorted Set 實作 API Rate Limiting — 滑動視窗 + 封鎖機制

前言

在面向全球用戶的平台系統中,API 被濫用幾乎是必然會遇到的問題——無論是攻擊者暴力嘗試登入、
爬蟲高頻抓取資料,還是用戶無意間重複提交表單。

本文分享一個我們實際在生產環境中使用的 Redis-based API Rate Limiting 架構
涵蓋兩種策略:滑動視窗(Sliding Window)滑動視窗 + 封鎖鎖定(Sliding Window + Block Lock)
並支援以 Session ID、IP、Member Code 等多種身份維度進行限流。

Read More

LazyCache 踩坑紀錄:直接傳入 TimeSpan 預設是 SlidingExpiration,高流量下永遠不會過期

前言

這是一個只在正式環境才暴露的問題。
我們使用 LazyCache + Redis 搭建了一個兩層快取架構:
MemoryCache 作為 L1 熱快取,Redis 作為 L2 資料來源。
在 Dev、QAT、UAT 環境一切正常,
但上了 Production 後發現 Redis 資料更新了,應用卻永遠拿到舊值

原因?LazyCache 的 GetOrAdd 直接傳入 TimeSpan,預設行為是 SlidingExpiration

Read More

Feature Version Control — APP 版本功能控管架構

前言

Mobile APP 與 Web 不同,使用者不一定會即時更新到最新版本。當後端上線新功能時,舊版 APP 可能尚未支援該功能的 UI 或邏輯。若後端直接回傳新功能相關的資料,可能導致:

  • 舊版 APP 出現不預期的 UI 或行為
  • 新功能的回應格式在舊版 APP 上解析失敗
  • 部分功能僅限特定版本以上使用(例如安全性相關的新裝置驗證)

因此,我們設計了 Feature Version Control 機制,讓後端根據 APP 傳入的版本號,動態決定哪些功能要啟用、哪些要關閉。

Read More