← 回所有專案

個人練習專案 ‧ 剛入行 · 2021

訂單開立發票系統

延續氣象排程練習專案的分層架構,套用到「訂單→發票」的業務情境:每日排程批次開立發票,並補上 FluentValidation 請求驗證、ActionFilter 效能量測,以及用 Docker 化 MSSQL 跑的 MSTest 整合測試。

ASP.NET CoreC#HangfireEF CoreFluentValidationMSTestMSSQL
訂單開立發票系統

專案概述

氣象開放資料排程系統 之後的練習專案,把同一 套「分層架構+Hangfire 排程」的模式,套用到更貼近實務的業務情境:訂單成立 後,由排程工作每日批次為訂單開立發票。程式庫是私有的練習專案,這裡整理設計 上的重點。

業務流程

Product(商品)→ Order / OrderDetail(訂單/訂單明細)→ Invoice / InvoiceDetail(發票/發票明細)層層關聯:建立商品、下訂單 後,背景排程每天固定時間抓取所有訂單,批次呼叫開立發票的邏輯,並把執行 過程記錄下來(工作 ID、來源工作 ID、起訖時間),方便事後追查某天的發票是哪 次排程工作產生的。

C#
// 排程工作:先建立一筆「工作紀錄」,再串接呼叫實際開立發票的工作,
// 並用 sourceJobId 把兩個工作串起來方便追查
public void CreateJob(PerformContext context)
{
    var jobId = context.BackgroundJob.Id;
    // ...寫入 LogContent...

    BackgroundJob.Enqueue<IHangfireJobs>(x => x.CreateInvoiceAsync(null, jobId));
}

public async Task CreateInvoiceAsync(PerformContext context, string sourceJobId)
{
    var orders = await _orderService.GetAllAsync();
    var result = await _invoiceService.CreateManyAsync(orders);
    // ...記錄結果...
}

Hangfire 排程改為每日定時批次開立發票(cron 0 0 * * *),而不是即時觸 發,符合發票通常是「日結」的業務性質;CreateManyAsync 會先篩掉已經開過 發票的訂單,只針對尚未開票的訂單批次寫入,避免重複開票。

API 輸入驗證(FluentValidation + ActionFilter)

API 層的輸入驗證改用 FluentValidation,並且不是在每個 Action 手動呼叫 驗證,而是包成一個共用的 ParameterValidatorAttribute:Attribute 建構時 指定要用哪個 Validator 類別,執行時從 Action 的參數裡取出目標物件跑驗證, 驗證失敗就直接組出帶錯誤碼/CorrelationId 的 FailResultViewModel 短路回 傳,Action 本體完全不用寫任何驗證邏輯:

C#
[HttpPost]
[CoreProfiling]
[ParameterValidator(typeof(OrderCreateParameterValidator))]
public async Task<IActionResult> CreateAsync([FromBody] OrderCreateParameter orderCreateParameter)
{
    var dto = _mapper.Map<OrderCreateParameter, OrderDto>(orderCreateParameter);
    var result = await _orderService.CreateAsync(dto);
    // ...
}

OrderCreateParameterValidator/OrderUpdateParameterValidator/ ProductCreateParameterValidator/ProductUpdateParameterValidator 依 Create/Update 情境分別寫規則,跟另一層內部模型驗證(ModelValidator, 封裝 DataAnnotations 的 Validator.TryValidateObject)分開職責:前者管 「API 請求格式對不對」,後者管「往下傳的內部模型合不合法」,兩層不混在一起。

自動化效能量測

同樣用 ActionFilter 包一層效能量測(CoreProfilingAttribute,底層是 CoreProfiler),在 OnActionExecuting / OnActionExecuted 自動開始/結 束一個 Profiling Step,Action 上只要掛一個 [CoreProfiling] 就好,不需要 每個 Action 手動加碼計時。

測試策略(MSTest)

延續 Controller/Service/Repository/UnitOfWork 分層,並補上 InvoiceSystem.ServiceTests 與 InvoiceSystem.IntegrationTest 兩個測試 專案,同樣全部使用 MSTest(MSTest.TestFramework + MSTest.TestAdapter),搭配 FluentAssertions、NSubstitute、AutoFixture:

  • InvoiceSystem.IntegrationTest:用 TestEnvironment.Docker 起一個 真的 MSSQL 容器,測試前先用 DbScript 目錄下的 SQL 腳本(Product_Data、 Order_Data、OrderDetail_Data、Invoice_Data、InvoiceDetail_Data) 灌一批固定的測試資料,每個測試包在交易裡、跑完 RollbackTransaction,驗 證「訂單已開過發票就不能重複開立」「批次開立時只處理尚未開票的訂單」這類 跟真實資料庫狀態有關的行為
  • 大量使用 MSTest 的 [DataRow] 做參數化測試,例如驗證發票查詢在 OrderId/ProductId/InvoiceId 七種組合下都能篩出正確筆數,一個測試 方法涵蓋七種情境,不用複製貼上七個測試
C#
[TestMethod()]
[DataRow(1, 0, 0, DisplayName = "輸入orderId為1")]
[DataRow(0, 1, 0, DisplayName = "輸入productId為1")]
[DataRow(0, 0, 1, DisplayName = "輸入invoiceId為1")]
[DataRow(1, 1, 0, DisplayName = "輸入orderId為1與productId為1")]
[DataRow(0, 1, 1, DisplayName = "輸入productId為1與invoiceId為1")]
[DataRow(1, 0, 1, DisplayName = "輸入orderId為1與invoiceId為1")]
[DataRow(1, 1, 1, DisplayName = "輸入orderId為1與productId為1與invoiceId為1")]
public async Task GetAsync_輸入特定參數_輸出相關資料數量(int orderId, int productId, int invoiceId)
{
    // arrange / act / assert
}

學到的東西

  • 同一套分層架構重複套用在第二個專案上,能明顯感覺到「哪些是可以直接複用的 骨架、哪些要依業務情境重新設計」
  • 排程工作之間用 sourceJobId 串起因果關係,是後來理解「如何追蹤一連串背景 工作」的起點
  • 把請求驗證(FluentValidation + ActionFilter)跟內部模型驗證 (DataAnnotations)分成兩層,職責更清楚,也是後來重視分層設計的起點
  • 用 MSTest 的 [DataRow] 把同一個方法的多種輸入組合收斂成一個測試方法, 比每種情境各寫一個測試方法更好維護
  • 用 Docker 化的 MSSQL 搭配 SQL 腳本灌測試資料、每個測試跑完自動回滾,讓 整合測試可以重複執行也不互相污染