Payment Manager 的設計原則和實踐:統一管理多種支付管道的心得分享

以 KOL Radar 串接第三方金流為例,實作簡單工廠模式(Simple Factory)、策略模式(Strategy) 來建立一個 Payment Manager

iRene
iKala 技術部落格
25 min readApr 6, 2023

--

Irene 是一位有熱情的後端工程師,擅長使用 Golang 開發高效能、可擴展的應用程式。她目前在 iKala 專注於 KOL Radar 產品開發,負責編寫高品質的後端系統。除 Golang 外,Irene 熟悉 Java 和 SQL。她重視團隊合作、持續學習,並與團隊成員分享知識。

前提

現今的電商和網路支付平台已經有許多不同的支付管道,例如信用卡、虛擬帳戶、銀行轉帳等,而這些不同的支付管道都有自己的特點和要求,讓開發人員面臨不小的挑戰。

Payment Manager 就是一個設計模式,可以幫助開發人員統一管理多種支付管道,並且實現易於擴充的支付模組。

這篇文章將會分享 Payment Manager 的設計原則和實踐經驗,並且探討如何使用抽象化的設計模式來管理多種支付管道。如果你正在開發一個支付相關的系統,希望這篇文章會對你有所啟發。

Payment Manager 的設計目的與重要性

首先,我們來舉個例子。假設有一個產品服務目前決定要串接 A Pay 金流的信用卡付款來為我們的產品客戶提供信用卡付款的需求,這時候你會選擇怎麼實現呢?

一開始我們可能會這樣做:

type Order struct{
OrderNo string
Amount decimal.Decimal
}

// 結帳
func Checkout(amount decimal.Decimal) error {
order := Order{
OrderNo: "TW20230314083814110285",
Amount: amount,
}

// 在結帳的 function 裡去 call APayCheckoutByCreditCard function
if err := APayCheckoutByCreditCard(order); err != nil {
return err
}

.....

return nil
}

// 請求 APay 信用卡付款 API
func APayCheckoutByCreditCard(order Order) error {
fmt.Printf("APay checkout order: %s", order.Amount.String())
...

return nil
}

實作完成了!這樣我們就完成信用卡付款功能了。

這時,過了一段時間後我們的服務決定要串接 B Pay 金流的虛擬帳戶付款來支援提供虛擬帳戶付款的功能,這時候我們可能會這樣做:

type PaymentType string

const (
APayCheckoutByCredit PaymentType = "APAY_CHECKOUT_BY_CREDIT"
BPayCheckoutByAccount PaymentType = "BPAY_CHECKOUT_BY_ACCOUNT"
)

type Order struct{
OrderNo string
Amount decimal.Decimal
// 新增付款方式
PaymentType PaymentType
}

// 結帳
func Checkout(amount decimal.Decimal, paymentType PaymentType) error {
order := Order{
OrderNo: "TW20230314083814110285",
Amount: amount,
PaymentType: paymentType,
}

// 根據不同的 paymentType 去 call 對應的支付方式
switch paymentType {
case APayCheckoutByCredit:
if err := APayCheckoutByCredit(order); err != nil {
return err
}
case BPayCheckoutByAccount:
if err := BPayCheckoutByAccount(order); err != nil {
return err
}
}

.....

return nil
}

// 請求 APay 信用卡付款 API
func APayCheckoutByCredit(order Order) error {
fmt.Printf("APay checkout order: %s", order.Amount.String())
...

return nil
}

// 請求 BPay 虛擬帳戶付款 API
func BPayCheckoutByAccount(order Order) error {
fmt.Printf("BPay checkout order: %s", order.Amount.String())
...

return nil
}

實作完成了!這時我們就能夠同時支援信用卡付款、虛擬帳戶付款功能了。

然而就在當你要 Commit 的那剎那,你不經開始思考如果過不久後我們的服務除了提供信用卡付款、虛擬帳戶付款外還需要能夠支援銀行轉帳又或是除了 A Pay、B Pay 還有 C Pay、D Pay 等要串接,那按照上面的設計方式會是易於後續開發、維護跟擴充的嗎

設計目的與重要性

KOL Radar 為例,目前產品在訂閱方案功能上是支援透過 Tappay 進行信用卡付款。而實作方式就是在進行付款的流程中去呼叫 Tappay Pay By Prime:

func PaymentForm() {
...

// Tappay Pay By Prime 的 client 封裝
res, err := util_tappay.Client.PayByPrime(req)
if err != nil {
logger.Error(ctx, err)
return
}

...
}

我們可以思考一下,這樣的設計可能會有什麼樣的缺點?

  1. 難以擴充支援更多種類的付款方式:隨著產品的成長、迭代當我們需要能夠支援更多種類的付款方式、第三方金流時如果按照原本的模式來進行後續開發將會變得難以擴充。
  2. 付款流程無法被統一管理:當需要同時支援多種類的付款方式時邏輯容易被散落在各處實作且加上這些不同的付款管道都有自己的特點和要求導致在後續會不易於維護。

所以透過上述的例子我們可以了解到當一開始只支援少數的付款方式時開發上確實是不複雜且易於實現的。但隨著需要能夠支援更多種類的付款方式時如果按照原本的模式來進行後續開發將會變得難以維護及擴充。

核心設計原則

在基於 KOL Radar 已實作支援信用卡付款的情況下,因應目前產品需要能夠串接更多付款行為,例如:3D 驗證付款、非 3D 驗證付款及退款交易等,以及考慮未來可能需要串接其他第三方金流或更多付款管道,我們整理出以下設計原則:

抽象化:如何抽象化付款管道的特徵

首先,我們可以先畫出一個完整的付款流程:

串接 Tappay Pay By Prime 的付款流程

同時我們也需要透過呼叫 Tappay Pay By CardToken 來支援非 3D 驗證的付款方式:

串接 Tappay Pay By CardToken 的付款流程

以及要能夠支援交易退款:

串接 Tappay Refund 的退款流程

看出來了嗎?上圖的付款流程中是不是有些行為相似的部分是我們可以抽象出來的呢?我們來整理一下:

由此可見,在完整的付款流程中主要就是包含以下特徵:

  1. 建立一筆交易紀錄
  2. 準備 支付供應商 Request
  3. 呼叫 支付供應商 的 API
  4. 處理 支付供應商 Response
  5. 更新交易資料狀態
  6. 紀錄 Access Log

因此我們只需要定義不同的 支付供應商 類型並將付款流程抽象化。

1. 可擴充性:如何支援更多的付款管道

考慮到需要支援更多第三方金流或更多付款管道,整理出我們所遇到的情況:

  1. 對於我們的系統來說,使用者進行付款時只在意是否成功付款,而不用去管付款流程是怎麼被實現出來的,以及這個付款流程的具體實現
  2. 需要根據不同的 支付供應商 類型創建不同的對象
  3. 需要將對象的創建和使用分離開來
  4. 需要將 支付供應商的付款流程 集中管理

根據上述的情況,我們可以選擇透過 簡單工廠模式(Simple Factory) 實作不同支付供應商來滿足我們的需求。

2. 可讀性:如何讓程式碼易於維護和擴充

目前雖然已經可以創建不同的 支付供應商 類型對象,但除此之外我們還需要考量以下情況:

  1. 需要根據不同的產品功能或使用者行為來執行對應的 支付供應商的付款流程
  2. 需要在系統中動態地切換 支付供應商的付款流程
  3. 付款流程中有多個相關的行為,而且這些行為可以被抽象成一個公共的接口

另外在系統能夠支援更多付款管道的同時也希望能夠解決以下的問題:

  1. 讓相同付款行為的代碼進行封裝,避免代碼重複
  2. 簡化代碼結構,提高代碼的可讀性和可維護性
  3. 方便在執行付款流程時動態地切換算法或策略

最後根據上述的情況,我們可以選擇透過 簡單工廠模式(Simple Factory)+策略模式(Strategy) 來建立一個 Payment Manager 來滿足我們的需求。

實踐 Payment Manager 的設計原則

Payment Manager 透過實現 簡單工廠模式(Simple Factory) +策略模式(Strategy) 模式 讓我們能夠創建不同 支付供應商對象 並在 系統中動態地切換付款流程。

簡單工廠模式(Simple Factory) 是用來建立物件的模式,主要解決對象創建的問題,使得客戶端不需要知道具體產品的類別,只需要知道產品所對應的參數,就能夠創建出對應的對象

策略模式(Strategy) 是一種行為模式,將不同的算法或策略進行抽象,將其封裝到不同的類別中,使得這些算法或策略可以互相替換,而不會對外界產生影響

// 定義 Payment 工廠
type payment struct {
checkoutManager map[constant.CheckoutProviderCode]checkoutManager
}

// 定義 PaymentManager Strategy 介面
type PaymentManager interface {
Checkout(ctx context.Context, paymentTransaction *model.PaymentTransaction, checkoutParameter *model.CheckoutParameter) (*model.CheckoutResponse, error)
}

// 初始 PaymentManager
func newPaymentManager(logger *log.Logger, db *gorm.DB, paymentProviderManager payment_provider.PaymentProviderManager) PaymentManager {
checkoutManager := map[constant.CheckoutProviderCode]checkoutManager{
constant.TappayPrimeCredit: newTapPayPrime(basePayment),
constant.TappayCardTokenCredit: newTapPayCardToken(basePayment),
}
}

// 實作 PaymentManager 介面
// 外部透過呼叫 Checkout function 並傳入 checkoutProviderCode 就可以執行對應的付款策略
func (p *payment) Checkout(ctx context.Context, paymentTransaction *model.PaymentTransaction, checkoutParameter *model.CheckoutParameter) (*model.CheckoutResponse, error) {
if val, ok := p.checkoutManager[checkoutParameter.CheckoutProviderCode]; ok {
....
res, err := val.Checkout(ctx, paymentTransaction, checkoutParameter)
return res, err
}
return nil, errors.New("unknown CheckoutProviderCode")
}

// checkoutManager,抽象付款流程
type checkoutManager interface {
PrepareCheckoutProviderRequest(checkoutParameter *model.CheckoutParameter) payment_provider.Request
Checkout(ctx context.Context, paymentTransaction *model.PaymentTransaction, checkoutParameter *model.CheckoutParameter) (*model.CheckoutResponse, error)
PrepareCheoutProviderResponse(ctx context.Context, response *payment_provider.Response) (*model.CheckoutResponse, error)
UpdatePaymentTransaction(ctx context.Context, paymentTransaction *model.PaymentTransaction, response *payment_provider.Response) error
CreateThirdPartyAccessLog(ctx context.Context, callerID uint, callerType util.CallerType, body []byte, response *payment_provider.Response) error
}

這裡創建一個 Tappay 非 3D 驗證信用卡付款 的實例,並實作 checkoutManager 的介面。

type TapPayCardToken struct {
*basePayment
}

func (tc *TapPayCardToken) Checkout(ctx context.Context, paymentTransaction *model.PaymentTransaction,
checkoutParameter *model.CheckoutParameter) (*model.CheckoutResponse, error) {
// 呼叫 支付供應商 的 API,實作 call tappay 付款 API
}

func (tc *TapPayCardToken) PrepareCheckoutProviderRequest(checkoutParameter *model.CheckoutParameter) payment_provider.Request {
// 準備 支付供應商 Request
}

func (tc *TapPayCardToken) PrepareCheckoutProviderResponse(ctx context.Context, response *payment_provider.Response) (*model.CheckoutResponse, error) {
// 處理 支付供應商 Response
}

func (tc *TapPayCardToken) UpdatePaymentTransaction(ctx context.Context, paymentTransaction *model.PaymentTransaction, response *payment_provider.Response) error {
// 更新交易資料狀態
}

相同地,如果當需要支援其他付款方式同樣只要創建一個實例,並實作 checkoutManager 的介面,這裡以串接 Tappay 3D 驗證信用卡付款為例:

type TapPayPrime struct {
*basePayment
}

func (tp *TapPayPrime) Checkout(ctx context.Context, paymentTransaction *model.PaymentTransaction,
checkoutParameter *model.CheckoutParameter) (*model.CheckoutResponse, error) {
// 呼叫 支付供應商 的 API,實作 call tappay 付款 API
}

func (tp *TapPayPrime) PrepareCheckoutProviderRequest(checkoutParameter *model.CheckoutParameter) payment_provider.Request {
// 準備 支付供應商 Request
}

func (tp *TapPayPrime) PrepareCheckoutProviderResponse(ctx context.Context, response *payment_provider.Response) (*model.CheckoutResponse, error) {
// 處理 支付供應商 Response
}

func (tp *TapPayPrime) UpdatePaymentTransaction(ctx context.Context, paymentTransaction *model.PaymentTransaction, response *payment_provider.Response) error {
// 更新交易資料狀態
}

對使用上來說當需要進行付款時只需要呼叫 Payment.Checkout 並傳入 checkoutParameter 便可以執行付款,而不用去管付款流程是怎麼被實現出來的,以及這個付款流程的具體實現。

.... 

checkoutParameter := &modelv2.CheckoutParameter{
// CheckoutProviderCode 傳入要使用的支付供應商
// 當為 Tappay 非 3D 驗證信用卡付款 就會是 "TAPPAY_CARD_TOKEN_CREDIT"
// 當為 Tappay 3D 驗證信用卡付款 就會是 "TAPPAY_PRIME_CREDIT"
CheckoutProviderCode: checkoutProviderCode,
// 支付供應商的特定參數
TappayPaymentParameter: tappayPaymentParameter,
}

// 付款流程只需要呼叫 Payment.Checkout 並傳入 checkoutParameter 便可以執行付款
checkoutResponse, err := payment.Payment.Checkout(ctx, paymentTransaction, checkoutParameter)
return nil, err
}

....

結論

Payment Manager 的優勢和限制

透過實現一個 Payment Manager 來統一管理多種支付管道,我們可以整理出以下優勢以及限制:

優勢

  1. 降低程式碼複雜度:透過統一的 Payment Manager 介面來管理多種支付管道,可以減少程式碼的複雜度,降低維護成本
  2. 增加擴充性:透過簡單工廠模式和策略模式,可以很容易地增加新的支付管道,擴充 Payment Manager 的功能
  3. 提高代碼的可讀性:透過統一的 Payment Manager 介面和清晰的設計模式,使得代碼更易於理解和維護

限制

  1. 程式碼複雜度:雖然 Payment Manager 可以降低整體的程式碼複雜度,但相對的若系統一開始只支援少數的支付管道時,套用 Payment Manager 模式反而可能會增加開發複雜度
  2. 介面設計:因需要能夠支援多種支付管道,可以觀察到在介面的接口設計上需要額外封裝參數,當未來串接越多的付款方式參數必然會大幅增加,可能造成維護不易
type CheckoutParameter struct {
CheckoutProviderCode constant.CheckoutProviderCode

// third-party payment transaction specific information
TappayPaymentParameter *TappayPaymentParameter
}

未來可能的改進方向

你有發現嗎?除了 簡單工廠模式(Simple Factory) +策略模式(Strategy) 外是否還可以考慮其他的設計模式?

讓我們來回顧一下付款流程的主要特徵:

然後我們可以發現,

不論系統需要使用哪種付款方式流程都會是固定的框架

模板方法模式(Template Method Pattern)是一種基於繼承的行為型設計模式,用於定義一個操作中的算法框架,將一些步驟推遲到子類別實現。其主要思想是:定義一個操作的算法框架,將一些步驟推遲到子類別實現,使得子類別能夠在不改變算法結構的情況下重新定義算法中的某些步驟,從而實現算法的個性化定制

先看一下目前在 TapPayPrime Checkout 的實作方式:

func (tp *TapPayPrime) Checkout(ctx context.Context, paymentTransaction *model.PaymentTransaction,
checkoutParameter *model.CheckoutParameter) (*model.CheckoutResponse, error) {
// 準備 支付供應商 Request
request := tp.PrepareCheckoutProviderRequest(checkoutParameter)

// 呼叫 支付供應商 的 API,實作 call tappay 付款 API
response, checkoutErr := tp.paymentProviderManager.Checkout(ctx, constant.TappayPrimeCredit, request)
if checkoutErr != nil {
return nil, checkoutErr
}

// 處理 支付供應商 Response
checkResponse, prepareResponseErr := tp.PrepareCheckoutProviderResponse(ctx, response)
if prepareResponseErr != nil {
return nil, prepareResponseErr
}

// 更新交易資料狀態
if err := tp.UpdatePaymentTransaction(ctx, paymentTransaction, response); err != nil {
logger.G(ctx).Error(err)
}

// 紀錄 Access Log
if err := tp.CreateThirdPartyAccessLog(ctx, paymentTransaction.ID, util.CampaignKolPayment,
tappayRequestBody, response); err != nil {
logger.G(ctx).Warn(err)
}

// check tappay response status and paymentURL...

return checkResponse, nil
}

其實我們可以考慮在 payment.Checkout 就套用定義好的付款框架,透過模板方法模式將相同的代碼放在抽象類別 (payment) 中實現,從而可以避免在具體類別 (TapPayPrime, TapPayCardToken) 中出現重複的代碼,提高代碼的重用性。

// 定義一個付款操作的算法框架
type checkoutManager interface {
PrepareCheckoutProviderRequest(checkoutParameter *model.CheckoutParameter) payment_provider.Request
Checkout(ctx context.Context, paymentTransaction *model.PaymentTransaction, checkoutParameter *model.CheckoutParameter) (*model.CheckoutResponse, error)
PrepareCheckoutProviderResponse(ctx context.Context, response *payment_provider.Response) (*model.CheckoutResponse, error)
UpdatePaymentTransaction(ctx context.Context, paymentTransaction *model.PaymentTransaction, response *payment_provider.Response) error
CreateThirdPartyAccessLog(ctx context.Context, callerID uint, callerType util.CallerType, body []byte, response *payment_provider.Response) error
}

// 這裡以 TapPayPrime 為例,將步驟將推遲到子類別分別實現 checkoutManager 介面
// 使得子類別能夠在不改變結構的情況下可以重新定義算法中的某些步驟
// 對外部使用也維持呼叫 Payment.Checkout 並傳入 checkoutParameter 便可以執行付款
func (p *payment) Checkout(ctx context.Context, paymentTransaction *model.PaymentTransaction, checkoutParameter *model.CheckoutParameter) (*model.CheckoutResponse, error) {
// 根據 CheckoutProviderCode 選擇對應的策略
val, ok := p.checkoutManager[checkoutParameter.CheckoutProviderCode]
if !ok {
return nil, errors.New("unknown CheckoutProviderCode")
}

// 準備 支付供應商 Request
request := val.PrepareCheckoutProviderRequest(checkoutParameter)

// 呼叫 支付供應商 的 API,實作 call tappay 付款 API
response, checkoutErr := val.Checkout(ctx, paymentTransaction, checkoutParameter)
if checkoutErr != nil {
return nil, checkoutErr
}

// 處理 支付供應商 Response
checkResponse, prepareResponseErr := val.PrepareCheckoutProviderResponse(ctx, response)
if prepareResponseErr != nil {
return nil, prepareResponseErr
}

// 更新交易資料狀態
if err := val.UpdatePaymentTransaction(ctx, paymentTransaction, response); err != nil {
logger.G(ctx).Error(err)
}

// 紀錄 Access Log
if err := val.CreateThirdPartyAccessLog(ctx, paymentTransaction.ID, util.CampaignKolPayment,
tappayRequestBody, response); err != nil {
logger.G(ctx).Warn(err)
}

// check tappay response status and paymentURL...

return res, err
}

最後,關於 KOL Radar 如何實踐 Payment Manager 的心得分享就到這邊啦,後續我們也會考慮往套用模板方法模式 (Template Method Pattern) 的方向來做調整,爭取實踐出更好維護、擴充、低複雜性的 Payment Manager 2.0!

感謝各位花時間讀這篇文章,如果覺得有得到收穫,不要吝嗇給個 「掌聲鼓勵」,有什麼想討論的也可以留言讓 iKala 知道!

--

--