會員在結帳時勾選「記住這張卡」之後,下次結帳畫面上完全看不到卡號輸入框,直接就能一鍵扣款,這個「免輸入卡號」的體驗背後,mweb 前台跟 PaymentMiddleware(以下簡稱 PMW)之間到底怎麼互相配合,才能既讓使用者方便,又不會讓卡片資訊外流。整體會先看 PMW 端 Stripe 外掛怎麼判斷「這次要用哪一種方式扣款」,再往前台看這些關鍵欄位究竟是怎麼被組出來、又是怎麼存回資料庫的。

PMW 收到 mweb 呼叫後,StripePlugin.Pay() 會依照這次請求帶的 ExtendInfo 內容依序判斷要走哪一種付款情境,命中即停,不會同時符合兩種。全文的卡片顏色都對應同一套語意,先認識這三個顏色,後面看圖表會更快上手:

情境一・單純 Pay
情境二・首次綁卡
情境三・舊卡復用
ExtendInfo 是否同時有 payment_method AND customer_id
✔ 是 → 情境三:舊卡復用(ReusePaymentMethodPaymentIntentProcess)
↓ 否,繼續判斷
ExtendInfo.is_reuse_payment_method == true
✔ 是 → 情境二:首次綁卡(RememberPaymentMethodProcess)
↓ 否
以上皆非
→ 情境一:單純 Pay(strategy.Pay(),明碼卡號直接扣款)
📄 對應程式碼:StripePlugin.cs Pay() L112-124 — 先檢查 PaymentMethodCustomerId 是否都非空 → 情境三;否則檢查 IsReusePaymentMethod == true → 情境二;都不符合則呼叫 strategy.Pay(request) → 情境一。行動錢包(Apple Pay / Google Pay)在最前面已被攔截,走獨立的 ProcessMobileWalletPayment,不在此三情境之列。

💳 三種付款情境詳解

01

情境一:單純 Pay(未綁卡 / 一般結帳)

strategy.Pay()

觸發條件

payment_method
空(未提供)
customer_id
空(未提供)
is_reuse_payment_method
false(預設)

DirectCharge 流程(一般商店)

1. POST /v1/payment_methods          ← 明碼卡號,換取 pm_xxx
2. POST /v1/payment_intents          ← 帶 pm_xxx、金額、幣別、confirm=true
      confirmation_method=automatic
      confirm=true
      application_fee_amount=...

DestinationCharge 流程(子帳號分潤商店,多一步)

1. POST /v1/payment_methods          ← 用主帳號,不帶 Stripe-Account Header
2. GET  /v1/accounts/{sub_account}  ← 取子帳號 statement_descriptor
3. POST /v1/payment_intents          ← transfer_data[destination]、on_behalf_of

回應結果

Stripe statusPMW ReturnCodemweb 行為
succeeded0000付款成功,訂單繼續走完後續 Processor
requires_action2003回傳 3D 驗證 URL,訂單進入 WaitingTo3DAuth
Stripe ApiException(卡被拒等)3000取消訂單,退還積點 / 券 / 購物金
📄StripePlugin.cs L121-123:兩個 if 都不符合時,直接 response = await strategy.Pay(request),由 DirectChargePaymentFlowStrategyDestinationChargePaymentFlowStrategy 處理實際 API 呼叫。
02

情境二:首次綁卡(記住信用卡)

RememberPaymentMethodProcess

觸發條件

is_reuse_payment_method
true
payment_method
空(新卡,尚無 Token)
customer_id

API 呼叫序列

// StripePlugin.ReusePaymentMethod() 先查詢 Customer
0. GET /v1/customers/search?query=name:"{shop_id}_{member_id}"

// RememberPaymentMethodProcess.Process()

  1. POST /v1/payment_methods ← 明碼卡號建立 PM
  2. POST /v1/payment_intents ← setup_future_usage=off_session(固定帶)

若 PaymentIntent.status 為 succeeded 或 requires_action:
├─ customers.data.Count == 1(已有 Customer)
│ 3a. POST /v1/payment_methods/{id}/attach ← 綁到既有 Customer
└─ customers.data.Count == 0(尚無 Customer)
3b. POST /v1/customers ← 建立並帶 payment_method 綁定
其他狀態(付款失敗)→ 不執行綁卡,直接回傳

回應結果

PaymentIntent status綁卡動作ReturnCode
succeededattach 或 create customer0000
requires_actionattach 或 create customer2003
其他失敗狀態不綁卡依失敗結果回傳
📄RememberPaymentMethodProcess.cs L54-70:只有 status is "requires_action" or "succeeded" 才會進入 switch(context.CustomersSearch.data.Count);Count 為 1 走 AttachMethod,為 0 走 CreateCustomer,其餘(大於 1)視為異常僅記 log 不處理,確保「一會員一 Customer」的資料原則。
03

情境三:舊卡復用

ReusePaymentMethodPaymentIntentProcess

觸發條件

payment_method
必填(已綁定的 PM Token)
customer_id
必填(既有 Stripe Customer ID)
off_session
true = 靜默扣款,可能跳過 3D

API 呼叫序列

// StripePlugin.ReusePaymentMethod() 先驗證 Customer
1. GET /v1/customers/search?query=name:"{shop_id}_{member_id}"

// ReusePaymentMethodPaymentIntentProcess.Process()
若 customer_id 存在於 search 結果中:
2. POST /v1/payment_intents ← 直接帶已存的 payment_method Token
customer={customer_id}
off_session=true ← 有帶則嘗試跳過 3D
否則:
throw new NotSupportedException(“Illegal Customer”) ← HTTP 500

回應結果

情況行為結果
customer_id 存在於 Search 結果正常執行 PaymentIntent依 status 走 0000 / 2003
customer_id 不存在於 Search 結果throw NotSupportedExceptionHTTP 500
Stripe ApiException(Token 失效等)捕捉例外3000
📄ReusePaymentMethodPaymentIntentProcess.cs L19-28:context.CustomersSearch.data.Any(x => x.id == context.CustomerId) 防止使用者竄改 customer_id 冒用他人已綁定的卡;驗證通過才呼叫 Strategy.PaymentIntentAsync(request, null)(不覆寫 setup_future_usage)。

三種情境的觸發依據、涉及的 Process 類別與 Stripe API 呼叫次數整理如下:

情境決定欄位負責類別DirectCharge API 數DestinationCharge API 數
① 單純 Pay皆空strategy.Pay()23
② 首次綁卡is_reuse_payment_method=trueRememberPaymentMethodProcess45
③ 舊卡復用payment_method + customer_idReusePaymentMethodPaymentIntentProcess23

情境二、情境三命中後,實際扣款與後續動作都委派給對應的 IReusePaymentMethodProcess 實作類別執行,以下說明兩者各自負責的職責與存在原因。

💳

RememberPaymentMethodProcess

情境二・首次綁卡專用

負責「用明碼卡號完成這一次付款,同時把這張卡的 Token 記到會員的 Stripe Customer 底下」,讓下次結帳可以直接用 Token 免輸入卡號(即情境三的前置準備)。

  1. 建立 PaymentMethod:呼叫 strategy.CreatePaymentMethodAsync(),用使用者這次輸入的明碼卡號向 Stripe 換取一次性的 pm_xxx Token。
  2. 建立扣款意圖:呼叫 strategy.PaymentIntentAsync(request, "off_session"),並固定帶入 setup_future_usage=off_session,等於跟 Stripe 講「這張卡之後我還會在使用者不在場的情況下扣款」,Stripe 才會允許之後做靜默扣款。
  3. 判斷是否要綁卡:只有 PaymentIntent.statussucceededrequires_action(代表這張卡本身有效)時才進行綁定;若付款直接失敗,這張卡不值得留下,直接略過。
  4. 依 Customer 是否存在分流:已有 Customer(data.Count == 1)就呼叫 /payment_methods/{id}/attach 把新卡掛到既有 Customer 底下;尚無 Customer(data.Count == 0)就呼叫 /customers 直接建立 Customer 並帶入卡片完成綁定。
為什麼需要它:把「扣款」與「綁卡」兩件事合而為一次 API 互動完成,同時用 data.Count 保證一個會員在同一子帳號下只會有一個 Stripe Customer,避免重複建立造成之後舊卡復用查詢時出現多筆歧義資料。
🔁

ReusePaymentMethodPaymentIntentProcess

情境三・舊卡復用專用

負責「驗證使用者傳入的 customer_id 是否真的屬於自己,通過後才用既有的 payment_method Token 直接扣款」,不再重新輸入卡號、不再重新建立 PaymentMethod。

  1. 合法性檢查(防冒用):用外層 StripePlugin.ReusePaymentMethod() 事先查好的 CustomersSearch 結果,執行 data.Any(x => x.id == context.CustomerId),確認前端傳來的 customer_id 確實存在於「這個會員」名下的 Stripe Customer 清單中。
  2. 驗證失敗直接擋下:若傳入的 customer_id 對不上,代表可能是竄改請求、冒用他人已綁定的卡,直接 throw NotSupportedException("Illegal Customer"),讓外層拋出 HTTP 500,不會呼叫任何扣款 API。
  3. 驗證通過才扣款:呼叫 strategy.PaymentIntentAsync(request, null)(第二參數傳 null,代表不覆寫 setup_future_usage,因為卡片早已在情境二綁定過),直接帶已存的 payment_method Token 與 customer_id 建立並確認 PaymentIntent。
  4. 省略建卡流程:與情境一 / 二不同,這裡完全不呼叫 /v1/payment_methods,因為 Token 早就存在,只需一支 /v1/payment_intents API 即可完成扣款。
為什麼需要它:是「快速結帳 / 記住卡片」體驗的核心安全閘門,讓已綁卡會員不需重新輸入卡號就能扣款,同時透過 Customer 歸屬驗證,避免有心人士竄改 payment_methodcustomer_id 參數盜用他人已存的信用卡。

CompleteForNewCartV2 進入 ThirdPartyProcess Pipeline 後,customer_id / payment_method 並不是由前端直接送出明碼,而是 mweb 依「快速結帳識別碼 identity」在後端撈出,並在付款完成後把 Stripe 回傳的新 Token 寫回資料庫,形成一個完整的閉環:

1
前端請求只帶「identity」或什麼都不帶 PayTypeExpressProcessor
Pipeline 前段執行。若商店有開啟「記住信用卡」,且使用者這次沒有輸入新卡號,就用 MemberId + ShopId + PayProfileType 查出該會員「預設」的 PayTypeExpress 記錄,把它的 Identity 塞進 context.ThirdPartyPaymentInfo.ExtendInfo["identity"](此時卡號等機敏資料尚未載入,只有識別碼)。
var payTypeExpressEntity = _payTypeExpressService
    .GetDefaultPayTypeExpress(memberId, payProfileType, shopId, gatewayType);

extendInfo.Add(“identity”, payTypeExpressEntity.Identity);
context.ThirdPartyPaymentInfo.ExtendInfo = extendInfo;

PayProcesses/Processors/PayTypeExpressProcessor.cs · AssignCreditCardInfo()
2
用 identity 撈出真正的 Token ArrangePayTypeExpressInfoProcessor
緊接著執行。用上一步的 identityPayTypeExpress 資料表查出同一筆記錄,反序列化其 Info(JSON)欄位,取得內含 customer_idpayment_method 的機敏資訊,包成 paytype_express_info 放回 context,準備交給 PMW。
var sameIdentity = payTypeExpressEntity.Single(i => i.Identity == identity);
var payTypeExpressInfo = _payTypeExpressService.GetPayTypeExpressInfo(sameIdentity);

context.ThirdPartyPaymentInfo.ExtendInfo = new Dictionary<string, object>
{
{ “identity”, sameIdentity.Identity },
{ “paytype_express_info”, payTypeExpressInfo.ExtendInfo } // 內含 customer_id / payment_method
};

PayProcesses/Processors/ArrangePayTypeExpressInfoProcessor.cs · Process()
3
組裝送給 PMW 的 ExtendInfo StripePayChannelService.GetPayExtendInfo()
ThirdPartyPayApiProcessor 呼叫 TradesOrderPaymentService.ProcessPayment() 時,實際呼叫此方法組出要 POST 給 PMW /api/v1/Pay/CreditCardOnce_Stripe/{tgCode} 的 Body。這裡才是 customer_id / payment_method 真正被放進送往 PMW 請求的地方,來源就是上一步的 paytype_express_info
// 定期購自動成單:從 RegularOrderCheckoutInfo JSON 取值(另一條路徑)
extendInfo.Add("customer_id", stripeRegularOrderCheckOurInfo.CustomerId);
extendInfo.Add("payment_method", stripeRegularOrderCheckOurInfo.PaymentMethod);

// 一般快速結帳:優先從 paytype_express_info 取值
var payTypeExpressInfo = thirdPartyPaymentInfo[“paytype_express_info”].ObjToDictionary<object>();
payTypeExpressInfo.TryGetValue(“customer_id”, out object customerId);
payTypeExpressInfo.TryGetValue(“payment_method”, out object paymentMethod);

extendInfo.Add(“customer_id”, customerId); // → 對應 PMW 情境三判斷欄位
extendInfo.Add(“payment_method”, paymentMethod); // → 對應 PMW 情境三判斷欄位

若查無 paytype_express_info(沒有 identity、沒有存過卡),就不會帶這兩個欄位,PMW 端 StripePlugin.Pay() 判斷落到情境一或情境二。
PayChannel/StripePayChannelService.cs · GetPayExtendInfo()
4
PMW 回傳新 Token,暫存於 context ChangeExtendInfoAfterPaymentResult
情境二(首次綁卡)付款完成後,PMW 回應的 ThirdPartyPayResponseEntity 會帶回新建立的 customer_id / payment_method(見前面「情境二」的 attach / create customer 結果)。mweb 收到後先暫存於 context.ThirdPartyPaymentInfo.ExtendInfo,供同一次請求後續 Processor 使用。
if (paymentResult.ExtendInfo["payment_method"] != null && paymentResult.ExtendInfo["customer_id"] != null)
{
    context.ThirdPartyPaymentInfo.ExtendInfo = new Dictionary<string, object>
    {
        { "payment_method", paymentResult.ExtendInfo["payment_method"] },
        { "customer_id", paymentResult.ExtendInfo["customer_id"] }
    };
}
PayChannel/StripePayChannelService.cs · SetPayTypeExpressInfo()
5
寫回 PayTypeExpress 資料表,供下次結帳使用 AfterOrderProcessor
Pipeline 尾端執行。把暫存的 customer_id / payment_method 包成 PayTypeExpressInfoForStripeEntity 序列化成 JSON,寫入(新增或更新)PayTypeExpress 資料表的 Info 欄位,並清快取。下次結帳時 Step 1、2 就能撈到這筆記錄,形成「首次明碼輸入 → 綁卡 → 之後都用 Token 復用」的閉環。
context.ThirdPartyPaymentInfo.ExtendInfo.TryGetValue("customer_id", out object customer);
context.ThirdPartyPaymentInfo.ExtendInfo.TryGetValue("payment_method", out object paymentMethod);

stripeData.ExtendInfo = new PayTypeExpressInfoForStripeEntity
{
Customer = customer?.ToString(),
PaymentMethod = paymentMethod?.ToString(),
Country = context.CreditCardInfo.IssueCountryCode
};

_payTypeExpressService.CreatePayTypeExpress(payTypeExpressCurrent); // 或 UpdatePayTypeExpress()
_payTypeExpressService.RemovePayTypeExpressCache(memberId, payProfileType, shopId);

PayProcesses/Processors/AfterOrderProcessor.cs · UpdatePayTypeExpressData() / GetPayTypeExpressInfo()
💡關鍵設計:customer_id / payment_method 全程「不落地到前端」,瀏覽器只知道 identity,真正的 Stripe Token 只存在 mweb 後端資料庫(下一分頁的 PayTypeExpress_Info)與 PMW/Stripe 之間,降低外洩風險,同時讓使用者能用「上次的卡」快速結帳而不必重新輸入卡號。

aaa53.png 這張 DB 查詢截圖是 PayTypeExpress 資料表的真實資料,完整欄位定義與 Info 欄位的 JSON 結構整理如下,方便日後查表對照。

🗂️ 資料表完整欄位

PayTypeExpress_Id
bigint,主鍵,流水號
PayTypeExpress_ShopId
bigint,商店序號
PayTypeExpress_MemberId
int,會員序號
PayTypeExpress_PayProfileType
varchar,付款方式類型,Stripe 固定為 CreditCardOnce_Stripe
PayTypeExpress_PaymentServiceProvider
varchar,金流服務商,Stripe 走 PMW 固定為 PaymentMiddleware
PayTypeExpress_Identity
varchar,「這張已存卡」的識別碼(一長串亂碼),前端/Processor 用它指定要用哪張卡,本身不含卡號或 Token
PayTypeExpress_IsDefault
bit,是否為該會員此金流的預設卡,決定 GetDefaultPayTypeExpress() 撈到哪一筆
PayTypeExpress_Info
nvarchar,JSON 字串,機敏內容本體,結構見下方
📄對應 Entity:DA/WebStoreDBV2/Tables/PayTypeExpress.cs(EF 產生的資料表模型)。

🧬 Info 欄位 JSON 結構(Stripe)

PayTypeExpress_Info 反序列化為 PayTypeExpressCreditCardEntity<PayTypeExpressInfoForStripeEntity>,外層是卡片顯示用資訊,ExtendInfo 才是真正要帶給 PMW 的付款憑證:

{
  "Issuer": null,                     // 發卡銀行(Stripe 多為 null)
  "Association": "Visa",              // 發卡組織:Visa / MasterCard / UnionPay...
  "No": "************6274",           // 卡號(僅顯示末四碼,其餘遮罩)
  "Month": "08",                      // 有效月份
  "Year": "27",                       // 有效年份
  "ExtendInfo": {
    "customer_id": "cus_xxxxxxxxxxxx",    // ← 帶給 PMW 情境三判斷欄位
    "payment_method": "pm_xxxxxxxxxxxx",  // ← 帶給 PMW 情境三判斷欄位
    "country": "TW"                       // 發卡行國家
  }
}
📄對應 Entity:BE/PayTypeExpress/PayTypeExpressCreditCardEntity.cs(外層 Issuer/Association/No/Month/Year/ExtendInfo)+ BE/PayTypeExpress/PayTypeExpressInfoForStripeEntity.csExtendInfo 內的 customer_id/payment_method/country,皆以 [JsonProperty] 對應小寫底線命名)。

📋 實際資料範例(節錄自 DB 查詢結果)

欄位範例值
PayTypeExpress_Id189518
PayTypeExpress_ShopId17
PayTypeExpress_MemberId1598546
PayTypeExpress_PayProfileTypeCreditCardOnce_Stripe
PayTypeExpress_PaymentServiceProviderPaymentMiddleware
PayTypeExpress_Identity9E160DB1471373603EF57A8F9455BCD0BEDA2C86DCE77BD31...
PayTypeExpress_IsDefault1
PayTypeExpress_Info{"Issuer":null,"Association":"Visa","No":"************6274","Mo...(同上方 JSON 結構,末端截斷)
💡關鍵設計:customer_id / payment_method 全程「不落地到前端」,瀏覽器只知道 identity,真正的 Stripe Token 只存在 mweb 後端資料庫(PayTypeExpress_Info)與 PMW/Stripe 之間,降低外洩風險,同時讓使用者能用「上次的卡」快速結帳而不必重新輸入卡號。

情境判斷的核心程式碼,位於 nineyi.payment.middlewarePlugins/NineYi.PaymentMiddleware.Plugins.Stripe/StripePlugin.cs

public async Task<PaymentResponseEntity...> Pay(request, headers, payMethod)
{
    var strategy = GetPaymentFlowStrategy(request.ExtendInfo.StripePaymentFlow);

    // 行動錢包(Apple Pay / Google Pay)另外處理,不屬於三情境
    if (_mobileWalletMethods.Contains(payMethod))
        return await strategy.ProcessMobileWalletPayment(request);

    var context = new ReusePaymentMethodEntity { ...Strategy = strategy, Request = request };

    // 情境三:舊卡復用 — payment_method 與 customer_id 都有值
    if (!string.IsNullOrWhiteSpace(request.ExtendInfo.PaymentMethod) &&
        !string.IsNullOrWhiteSpace(request.ExtendInfo.CustomerId))
    {
        response = await ReusePaymentMethod(context, ReusePaymentMethodPaymentIntent);
    }
    // 情境二:首次綁卡 — is_reuse_payment_method == true
    else if (request.ExtendInfo.IsReusePaymentMethod == true)
    {
        response = await ReusePaymentMethod(context, RememberPaymentMethod);
    }
    // 情境一:單純 Pay — 兩者皆非
    else
    {
        response = await strategy.Pay(request);
    }
    return GetThirdPartyPayResponseEntity(request, response, context);
}
📄 程式碼判斷順序固定為「情境三 → 情境二 → 情境一」,且一旦命中即不再往下判斷。ReusePaymentMethod() 內部會先呼叫 CustomersSearchAsync 查出該會員({shop_id}_{member_id})在 Stripe 的 Customer 記錄,再交給對應的 IReusePaymentMethodProcess(情境二為 RememberPaymentMethodProcess,情境三為 ReusePaymentMethodPaymentIntentProcess)執行後續動作。

第一次消費、這個會員在這間商店這個付款方式下 PayTypeExpress 表完全是空的時候,identity 是從哪冒出來的?答案是:伺服器端自己用「這次剛輸入的卡片明細」現算一組 SHA256 雜湊值,算完立刻寫入 DB,並不是從任何既有資料查出來的

觸發點:AfterOrderProcessor(成立訂單後動作)

AfterOrderProcessor 掛在幾乎所有付款完成流程(信用卡、ApplePay、ATM、LinePay…)的 Pipeline 尾端,付款成功、訂單成立後才會執行:

// AfterOrderProcessor.cs:330-390
// 沒勾選記住信用卡則整段略過
if (context.RememberCreditCardNo == false) return;

// 查詢會員在此商店/付款方式下,DB 目前已有的記住卡片清單
var payTypeExpressList = this._payTypeExpressService.GetPayTypeExpressList(memberId, payProfileType, shopId);
// ← 第一次消費時,這裡查出來是空 List

// 取得「這次刷卡」的卡片識別碼
var cardIdentity = this.GetCreditCardIdentity(context);

var payTypeExpressCurrent = payTypeExpressList.FirstOrDefault(x =>
    x.Identity == cardIdentity && x.PaymentServiceProvider == paymentServiceProvider);

// 空 List 一定找不到,進入「新增」分支
if (payTypeExpressCurrent == null)
{
    payTypeExpressCurrent = new PayTypeExpressEntity
    {
        ShopId = shopId,
        MemberId = memberId,
        PayProfileType = payProfileType,
        PaymentServiceProvider = paymentServiceProvider,
        IsDefault = true,
        Identity = this.GetPayTypeExpressIdentity(context),  // ← 現算出來的新 identity
        Info = this.GetPayTypeExpressInfo(context)
    };

    this._payTypeExpressService.CreatePayTypeExpress(payTypeExpressCurrent);  // ← 真正 INSERT
    this._payTypeExpressService.RemovePayTypeExpressCache(memberId, payProfileType, shopId);
}

🧮 GetPayTypeExpressIdentity():identity 的真正計算公式

// AfterOrderProcessor.cs:610-641
private string GetPayTypeExpressIdentity(PayProcessContextEntity context)
{
    var shopId = context.ShoppingCartV2.ShopId;
    var memberId = Convert.ToInt32(context.MemberId);
    string creditCardIdentity;

    switch (context.PayProfileType)
    {
        case CreditCardOnce:
        case CreditCardInstallment:
            // NCCC / TapPay:用 TapPay SDK 回傳的識別碼 + 到期日
            var identificationCode = context.TapPayCardInfo?.IdentificationCode
                                      ?? context.PaymentInfo?.CreditCardInfo?.CardCode;
            var creditCardExpiryDate = context.TapPayCardInfo?.ExpiryDate
                                        ?? context.PaymentInfo?.CreditCardInfo?.ExpiryDate;
            creditCardIdentity = $"{identificationCode}_{creditCardExpiryDate}";
            break;

        default:
            // Stripe / CheckoutDotCom / KPay:直接用「這次使用者剛輸入的卡號 + 到期日」
            creditCardIdentity = $"{context.CreditCardInfo.CreditCardNo}_{context.CreditCardInfo.CreditCardDate}";
            break;
    }

    var result = $"{shopId}_{memberId}_{creditCardIdentity}";
    return result.ToSHA256();  // ← 最終的 identity 字串
}
📄對應公式:Stripe/CheckoutDotCom/KPay 通道 → identity = SHA256(shopId_memberId_卡號_到期日)NCCC/TapPay 通道 → identity = SHA256(shopId_memberId_TapPay識別碼_到期日)。輸入完全來自使用者「這次結帳畫面上親自輸入」的卡片明細,不依賴任何既有 DB 資料。

🗝️ 為什麼這樣設計:用卡片特徵值做冪等 key

💡關鍵設計:同一張卡(卡號 + 到期日相同)每次現算出來的 SHA256 都會是同一組值,因此即使「第一次沒有資料可查」,系統也能在首刷當下自己生成、自己存證;下次同一張卡再刷時,兩邊算出的 identity 會對得上,藉此判斷「是不是同一張已記住的卡」,而不必額外維護一組自增序號或額外的比對機制。

🔁 兩個時機的對照

第一次消費
(DB 無資料)
伺服器用「這次輸入的卡片明細」現算 SHA256(shopId_memberId_卡號/識別碼_到期日),並由 AfterOrderProcessor 在訂單成立後立刻呼叫 CreatePayTypeExpress 寫入 DB
後續消費
(DB 已有記錄)
直接從 DB PayTypeExpress 表撈出上次算好、存好的那筆 Identity,經 Shopping 服務 PayTypeExpressProcessor 回填給前端,前端原樣帶回,mweb 端再用它反查機敏資料(見「🔑 mweb 如何取得 customer_id / payment_method」分頁)
📄對應程式碼:WebStore/Frontend/BLV2/PayProcesses/Processors/AfterOrderProcessor.csGetCreditCardIdentity 610 行前、GetPayTypeExpressIdentity 610-641 行、新增分支 365-390 行)+ WebStore/DA/WebStoreDBV2/Repositories/PayTypeExpressRepository.csCreatePayTypeExpress 102-120 行,真正執行 INSERT 的地方)。

前面幾個分頁都是站在 mweb(nineyi.webstore.mobilewebmall)這個 repo 裡面看事情,但實際上結帳頁 identity 的產生與回填,橫跨了三個各自獨立部署的服務。這個分頁把「使用者打開結帳頁」到「送出付款」這一整趟旅程,按實際呼叫順序完整串起來,釐清 is_reuse_payment_methodidentityExtendInfo["identity"] 到底是誰、在哪個服務、哪一支程式碼裡被組裝跟傳遞的。

🧭 三個服務各自的角色

Shopping
C:\91APP\Shopping
前台結帳頁 API 入口(GET api/checkoutapi/checkout/info),負責組出畫面要顯示的所有資料,包含「記住這張卡」checkbox 狀態與已記住卡片摘要
Cart
C:\91APP\Cart\cart2\nine1.cart
購物車/結帳快照存放於 Redis,並負責 api/checkouts/get(給 Shopping 讀快照)與 api/checkouts/complete(送出付款,轉呼叫 mweb)
mweb
nineyi.webstore.mobilewebmall
真正執行付款、寫入訂單與 PayTypeExpress 表的地方,對外入口是 tradesOrderLite/CompleteForNewCartV2

1️⃣ 開啟結帳頁:GET api/checkout?checkoutUniqueKey=...(Shopping 服務)

CheckoutController.GetCheckoutService.GetCartFromP2Async 依序跑 12 段 Processor Pipeline(ProcessorDefinitionCenter.GetCheckoutProcessorLayers),其中兩段是本次追蹤的重點:

// Stage 1:GetCheckoutProcessor 呼叫 Cart 服務讀 Redis 快照
GetCheckoutProcessor → Cart api/checkouts/get(純讀 Redis,快照是建立 checkout 時寫入的)

// Stage 6:決定「記住這張卡」checkbox 要不要顯示
GetPayProcessDataProcessor → 讀 ShopDefault.IsRememberCreditCard(PaymentMiddleWare 閘道設定)
                            → 組出 IsEnabledRememberCreditCardNo

// Stage 7:若已啟用記住卡,撈出上次記住的卡片摘要
PayTypeExpressProcessor(Shopping 版本) → PayTypeExpressService.GetDefaultPayTypeExpressesAsync(...)
                            (gateway type = "PaymentMiddleWare")
                            → 組出 PaymentMiddlewareCreditCardInfoEntity(含 Identity)
                            → 塞進 context.Data.DisplayCreditCard.PaymentMiddlewareCreditCardInfo
📄對應:Shopping/.../CheckoutProcessor/GetPayProcessDataProcessor.cs(153-192 行)+ Shopping/.../CheckoutProcessor/PayTypeExpressProcessor.cs(Shopping 端獨立實作,非 mweb 那支同名類別,57-151 行)。回應型別為 IGetCheckoutResponseEntity(前端 types.ts:782-839),內含 isEnabledRememberCreditCardNodisplayCreditCard
⚠️容易混淆的地方:api/checkout/info 是完全不同的另一支 API,只回傳海外配送聲明/訂購須知/金物流顯示樣式三個純顯示欄位,跟金流、記住信用卡完全無關;真正跟 checkbox、卡片摘要有關的是 api/checkout?checkoutUniqueKey=...(即 getCheckout)。

2️⃣ 使用者按下送出:api/checkouts/complete(Cart 服務)

前端把畫面上(可能是使用者剛輸入的新卡,也可能是「記住的卡」原樣帶回的)PaymentMiddlewareCreditCardInfo.Identity 送回 Cart 服務。Cart 端真正的閉環發生在:

// GetPayDataProcessor.cs:560-624
AssignPaymentMiddlewareCreditCardInfo(requestCreditCardInfo, payProcessContext)
{
    // 把前端回傳的 Identity 寫入 mweb 認得的欄位
    payProcessContext.ThirdPartyPaymentInfo.ExtendInfo["identity"] = requestCreditCardInfo.Identity;
}
📄對應:Cart/cart2/.../Processor/Checkout/Complete/GetPayDataProcessor.cs(560-660+ 行)。這裡組出來的 payProcessContext,其型別正是 mweb 的 PayProcessContextEntity——也就是這份文件從頭到尾在討論的那個 context。組好之後,Cart 服務呼叫 mweb 的 tradesOrderLite/CompleteForNewCartV2,正式進入本文件其他分頁描述的付款流程。

🔁 五步驟收斂圖

① 開啟結帳頁
Shopping GetCheckoutProcessor 呼叫 Cart 讀 Redis 快照,組出畫面資料
② 顯示「記住這張卡」
Shopping GetPayProcessDataProcessor + PayTypeExpressProcessor 決定 checkbox 狀態與卡片摘要(含 DB 撈出的 Identity
③ 前端原樣帶回
使用者送出付款,前端把 Identity(新卡或舊卡皆然)原封不動送到 Cart api/checkouts/complete
④ Cart 轉譯欄位
GetPayDataProcessor.AssignPaymentMiddlewareCreditCardInfoIdentity 寫入 payProcessContext.ThirdPartyPaymentInfo.ExtendInfo["identity"]
⑤ 進入 mweb 付款流程
呼叫 tradesOrderLite/CompleteForNewCartV2,mweb ArrangePayTypeExpressInfoProcessorthirdPartyPaymentInfo.ExtendInfo["identity"] 反查 DB 機敏資料;若是全新卡片(DB 查無資料),則由 AfterOrderProcessor 現算新的 identity 並寫回 DB(詳見「🌱 首次消費 identity 從何而來」分頁)
💡關鍵理解:Shopping/Cart/mweb 三個服務各自都有自己的 PayTypeExpressProcessor/付款相關 Entity,彼此不是互相呼叫同一份程式碼,而是各自讀同一份 DB 設定(ShopDefault.IsRememberCreditCard)、傳遞同一個字串欄位(identity)來達成邏輯一致;一旦要修改「記住信用卡」相關邏輯,必須同時檢查三個 repo,而不是只改 mweb 這一份。