ASP.NET Core 站內通知最常見的判斷錯誤,是只看更新後的狀態。若程式寫成「新狀態等於 Ready 就通知」,使用者在 Ready 階段修改標題、備註或排序時,每次儲存都會再次產生通知。真正的需求通常不是「目前位於 Ready」,而是「這一次由非 Ready 進入 Ready」。因此判斷式必須同時比較更新前與更新後的值,並且另外處理同一事件被重播的情況。

本文將來源筆記的耐久觀念抽象成一個不依賴特定資料表、部門名稱、路由或執行主機的設計。重點不是建立另一套一般訊息佇列,而是把狀態邊界、事件身分、批次更新與失敗觀測寫成可測試契約。範例使用 .NET SDK 10.0.400 的隔離主控台程式,只在記憶體中計數,不連接網站、資料庫或任何外部服務。

核心答案:通知條件是狀態邊界,不是狀態快照

短答:只有 before != Ready && after == Ready 才代表進入 Ready;Ready -> Ready 是維持,不應再次通知。 這個雙值判斷把「物件現在在哪裡」與「這次操作發生了什麼」分開,讓更新其他欄位不會被誤解成新的業務事件。

狀態快照只回答觀察當下的結果。例如資料列讀出來是 Ready,可能是剛進入,也可能已停留數天,甚至只是重送同一請求。單看 after == Ready 無法區分這些原因。狀態轉換則由有方向的配對表示:Draft 到 Ready 是進入,Ready 到 Ready 是維持,Ready 到 Done 是離開,Done 再到 Ready 是一次新的進入。這四種情形應有各自的驗收結果。

這種設計也讓程式審查更準確。方法若只接收新狀態,便不具備判斷邊界所需的資訊;呼叫端必須在寫入前取得舊值,或由持久層以明確的比較條件完成更新。若先覆寫追蹤中的實體,再讀取同一個屬性當舊值,兩邊可能已相同,正確邊界就會消失。最安全的做法是把 before 保存為不可變區域變數,再套用輸入。

C# 模式比對可以清楚表達值、型別與形狀條件;Microsoft Learn 說明模式可用於 is、switch 與 switch 運算式。本文的二值條件不需要複雜語法,但可用 tuple switch 將每一種狀態配對集中列出,避免布林式散落於控制器、服務與批次迴圈。官方說明見文末參考資料。

把事件身分與物件身分分開

短答:狀態邊界決定「是否值得通知」,事件身分決定「這次是否已處理」。兩者缺一不可。 只做邊界判斷,重播相同的 Draft 到 Ready 事件仍可能再送一次;只做事件去重,又無法阻止 Ready 到 Ready 這類根本不該送出的更新。

物件身分通常代表被修改的工作項目,事件身分則代表一次已接受的變更。兩次不同操作可能針對同一物件:第一次由 Draft 進入 Ready,稍後離開,再由 Done 回到 Ready。這是兩次有效邊界,應有兩個不同事件身分,也應通知兩次。反過來,同一事件因逾時、重試或讀取端重新啟動而重播,仍應保持相同事件身分,只接受第一次。

範例使用 HashSet 記錄已處理事件,目的是展示語意,而不是宣稱它足以支援多程序網站。記憶體集合會在程序重啟後消失,也無法在多個站台節點之間自動協調。正式設計可使用具唯一限制的處理紀錄,讓「首次接受事件」成為原子結果;真正必要的是穩定事件身分與唯一性規則,而不是某個特定儲存產品。

去重順序也值得明確規範。範例先登記事件,再檢查是否為目標邊界,表示每個事件身分只能評估一次。另一種策略只記錄會通知的事件,適合上游可能修正非目標事件內容的情境。兩者沒有通用答案,但不可含糊:測試必須說明同一身分若先以非目標配對出現、稍後改成目標配對,系統要拒絕還是接受。不可變事件通常選擇前者。

更新入口必須保留舊狀態

短答:單筆與批次入口都要逐筆保存 before,再套用 after;不能在整批外共用一個舊值。 每一筆資料的起點可能不同,只有自己的前後配對能判斷是否跨越目標邊界。

單筆服務可以先查詢目前狀態,把值複製到區域變數,驗證允許的轉換,套用更新,儲存後再交由通知協調元件處理。若資料可能被並行修改,更新條件還應包含原版本或原狀態;受影響筆數不符合預期時,應回到衝突處理,而不是以過期的 before 送出通知。這能避免兩個請求同時看見 Draft,之後都自認完成 Draft 到 Ready。

批次更新更容易出錯。不能先讀第一筆的狀態,然後把它套在整個迴圈;也不能把所有項目改完後,再從已修改的集合回推原值。應先取得每筆的識別值、舊狀態與版本,形成不可變快照,再逐筆產生變更事件。即使輸入要求全數設為 Ready,原本已在 Ready 的項目也必須被判定為維持,不可因為同一批次而全部通知。

控制器不宜承擔全部判斷。控制器負責解析請求與回應,領域服務負責轉換規則,通知閘門負責邊界與重播契約。這樣單筆畫面、批次端點與背景補償流程都可呼叫同一規則。若每個入口各寫一份 if,日後新增狀態或改名時,很容易只有其中一條路徑正確。

通知失敗與主要更新要有明確政策

短答:先定義主要更新是否可因通知失敗而回復,再讓記錄、重試與去重遵守同一政策。 單純用空的例外處理包住通知,雖能保住主流程,卻會把失敗變成無法追查的遺失。

若通知屬於輔助資訊,主要狀態通常不應因呈現層通知暫時失敗而無法保存。此時捕捉通知階段的錯誤是合理起點,但至少要留下結構化記錄:事件身分、物件身分、前狀態、後狀態、處理階段與結果。ASP.NET Core 提供 ILogger 的結構化記錄機制,並可由不同 provider 寫往設定的目的地;Microsoft Learn 的 logging 文件可作為 API 與設定依據。

然而「主流程不失敗」不等於「通知可直接遺失」。若系統需要稍後補送,就要保留可重新讀取的待處理事實,並讓重試使用相同事件身分。重播保護不可在補送時產生新身分,否則每次嘗試都會看起來像新事件。本文刻意不延伸成一般佇列架構;這裡只要求轉換事實可追蹤、補送可識別、成功結果可去重。

也要檢查建立通知方法的回傳結果。若內部捕捉錯誤後以空值或失敗物件回傳,而呼叫端只因沒有拋出例外就當作成功,監控會得到錯誤結論。建立結果應明確區分成功、已處理、非目標邊界與失敗。日誌訊息則應描述發生的階段,不應把所有失敗都記成「重複通知」。

內容與收件規則要可驗證但保持解耦

短答:狀態規則只產生一個中性的通知命令,收件人展開、顯示內容與連結驗證由後續元件完成。 這樣邊界測試不必知道人名、頁面路徑或畫面框架,內容變更也不會改動核心判斷。

收件範圍應由角色或群組規則取得,不要把個別人名寫進狀態服務。查詢「唯一一位」對象時,應使用唯一識別值或精確條件,而不是模糊文字比對。模糊條件可能在資料較少時看似正確,資料增加後卻把單人通知擴張為多人通知。這屬於收件規則測試,應與狀態邊界矩陣分開驗收。

內容可以包含可辨識標題與可操作連結,但路由必須以應用程式實際登錄的端點驗證。把內容序列化為 JSON 前,先定義固定結構,例如 message 與 links,並測試前端能解析。內容測試應使用合成識別值,確認標題、連結文字與相對路徑,不需要接觸真實使用者資料。

解耦後,一次狀態事件可以先產生 NotificationIntent:包含事件身分、工作項目身分、轉換配對與顯示所需的最少欄位。收件服務將角色展開為當下有效成員,內容服務依既定路由產生模型,寫入元件回報結果。每一層都有窄而清楚的輸入,因此能判斷究竟是邊界錯、收件範圍錯、內容錯或寫入失敗。

驗證:用 .NET 10 隔離重現邊界與重播

短答:最小測試至少覆蓋進入、維持、離開、同事件重播與離開後再次進入,最後核對通知總數。 本次已在 Windows 主機以 .NET SDK 10.0.400 實際建置與執行,六項檢查通過,總通知數為二,測試程式沒有外部寫入。

重現步驟如下:

  1. 建立空白目錄,執行 dotnet new console --framework net10.0,並確認 dotnet --version 顯示 10.0.400。將下列程式存成 Program.cs;專案不需要額外套件。
  2. 執行 dotnet build --configuration Release。只有建置輸出為零個警告、零個錯誤時才進入下一步;不要以編輯器語法提示取代真實建置。
  3. 執行 dotnet run --configuration Release --no-build,逐行核對六個 PASS 與最後的總數。任何一行缺少、值不同或程序結束碼非零,都代表契約尚未通過。
  4. 修改案例矩陣時,保留相同事件身分給重播案例,並給真正的再次進入新事件身分。若全部改成新身分,便沒有測到重播保護。
enum WorkState { Draft, Ready, Done }

sealed record Change(string EventId, WorkState Before, WorkState After);

sealed class NotificationGate
{
    private readonly HashSet _processed = [];
    public int SentCount { get; private set; }

    public bool Apply(Change change)
    {
        if (!_processed.Add(change.EventId)) return false;
        if (change.Before == WorkState.Ready || change.After != WorkState.Ready) return false;
        SentCount++;
        return true;
    }
}

static class Check
{
    public static void Equal(T expected, T actual, string name)
    {
        if (!EqualityComparer.Default.Equals(expected, actual))
            throw new InvalidOperationException($"FAIL {name}: expected={expected}, actual={actual}");
        Console.WriteLine($"PASS {name}: {actual}");
    }
}

static class Program
{
    public static void Main()
    {
        var gate = new NotificationGate();
        Check.Equal(true, gate.Apply(new("evt-001", WorkState.Draft, WorkState.Ready)), "enter-target-sends");
        Check.Equal(false, gate.Apply(new("evt-002", WorkState.Ready, WorkState.Ready)), "stay-target-does-not-send");
        Check.Equal(false, gate.Apply(new("evt-003", WorkState.Ready, WorkState.Done)), "leave-target-does-not-send");
        Check.Equal(false, gate.Apply(new("evt-001", WorkState.Draft, WorkState.Ready)), "replay-same-event-does-not-send");
        Check.Equal(true, gate.Apply(new("evt-004", WorkState.Done, WorkState.Ready)), "reenter-target-new-event-sends");
        Check.Equal(2, gate.SentCount, "exact-total");
        Console.WriteLine("RESULT all 6 checks passed; notifications=2; externalWrites=0");
    }
}

真實執行輸出如下:

PASS enter-target-sends: True
PASS stay-target-does-not-send: False
PASS leave-target-does-not-send: False
PASS replay-same-event-does-not-send: False
PASS reenter-target-new-event-sends: True
PASS exact-total: 2
RESULT all 6 checks passed; notifications=2; externalWrites=0

這個測試刻意不啟動 ASP.NET Core、不中介 HTTP,也不連接持久層,因此結果只證明純函式式閘門的規則與單程序重播集合。它不證明多節點原子性、交易邊界、收件人展開或前端顯示。下一層整合測試應使用隔離資料庫與合成對象,建立唯一事件紀錄,再以並行請求確認只有一個寫入者取得首次處理權。

驗收矩陣與回歸策略

短答:把每個前後狀態配對、事件是否重播、預期通知數與理由列成表格,讓新增入口必須通過同一矩陣。 測試名稱應描述行為,而不是描述某個方法的內部實作。

before after 事件情況 預期 理由
Draft Ready 首次 通知一次 進入目標邊界
Ready Ready 首次 不通知 只是在目標狀態內更新
Ready Done 首次 不通知 離開目標狀態
Draft Ready 同身分重播 不再通知 事件已處理
Done Ready 新事件 通知一次 再次跨入目標邊界

回歸測試還要涵蓋批次混合資料。準備三筆合成項目,分別從 Draft、Ready、Done 更新到 Ready,預期只有 Draft 與 Done 兩筆建立意圖。重新提交完全相同的三個事件身分,總數仍維持二。接著只替 Done 到 Ready 建立另一個新事件,才可增加一次。這組測試能同時抓出共用舊值、只看新狀態與每次重試產生新身分三種問題。

若採用持久處理紀錄,還應加上競爭測試:兩個執行路徑同時提交相同事件,只有一個能建立唯一紀錄並繼續。失敗的一方應得到「已處理」結果,而不是一般系統錯誤。測試必須查詢實際紀錄數與通知數,不可只看 HTTP 成功碼;成功回應只能證明請求被處理,不能證明副作用剛好一次。

上線後的觀測可統計四類結果:非目標邊界、首次接受、已處理重播與通知失敗。這些計數能解釋為何某次更新沒有通知,也能發現重播突然增加。不要只記一個籠統的成功率,因為「正確地不通知」本來就是大量正常結果。

官方參考與設計界線

短答:Microsoft 官方文件支持本文使用的 C# 條件表達、ILogger 診斷與 dotnet test 測試流程;邊界與重播契約則由本文的明確案例驗證。 官方文件不會替特定產品決定何時通知,因此需求仍要以可觀察案例寫清楚。

本文的隔離程式採自訂斷言,避免引入套件並讓主控台輸出可直接核對。正式程式庫可將同一案例矩陣搬入 MSTest 或既有測試框架。無論採用哪種框架,核心驗收不變:只有跨入目標狀態才通知;維持與離開不通知;相同事件重播不增加數量;真正離開後以新事件再次進入則再次通知。