
💵 站台建立的目的
這是一個專門串接多家第三方金流的站台,前台可以依照情況切換不同金流提供者。整個站台的角色是「金流中介者」,幫助前台整合各家金流流程。
在這樣的架構下,例外處理(Exception Handling)變得非常關鍵。因為錯誤不僅可能來自系統內部,也可能是來自外部金流系統的 timeout、錯誤參數或伺服器無回應等狀況。
因此,我們使用 ExceptionHandlerMiddleware 來當作 API 的防爆層(Fallback Layer),讓:
- 內部開發者能清楚知道發生什麼錯
- 外部串接方只看到簡單明確的錯誤資訊
這樣能確保資訊安全,同時提升除錯效率。
💵 Global ExceptionMiddleware
設計的重點:
- 捕捉所有未處理的 Exception
- 記錄 Log
- 給用戶一種統一格式的 JSON 回應
- 在 Debug 模式下額外附上 StackTrace
實作參考
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
| public class ExceptionHandlingMiddleware { private const string ResponseMessage = "Not Handling Exception Has Happened!!"; private readonly RequestDelegate _next; private readonly ILogger<ExceptionHandlingMiddleware> _logger; public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger) { _next = next; _logger = logger; }
public async Task Invoke(HttpContext context) { Stream originalBodyStream = context.Response.Body; try { await _next.Invoke(context); } catch (Exception ex) { this._logger.LogError(ex, $"{ResponseMessage} - Request-id {context.GetTraceId()} - Message {ex.Message}"); await using Stream exceptionResponseStream = this.GetExeptionSteam(ex); context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; context.Response.ContentType = MediaTypeNames.Application.Json; await exceptionResponseStream.CopyToAsync(originalBodyStream); } }
private Stream GetExeptionSteam(Exception ex) { object responseObj = new { Message = ex.Message, ReturnCode = ReturnCodes.UnknownException }; #if DEBUG responseObj = new { Message = ex.Message, StackTrace = ex.StackTrace, ReturnCode = ReturnCodes.UnknownException }; #endif var json = JsonSerializer.Serialize(responseObj); var bytes = Encoding.UTF8.GetBytes(json); var stream = new MemoryStream(bytes); stream.Seek(0, SeekOrigin.Begin); return stream; } }
|
註冊方式:
1 2 3
| app.UseMiddleware<ExceptionHandlerMiddleware>();
|
這段 Middleware 是一個「最低限度的防爆機制」,可有效攔截所有未處理例外。對於金流中介站台而言,它提供了以下幫助:
- 將錯誤訊息、堆疊、時間點、Request ID 等記錄到日誌中(Log)以利追蹤
- 在 Debug 模式下提供完整的 StackTrace 來幫助開發除錯
- 設定正確的 Content-Type = application/json,讓前端知道格式如何解析
- 避免內部堆疊訊息或機密資訊直接暴露在外部使用者眼前
💵 改善方向
現有程式能執行錯誤捕捉,但針對金流中介者的設計,還可更進一步改善:
🔁 更精簡的錯誤分類
目前只攔截 Exception,建議針對不同類型的錯誤明確分類:
目前只抓 Exception → 建議加入更多 error type:
1 2 3 4
| catch (PaymentException ex) {} catch (TimeoutException ex) {} catch (HttpRequestException ex) {} catch (Exception ex) {}
|
- 可 Retry 的錯誤:如 HTTP 503
- 第三方 SDK 錯誤:如 TapPayTimeout
- 用戶傳入的錯誤資料
- 對應程式可分別給予 HTTP 400, 422, 503 等合理回應碼
🧱 解耦錯誤包裝邏輯
目前 GetExeptionSteam() 是直接寫死在 middleware 裡,這會造成:
建議將錯誤格式轉換抽出成服務:
1 2 3 4 5 6
| public interface IErrorResponseBuilder { string Build(Exception ex, bool isDebug, string traceId); }
|
➡️ Middleware 中就只需要:
1 2 3
| context.Response.WriteAsync(_errorResponseBuilder.Build(ex, ...));
|
💵 API 本身的錯誤處理
我們再回到 API 本身的例外處理機制
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
|
public async Task<PaymentResponseEntity<IDictionary<string, object>>> Pay(PaymentRequestEntity<CreatePaymentRequestExtendInfo> request, RequestHeaders _, string payMethod) { try { } catch (ApiException ex) { ErrorResponseEntity error = await ex.GetContentAsAsync<ErrorResponseEntity>();
return new PaymentResponseEntity<IDictionary<string, object>> { ReturnCode = ReturnCodes.Failed, ReturnMessage = error.Error.Message, TransactionId = string.Empty, RequestId = request.RequestId, TradesOrderGroupCode = request.TradesOrderGroupCode, }; } catch (Exception e) { this._logger.LogError(e, $"Unhandled Payment Error - {e.Message}"); throw; } }
|
使用 Refit 套件管理三方金流的串接,好處是可以透過定義介面的方式定義怎麼打,並且幫你處理序列話語反序列化,還有,如果三方 api 回錯誤,會被包成 ApiException,藉以區分是三方發生的錯誤而不影響站台的運作,所以我們有包裝成統一的 ResponseEntity回給使用者端
☘️ 結語
錯誤是不可避免的,但錯誤不該讓使用者或開發團隊措手不及。
Exception Middleware 不只是為了「不炸掉」,更是為了:
- 給開發者準確的除錯線索
- 給前端簡潔的一致格式
- 給 API 對接方一份「可預期的回應」
在金流整合這種高風險、高變動的場景中,這樣的設計不只是加分,是基本生存配備。
別讓錯誤拖垮你辛苦建好的系統,從一個好的 Middleware 開始,把錯誤收進框框裡,用乾淨的方式告訴世界:「我有錯,但我掌控它。」