第一層:shopId Key 格式: Core:PartialCheckoutData-20230410:{shopId}:...
防護:多租戶(Multi-tenant)
91APP 服務數千個商店,每個商店共用同一套程式和同一個 Redis。 若沒有 shopId 隔離,商店 A(ShopId=100)的結帳資料,理論上可以被商店 B(ShopId=200)的程式讀到。
第二層:memberId Key 格式: Core:PartialCheckoutData-20230410:{shopId}:{memberId}:...
防護:跨用戶資料洩漏
同一個商店的不同會員,結帳資料要絕對隔離。 會員 A(MemberId=12345)的收件人、信用卡資料,絕不能被會員 B(MemberId=67890)讀到。
第三層:checkoutUniqueKey Key 格式: Core:PartialCheckoutData-20230410:{shopId}:{memberId}:{UUID}
防護:同一用戶的多個並行結帳 Session
同一個人同時開兩個結帳分頁,各自有不同的 UUID,操作互不影響。
ICoreCacheService 的介面設計也體現了這個分層 1 2 3 4 5 6 Task SaveAsync <T >(T data, CoreCacheKeyEnum key, long shopId, long memberId, params string [] args ) ;Task SaveAsync <T >(T data, CoreCacheKeyEnum key, long shopId, params string [] args ) ;
實際 Key 對照
情境
Redis Key
商店 10416,會員 39326184,第一個結帳分頁
Core:PartialCheckoutData-20230410:10416:39326184:a1b2-c3d4-...
商店 10416,會員 39326184,第二個結帳分頁(同時開著)
Core:PartialCheckoutData-20230410:10416:39326184:e5f6-g7h8-...
商店 10416,不同會員 99999999
Core:PartialCheckoutData-20230410:10416:99999999:x9y0-z1a2-...
AntiForgeryToken(無 memberId)
Core:AntiForgeryToken-20230821:10416:{Guid-Token-Value}
三層都必須正確才能命中 Cache,單一層錯誤就找不到資料。這個設計確保了電商系統中最敏感的結帳個資,絕對不會洩漏給其他使用者或商店。
存入 Redis(C# → Redis) 由 PartialCheckoutDataHashTransformer.ToHash() 負責把 C# 物件拆開成一個 Dictionary:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public IDictionary<string , object > ToHash (PartialCheckoutEntity data ){ var dictionary = new Dictionary<string , object ?> { [nameof(data.Member) ] = data.Member, [nameof(data.InvoiceIssuanceList) ] = data.InvoiceIssuanceList, [nameof(data.UserClientTrack) ] = data.UserClientTrack, [nameof(data.TemperatureResponseItems) ] = data.TemperatureResponseItems, [nameof(data.TemperatureSalepageList) ] = data.TemperatureSalepageList, [nameof(data.SelectedShippingAreaId) ] = data.SelectedShippingAreaId, [nameof(data.SelectedDesignatePaymentPromotionId) ] = data.SelectedDesignatePaymentPromotionId, [nameof(data.ShoppingDateTimeUtc) ] = data.ShoppingDateTimeUtc.ToString(...) }; }
nameof(data.Member) 就是字串 "Member"。這個 Dictionary 送進 Redis 後,實際樣貌如下(所有 value 都是字串 ):
1 2 3 4 5 6 7 8 9 10 HGETALL Core:PartialCheckoutData-...: "Member" → "{\"CellPhone\":\"0912345678\",...}" "InvoiceIssuanceList" → "[{\"CarrierTypeDef\":\"ER0027\",...}]" "UserClientTrack" → "{\"Channel\":\"Web\",...}" "TemperatureSalepageList" → "[{\"EshopId\":\"...\",\"Qty\":1,...}]" "TemperatureResponseItems" → "[]" "SelectedShippingAreaId" → "1" "SelectedDesignatePaymentPromotionId" → "0" "ShoppingDateTimeUtc" → "03/15/2026 01:00:00"
Redis 不知道 "Member" 是什麼型別,它只知道那是一串 JSON 字串,型別由程式碼自己負責。
從 Redis 讀回來(Redis → C#) 由 PartialCheckoutDataHashTransformer.ToType() 負責把 Redis 回傳的字串 Dictionary 組裝回 C# 物件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public PartialCheckoutEntity ToType (Dictionary<string , string > redisHash ){ var partialCheckoutEntity = new PartialCheckoutEntity { Member = redisHash.ExtractHashValue((PartialCheckoutEntity x) => x.Member)!, InvoiceIssuanceList = redisHash.ExtractHashValue((PartialCheckoutEntity x) => x.InvoiceIssuanceList)!, ShoppingDateTimeUtc = Convert.ToDateTime( redisHash.ExtractHashValue(nameof (CartEntity.ShoppingDateTimeUtc))) }; return partialCheckoutEntity; }
整體流程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 C │ │ ToHash():把每個屬性序列化成 JSON 字串,放進 Dictionary ▼ Dictionary<string, string> "Member" → "{\"CellPhone\":\"0912...\"}" "InvoiceIssuanceList" → "[{...},{...}]" "ShoppingDateTimeUtc" → "03/15/2026 01:00:00" │ │ Redis HMSET ▼ Redis Hash(所有 value 都是字串,Redis 不知道型別) │ │ Redis HMGET / HGET / HGETALL ▼ Dictionary<string, string>(從 Redis 讀回來) │ │ ToType():把每個字串 JSON 反序列化回對應的 C ▼ C
總結
問題
答案
Redis 本身有型別嗎?
沒有,Redis Hash 的每個 field value 都只是字串
型別由誰負責?
PartialCheckoutDataHashTransformer,存的時候序列化,取的時候反序列化
field 名稱是怎麼來的?
用 C# 的 nameof(data.Member) 取屬性名,存為 field 名
為什麼可以只讀一個欄位?
Redis 的 HGET / HMGET 命令本來就支援只讀指定 field,這是 Redis Hash 資料結構的原生特性
問題:字串寫死 field 名的風險 假設要「只更新 IsDeliverySet 這個 field」,最直覺的寫法是:
1 2 await _redisDataProvider.HashSetAsync(key, "IsDeliverySet" , true );
這樣有一個致命問題:如果有人把屬性改名叫 IsDeliverySetting,程式不會報錯 ,但 Redis 裡會多出一個新的 field,舊的 field 還在,資料就亂掉了。字串是死的,編譯器看不到。
解法:用 Lambda Expression 傳入屬性 1 2 3 4 await _coreCacheService.HashSetAsync( (CheckoutEntity x) => x.IsDeliverySet, true , ...);
這樣如果有人把 IsDeliverySet 改名,編譯器會直接報錯 ,不會等到 runtime 才出問題。
這個 Lambda 怎麼變成 Redis 的 field 名? 第一步:方法簽章接收的是 Expression,不是普通 Func
1 2 3 4 5 6 7 8 9 10 public Task HashSetAsync <TSourceClass , TProperty >( Expression<Func<TSourceClass, TProperty>> expr, // ← Expression,不是普通 lambda TProperty data, ... ){ var memberInfo = GetPropertyMemberInfo(expr); return _redisDataProvider.HashSetAsync(key, memberInfo.Name, data, expireTime); }
第二步:GetPropertyMemberInfo 走訪語法樹取屬性名
1 2 3 4 5 6 7 8 9 10 11 private MemberInfo GetPropertyMemberInfo <TDelegate >(Expression<TDelegate> expr ){ var bodyExpr = expr.Body; if (bodyExpr is MemberExpression memberExpr) { return memberExpr.Member; } throw new NotSupportedException("只支援 x => x.屬性 這種寫法" ); }
第三步:Expression 和普通 Func 的差別
1 2 3 4 5 6 7 8 9 10 Func<CheckoutEntity, bool > f = x => x.IsDeliverySet; Expression<Func<CheckoutEntity, bool >> expr = x => x.IsDeliverySet;
語法樹結構示意:
1 2 3 4 5 6 7 8 9 x => x.IsDeliverySet LambdaExpression │ Body(主體) │ MemberExpression ← bodyExpr is MemberExpression → true ├── Expression: x (參數) └── Member: IsDeliverySet ← memberExpr.Member.Name = "IsDeliverySet"
完整呼叫追蹤:只寫單一 field 以結帳設定收件人完成後,更新 IsDeliverySet = true 為例:
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 await _coreCacheService.HashSetAsync( (CheckoutEntity x) => x.IsDeliverySet, true , CoreCacheKeyEnum.CheckoutEntity, shopId, memberId, expireTime: null , args: checkoutUniqueKey); var memberInfo = GetPropertyMemberInfo(expr);var queryKey = GetQueryKey(CoreCacheKeyEnum.CheckoutEntity, shopId, memberId, checkoutUniqueKey);return _redisDataProvider.HashSetAsync( queryKey, "IsDeliverySet" , true , expireTime);
完整呼叫追蹤:只讀單一 field 以過期檢查只讀 ShoppingDateTimeUtc 為例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 var shoppingDateTimeUtc = await _coreCacheService.HashGetAsync<CheckoutEntity, DateTime>( (CheckoutEntity x) => x.ShoppingDateTimeUtc, CoreCacheKeyEnum.CheckoutEntity, shopId, memberId, args: checkoutUniqueKey); var memberInfo = GetPropertyMemberInfo(expr);var queryKey = GetQueryKey(...);return _redisDataProvider.HashGetAsync<string , DateTime>( queryKey, "ShoppingDateTimeUtc" );
整體機制總結 1 2 3 4 5 6 7 8 9 10 11 12 呼叫端寫: x => x.IsDeliverySet ↓ C# 編譯器把 lambda 轉成「語法樹物件」(Expression) ↓ GetPropertyMemberInfo() 走訪語法樹,找到 MemberExpression ↓ memberExpr.Member.Name = "IsDeliverySet" (取出屬性名字串) ↓ 用這個字串當 Redis HSET / HGET 的 field 名 ↓ Redis 只讀/寫這一個 field ,其他 field 完全不動
用 Lambda 取代硬寫字串,讓「field 名」在編譯期就被型別系統保護。改名時編譯器立刻報錯,不怕打錯字,也不怕無聲地寫進錯誤的 field。
Shopping 每次呼叫 api/checkouts/create,都會從 response 拿 UniqueKey,然後用這個 Key 存 Redis:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 var result = await _domainApiHelper.PostAsJsonAsync<CheckoutCreateResponse, CartCheckoutCreateRequest>( new Uri("api/checkouts/create" , UriKind.Relative), context.Request, ...); context.CheckoutUniqueKey = context.Response.Data!.UniqueKey; public class CheckoutCreateResponse { public string Url { get ; set ; } public string UniqueKey { get ; set ; } public string ReturnCode { get ; set ; } }
UUID 不是 CartService 產生的,是 WebStore(舊系統)給的 從 DoPayProcessFlowProcessor.cs 找到鐵證:
1 2 3 4 5 6 7 8 9 10 11 var (statusCode, result) = await _webStoreWebApiClient.PostAsJsonAsync<ApiResultEntity<string >>( "PayLite/RequestPayProcessUrlV3" , ...); var returnUrl = new Uri(result.Data);var checkoutUniqueKey = HttpUtility.ParseQueryString(returnUrl.Query).Get("k" )!;
完整流程 1 2 3 4 5 6 7 CartService → 呼叫 WebStore PayLite/RequestPayProcessUrlV3 ↓ WebStore 建立結帳 session,產生一個新的 k 值,回傳 URL ↓ CartService 從 URL 的 ?k= 參數取出這個 k 值 ↓ CartService 用這個 k 當作 checkoutUniqueKey,回傳給 Shopping
「每次不同」的根源在於:WebStore 每次呼叫 RequestPayProcessUrlV3 都會建立一個全新的 session,k 值自然每次不同。
問題背景 結帳頁需要顯示收件人資料,但手機號碼、地址是敏感個資,不應完整曝露在前端(browser) 。然而送出訂單時,後端又需要完整的原始個資才能打 CartService API。
系統解法 Step 1:GetCheckout 流程末端 — MaskProcessDataProcessor 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 public async Task ExecuteAsync (CheckoutContext context ){ var personalData = new PersonalDataMaskEntity() { CheckoutUniqueKey = context.CheckoutUniqueKey, DisplayReceiver = context.Data.DisplayReceiver, Email = context.Data.Member.Email, MemberInvoice = context.Data.MemberInvoice, CellPhone = context.Data.Member.CellPhone }; await this ._personalDataMaskService.SetPersonalDataAsync(personalData); if (await this ._personalDataMaskService.IsPersonalDataMaskEnabledAsync(...) == false ) { return ; } var maskedData = this ._personalDataMaskService.GetMaskedPersonalData(personalData); context.Data.DisplayReceiver = maskedData.DisplayReceiver; context.Data.MemberInvoice.Email = maskedData.MemberInvoice.Email; context.Data.Member.Email = maskedData.Email ?? string .Empty; context.Data.Member.CellPhone = maskedData.CellPhone ?? string .Empty; }
Step 2:SetDelivery(設定物流)時還原個資 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private async Task<Internal.DeliverySetRequest> UnmaskSetDeliveryRequest(...){ var redisData = (await _personalDataMaskService.GetPersonalDataAsync(request.CheckoutUniqueKey))!; var maskedData = _personalDataMaskService.GetMaskedPersonalData(redisData); request.Receiver.CellPhone = RestoreMakedData( request.Receiver.CellPhone, maskedReceiverData?.CellPhone, unmaskedReceiverData?.CellPhone, "DeliverySet" ); await this ._personalDataMaskService.SetPersonalDataAsync(redisData); }
Step 3:SendCompleteRequest(送出訂單)也還原個資 1 2 3 entity = await this .UnmaskOrderCompleteRequest(entity);
遮罩規則
資料
原始
遮罩後
手機
0912345678
0912***678
Email
user@gmail.com
u***@g***.com
地址
台北市信義區信義路五段7號
台北市信義區***
姓名
王小明
王**
遮罩開關:兩層控制 1 2 3 4 5 6 7 8 9 var marketSwitchEnabled = await IsMarketSwitchEnabledAsync();if (marketSwitchEnabled == false ) return false ;var shopSwitchEnabled = await IsShopSwitchEnabledAsync(shopId);return shopSwitchEnabled;
即使遮罩功能關閉,原始個資仍然會存入 Redis,因為 SetPersonalDataAsync 在遮罩判斷之前就執行了。這確保了 Unmask 邏輯無論開不開都能正常運作。
程式碼 1 2 3 4 5 6 7 8 9 10 11 12 13 public CoreCacheService ( IRedisInstanceResolver redisInstanceResolver, IConfigurationService configurationService ){ _redisDataProvider = redisInstanceResolver.GetCacheProvider(CacheInstanceType.ShoppingCache); _defaultCacheExpireTime = TimeSpan.FromHours( configurationService.GetConfiguration<int >( ConfigTypeEnum.Normal, "CacheExpireSetting:CoreData.ExpireHours" )); }
設定檔(appsettings.json 或環境設定):
1 2 3 4 5 { "CacheExpireSetting" : { "CoreData.ExpireHours" : 2 } }
好處
情境
不用設定檔控制
用設定檔控制
促銷活動期間,希望延長結帳保留時間
需要改程式碼 + 重新部署
改設定,即時生效
發現 TTL 太長,Redis 記憶體壓力大
需要改程式碼 + 重新部署
改設定,即時生效
QA / Prod 環境希望不同 TTL
無法區分
各環境獨立設定
兩個 TTL 的不同 1 2 3 4 5 6 7 8 9 await _coreCacheService.SaveAsync( entity, CoreCacheKeyEnum.PersonalDataMask, shopId, memberId, TimeSpan.FromMinutes(60 ), entity.CheckoutUniqueKey);
購物車過期判斷是 30 分鐘(CacheLifeCycleService):
1 2 3 4 private const int CartExpireMinutes = 30 ;return dateTime.ToLocalTime().AddMinutes(CartExpireMinutes) < DateTime.Now;
為什麼 PersonalDataMask 要比購物車過期時間長? 1 2 3 4 5 使用者流程: t=0min 進入結帳頁(建立 PartialCheckoutData) t=25min 選好配送方式、付款方式(SetDelivery 需要 Unmask) t=30min PartialCheckoutData 可能過期(系統判斷為過期) t=31min 使用者點「送出訂單」(SendCompleteRequest 需要 Unmask)
如果 PersonalDataMask 也是 30 分鐘,t=31min 就讀不到原始個資,訂單無法成立。60 分鐘確保即使購物車已標記過期,個資仍可被使用於最後的送單動作。
問題背景 要判斷購物車是否過期,最直觀的做法是:讀回整份 PartialCheckoutEntity,再取其中的 ShoppingDateTimeUtc。但整份 Entity 可能很大(包含 TemperatureSalepageList 等 List),每次進入結帳頁都要判斷,代價太高。
系統做法:只讀一個 field 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public async Task<bool > IsCheckoutExpireAsync (string uniqueKey, long memberId ){ var nameOfShoppingDateTimeField = nameof (PartialCheckoutEntity.ShoppingDateTimeUtc); var redisHash = await _coreCacheService.HashBatchGetAsync<string >( new List<string > { nameOfShoppingDateTimeField }, CoreCacheKeyEnum.PartialCheckoutData, _requestDataRetriever.ShopId, memberId, uniqueKey); var shoppingDateTimeUtc = redisHash.ExtractHashValue(nameOfShoppingDateTimeField); var dateTime = Convert.ToDateTime(shoppingDateTimeUtc); return dateTime.ToLocalTime().AddMinutes(CartExpireMinutes) < DateTime.Now; }
底層 Redis 命令:
1 2 HMGET Core:PartialCheckoutData-20230410:10416:39326184:uuid ShoppingDateTimeUtc
觸發時機:每次 GetCartFromP2Async 都先檢查 1 2 3 4 5 6 7 8 9 10 public async Task<CheckoutEntity> GetCartFromP2Async (string checkoutUniqueKey ){ if (await _cacheLifyCycleService.IsCheckoutExpireAsync(checkoutUniqueKey, ...)) { throw new CheckoutGetException(CheckoutGetExceptionTypeEnum.CheckoutCacheExpired); } }
設計精髓: 用最小的 Redis 查詢代價,做最快速的「是否過期」判斷。每次進入結帳頁都要檢查,因此要盡可能輕量。