PROMOTION / DATA FLOW / INCIDENT TRACE

Salepage-Promotion-Flow

沿著一次 POST /api/cart-calculate,理解商品如何找到候選活動、規則從哪裡載入,以及促銷排序為什麼會因會員範圍資料而失敗。

先記住三件事:Cart 提供購物車資料;Collection API 提供商品與活動的關聯;完整活動規則由 Repository 載入。

本次失敗焦點
MatchedUserScopesnullGetUserScopePriority() 對它執行 LINQ 時拋出 ArgumentNullException。這是本文記錄的直接失敗位置;資料在哪一層變成 null,仍需比對原始資料、轉換結果與快取。

系統關係:誰負責哪一段?

下圖區分 Promotion Frontend 的內部元件與外部支援系統。箭頭表示呼叫或資料存取方向;實際執行順序在「請求與資料流」分頁用時序圖說明。

圖 1|藍色:服務與內部元件;綠色:快取;灰色:持久化資料源。
元件 主要責任
Cart Service 組裝商品、會員、付款與配送資料,送出計算請求。
CalculateController / CalculateService 接收請求、協調計算流程與包裝回應。
PromotionRuleRepository 取得商品匹配結果、找候選活動,並載入完整規則。
Collection API 提供商品頁與集合、活動的關聯。
Promotion Engine 執行規則匹配與 DynamicPriority 排序。
圖的層級:這是系統邊界與元件關係示意。Controller、Service、Repository 是應用程式內部元件,因此不將這張圖稱為嚴格的 C4 Container Diagram。

請求時序:一次計算如何完成?

先看正常完成的主流程。時序圖將 Controller 與 Service 合併為「Frontend」,並以業務動作標註呼叫;規則載入的快取細節在「活動規則來源」分頁展開。

圖 2|正常完成路徑。實線表示呼叫,虛線表示回傳;alt 區塊中的兩條路徑擇一執行。圖中不推定錯誤時的 HTTP 狀態碼。

把資料識別值串起來

購物車商品頁 → 商品集合 → 候選活動 → 完整活動規則

識別值 在流程中的意義
SalepageId 來自購物車的商品頁 ID,用於查詢匹配集合。
SalepageCollectionId 商品所屬集合,用來串接活動關聯。
PromotionEngineId 候選活動識別值,用於載入活動規則。
關聯與規則分開看:找到候選活動,不代表已取得完整規則。Collection API 提供關聯;Repository 再向 Redis、WebStoreDB 或 MongoDB 取得規則內容。

快取分支:命中就使用,未命中才查來源

本流程採用 Cache Aside。以下聚焦「活動規則快取」:HIT 與 MISS 是互斥分支,命中後不會接著執行資料庫 fallback。

圖 3|活動規則的 HIT/MISS 與資料庫選擇。以單筆規則示意,省略批次處理細節;Mongo 分界設定為 MongoDB.PromotionId.Divide。
使用條件 資料來源 查核內容
規則快取命中 Redis 快取中的活動規則,可能仍是舊版本。
未命中,且為傳統活動或低於 Mongo 分界 WebStoreDB PromotionEngine 的基本欄位與 PromotionEngine_Rule JSON。
未命中,且活動 ID 大於等於 Mongo 分界 MongoDB PromotionDB PromotionRulePromotionRuleSetting,包含 UserScopeProductScope 等設定。
排查時要比對兩份資料:資料庫已修正,不代表命中的 Redis value 同步更新。應同時比對快取內容與持久化來源,再判斷規則實際使用的版本。

Redis Key:用哪個 ID 找哪份資料?

兩種 Key 都包含 ShopId,但最後一段分別是商品頁 ID 與活動 ID。日期字串是程式內的 Key 版本,不是資料產生日期。

商品頁匹配結果 CartCalculate:SalepageCollection-20230801:{shopId}:{salepageId}

用購物車的 salepageId 查商品匹配結果,再追到候選活動。

程式位置:PromotionRuleRepository.GetMatchedSalepageCollectionAsyncFormatCacheKey 區域。

活動規則資料 CartCalculate:PromotionEngine-20260317:{shopId}:{promotionEngineId}

用候選活動的 promotionEngineId 查規則快取,重點檢查會員範圍相關資料。

程式位置:PromotionRuleRepository.GetPromotionEngineDataListAsyncFormatCacheKey 區域。

建立活動時,會員範圍如何寫進規則?

建立「全體會員適用」「金卡限定」或「當月壽星限定」活動時,WebAPI 會先把會員條件組成清單,再與折扣或回饋條件一起存入 SQL。購物車計算讀取的是這份已保存的規則。

本節聚焦 POST /api/promotion-rules/create 的 SQL 建立流程。MatchedUserScopes 由 request 建構,最後寫進 PromotionEngine_Rule 的 JSON。

兩個相似欄位,負責不同事情

Request 欄位 用途
TargetMemberTypeDef 字串,供基本驗證、主檔活動對象設定等邏輯使用。
TargetMemberType Enum,實際決定 MatchedUserScopes 的建立分支。
CrmShopMemberCardIds 指定會員等級時,轉成各會員卡的 Tag。
MemberCollectionId 指定客群時,作為客群 Tag。
IsBirthdayMonthEnabled 壽星開關;支援的回饋活動會追加壽星 Tag,並把開關存進規則。
兩個活動對象欄位不會自動同步。TargetMemberTypeDefTargetMemberType 是獨立屬性。只傳前者為 All,後者仍可能保留預設值 0,使會員範圍建構得到 null

SQL Create 的建立順序

  1. 驗證 request

    PromotionEngineController.Create() 先執行 ValidateAndThrowAsync()。基本驗證檢查 TargetMemberTypeDef 非空且為合法 enum 名稱;部分活動另有限制,但沒有全面檢查兩個活動對象欄位一致。

  2. 選擇規則服務、整理客群設定

    PromotionEngineService.Create()TypeDef 選出 ruleServiceSetMemberCollectionAsync() 依活動種類與商店開關調整客群 ID,例如部分全體會員活動設成 -1;這一步不會將字串欄位同步到 enum 欄位。

  3. 建立 SQL 活動資料

    開啟 TransactionScope,建立主檔、設定與商品範圍。若對象是 MemberTier,另外將活動與會員卡的關聯寫入 PromotionEngineMemberTier

  4. 在記憶體建立會員範圍

    SetMatchedUserScopes(entity.TargetMemberType, entity.CrmShopMemberCardIds, entity.MemberCollectionId) 呼叫 GetUserScopes(),將結果放進 _matchedUserScopes。這裡直接使用 request,沒有回查剛寫入的會員等級關聯表。

  5. 符合壽星設定時追加條件

    支援的回饋活動啟用壽星開關後,會確認或建立壽星客群,再以 SetBirthdayMonthMatchedUserScopes() 對既有清單追加 CurrentBirthdayMonth

  6. 組合規則並保存

    各活動的 GetPromotionEngineRule() 組合折扣或回饋條件。共用的 GetRuleObject() 設定 result.MatchedUserScopes = _matchedUserScopes,序列化後嘗試 engine.LoadRules(),再更新 PromotionEngine_Rule。完成其餘建立步驟後提交交易。

會員範圍的建立分支

TargetMemberType 建立內容 結果
All 加入一個 AllUserScope 非 null,代表全體會員。
MemberTier 每個會員卡 ID 建立 TagUserScope,Tag 為 CrmShopMemberCard:{id} 有值為 Tag 清單;空清單得到 []
MemberCollection,ID 有值 建立一個 TagUserScope,Tag 直接使用客群 ID。 非 null,不展開個別會員名單。
MemberCollection,ID 為 null 或空字串 不加入任何元素。 空集合 [];部分活動會先被客群驗證拒絕。
None 明確設定 result = null null。
其他值,包含預設值 0 進入 default,設定 result = null null。

MemberTier 若收到 null 的會員卡清單,會在 typeIds.ForEach() 拋錯。正常且欄位一致的會員等級 request,會先受到驗證器的非空檢查。空集合、null 與全體會員條件是三種不同結果。

案例一至三:全體會員、會員等級與指定客群

以下都是示例設定,並非真實商店紀錄。只列會員相關欄位,其餘建立活動的必要欄位假設正確提供。

活動情境 Request 設定 寫入 MatchedUserScopes 的內容
全體會員滿 1,000 元折 100 元 TargetMemberTypeDefTargetMemberType 都為 All 一個 AllUserScope
金卡會員滿 1,000 元折 100 元 兩個對象欄位都為 MemberTierCrmShopMemberCardIds = [123] 一個 Tag 為 CrmShopMemberCard:123TagUserScope。另寫入會員卡關聯資料。
指定客群限定活動 兩個對象欄位都為 MemberCollectionMemberCollectionId = "audience-001",假設該 ID 已存在且通過驗證。 一個 Tag 為 audience-001TagUserScope

案例四:全體會員的當月壽星,滿 1,000 元送 100 點

RewardReachPriceWithPoint2 為例。會員相關 request 如下:

1
2
3
4
5
{
"TargetMemberTypeDef": "All",
"TargetMemberType": "All",
"IsBirthdayMonthEnabled": true
}

先由 All 建立全體會員條件,再追加壽星 Tag。保存的規則片段如下:

1
2
3
4
5
6
7
8
9
10
11
12
{
"IsBirthdayMonthEnabled": true,
"MatchedUserScopes": [
{
"UserScopeType": "NineYi.Msa.Promotion.Engine.AllUserScope"
},
{
"UserScopeType": "NineYi.Msa.Tagging.TagUserScope",
"Tag": "CurrentBirthdayMonth"
}
]
}

給點條件中的 RewardPointRuleList.CrmShopMemberCardId 設為 0,會建立以 AllUserScope 為 Key 的給點門檻。當會員具備對應 Tag,且金額、商品與其他條件都符合時,一般會員與金卡會員只要是當月壽星都能給點;非當月壽星會被獨立的壽星檢查拒絕。

案例五:金卡的當月壽星,滿 1,000 元送 100 點

假設金卡會員卡 ID 為 123,仍以 RewardReachPriceWithPoint2 為例:

1
2
3
4
5
6
{
"TargetMemberTypeDef": "MemberTier",
"TargetMemberType": "MemberTier",
"CrmShopMemberCardIds": [123],
"IsBirthdayMonthEnabled": true
}

先建立會員卡條件,再追加壽星 Tag。保存的規則片段如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"IsBirthdayMonthEnabled": true,
"MatchedUserScopes": [
{
"UserScopeType": "NineYi.Msa.Tagging.TagUserScope",
"Tag": "CrmShopMemberCard:123"
},
{
"UserScopeType": "NineYi.Msa.Tagging.TagUserScope",
"Tag": "CurrentBirthdayMonth"
}
]
}

給點條件的 CrmShopMemberCardId 也設成 123,因此 Thresholds 中只有 CrmShopMemberCard:123 對應的給點門檻。

兩個 Scope 本身不是 AND。IsUserScopeMatched() 使用 Any(),清單中任一條件符合即可。金卡與壽星的共同限制,還依賴獨立的壽星開關檢查,以及給點門檻的會員 Tag 對應。
  1. 會員範圍

    MatchedUserScopes 中任一條件符合即可。銀卡壽星也可能因壽星 Tag 通過這一關。

  2. 壽星資格

    IsBirthdayMonthEnabled = true 時,會員必須具有 CurrentBirthdayMonth Tag。金卡非壽星在這裡被拒絕。

  3. 給點門檻

    GetThreshold() 以會員 Tag 尋找 Thresholds。本例只設定金卡門檻,銀卡壽星找不到對應門檻,仍無法給點。找到門檻後還要符合金額等活動條件。

會員 當月壽星 判斷結果(其餘活動條件均符合)
金卡 123 會員範圍、壽星資格與金卡給點門檻都符合,可以給點。
金卡 123 未通過獨立的壽星檢查。
銀卡 456 可以因壽星 Tag 通過會員範圍,但找不到銀卡給點門檻,不給點。
銀卡 456 不符合活動。

Create 追加壽星 Tag 的活動類型為 RewardReachPriceWithPoint2RewardReachPriceWithRatePoint2RewardReachPriceWithCoupon。不能直接把這套建立行為套用到所有折扣活動。

案例六與七:漏傳活動對象,或明確指定 None

Request 情境 建構結果 後續影響
只傳 TargetMemberTypeDef = "All",漏傳 TargetMemberType Enum 保留 0,走 default 得到 null。 一般活動若通過其餘驗證與建立步驟,可能保存 null 規則;主檔對象仍可能顯示 All
兩個活動對象欄位都指定 None 明確回傳 null。 None 並不等於全體會員;部分活動會先被額外驗證拒絕。
支援壽星的活動漏傳 TargetMemberType,並啟用壽星開關 基本會員範圍先得到 null。 若執行到追加壽星的 .Add(),會因 null 拋錯,本次 SQL 建立交易無法正常完成。
壽星追加不會修復 null。SetBirthdayMonthMatchedUserScopes() 只對既有清單執行 .Add(),沒有初始化空清單。正常的全體會員或會員等級設定會先建立清單;若前一步已是 null,錯誤會提早在 Create 階段發生。

為什麼建立時能載入,計算時卻出錯?

SerializeRule() 的檢查是序列化後嘗試 engine.LoadRules()。規則成功載入,不代表已執行活動排序。引擎的 ProcessPromotion() 先篩選啟用與日期,再讀取 DynamicPriority 排序,之後才進行活動匹配。

當符合排序前置條件的規則包含 MatchedUserScopes = nullGetUserScopePriority() 對 null 呼叫 OfType<AllUserScope>(),便會拋出 ArgumentNullException,參數為 source。是否適用會員的後續判斷還來不及執行。

這些是可由程式確認的建構與失敗路徑。要判定特定異常活動的原因,仍需比對原始 Create request 的兩個活動對象欄位、SQL 的 PromotionEngine_Rule,以及實際命中的快取內容。

程式碼對照

分析依據為 WebAPI 本機版本 fcba6180 與引擎版本 d852c3c;以下為對應程式位置。

階段 程式位置
Request 與驗證 PromotionBaseEntity.cs:62PromotionBaseValidator.cs:182CreatePromotionRequestEntityValidator.cs:243
API 入口 PromotionEngineController.cs:219,先驗證再呼叫服務。
SQL Create PromotionEngineService.cs:587;會員範圍設定在 650,壽星分支在 655,規則組合在 727
基本範圍與壽星追加 PromotionEngineRuleBaseService.cs:226:234:1007
規則 JSON PromotionEngineRuleBaseService.cs:370PromotionEngineHelper.cs:77
給點門檻 RewardReachPriceWithPoint2RuleService.cs:64,以會員卡 ID 產生門檻 Key。
引擎範圍與壽星判斷 IBasicUserScope.cs:40PromotionCommonRuleBase.cs:152
給點資格與門檻查找 RewardReachPriceWithPoint2.cs:55:175
排序失敗位置 PromotionEngine.cs:128IBasicUserScope.cs:32

失敗位置:取得活動之後,排序期間出錯

本文記錄的失敗呼叫鏈如下。紅色僅標示直接拋出例外的位置;前面的節點用來交代例外如何被觸發。

圖 4|從規則排序追到 LINQ 的 null 集合來源。這張圖說明失敗位置,不代表已找到資料變成 null 的原因。
程式證據:GetUserScopePriority()
1
2
3
4
public static int GetUserScopePriority(IBasicUserScope target) =>
target.MatchedUserScopes.OfType<AllUserScope>().Any()
? 9000
: 1000;

OfType 的來源集合是 MatchedUserScopes。當來源為 null,程式會在這裡拋出例外,尚未完成後續的 Any() 與優先序判斷。

位置 本文記錄的行為與判定
CalculateController.CartCalculateAsync 接收 Cart request;請求已進入 Promotion Frontend。
PromotionRuleRepository.GetMatchedSalepageCollectionAsync 取得商品匹配結果的流程位置;未命中快取時呼叫 Collection API。
PromotionEngine.ProcessPromotion OrderBy 求取 DynamicPriority,觸發失敗呼叫鏈。
IBasicUserScope.GetUserScopePriority 對 null 的 MatchedUserScopes 呼叫 OfType,直接拋出例外。
已知:失敗發生在會員範圍優先序計算。
待查:null 來自原始規則、反序列化、Mapping,或舊版快取。本文記錄的堆疊沒有指向 Cart 或 Collection API 查詢失敗,也不能僅憑此排除所有上游資料問題。

查核與修正:沿著同一筆活動追到底

  1. 鎖定請求與商店

    從錯誤 trace 取得 ShopIdSalepageId,確保後續查詢對應同一筆請求與商店。

  2. 找到候選活動

    使用商品頁匹配 Key,檢查集合關聯與 PromotionEngineId;保留查到的匹配結果。

  3. 比對快取與原始規則

    使用活動規則 Key 取得 Redis value,再依活動 ID 分界回查 WebStoreDB 或 MongoDB,確認會員範圍欄位是否缺少或為 null。

  4. 追蹤資料轉換

    比對反序列化與 Mapping 前後的物件,找出 MatchedUserScopes 首次成為 null 的位置。

  5. 決定修正語意並驗證

    依查核結果補齊資料、處理快取版本,或定義明確的 null 處理策略。確認單筆異常活動的處置符合業務規則,並驗證正常活動仍可完成計算。

修正不能只讓例外消失:會員範圍缺失時要拒絕規則、略過活動,還是採用預設行為,需先確認業務語意;不能直接把 null 視為「適用所有會員」。
展開修正後的驗證項目
  • Redis HIT:使用快取內容,不查持久化來源。
  • Redis MISS:依活動 ID 選擇來源,取得資料後回填快取。
  • 同一活動的快取與來源資料不一致:能辨認實際使用的版本。
  • MatchedUserScopes 分別為 null、空集合及正常集合:結果符合明確的業務規則。
  • 單筆異常活動:整筆請求的結果與錯誤處置符合預期,其他正常活動的計算未受到非預期影響。

延伸參考

本文圖表依原文整理,呈現業務層級的呼叫與資料流,省略未提供的批次、重試與錯誤回應細節。方法名稱、Key 版本與 Mongo 分界應以排查當下的程式版本為準。