Promotion Cart Calculate
Salepage-Promotion-Flow
沿著一次 POST /api/cart-calculate,理解商品如何找到候選活動、規則從哪裡載入,以及促銷排序為什麼會因會員範圍資料而失敗。
先記住三件事:Cart 提供購物車資料;Collection API 提供商品與活動的關聯;完整活動規則由 Repository 載入。
MatchedUserScopes 為 null,GetUserScopePriority() 對它執行 LINQ 時拋出 ArgumentNullException。這是本文記錄的直接失敗位置;資料在哪一層變成 null,仍需比對原始資料、轉換結果與快取。系統關係:誰負責哪一段?
下圖區分 Promotion Frontend 的內部元件與外部支援系統。箭頭表示呼叫或資料存取方向;實際執行順序在「請求與資料流」分頁用時序圖說明。
flowchart TB
Cart["Cart Service"]
subgraph Frontend["Promotion Frontend · 內部元件"]
Controller["CalculateController"]
Service["CalculateService"]
Repo["PromotionRuleRepository"]
Engine["Promotion Engine"]
Controller -->|協調計算| Service
Service -->|取得候選活動與規則| Repo
Service -->|匹配與排序| Engine
end
Cart -->|POST /api/cart-calculate| Controller
Repo -->|商品與活動關聯| Collection["Collection API"]
Repo -->|讀取與回填快取| Redis[("Redis")]
Repo -->|傳統活動規則| SQL[("WebStoreDB")]
Repo -->|Mongo 活動規則| Mongo[("MongoDB PromotionDB")]
classDef service fill:#edf4ff,stroke:#517ab0,color:#203c60
classDef cache fill:#edf9f4,stroke:#32856b,color:#175440
classDef store fill:#f3f4f6,stroke:#7d8998,color:#334155
class Cart,Controller,Service,Repo,Engine,Collection service
class Redis cache
class SQL,Mongo store
| 元件 | 主要責任 |
|---|---|
| Cart Service | 組裝商品、會員、付款與配送資料,送出計算請求。 |
| CalculateController / CalculateService | 接收請求、協調計算流程與包裝回應。 |
| PromotionRuleRepository | 取得商品匹配結果、找候選活動,並載入完整規則。 |
| Collection API | 提供商品頁與集合、活動的關聯。 |
| Promotion Engine | 執行規則匹配與 DynamicPriority 排序。 |
請求時序:一次計算如何完成?
先看正常完成的主流程。時序圖將 Controller 與 Service 合併為「Frontend」,並以業務動作標註呼叫;規則載入的快取細節在「活動規則來源」分頁展開。
%%{init: {'sequence': {'actorMargin': 24, 'width': 120, 'mirrorActors': false, 'diagramMarginX': 16}}}%%
sequenceDiagram
autonumber
participant Cart as Cart Service
participant Front as Frontend
participant Repo as Repository
participant Collection as Collection API
participant Engine as Promotion Engine
Cart->>Front: POST /api/cart-calculate
Note over Cart,Front: 商品、會員、付款與配送資料
Front->>Front: 從 SalepageSkuList 取得 SalepageIds
Front->>Repo: 取得商品匹配結果
alt 商品匹配快取命中
Note over Repo: 使用 Redis 中的匹配結果
else 商品匹配快取未命中
Repo->>Collection: api/salepage-collections:match
Collection-->>Repo: 商品集合與候選活動關聯
Note over Repo: 回填商品匹配快取
end
Repo-->>Front: 候選活動識別值
Front->>Repo: 載入候選活動規則
Note over Repo: Redis 或資料庫,詳見圖 3
Repo-->>Front: 完整規則物件
Front->>Engine: 執行促銷匹配與排序
Note over Engine: 本次例外發生在排序期間,詳見「失敗路徑」分頁
Engine-->>Front: 促銷計算結果(成功時)
Front-->>Cart: 包裝後的計算回應(成功時)
把資料識別值串起來
購物車商品頁 → 商品集合 → 候選活動 → 完整活動規則
| 識別值 | 在流程中的意義 |
|---|---|
SalepageId |
來自購物車的商品頁 ID,用於查詢匹配集合。 |
SalepageCollectionId |
商品所屬集合,用來串接活動關聯。 |
PromotionEngineId |
候選活動識別值,用於載入活動規則。 |
快取分支:命中就使用,未命中才查來源
本流程採用 Cache Aside。以下聚焦「活動規則快取」:HIT 與 MISS 是互斥分支,命中後不會接著執行資料庫 fallback。
%%{init: {'sequence': {'actorMargin': 24, 'width': 120, 'mirrorActors': false, 'diagramMarginX': 16}}}%%
sequenceDiagram
autonumber
participant Repo as Repository
participant Redis as Redis
participant SQL as WebStoreDB
participant Mongo as MongoDB
Repo->>Redis: 以 ShopId 與 PromotionEngineId 查規則
Redis-->>Repo: 快取查詢結果
alt HIT:規則已存在
Note over Repo: 直接使用快取規則,跳過資料庫
else MISS:規則不存在
alt 傳統活動或低於 Mongo 分界
Repo->>SQL: 讀取 PromotionEngine 與規則 JSON
SQL-->>Repo: 活動規則資料
else 活動 ID 大於等於 Mongo 分界
Repo->>Mongo: 讀取 PromotionRule 與相關設定
Mongo-->>Repo: 活動規則資料
end
Repo->>Redis: 回填活動規則快取
end
Note over Repo: 將取得的規則交給後續促銷計算
| 使用條件 | 資料來源 | 查核內容 |
|---|---|---|
| 規則快取命中 | Redis | 快取中的活動規則,可能仍是舊版本。 |
| 未命中,且為傳統活動或低於 Mongo 分界 | WebStoreDB | PromotionEngine 的基本欄位與 PromotionEngine_Rule JSON。 |
| 未命中,且活動 ID 大於等於 Mongo 分界 | MongoDB PromotionDB | PromotionRule、PromotionRuleSetting,包含 UserScope、ProductScope 等設定。 |
Redis Key:用哪個 ID 找哪份資料?
兩種 Key 都包含 ShopId,但最後一段分別是商品頁 ID 與活動 ID。日期字串是程式內的 Key 版本,不是資料產生日期。
CartCalculate:SalepageCollection-20230801:{shopId}:{salepageId}
用購物車的 salepageId 查商品匹配結果,再追到候選活動。
程式位置:PromotionRuleRepository.GetMatchedSalepageCollectionAsync 的 FormatCacheKey 區域。
CartCalculate:PromotionEngine-20260317:{shopId}:{promotionEngineId}
用候選活動的 promotionEngineId 查規則快取,重點檢查會員範圍相關資料。
程式位置:PromotionRuleRepository.GetPromotionEngineDataListAsync 的 FormatCacheKey 區域。
建立活動時,會員範圍如何寫進規則?
建立「全體會員適用」「金卡限定」或「當月壽星限定」活動時,WebAPI 會先把會員條件組成清單,再與折扣或回饋條件一起存入 SQL。購物車計算讀取的是這份已保存的規則。
本節聚焦 POST /api/promotion-rules/create 的 SQL 建立流程。MatchedUserScopes 由 request 建構,最後寫進 PromotionEngine_Rule 的 JSON。
兩個相似欄位,負責不同事情
| Request 欄位 | 用途 |
|---|---|
TargetMemberTypeDef |
字串,供基本驗證、主檔活動對象設定等邏輯使用。 |
TargetMemberType |
Enum,實際決定 MatchedUserScopes 的建立分支。 |
CrmShopMemberCardIds |
指定會員等級時,轉成各會員卡的 Tag。 |
MemberCollectionId |
指定客群時,作為客群 Tag。 |
IsBirthdayMonthEnabled |
壽星開關;支援的回饋活動會追加壽星 Tag,並把開關存進規則。 |
TargetMemberTypeDef 與 TargetMemberType 是獨立屬性。只傳前者為 All,後者仍可能保留預設值 0,使會員範圍建構得到 null。SQL Create 的建立順序
- 驗證 request
PromotionEngineController.Create()先執行ValidateAndThrowAsync()。基本驗證檢查TargetMemberTypeDef非空且為合法 enum 名稱;部分活動另有限制,但沒有全面檢查兩個活動對象欄位一致。 - 選擇規則服務、整理客群設定
PromotionEngineService.Create()依TypeDef選出ruleService。SetMemberCollectionAsync()依活動種類與商店開關調整客群 ID,例如部分全體會員活動設成-1;這一步不會將字串欄位同步到 enum 欄位。 - 建立 SQL 活動資料
開啟
TransactionScope,建立主檔、設定與商品範圍。若對象是MemberTier,另外將活動與會員卡的關聯寫入PromotionEngineMemberTier。 - 在記憶體建立會員範圍
SetMatchedUserScopes(entity.TargetMemberType, entity.CrmShopMemberCardIds, entity.MemberCollectionId)呼叫GetUserScopes(),將結果放進_matchedUserScopes。這裡直接使用 request,沒有回查剛寫入的會員等級關聯表。 - 符合壽星設定時追加條件
支援的回饋活動啟用壽星開關後,會確認或建立壽星客群,再以
SetBirthdayMonthMatchedUserScopes()對既有清單追加CurrentBirthdayMonth。 - 組合規則並保存
各活動的
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 元 | TargetMemberTypeDef 與 TargetMemberType 都為 All。 |
一個 AllUserScope。 |
| 金卡會員滿 1,000 元折 100 元 | 兩個對象欄位都為 MemberTier;CrmShopMemberCardIds = [123]。 |
一個 Tag 為 CrmShopMemberCard:123 的 TagUserScope。另寫入會員卡關聯資料。 |
| 指定客群限定活動 | 兩個對象欄位都為 MemberCollection;MemberCollectionId = "audience-001",假設該 ID 已存在且通過驗證。 |
一個 Tag 為 audience-001 的 TagUserScope。 |
案例四:全體會員的當月壽星,滿 1,000 元送 100 點
以 RewardReachPriceWithPoint2 為例。會員相關 request 如下:
1 | { |
先由 All 建立全體會員條件,再追加壽星 Tag。保存的規則片段如下:
1 | { |
給點條件中的 RewardPointRuleList.CrmShopMemberCardId 設為 0,會建立以 AllUserScope 為 Key 的給點門檻。當會員具備對應 Tag,且金額、商品與其他條件都符合時,一般會員與金卡會員只要是當月壽星都能給點;非當月壽星會被獨立的壽星檢查拒絕。
案例五:金卡的當月壽星,滿 1,000 元送 100 點
假設金卡會員卡 ID 為 123,仍以 RewardReachPriceWithPoint2 為例:
1 | { |
先建立會員卡條件,再追加壽星 Tag。保存的規則片段如下:
1 | { |
給點條件的 CrmShopMemberCardId 也設成 123,因此 Thresholds 中只有 CrmShopMemberCard:123 對應的給點門檻。
IsUserScopeMatched() 使用 Any(),清單中任一條件符合即可。金卡與壽星的共同限制,還依賴獨立的壽星開關檢查,以及給點門檻的會員 Tag 對應。- 會員範圍
MatchedUserScopes中任一條件符合即可。銀卡壽星也可能因壽星 Tag 通過這一關。 - 壽星資格
IsBirthdayMonthEnabled = true時,會員必須具有CurrentBirthdayMonthTag。金卡非壽星在這裡被拒絕。 - 給點門檻
GetThreshold()以會員 Tag 尋找Thresholds。本例只設定金卡門檻,銀卡壽星找不到對應門檻,仍無法給點。找到門檻後還要符合金額等活動條件。
| 會員 | 當月壽星 | 判斷結果(其餘活動條件均符合) |
|---|---|---|
金卡 123 |
是 | 會員範圍、壽星資格與金卡給點門檻都符合,可以給點。 |
金卡 123 |
否 | 未通過獨立的壽星檢查。 |
銀卡 456 |
是 | 可以因壽星 Tag 通過會員範圍,但找不到銀卡給點門檻,不給點。 |
銀卡 456 |
否 | 不符合活動。 |
Create 追加壽星 Tag 的活動類型為 RewardReachPriceWithPoint2、RewardReachPriceWithRatePoint2 與 RewardReachPriceWithCoupon。不能直接把這套建立行為套用到所有折扣活動。
案例六與七:漏傳活動對象,或明確指定 None
| Request 情境 | 建構結果 | 後續影響 |
|---|---|---|
只傳 TargetMemberTypeDef = "All",漏傳 TargetMemberType |
Enum 保留 0,走 default 得到 null。 |
一般活動若通過其餘驗證與建立步驟,可能保存 null 規則;主檔對象仍可能顯示 All。 |
兩個活動對象欄位都指定 None |
明確回傳 null。 | None 並不等於全體會員;部分活動會先被額外驗證拒絕。 |
支援壽星的活動漏傳 TargetMemberType,並啟用壽星開關 |
基本會員範圍先得到 null。 | 若執行到追加壽星的 .Add(),會因 null 拋錯,本次 SQL 建立交易無法正常完成。 |
SetBirthdayMonthMatchedUserScopes() 只對既有清單執行 .Add(),沒有初始化空清單。正常的全體會員或會員等級設定會先建立清單;若前一步已是 null,錯誤會提早在 Create 階段發生。為什麼建立時能載入,計算時卻出錯?
SerializeRule() 的檢查是序列化後嘗試 engine.LoadRules()。規則成功載入,不代表已執行活動排序。引擎的 ProcessPromotion() 先篩選啟用與日期,再讀取 DynamicPriority 排序,之後才進行活動匹配。
當符合排序前置條件的規則包含 MatchedUserScopes = null,GetUserScopePriority() 對 null 呼叫 OfType<AllUserScope>(),便會拋出 ArgumentNullException,參數為 source。是否適用會員的後續判斷還來不及執行。
這些是可由程式確認的建構與失敗路徑。要判定特定異常活動的原因,仍需比對原始 Create request 的兩個活動對象欄位、SQL 的 PromotionEngine_Rule,以及實際命中的快取內容。
程式碼對照
分析依據為 WebAPI 本機版本 fcba6180 與引擎版本 d852c3c;以下為對應程式位置。
| 階段 | 程式位置 |
|---|---|
| Request 與驗證 | PromotionBaseEntity.cs:62、PromotionBaseValidator.cs:182、CreatePromotionRequestEntityValidator.cs:243。 |
| API 入口 | PromotionEngineController.cs:219,先驗證再呼叫服務。 |
| SQL Create | PromotionEngineService.cs:587;會員範圍設定在 650,壽星分支在 655,規則組合在 727。 |
| 基本範圍與壽星追加 | PromotionEngineRuleBaseService.cs:226、:234、:1007。 |
| 規則 JSON | PromotionEngineRuleBaseService.cs:370、PromotionEngineHelper.cs:77。 |
| 給點門檻 | RewardReachPriceWithPoint2RuleService.cs:64,以會員卡 ID 產生門檻 Key。 |
| 引擎範圍與壽星判斷 | IBasicUserScope.cs:40、PromotionCommonRuleBase.cs:152。 |
| 給點資格與門檻查找 | RewardReachPriceWithPoint2.cs:55、:175。 |
| 排序失敗位置 | PromotionEngine.cs:128、IBasicUserScope.cs:32。 |
失敗位置:取得活動之後,排序期間出錯
本文記錄的失敗呼叫鏈如下。紅色僅標示直接拋出例外的位置;前面的節點用來交代例外如何被觸發。
flowchart TB
Process["ProcessPromotion()"] --> Sort["OrderBy(x => x.DynamicPriority)"]
Sort --> Priority["GetUserScopePriority()"]
Priority --> Null["MatchedUserScopes 為 null"]
Null --> Error["OfType 呼叫拋出 ArgumentNullException<br/>Parameter: source"]
classDef normal fill:#edf4ff,stroke:#517ab0,color:#203c60
classDef failure fill:#fff0f1,stroke:#b42332,color:#8e1b28
class Process,Sort,Priority,Null normal
class Error failure
程式證據:GetUserScopePriority()
1 | public static int GetUserScopePriority(IBasicUserScope target) => |
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 查詢失敗,也不能僅憑此排除所有上游資料問題。
查核與修正:沿著同一筆活動追到底
- 鎖定請求與商店
從錯誤 trace 取得
ShopId與SalepageId,確保後續查詢對應同一筆請求與商店。 - 找到候選活動
使用商品頁匹配 Key,檢查集合關聯與
PromotionEngineId;保留查到的匹配結果。 - 比對快取與原始規則
使用活動規則 Key 取得 Redis value,再依活動 ID 分界回查 WebStoreDB 或 MongoDB,確認會員範圍欄位是否缺少或為 null。
- 追蹤資料轉換
比對反序列化與 Mapping 前後的物件,找出
MatchedUserScopes首次成為 null 的位置。 - 決定修正語意並驗證
依查核結果補齊資料、處理快取版本,或定義明確的 null 處理策略。確認單筆異常活動的處置符合業務規則,並驗證正常活動仍可完成計算。
展開修正後的驗證項目
- Redis HIT:使用快取內容,不查持久化來源。
- Redis MISS:依活動 ID 選擇來源,取得資料後回填快取。
- 同一活動的快取與來源資料不一致:能辨認實際使用的版本。
MatchedUserScopes分別為 null、空集合及正常集合:結果符合明確的業務規則。- 單筆異常活動:整筆請求的結果與錯誤處置符合預期,其他正常活動的計算未受到非預期影響。
延伸參考
本文圖表依原文整理,呈現業務層級的呼叫與資料流,省略未提供的批次、重試與錯誤回應細節。方法名稱、Key 版本與 Mongo 分界應以排查當下的程式版本為準。




